Merge branch 'main' into feat/snapshot-source-info

This commit is contained in:
Nico 2026-03-11 19:29:54 +01:00 committed by GitHub
commit fb523aae44
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
183 changed files with 13797 additions and 5576 deletions

View file

@ -17,20 +17,20 @@ jobs:
uses: actions/checkout@v6
- name: Log in to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
with:
driver: cloud
endpoint: "meienberger/runtipi-builder"
install: true
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@ -38,14 +38,14 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@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@v6
uses: docker/build-push-action@v7
with:
context: .
target: production

View file

@ -49,27 +49,27 @@ jobs:
ref: ${{ github.ref }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
with:
driver: cloud
endpoint: "meienberger/runtipi-builder"
install: true
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build docker image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
target: production
@ -98,7 +98,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository_owner }}/zerobyte
tags: |
@ -110,7 +110,7 @@ jobs:
latest=${{ needs.determine-release-type.outputs.release_type == 'release' }}
- name: Push images to GitHub Container Registry
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
target: production

1
.gitignore vendored
View file

@ -41,3 +41,4 @@ openapi-ts-error-*.log
tmp/
qa-output
.worktrees/

View file

@ -50,6 +50,7 @@
"no-unused-vars": [
"warn",
{
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
"argsIgnorePattern": "^_"
}
@ -114,6 +115,7 @@
"unicorn/prefer-set-size": "warn",
"unicorn/prefer-string-starts-ends-with": "warn",
"import/no-cycle": "error",
"import/no-unused-modules": ["error", { "unusedExports": true }],
"eslint/no-console": ["warn", { "allow": ["warn", "error", "info"] }]
},
"settings": {

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"editor.defaultFormatter": "oxc.oxc-vscode"
}

View file

@ -12,7 +12,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
apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini tzdata
ENTRYPOINT ["/sbin/tini", "-s", "--"]

View file

@ -40,7 +40,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.29
image: ghcr.io/nicotsx/zerobyte:v0.30
container_name: zerobyte
restart: unless-stopped
cap_add:
@ -50,7 +50,7 @@ services:
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris # Set your timezone here
- TZ=Europe/Zurich # Set your timezone here
- BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte
- APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32`
volumes:
@ -95,7 +95,7 @@ Zerobyte can be customized using environment variables. Below are the available
| `APP_SECRET` | **Required.** A random secret key (32+ chars) used to encrypt sensitive data in the database. Generate with `openssl rand -hex 32`. | (none) |
| `PORT` | The port the web interface and API will listen on. | `4096` |
| `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/Paris`). **Crucial for accurate backup scheduling.** | `UTC` |
| `TZ` | Timezone for the container (e.g., `Europe/Zurich`). **Crucial for accurate backup scheduling.** | `UTC` |
| `TRUSTED_ORIGINS` | Comma-separated list of extra trusted origins for CORS (e.g., `http://localhost:3000,http://example.com`). | (none) |
| `LOG_LEVEL` | Logging verbosity. Options: `debug`, `info`, `warn`, `error`. | `info` |
| `SERVER_IDLE_TIMEOUT` | Idle timeout for the server in seconds. | `60` |
@ -118,13 +118,13 @@ If you only need to back up locally mounted folders and don't require remote sha
```yaml
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.29
image: ghcr.io/nicotsx/zerobyte:v0.30
container_name: zerobyte
restart: unless-stopped
ports:
- "4096:4096"
environment:
- TZ=Europe/Paris # Set your timezone here
- TZ=Europe/Zurich # Set your timezone here
- BASE_URL=http://localhost:4096 # Change this to your actual URL (use https:// for secure cookies)
- APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32`
volumes:
@ -157,7 +157,7 @@ If you want to track a local directory on the same server where Zerobyte is runn
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.29
image: ghcr.io/nicotsx/zerobyte:v0.30
container_name: zerobyte
restart: unless-stopped
cap_add:
@ -167,7 +167,7 @@ services:
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris
- TZ=Europe/Zurich
- BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte
- APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32`
volumes:
@ -232,7 +232,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.29
image: ghcr.io/nicotsx/zerobyte:v0.30
container_name: zerobyte
restart: unless-stopped
cap_add:
@ -242,7 +242,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris
- TZ=Europe/Zurich
- BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte
volumes:
- /etc/localtime:/etc/localtime:ro

View file

@ -37,7 +37,6 @@ body {
}
body {
@apply bg-[#131313];
min-height: 100dvh;
}
@ -67,6 +66,8 @@ body {
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
@ -88,38 +89,40 @@ body {
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--card-header: oklch(0.922 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--background: oklch(0.98 0.002 250);
--foreground: oklch(0.20 0.01 260);
--card: #ffffff;
--card-foreground: oklch(0.20 0.01 260);
--card-header: oklch(0.99 0.001 260);
--popover: #ffffff;
--popover-foreground: oklch(0.20 0.01 260);
--primary: oklch(0.20 0.01 260);
--primary-foreground: #ffffff;
--secondary: oklch(0.96 0.005 260);
--secondary-foreground: oklch(0.20 0.01 260);
--muted: oklch(0.95 0.005 260);
--muted-foreground: oklch(0.45 0.01 260);
--accent: oklch(0.96 0.005 260);
--accent-foreground: oklch(0.20 0.01 260);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--destructive-foreground: #fff;
--success: oklch(0.57 0.16 148);
--border: oklch(0.90 0.01 260);
--input: oklch(0.90 0.01 260);
--ring: oklch(0.7 0.01 260);
--chart-1: oklch(0.55 0.22 260);
--chart-2: oklch(0.6 0.15 200);
--chart-3: oklch(0.45 0.12 280);
--chart-4: oklch(0.7 0.15 190);
--chart-5: oklch(0.55 0.18 300);
--sidebar: #ffffff;
--sidebar-foreground: oklch(0.35 0.01 260);
--sidebar-primary: oklch(0.20 0.01 260);
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: oklch(0.97 0.005 260);
--sidebar-accent-foreground: oklch(0.20 0.01 260);
--sidebar-border: oklch(0.90 0.01 260);
--sidebar-ring: oklch(0.7 0.01 260);
--strong-accent: #ff543a;
}
@ -144,6 +147,8 @@ body {
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: #ff543a;
--destructive-foreground: #fff;
--success: oklch(0.696 0.17 162.48);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
@ -174,11 +179,11 @@ body {
@layer base {
:root {
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--chart-1: oklch(0.55 0.22 260);
--chart-2: oklch(0.6 0.15 200);
--chart-3: oklch(0.45 0.12 280);
--chart-4: oklch(0.7 0.15 190);
--chart-5: oklch(0.55 0.18 300);
}
.dark {
@ -190,6 +195,17 @@ body {
}
}
.snapshot-scrollable::-webkit-scrollbar {
height: 6px;
}
.snapshot-scrollable::-webkit-scrollbar-track {
background: transparent;
}
.snapshot-scrollable::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 999px;
}
/* Hide built-in password reveal/clear controls (notably in Edge on Windows) */
[data-secret-input] input[type="password"]::-ms-reveal,
[data-secret-input] input[type="password"]::-ms-clear {

View file

@ -4,8 +4,8 @@
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen';
import { browseFilesystem, cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, devPanelExec, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getNotificationDestination, getOrgMembers, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DevPanelExecData, DevPanelExecResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, RemoveOrgMemberData, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
import { browseFilesystem, cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getNotificationDestination, getOrgMembers, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshRepositoryStats, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshRepositoryStatsData, RefreshRepositoryStatsResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, RemoveOrgMemberData, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
export type QueryKey<TOptions extends Options> = [
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
@ -58,93 +58,6 @@ export const getStatusOptions = (options?: Options<GetStatusData>) => queryOptio
queryKey: getStatusQueryKey(options)
});
export const getPublicSsoProvidersQueryKey = (options?: Options<GetPublicSsoProvidersData>) => createQueryKey('getPublicSsoProviders', options);
/**
* Get public SSO providers for the instance
*/
export const getPublicSsoProvidersOptions = (options?: Options<GetPublicSsoProvidersData>) => queryOptions<GetPublicSsoProvidersResponse, DefaultError, GetPublicSsoProvidersResponse, ReturnType<typeof getPublicSsoProvidersQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getPublicSsoProviders({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getPublicSsoProvidersQueryKey(options)
});
export const getSsoSettingsQueryKey = (options?: Options<GetSsoSettingsData>) => createQueryKey('getSsoSettings', options);
/**
* Get SSO providers and invitations for the active organization
*/
export const getSsoSettingsOptions = (options?: Options<GetSsoSettingsData>) => queryOptions<GetSsoSettingsResponse, DefaultError, GetSsoSettingsResponse, ReturnType<typeof getSsoSettingsQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getSsoSettings({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getSsoSettingsQueryKey(options)
});
/**
* Delete an SSO provider
*/
export const deleteSsoProviderMutation = (options?: Partial<Options<DeleteSsoProviderData>>): UseMutationOptions<unknown, DefaultError, Options<DeleteSsoProviderData>> => {
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<DeleteSsoProviderData>> = {
mutationFn: async (fnOptions) => {
const { data } = await deleteSsoProvider({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Update whether SSO sign-in can auto-link existing accounts by email
*/
export const updateSsoProviderAutoLinkingMutation = (options?: Partial<Options<UpdateSsoProviderAutoLinkingData>>): UseMutationOptions<unknown, DefaultError, Options<UpdateSsoProviderAutoLinkingData>> => {
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<UpdateSsoProviderAutoLinkingData>> = {
mutationFn: async (fnOptions) => {
const { data } = await updateSsoProviderAutoLinking({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Delete an SSO invitation
*/
export const deleteSsoInvitationMutation = (options?: Partial<Options<DeleteSsoInvitationData>>): UseMutationOptions<unknown, DefaultError, Options<DeleteSsoInvitationData>> => {
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<DeleteSsoInvitationData>> = {
mutationFn: async (fnOptions) => {
const { data } = await deleteSsoInvitation({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getAdminUsersQueryKey = (options?: Options<GetAdminUsersData>) => createQueryKey('getAdminUsers', options);
/**
@ -250,6 +163,93 @@ export const removeOrgMemberMutation = (options?: Partial<Options<RemoveOrgMembe
return mutationOptions;
};
export const getPublicSsoProvidersQueryKey = (options?: Options<GetPublicSsoProvidersData>) => createQueryKey('getPublicSsoProviders', options);
/**
* Get public SSO providers for the instance
*/
export const getPublicSsoProvidersOptions = (options?: Options<GetPublicSsoProvidersData>) => queryOptions<GetPublicSsoProvidersResponse, DefaultError, GetPublicSsoProvidersResponse, ReturnType<typeof getPublicSsoProvidersQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getPublicSsoProviders({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getPublicSsoProvidersQueryKey(options)
});
export const getSsoSettingsQueryKey = (options?: Options<GetSsoSettingsData>) => createQueryKey('getSsoSettings', options);
/**
* Get SSO providers and invitations for the active organization
*/
export const getSsoSettingsOptions = (options?: Options<GetSsoSettingsData>) => queryOptions<GetSsoSettingsResponse, DefaultError, GetSsoSettingsResponse, ReturnType<typeof getSsoSettingsQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getSsoSettings({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getSsoSettingsQueryKey(options)
});
/**
* Delete an SSO provider
*/
export const deleteSsoProviderMutation = (options?: Partial<Options<DeleteSsoProviderData>>): UseMutationOptions<unknown, DefaultError, Options<DeleteSsoProviderData>> => {
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<DeleteSsoProviderData>> = {
mutationFn: async (fnOptions) => {
const { data } = await deleteSsoProvider({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Update whether SSO sign-in can auto-link existing accounts by email
*/
export const updateSsoProviderAutoLinkingMutation = (options?: Partial<Options<UpdateSsoProviderAutoLinkingData>>): UseMutationOptions<unknown, DefaultError, Options<UpdateSsoProviderAutoLinkingData>> => {
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<UpdateSsoProviderAutoLinkingData>> = {
mutationFn: async (fnOptions) => {
const { data } = await updateSsoProviderAutoLinking({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Delete an SSO invitation
*/
export const deleteSsoInvitationMutation = (options?: Partial<Options<DeleteSsoInvitationData>>): UseMutationOptions<unknown, DefaultError, Options<DeleteSsoInvitationData>> => {
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<DeleteSsoInvitationData>> = {
mutationFn: async (fnOptions) => {
const { data } = await deleteSsoInvitation({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey('listVolumes', options);
/**
@ -457,7 +457,7 @@ export const listFilesInfiniteQueryKey = (options: Options<ListFilesData>): Quer
/**
* List files in a volume directory
*/
export const listFilesInfiniteOptions = (options: Options<ListFilesData>) => infiniteQueryOptions<ListFilesResponse, DefaultError, InfiniteData<ListFilesResponse>, QueryKey<Options<ListFilesData>>, string | Pick<QueryKey<Options<ListFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
export const listFilesInfiniteOptions = (options: Options<ListFilesData>) => infiniteQueryOptions<ListFilesResponse, DefaultError, InfiniteData<ListFilesResponse>, QueryKey<Options<ListFilesData>>, number | Pick<QueryKey<Options<ListFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
// @ts-ignore
{
queryFn: async ({ pageParam, queryKey, signal }) => {
@ -620,6 +620,23 @@ export const getRepositoryStatsOptions = (options: Options<GetRepositoryStatsDat
queryKey: getRepositoryStatsQueryKey(options)
});
/**
* Refresh repository storage and compression statistics
*/
export const refreshRepositoryStatsMutation = (options?: Partial<Options<RefreshRepositoryStatsData>>): UseMutationOptions<RefreshRepositoryStatsResponse, DefaultError, Options<RefreshRepositoryStatsData>> => {
const mutationOptions: UseMutationOptions<RefreshRepositoryStatsResponse, DefaultError, Options<RefreshRepositoryStatsData>> = {
mutationFn: async (fnOptions) => {
const { data } = await refreshRepositoryStats({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Delete multiple snapshots from a repository
*/
@ -730,7 +747,7 @@ export const listSnapshotFilesInfiniteQueryKey = (options: Options<ListSnapshotF
/**
* List files and directories in a snapshot
*/
export const listSnapshotFilesInfiniteOptions = (options: Options<ListSnapshotFilesData>) => infiniteQueryOptions<ListSnapshotFilesResponse, DefaultError, InfiniteData<ListSnapshotFilesResponse>, QueryKey<Options<ListSnapshotFilesData>>, string | Pick<QueryKey<Options<ListSnapshotFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
export const listSnapshotFilesInfiniteOptions = (options: Options<ListSnapshotFilesData>) => infiniteQueryOptions<ListSnapshotFilesResponse, DefaultError, InfiniteData<ListSnapshotFilesResponse>, QueryKey<Options<ListSnapshotFilesData>>, number | Pick<QueryKey<Options<ListSnapshotFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
// @ts-ignore
{
queryFn: async ({ pageParam, queryKey, signal }) => {
@ -855,23 +872,6 @@ export const tagSnapshotsMutation = (options?: Partial<Options<TagSnapshotsData>
return mutationOptions;
};
/**
* Execute a restic command against a repository (dev panel only)
*/
export const devPanelExecMutation = (options?: Partial<Options<DevPanelExecData>>): UseMutationOptions<DevPanelExecResponse, DefaultError, Options<DevPanelExecData>> => {
const mutationOptions: UseMutationOptions<DevPanelExecResponse, DefaultError, Options<DevPanelExecData>> = {
mutationFn: async (fnOptions) => {
const { data } = await devPanelExec({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) => createQueryKey('listBackupSchedules', options);
/**

View file

@ -38,7 +38,7 @@ export const createClient = (config: Config = {}): Client => {
...options,
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
headers: mergeHeaders(_config.headers, options.headers),
serializedBody: undefined,
serializedBody: undefined as string | undefined,
};
if (opts.security) {
@ -53,7 +53,7 @@ export const createClient = (config: Config = {}): Client => {
}
if (opts.body !== undefined && opts.bodySerializer) {
opts.serializedBody = opts.bodySerializer(opts.body);
opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined;
}
// remove Content-Type header if body is empty to avoid sending invalid requests
@ -259,8 +259,10 @@ export const createClient = (config: Config = {}): Client => {
});
};
const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options });
return {
buildUrl,
buildUrl: _buildUrl,
connect: makeMethodFn('CONNECT'),
delete: makeMethodFn('DELETE'),
get: makeMethodFn('GET'),

View file

@ -5,7 +5,7 @@ import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerialize
export type QuerySerializer = (query: Record<string, unknown>) => string;
export type BodySerializer = (body: any) => any;
export type BodySerializer = (body: unknown) => unknown;
type QuerySerializerOptionsObject = {
allowReserved?: boolean;
@ -40,12 +40,10 @@ const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value:
};
export const formDataBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
body: T,
): FormData => {
bodySerializer: (body: unknown): FormData => {
const data = new FormData();
Object.entries(body).forEach(([key, value]) => {
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
@ -61,15 +59,15 @@ export const formDataBodySerializer = {
};
export const jsonBodySerializer = {
bodySerializer: <T>(body: T): string =>
bodySerializer: (body: unknown): string =>
JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)),
};
export const urlSearchParamsBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): string => {
bodySerializer: (body: unknown): string => {
const data = new URLSearchParams();
Object.entries(body).forEach(([key, value]) => {
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}

View file

@ -97,7 +97,7 @@ interface Params {
const stripEmptySlots = (params: Params) => {
for (const [slot, value] of Object.entries(params)) {
if (value && typeof value === 'object' && !Object.keys(value).length) {
if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) {
delete params[slot as Slot];
}
}

View file

@ -52,6 +52,7 @@ export {
listVolumes,
mountVolume,
type Options,
refreshRepositoryStats,
refreshSnapshots,
removeOrgMember,
reorderBackupSchedules,
@ -230,6 +231,9 @@ export type {
MountVolumeData,
MountVolumeResponse,
MountVolumeResponses,
RefreshRepositoryStatsData,
RefreshRepositoryStatsResponse,
RefreshRepositoryStatsResponses,
RefreshSnapshotsData,
RefreshSnapshotsResponse,
RefreshSnapshotsResponses,

View file

@ -3,7 +3,7 @@
import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshRepositoryStatsData, RefreshRepositoryStatsResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
@ -24,38 +24,6 @@ export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends
*/
export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>) => (options?.client ?? client).get<GetStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/status', ...options });
/**
* Get public SSO providers for the instance
*/
export const getPublicSsoProviders = <ThrowOnError extends boolean = false>(options?: Options<GetPublicSsoProvidersData, ThrowOnError>) => (options?.client ?? client).get<GetPublicSsoProvidersResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/sso-providers', ...options });
/**
* Get SSO providers and invitations for the active organization
*/
export const getSsoSettings = <ThrowOnError extends boolean = false>(options?: Options<GetSsoSettingsData, ThrowOnError>) => (options?.client ?? client).get<GetSsoSettingsResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/sso-settings', ...options });
/**
* Delete an SSO provider
*/
export const deleteSsoProvider = <ThrowOnError extends boolean = false>(options: Options<DeleteSsoProviderData, ThrowOnError>) => (options.client ?? client).delete<DeleteSsoProviderResponses, DeleteSsoProviderErrors, ThrowOnError>({ url: '/api/v1/auth/sso-providers/{providerId}', ...options });
/**
* Update whether SSO sign-in can auto-link existing accounts by email
*/
export const updateSsoProviderAutoLinking = <ThrowOnError extends boolean = false>(options: Options<UpdateSsoProviderAutoLinkingData, ThrowOnError>) => (options.client ?? client).patch<UpdateSsoProviderAutoLinkingResponses, UpdateSsoProviderAutoLinkingErrors, ThrowOnError>({
url: '/api/v1/auth/sso-providers/{providerId}/auto-linking',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Delete an SSO invitation
*/
export const deleteSsoInvitation = <ThrowOnError extends boolean = false>(options: Options<DeleteSsoInvitationData, ThrowOnError>) => (options.client ?? client).delete<DeleteSsoInvitationResponses, DeleteSsoInvitationErrors, ThrowOnError>({ url: '/api/v1/auth/sso-invitations/{invitationId}', ...options });
/**
* List admin users for settings management
*/
@ -93,6 +61,38 @@ export const updateMemberRole = <ThrowOnError extends boolean = false>(options:
*/
export const removeOrgMember = <ThrowOnError extends boolean = false>(options: Options<RemoveOrgMemberData, ThrowOnError>) => (options.client ?? client).delete<RemoveOrgMemberResponses, RemoveOrgMemberErrors, ThrowOnError>({ url: '/api/v1/auth/org-members/{memberId}', ...options });
/**
* Get public SSO providers for the instance
*/
export const getPublicSsoProviders = <ThrowOnError extends boolean = false>(options?: Options<GetPublicSsoProvidersData, ThrowOnError>) => (options?.client ?? client).get<GetPublicSsoProvidersResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/sso-providers', ...options });
/**
* Get SSO providers and invitations for the active organization
*/
export const getSsoSettings = <ThrowOnError extends boolean = false>(options?: Options<GetSsoSettingsData, ThrowOnError>) => (options?.client ?? client).get<GetSsoSettingsResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/sso-settings', ...options });
/**
* Delete an SSO provider
*/
export const deleteSsoProvider = <ThrowOnError extends boolean = false>(options: Options<DeleteSsoProviderData, ThrowOnError>) => (options.client ?? client).delete<DeleteSsoProviderResponses, DeleteSsoProviderErrors, ThrowOnError>({ url: '/api/v1/auth/sso-providers/{providerId}', ...options });
/**
* Update whether SSO sign-in can auto-link existing accounts by email
*/
export const updateSsoProviderAutoLinking = <ThrowOnError extends boolean = false>(options: Options<UpdateSsoProviderAutoLinkingData, ThrowOnError>) => (options.client ?? client).patch<UpdateSsoProviderAutoLinkingResponses, UpdateSsoProviderAutoLinkingErrors, ThrowOnError>({
url: '/api/v1/auth/sso-providers/{providerId}/auto-linking',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Delete an SSO invitation
*/
export const deleteSsoInvitation = <ThrowOnError extends boolean = false>(options: Options<DeleteSsoInvitationData, ThrowOnError>) => (options.client ?? client).delete<DeleteSsoInvitationResponses, DeleteSsoInvitationErrors, ThrowOnError>({ url: '/api/v1/auth/sso-invitations/{invitationId}', ...options });
/**
* List all volumes
*/
@ -101,24 +101,24 @@ export const listVolumes = <ThrowOnError extends boolean = false>(options?: Opti
/**
* Create a new volume
*/
export const createVolume = <ThrowOnError extends boolean = false>(options?: Options<CreateVolumeData, ThrowOnError>) => (options?.client ?? client).post<CreateVolumeResponses, unknown, ThrowOnError>({
export const createVolume = <ThrowOnError extends boolean = false>(options: Options<CreateVolumeData, ThrowOnError>) => (options.client ?? client).post<CreateVolumeResponses, unknown, ThrowOnError>({
url: '/api/v1/volumes',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
...options.headers
}
});
/**
* Test connection to backend
*/
export const testConnection = <ThrowOnError extends boolean = false>(options?: Options<TestConnectionData, ThrowOnError>) => (options?.client ?? client).post<TestConnectionResponses, unknown, ThrowOnError>({
export const testConnection = <ThrowOnError extends boolean = false>(options: Options<TestConnectionData, ThrowOnError>) => (options.client ?? client).post<TestConnectionResponses, unknown, ThrowOnError>({
url: '/api/v1/volumes/test-connection',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
...options.headers
}
});
@ -177,12 +177,12 @@ export const listRepositories = <ThrowOnError extends boolean = false>(options?:
/**
* Create a new restic repository
*/
export const createRepository = <ThrowOnError extends boolean = false>(options?: Options<CreateRepositoryData, ThrowOnError>) => (options?.client ?? client).post<CreateRepositoryResponses, unknown, ThrowOnError>({
export const createRepository = <ThrowOnError extends boolean = false>(options: Options<CreateRepositoryData, ThrowOnError>) => (options.client ?? client).post<CreateRepositoryResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
...options.headers
}
});
@ -218,6 +218,11 @@ export const updateRepository = <ThrowOnError extends boolean = false>(options:
*/
export const getRepositoryStats = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryStatsData, ThrowOnError>) => (options.client ?? client).get<GetRepositoryStatsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/stats', ...options });
/**
* Refresh repository storage and compression statistics
*/
export const refreshRepositoryStats = <ThrowOnError extends boolean = false>(options: Options<RefreshRepositoryStatsData, ThrowOnError>) => (options.client ?? client).post<RefreshRepositoryStatsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/stats/refresh', ...options });
/**
* Delete multiple snapshots from a repository
*/
@ -319,12 +324,12 @@ export const listBackupSchedules = <ThrowOnError extends boolean = false>(option
/**
* Create a new backup schedule for a volume
*/
export const createBackupSchedule = <ThrowOnError extends boolean = false>(options?: Options<CreateBackupScheduleData, ThrowOnError>) => (options?.client ?? client).post<CreateBackupScheduleResponses, unknown, ThrowOnError>({
export const createBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<CreateBackupScheduleData, ThrowOnError>) => (options.client ?? client).post<CreateBackupScheduleResponses, unknown, ThrowOnError>({
url: '/api/v1/backups',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
...options.headers
}
});
@ -412,12 +417,12 @@ export const getMirrorCompatibility = <ThrowOnError extends boolean = false>(opt
/**
* Reorder backup schedules by providing an array of schedule short IDs in the desired order
*/
export const reorderBackupSchedules = <ThrowOnError extends boolean = false>(options?: Options<ReorderBackupSchedulesData, ThrowOnError>) => (options?.client ?? client).post<ReorderBackupSchedulesResponses, unknown, ThrowOnError>({
export const reorderBackupSchedules = <ThrowOnError extends boolean = false>(options: Options<ReorderBackupSchedulesData, ThrowOnError>) => (options.client ?? client).post<ReorderBackupSchedulesResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/reorder',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
...options.headers
}
});
@ -434,12 +439,12 @@ export const listNotificationDestinations = <ThrowOnError extends boolean = fals
/**
* Create a new notification destination
*/
export const createNotificationDestination = <ThrowOnError extends boolean = false>(options?: Options<CreateNotificationDestinationData, ThrowOnError>) => (options?.client ?? client).post<CreateNotificationDestinationResponses, unknown, ThrowOnError>({
export const createNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<CreateNotificationDestinationData, ThrowOnError>) => (options.client ?? client).post<CreateNotificationDestinationResponses, unknown, ThrowOnError>({
url: '/api/v1/notifications/destinations',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
...options.headers
}
});
@ -488,24 +493,24 @@ export const getRegistrationStatus = <ThrowOnError extends boolean = false>(opti
/**
* Update the registration status for new users. Requires global admin role.
*/
export const setRegistrationStatus = <ThrowOnError extends boolean = false>(options?: Options<SetRegistrationStatusData, ThrowOnError>) => (options?.client ?? client).put<SetRegistrationStatusResponses, unknown, ThrowOnError>({
export const setRegistrationStatus = <ThrowOnError extends boolean = false>(options: Options<SetRegistrationStatusData, ThrowOnError>) => (options.client ?? client).put<SetRegistrationStatusResponses, unknown, ThrowOnError>({
url: '/api/v1/system/registration-status',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
...options.headers
}
});
/**
* Download the organization's Restic password for backup recovery. Requires organization owner or admin role and password re-authentication.
*/
export const downloadResticPassword = <ThrowOnError extends boolean = false>(options?: Options<DownloadResticPasswordData, ThrowOnError>) => (options?.client ?? client).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({
export const downloadResticPassword = <ThrowOnError extends boolean = false>(options: Options<DownloadResticPasswordData, ThrowOnError>) => (options.client ?? client).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({
url: '/api/v1/system/restic-password',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
...options.headers
}
});

File diff suppressed because it is too large Load diff

View file

@ -69,7 +69,7 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
return (
<Sidebar variant="inset" collapsible="icon" className="p-0">
<SidebarHeader className="bg-card-header border-b border-border/50 hidden md:flex h-16.25 flex-row items-center p-4">
<SidebarHeader className="bg-card-header border-b border-border/80 dark:border-border/50 hidden md:flex h-16.25 flex-row items-center p-4">
<Link to="/volumes" className="flex items-center gap-3 font-semibold pl-2">
<img src="/images/zerobyte.png" alt="Zerobyte Logo" className={cn("h-8 w-8 shrink-0 object-contain -ml-2")} />
<span
@ -115,7 +115,7 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
/>
<span
className={cn({
"text-white font-medium": isActive,
"text-foreground font-medium": isActive,
"text-muted-foreground": !isActive,
})}
>
@ -171,7 +171,7 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
/>
<span
className={cn({
"text-white font-medium": isActive,
"text-foreground font-medium": isActive,
"text-muted-foreground": !isActive,
})}
>
@ -194,7 +194,7 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
</>
)}
</SidebarContent>
<SidebarFooter className="p-4 border-r border-border/50">
<SidebarFooter className="p-4 border-r border-border/80 dark:border-border/50">
<OrganizationSwitcher />
<div className="flex items-center justify-between gap-2">
<HoverCard openDelay={200}>

View file

@ -59,7 +59,7 @@ export function CronInput({ value, onChange, error }: CronInputProps) {
{value && (
<div>
{isValid ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
<CheckCircle2 className="h-4 w-4 text-success" />
) : (
<AlertCircle className="h-4 w-4 text-destructive" />
)}

View file

@ -74,11 +74,7 @@ export const SnapshotTreeBrowser = ({
return await queryClient.ensureQueryData(
listSnapshotFilesOptions({
path: { shortId: repositoryId, snapshotId },
query: {
path,
offset: offset.toString(),
limit: pageSize.toString(),
},
query: { path, offset: offset, limit: pageSize },
}),
);
},
@ -86,11 +82,7 @@ export const SnapshotTreeBrowser = ({
void queryClient.prefetchQuery(
listSnapshotFilesOptions({
path: { shortId: repositoryId, snapshotId },
query: {
path,
offset: "0",
limit: pageSize.toString(),
},
query: { path, offset: 0, limit: pageSize },
}),
);
},

View file

@ -24,7 +24,7 @@ export const VolumeFileBrowser = ({ volumeId, enabled = true, ...uiProps }: Volu
return await queryClient.ensureQueryData(
listFilesOptions({
path: { shortId: volumeId },
query: { path, offset: offset?.toString() },
query: { path, offset: offset },
}),
);
},

View file

@ -14,7 +14,7 @@ export function GridBackground({ children, className, containerClassName }: Grid
className={cn(
"pointer-events-none absolute inset-0 w-full h-full",
"bg-[size:40px_40px]",
"bg-[linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
"bg-[linear-gradient(to_right,rgba(0,0,0,0.04)_1px,transparent_1px),linear-gradient(to_bottom,rgba(0,0,0,0.04)_1px,transparent_1px)]",
"dark:bg-[linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
"[mask-image:radial-gradient(ellipse_at_top,black_70%,transparent_100%)]",
)}

View file

@ -10,6 +10,7 @@ import { DevPanelListener } from "./dev-panel-listener";
import { Outlet, useNavigate } from "@tanstack/react-router";
import { AppBreadcrumb } from "./app-breadcrumb";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { ThemeToggle } from "./theme-toggle";
type Props = {
loaderData: AppContext;
@ -35,23 +36,24 @@ export function Layout({ loaderData }: Props) {
<SidebarProvider defaultOpen={loaderData.sidebarOpen}>
<AppSidebar isInstanceAdmin={loaderData.user?.role === "admin"} />
<div className="w-full relative flex flex-col min-h-screen md:h-screen md:overflow-hidden">
<header className="z-50 bg-card-header border-b border-border/50 shrink-0 h-16.25">
<header className="z-50 bg-card-header border-b border-border/80 dark:border-border/50 shrink-0 h-16.25">
<div className="flex items-center h-full justify-between px-2 sm:px-8 mx-auto container gap-4">
<div className="flex items-center gap-4 min-w-0">
<SidebarTrigger />
<AppBreadcrumb />
</div>
{loaderData.user && (
<div className="flex items-center bg-muted/30 border border-border/50 px-2 py-1 rounded-full shadow-sm">
<div className="flex items-center bg-card dark:bg-muted/30 border border-border/80 dark:border-border/50 px-2 py-1 rounded-full shadow-[0_2px_10px_-4px_rgba(0,0,0,0.05)] dark:shadow-sm">
<span className="text-sm text-muted-foreground hidden md:inline-flex pl-2 mr-5">
<span className="text-foreground">{loaderData.user.name}</span>
</span>
<ThemeToggle />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="rounded-full h-7 text-xs text-muted-foreground hover:text-white"
className="rounded-full h-7 text-xs text-muted-foreground hover:text-foreground"
onClick={handleLogout}
>
<LogOut className="w-4 h-4" />
@ -64,7 +66,7 @@ export function Layout({ loaderData }: Props) {
<Button
variant="ghost"
size="icon"
className="relative overflow-hidden hidden lg:inline-flex rounded-full h-7 w-7 text-muted-foreground hover:text-white"
className="relative overflow-hidden hidden lg:inline-flex rounded-full h-7 w-7 text-muted-foreground hover:text-foreground"
>
<a
href="https://github.com/nicotsx/zerobyte/issues/new"

View file

@ -15,7 +15,7 @@ export const OnOff = ({ isOn, toggle, enabledLabel, disabledLabel, disabled }: P
className={cn(
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors",
isOn
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-200"
? "border-success/30 bg-success/10 text-success"
: "border-muted bg-muted/40 text-muted-foreground dark:border-muted/60 dark:bg-muted/10",
)}
>

View file

@ -59,7 +59,7 @@ export function OrganizationSwitcher() {
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<div className="bg-black text-sidebar-primary-foreground flex aspect-square size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg">
<div className="bg-foreground text-sidebar-primary-foreground flex aspect-square size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg">
{activeOrganization?.logo ? (
<img
src={activeOrganization.logo}

View file

@ -133,6 +133,7 @@ export function RestoreForm({ repository, snapshotId, returnPath, basePath }: Re
body: {
snapshotId,
include: includePaths.length > 0 ? includePaths : undefined,
selectedItemKind: includePaths.length === 1 ? (selectedPathKind ?? undefined) : undefined,
excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
targetPath,
overwrite: overwriteMode,
@ -145,6 +146,7 @@ export function RestoreForm({ repository, snapshotId, returnPath, basePath }: Re
restoreLocation,
customTargetPath,
selectedPaths,
selectedPathKind,
overwriteMode,
restoreSnapshot,
]);

View file

@ -12,8 +12,8 @@ interface StatusDotProps {
export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
const statusMapping = {
success: {
color: "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]",
colorLight: "bg-emerald-400",
color: "bg-success shadow-[0_0_8px_rgba(16,185,129,0.5)]",
colorLight: "bg-success/60",
animated: animated ?? true,
},
neutral: {

View file

@ -0,0 +1,35 @@
import { createContext, useCallback, useContext, useState } from "react";
type Theme = "light" | "dark";
type ThemeContextValue = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
export const THEME_COOKIE_NAME = "theme";
export const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
const DEFAULT_THEME: Theme = "dark";
export function ThemeProvider({ children, initialTheme }: { children: React.ReactNode; initialTheme?: Theme }) {
const [theme, setThemeState] = useState<Theme>(initialTheme ?? DEFAULT_THEME);
const setTheme = useCallback((newTheme: Theme) => {
setThemeState(newTheme);
document.cookie = `${THEME_COOKIE_NAME}=${newTheme}; path=/; max-age=${THEME_COOKIE_MAX_AGE}`;
document.documentElement.classList.toggle("dark", newTheme === "dark");
document.documentElement.style.colorScheme = newTheme;
}, []);
return <ThemeContext value={{ theme, setTheme }}>{children}</ThemeContext>;
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}

View file

@ -0,0 +1,26 @@
import { Moon, Sun } from "lucide-react";
import { useTheme } from "./theme-provider";
import { Button } from "./ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative rounded-full h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
aria-label="Toggle theme"
>
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
</Button>
</TooltipTrigger>
<TooltipContent>Toggle theme</TooltipContent>
</Tooltip>
);
}

View file

@ -11,13 +11,12 @@ const buttonVariants = cva(
{
variants: {
variant: {
default:
"bg-transparent text-white hover:bg-[#3A3A3A]/80 border hover:border-white/40 dark:text-white dark:hover:bg-[#3A3A3A]/80",
default: "bg-transparent text-foreground hover:bg-muted border hover:border-border",
primary: "bg-strong-accent text-white hover:bg-strong-accent/90 focus-visible:ring-strong-accent/50",
destructive:
"border border-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/50 text-destructive hover:text-white",
"border border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground focus-visible:ring-destructive/50",
outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-transparent text-white hover:bg-[#3A3A3A]/80 border dark:text-white dark:hover:bg-[#3A3A3A]/80",
secondary: "bg-transparent text-foreground hover:bg-muted border",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},

View file

@ -7,7 +7,7 @@ function Card({ className, children, interactive, ...props }: React.ComponentPro
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground group relative flex flex-col gap-6 border border-border py-6 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.02)] transition-colors duration-300 ",
"bg-card text-card-foreground group relative flex flex-col gap-6 border border-border py-6 shadow-[0_8px_30px_-15px_rgba(0,0,0,0.04)] dark:shadow-[inset_0_0_0_1px_rgba(255,255,255,0.02)] transition-colors duration-300 ",
className,
)}
{...props}
@ -21,14 +21,14 @@ function Card({ className, children, interactive, ...props }: React.ComponentPro
},
)}
>
<span className="absolute -left-0.5 -top-0.5 h-0.5 w-4 bg-white/80" />
<span className="absolute -left-0.5 -top-0.5 h-4 w-0.5 bg-white/80" />
<span className="absolute -right-0.5 -top-0.5 h-0.5 w-4 bg-white/80" />
<span className="absolute -right-0.5 -top-0.5 h-4 w-0.5 bg-white/80" />
<span className="absolute -left-0.5 -bottom-0.5 h-0.5 w-4 bg-white/80" />
<span className="absolute -left-0.5 -bottom-0.5 h-4 w-0.5 bg-white/80" />
<span className="absolute -right-0.5 -bottom-0.5 h-0.5 w-4 bg-white/80" />
<span className="absolute -right-0.5 -bottom-0.5 h-4 w-0.5 bg-white/80" />
<span className="absolute -left-0.5 -top-0.5 h-0.5 w-4 bg-foreground" />
<span className="absolute -left-0.5 -top-0.5 h-4 w-0.5 bg-foreground" />
<span className="absolute -right-0.5 -top-0.5 h-0.5 w-4 bg-foreground" />
<span className="absolute -right-0.5 -top-0.5 h-4 w-0.5 bg-foreground" />
<span className="absolute -left-0.5 -bottom-0.5 h-0.5 w-4 bg-foreground" />
<span className="absolute -left-0.5 -bottom-0.5 h-4 w-0.5 bg-foreground" />
<span className="absolute -right-0.5 -bottom-0.5 h-0.5 w-4 bg-foreground" />
<span className="absolute -right-0.5 -bottom-0.5 h-4 w-0.5 bg-foreground" />
</span>
{children}
</div>

View file

@ -1,10 +1,13 @@
import type * as React from "react";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { useWebHaptics } from "web-haptics/react";
import { cn } from "~/client/lib/utils";
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
const trigger = useWebHaptics().trigger;
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
@ -12,6 +15,10 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
onCheckedChange={(checked) => {
void trigger([{ duration: 8 }], { intensity: 0.3 });
props.onCheckedChange?.(checked);
}}
{...props}
>
<CheckboxPrimitive.Indicator

View file

@ -8,8 +8,8 @@ interface CodeBlockProps {
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
return (
<div className="overflow-hidden rounded-sm bg-card-header ring-1 ring-white/10">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-2 text-xs">
<div className="overflow-hidden rounded-sm bg-card-header ring-1 ring-border">
<div className="flex items-center justify-between border-b border-border px-4 py-2 text-xs">
<div className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-rose-500" />
<span className="h-2.5 w-2.5 rounded-full bg-amber-500" />
@ -18,7 +18,7 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
</div>
</div>
<pre className="text-xs m-0 px-4 py-2 bg-card-header">
<code className="text-white/80 select-all">{code}</code>
<code className="text-foreground/80 select-all">{code}</code>
</pre>
</div>
);

View file

@ -1,4 +1,4 @@
import { useTheme } from "next-themes";
import { useTheme } from "~/client/components/theme-provider";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {

View file

@ -1,9 +1,12 @@
import * as SwitchPrimitive from "@radix-ui/react-switch";
import type * as React from "react";
import * as React from "react";
import { useWebHaptics } from "web-haptics/react";
import { cn } from "~/client/lib/utils";
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
const trigger = useWebHaptics().trigger;
return (
<SwitchPrimitive.Root
data-slot="switch"
@ -11,6 +14,10 @@ function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimi
"cursor-pointer peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
onCheckedChange={(checked) => {
void trigger([{ duration: 8 }], { intensity: 0.3 });
props.onCheckedChange?.(checked);
}}
{...props}
>
<SwitchPrimitive.Thumb

View file

@ -1,6 +1,7 @@
import * as TabsPrimitive from "@radix-ui/react-tabs";
import type * as React from "react";
import { useWebHaptics } from "web-haptics/react";
import { cn } from "~/client/lib/utils";
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
@ -17,7 +18,14 @@ function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimi
);
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
function TabsTrigger({ className, onClick, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
const { trigger } = useWebHaptics();
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (event) => {
void trigger([{ duration: 8 }], { intensity: 0.3 });
onClick?.(event);
};
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
@ -29,24 +37,25 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPr
// Padding: 20px horizontal (8px for bracket tick + 12px gap to text)
"px-5",
// Transparent orange background for active state
"data-[state=active]:bg-[#FF453A]/10",
"data-[state=active]:bg-strong-accent/10",
// Left bracket - vertical line
"before:absolute before:left-0 before:top-0 before:h-7 before:w-0.5 before:bg-[#5D6570] before:transition-colors data-[state=active]:before:bg-[#FF453A]",
"before:absolute before:left-0 before:top-0 before:h-7 before:w-0.5 before:bg-muted-foreground/60 before:transition-colors data-[state=active]:before:bg-strong-accent",
// Left bracket - top tick
"after:absolute after:left-0 after:-top-px after:w-2 after:h-0.5 after:bg-[#5D6570] after:transition-colors data-[state=active]:after:bg-[#FF453A]",
"after:absolute after:left-0 after:-top-px after:w-2 after:h-0.5 after:bg-muted-foreground/60 after:transition-colors data-[state=active]:after:bg-strong-accent",
className,
)}
onClick={handleClick}
{...props}
>
<span className="relative z-10">{props.children}</span>
{/* Left bracket - bottom tick */}
<span className="absolute left-0 bottom-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
<span className="absolute left-0 bottom-[-1px] h-0.5 w-2 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" />
{/* Right bracket - top tick */}
<span className="absolute right-0 top-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
<span className="absolute right-0 top-[-1px] h-0.5 w-2 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" />
{/* Right bracket - vertical line */}
<span className="absolute right-0 top-0 h-7 w-0.5 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
<span className="absolute right-0 top-0 h-7 w-0.5 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" />
{/* Right bracket - bottom tick */}
<span className="absolute right-0 bottom-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
<span className="absolute right-0 bottom-[-1px] h-0.5 w-2 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" />
</TabsPrimitive.Trigger>
);
}

View file

@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render, screen } from "@testing-library/react";
import { useEffect, useState } from "react";
import { useServerEvents } from "../use-server-events";
class MockEventSource {
static instances: MockEventSource[] = [];
public onerror: ((event: Event) => void) | null = null;
public close = mock(() => {});
private listeners = new Map<string, Set<(event: Event) => void>>();
constructor(public url: string) {
MockEventSource.instances.push(this);
}
addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
const listeners = this.listeners.get(type) ?? new Set<(event: Event) => void>();
const callback = typeof listener === "function" ? listener : (event: Event) => listener.handleEvent(event);
listeners.add(callback);
this.listeners.set(type, listeners);
}
emit(type: string, data: unknown) {
const event = new MessageEvent(type, {
data: JSON.stringify(data),
});
for (const listener of this.listeners.get(type) ?? []) {
listener(event);
}
}
static reset() {
MockEventSource.instances = [];
}
}
const originalEventSource = globalThis.EventSource;
const originalConsoleInfo = console.info;
const originalConsoleError = console.error;
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: Infinity,
},
mutations: {
gcTime: Infinity,
},
},
});
const ConnectionConsumer = () => {
useServerEvents();
return null;
};
const BackupCompletedListener = ({ scheduleId }: { scheduleId: string }) => {
const { addEventListener } = useServerEvents();
const [status, setStatus] = useState("pending");
useEffect(() => {
const abortController = new AbortController();
addEventListener(
"backup:completed",
(event) => {
if (event.scheduleId === scheduleId) {
setStatus(event.status);
}
},
{ signal: abortController.signal },
);
return () => abortController.abort();
}, [addEventListener, scheduleId]);
return <div>{status}</div>;
};
describe("useServerEvents", () => {
beforeEach(() => {
MockEventSource.reset();
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
console.info = mock(() => {});
console.error = mock(() => {});
});
afterEach(() => {
cleanup();
globalThis.EventSource = originalEventSource;
console.info = originalConsoleInfo;
console.error = originalConsoleError;
MockEventSource.reset();
});
test("shares one EventSource across consumers and invalidates queries once on backup completion", async () => {
const queryClient = createTestQueryClient();
const invalidateQueries = mock(async () => undefined);
const refetchQueries = mock(async () => undefined);
queryClient.invalidateQueries = invalidateQueries as typeof queryClient.invalidateQueries;
queryClient.refetchQueries = refetchQueries as typeof queryClient.refetchQueries;
render(
<QueryClientProvider client={queryClient}>
<ConnectionConsumer />
<BackupCompletedListener scheduleId="0b9c940b" />
</QueryClientProvider>,
);
expect(MockEventSource.instances).toHaveLength(1);
MockEventSource.instances[0]?.emit("backup:completed", {
organizationId: "default-org",
scheduleId: "0b9c940b",
volumeName: "synology",
repositoryName: "swiss-backup",
status: "success",
});
expect(await screen.findByText("success")).toBeTruthy();
expect(invalidateQueries).toHaveBeenCalledTimes(1);
expect(refetchQueries).not.toHaveBeenCalled();
cleanup();
expect(MockEventSource.instances[0]?.close).toHaveBeenCalledTimes(1);
});
});

View file

@ -2,18 +2,20 @@ import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
function subscribe(callback: () => void) {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
mql.addEventListener("change", callback);
return () => mql.removeEventListener("change", callback);
}
function getSnapshot() {
return window.innerWidth < MOBILE_BREAKPOINT;
}
function getServerSnapshot() {
return false;
}
export function useIsMobile() {
return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

View file

@ -1,4 +1,4 @@
import { useQueryClient } from "@tanstack/react-query";
import { type QueryClient, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef } from "react";
import { serverEventNames, type ServerEventPayloadMap } from "~/schemas/server-events";
@ -16,6 +16,13 @@ type EventHandlerMap = {
[K in ServerEventType]?: EventHandlerSet<K>;
};
type SharedServerEventsState = {
eventSource: EventSource | null;
handlers: EventHandlerMap;
queryClient: QueryClient | null;
subscribers: number;
};
const invalidatingEvents = new Set<ServerEventType>([
"backup:completed",
"volume:updated",
@ -31,103 +38,151 @@ export type RestoreProgressEvent = ServerEventsPayloadMap["restore:progress"];
export type RestoreCompletedEvent = ServerEventsPayloadMap["restore:completed"];
export type BackupProgressEvent = ServerEventsPayloadMap["backup:progress"];
const sharedState: SharedServerEventsState = {
eventSource: null,
handlers: {},
queryClient: null,
subscribers: 0,
};
const parseEventData = <T extends ServerEventType>(event: Event): ServerEventsPayloadMap[T] =>
JSON.parse((event as MessageEvent<string>).data) as ServerEventsPayloadMap[T];
const isAbortError = (error: unknown): error is Error => error instanceof Error && error.name === "AbortError";
const emit = <T extends ServerEventType>(eventName: T, data: ServerEventsPayloadMap[T]) => {
const handlers = sharedState.handlers[eventName] as EventHandlerSet<T> | undefined;
handlers?.forEach((handler) => {
handler(data);
});
};
const refreshQueriesForEvent = (eventName: ServerEventType) => {
if (!invalidatingEvents.has(eventName) || !sharedState.queryClient) {
return;
}
void sharedState.queryClient.invalidateQueries().catch((error) => {
if (!isAbortError(error)) {
console.error(`[SSE] Failed to refresh queries after ${eventName}:`, error);
}
});
};
const connectEventSource = (queryClient: QueryClient) => {
sharedState.queryClient = queryClient;
if (sharedState.eventSource) {
return;
}
const eventSource = new EventSource("/api/v1/events");
sharedState.eventSource = eventSource;
eventSource.addEventListener("connected", (event) => {
const data = parseEventData<"connected">(event);
console.info("[SSE] Connected to server events");
emit("connected", data);
});
eventSource.addEventListener("heartbeat", (event) => {
emit("heartbeat", parseEventData<"heartbeat">(event));
});
for (const eventName of serverEventNames) {
eventSource.addEventListener(eventName, (event) => {
const data = parseEventData<typeof eventName>(event);
console.info(`[SSE] ${eventName}:`, data);
refreshQueriesForEvent(eventName);
if (eventName === "volume:status_changed") {
const statusData = data as ServerEventsPayloadMap["volume:status_changed"];
emit("volume:status_changed", statusData);
emit("volume:updated", statusData);
return;
}
emit(eventName, data);
});
}
eventSource.onerror = (error) => {
console.error("[SSE] Connection error:", error);
};
};
const disconnectEventSource = () => {
if (!sharedState.eventSource) {
return;
}
console.info("[SSE] Disconnecting from server events");
sharedState.eventSource.close();
sharedState.eventSource = null;
sharedState.queryClient = null;
sharedState.handlers = {};
};
const addSharedEventListener = <T extends ServerEventType>(
eventName: T,
handler: EventHandler<T>,
options?: { signal?: AbortSignal },
) => {
if (options?.signal?.aborted) {
return () => {};
}
const existingHandlers = sharedState.handlers[eventName] as EventHandlerSet<T> | undefined;
const eventHandlers = existingHandlers ?? new Set<EventHandler<T>>();
eventHandlers.add(handler);
sharedState.handlers[eventName] = eventHandlers as EventHandlerMap[T];
const unsubscribe = () => {
const handlers = sharedState.handlers[eventName] as EventHandlerSet<T> | undefined;
handlers?.delete(handler);
if (handlers && handlers.size === 0) {
delete sharedState.handlers[eventName];
}
if (options?.signal) {
options.signal.removeEventListener("abort", unsubscribe);
}
};
if (options?.signal) {
options.signal.addEventListener("abort", unsubscribe, { once: true });
}
return unsubscribe;
};
/**
* Hook to listen to Server-Sent Events (SSE) from the backend
* Automatically handles cache invalidation for backup and volume events
*/
export function useServerEvents() {
const queryClient = useQueryClient();
const eventSourceRef = useRef<EventSource | null>(null);
const handlersRef = useRef<EventHandlerMap>({});
const emit = useCallback(<T extends ServerEventType>(eventName: T, data: ServerEventsPayloadMap[T]) => {
const handlers = handlersRef.current[eventName] as EventHandlerSet<T> | undefined;
handlers?.forEach((handler) => {
handler(data);
});
}, []);
const addEventListener = useCallback(addSharedEventListener, []);
const hasMountedRef = useRef(false);
useEffect(() => {
const eventSource = new EventSource("/api/v1/events");
eventSourceRef.current = eventSource;
eventSource.addEventListener("connected", (event) => {
const data = parseEventData<"connected">(event);
console.info("[SSE] Connected to server events");
emit("connected", data);
});
eventSource.addEventListener("heartbeat", (event) => {
emit("heartbeat", parseEventData<"heartbeat">(event));
});
for (const eventName of serverEventNames) {
eventSource.addEventListener(eventName, (event) => {
const data = parseEventData<typeof eventName>(event);
console.info(`[SSE] ${eventName}:`, data);
if (invalidatingEvents.has(eventName)) {
void queryClient.invalidateQueries();
}
if (eventName === "backup:completed") {
void queryClient.refetchQueries();
}
if (eventName === "volume:status_changed") {
const statusData = data as ServerEventsPayloadMap["volume:status_changed"];
emit("volume:status_changed", statusData);
emit("volume:updated", statusData);
return;
}
emit(eventName, data);
});
connectEventSource(queryClient);
if (!hasMountedRef.current) {
sharedState.subscribers += 1;
hasMountedRef.current = true;
}
eventSource.onerror = (error) => {
console.error("[SSE] Connection error:", error);
};
return () => {
console.info("[SSE] Disconnecting from server events");
eventSource.close();
eventSourceRef.current = null;
if (!hasMountedRef.current) {
return;
}
hasMountedRef.current = false;
sharedState.subscribers = Math.max(0, sharedState.subscribers - 1);
if (sharedState.subscribers === 0) {
disconnectEventSource();
}
};
}, [emit, queryClient]);
const addEventListener = useCallback(
<T extends ServerEventType>(eventName: T, handler: EventHandler<T>, options?: { signal?: AbortSignal }) => {
if (options?.signal?.aborted) {
return () => {};
}
const existingHandlers = handlersRef.current[eventName] as EventHandlerSet<T> | undefined;
const eventHandlers = existingHandlers ?? new Set<EventHandler<T>>();
eventHandlers.add(handler);
handlersRef.current[eventName] = eventHandlers as EventHandlerMap[T];
const unsubscribe = () => {
const handlers = handlersRef.current[eventName] as EventHandlerSet<T> | undefined;
handlers?.delete(handler);
if (handlers && handlers.size === 0) {
delete handlersRef.current[eventName];
}
if (options?.signal) {
options.signal.removeEventListener("abort", unsubscribe);
}
};
if (options?.signal) {
options.signal.addEventListener("abort", unsubscribe, { once: true });
}
return unsubscribe;
},
[],
);
}, [queryClient]);
return { addEventListener };
}

View file

@ -1,45 +0,0 @@
export type LoginErrorCode =
| "ACCOUNT_LINK_REQUIRED"
| "EMAIL_NOT_VERIFIED"
| "INVITE_REQUIRED"
| "BANNED_USER"
| "SSO_LOGIN_FAILED";
const VALID_ERROR_CODES: Set<LoginErrorCode> = new Set([
"ACCOUNT_LINK_REQUIRED",
"EMAIL_NOT_VERIFIED",
"INVITE_REQUIRED",
"BANNED_USER",
"SSO_LOGIN_FAILED",
]);
export function decodeLoginError(error?: string): LoginErrorCode | null {
if (!error) {
return null;
}
const code = decodeURIComponent(error);
if (VALID_ERROR_CODES.has(code as LoginErrorCode)) {
return code as LoginErrorCode;
}
return null;
}
export function getLoginErrorDescription(errorCode: LoginErrorCode | null): string | null {
switch (errorCode) {
case "ACCOUNT_LINK_REQUIRED":
return "Your account exists but is not linked to this SSO provider. Sign in with username/password first, then enable auto linking in your provider settings or contact your administrator.";
case "EMAIL_NOT_VERIFIED":
return "Your identity provider did not mark your email as verified.";
case "INVITE_REQUIRED":
return "Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
case "BANNED_USER":
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.";
default:
return null;
}
}

View file

@ -0,0 +1,20 @@
import { LOGIN_ERROR_CODES, type LoginErrorCode } from "~/lib/sso-errors";
export type { LoginErrorCode } from "~/lib/sso-errors";
export { getLoginErrorDescription } from "~/lib/sso-errors";
const VALID_ERROR_CODES = new Set<LoginErrorCode>(LOGIN_ERROR_CODES);
export function decodeLoginError(error?: string): LoginErrorCode | null {
if (!error) {
return null;
}
const code = decodeURIComponent(error);
if (VALID_ERROR_CODES.has(code as LoginErrorCode)) {
return code as LoginErrorCode;
}
return null;
}

View file

@ -69,7 +69,7 @@ describe("LoginPage", () => {
expect(
await screen.findByText(
"Your account exists but is not linked to this SSO provider. Sign in with username/password first, then enable auto linking in your provider settings or contact your administrator.",
"SSO sign-in was blocked because this email already belongs to another user in this instance. Contact your administrator to resolve the account conflict.",
),
).toBeTruthy();
});

View file

@ -1,5 +1,4 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { type } from "arktype";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@ -10,20 +9,23 @@ import { Input } from "~/client/components/ui/input";
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/client/components/ui/input-otp";
import { Label } from "~/client/components/ui/label";
import { authClient } from "~/client/lib/auth-client";
import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/auth-errors";
import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/sso-errors";
import { ResetPasswordDialog } from "../components/reset-password-dialog";
import { useNavigate } from "@tanstack/react-router";
import { normalizeUsername } from "~/lib/username";
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { getPublicSsoProvidersOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { cn } from "~/client/lib/utils";
import { SsoLoginSection } from "~/client/modules/sso/components/sso-login-section";
import { z } from "zod";
const loginSchema = type({
username: "2<=string<=50",
password: "string>=1",
const loginSchema = z.object({
username: z
.string()
.min(2, "Username must be at least 2 characters")
.max(50, "Username must be at most 50 characters"),
password: z.string().min(1, "Password is required"),
});
type LoginFormValues = typeof loginSchema.inferIn;
type LoginFormValues = z.input<typeof loginSchema>;
type LoginPageProps = {
error?: string;
@ -38,14 +40,10 @@ export function LoginPage({ error }: LoginPageProps = {}) {
const [isVerifying2FA, setIsVerifying2FA] = useState(false);
const [trustDevice, setTrustDevice] = useState(false);
const errorCode = decodeLoginError(error);
const errorDescription = getLoginErrorDescription(errorCode);
const { data: ssoProviders } = useSuspenseQuery({
...getPublicSsoProvidersOptions(),
});
const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null;
const form = useForm<LoginFormValues>({
resolver: arktypeResolver(loginSchema),
resolver: zodResolver(loginSchema),
defaultValues: {
username: "",
password: "",
@ -129,27 +127,6 @@ export function LoginPage({ error }: LoginPageProps = {}) {
form.reset();
};
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) => {
console.error(error);
toast.error("SSO Login failed", { description: error.message });
},
});
if (requires2FA) {
return (
<AuthLayout title="Two-Factor Authentication" description="Enter the 6-digit code from your authenticator app">
@ -265,26 +242,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
</form>
</Form>
{ssoProviders.providers.length > 0 && (
<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>
)}
<SsoLoginSection />
<ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} />
</AuthLayout>

View file

@ -1,5 +1,4 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { type } from "arktype";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
@ -19,24 +18,28 @@ import { authClient } from "~/client/lib/auth-client";
import { useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import { normalizeUsername } from "~/lib/username";
import { z } from "zod";
export const clientMiddleware = [authMiddleware];
const onboardingSchema = type({
username: type("2<=string<=30").pipe(normalizeUsername),
email: type("string.email").pipe((str) => str.trim().toLowerCase()),
password: "string>=8",
confirmPassword: "string>=1",
const onboardingSchema = z.object({
username: z.string().min(2).max(30).transform(normalizeUsername),
email: z
.string()
.email()
.transform((str) => str.trim().toLowerCase()),
password: z.string().min(8),
confirmPassword: z.string().min(1),
});
type OnboardingFormValues = typeof onboardingSchema.inferIn;
type OnboardingFormValues = z.input<typeof onboardingSchema>;
export function OnboardingPage() {
const navigate = useNavigate();
const [submitting, setSubmitting] = useState(false);
const form = useForm<OnboardingFormValues>({
resolver: arktypeResolver(onboardingSchema),
resolver: zodResolver(onboardingSchema),
defaultValues: {
username: "",
password: "",

View file

@ -32,21 +32,21 @@ export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
<CardContent className="flex-1 space-y-4">
<div className="space-y-3">
<div className="flex items-center text-sm gap-2">
<span className="text-zinc-500 shrink-0">Schedule</span>
<div className="flex-1 border-b border-dashed border-white/10" />
<code className="text-xs text-zinc-100 font-mono bg-muted px-2 py-1 rounded shrink-0">
<span className="text-muted-foreground shrink-0">Schedule</span>
<div className="flex-1 border-b border-dashed border-border/80 dark:border-border/50" />
<code className="text-xs text-foreground font-mono bg-muted px-2 py-1 rounded shrink-0">
{schedule.cronExpression}
</code>
</div>
<div className="flex items-center text-sm gap-2">
<span className="text-zinc-500 shrink-0">Last backup</span>
<div className="flex-1 border-b border-dashed border-white/10" />
<span className="text-zinc-100 font-mono text-sm shrink-0">{formatTimeAgo(schedule.lastBackupAt)}</span>
<span className="text-muted-foreground shrink-0">Last backup</span>
<div className="flex-1 border-b border-dashed border-border/80 dark:border-border/50" />
<span className="text-foreground font-mono text-sm shrink-0">{formatTimeAgo(schedule.lastBackupAt)}</span>
</div>
<div className="flex items-center text-sm gap-2">
<span className="text-zinc-500 shrink-0">Next backup</span>
<div className="flex-1 border-b border-dashed border-white/10" />
<span className="text-zinc-100 font-mono text-sm shrink-0">
<span className="text-muted-foreground shrink-0">Next backup</span>
<div className="flex-1 border-b border-dashed border-border/80 dark:border-border/50" />
<span className="text-foreground font-mono text-sm shrink-0">
{formatShortDateTime(schedule.nextBackupAt)}
</span>
</div>

View file

@ -46,6 +46,7 @@ export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props)
const currentFile = progress?.current_files?.[0] || "";
const fileName = currentFile.split("/").pop() || currentFile;
const speed = progress ? formatBytes(progress.bytes_done / progress.seconds_elapsed) : null;
const eta = progress?.seconds_remaining ? formatDuration(progress.seconds_remaining) : null;
return (
<Card className="p-4">
@ -96,6 +97,10 @@ export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props)
{progress ? (progress.seconds_elapsed > 0 ? `${speed?.text} ${speed?.unit}/s` : "Calculating...") : "—"}
</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">ETA</p>
<p className="font-medium">{progress ? (eta ?? "Calculating...") : "—"}</p>
</div>
</div>
<div className="pt-2 border-t border-border">

View file

@ -0,0 +1,35 @@
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 { InternalFormValues } from "./types";
type AdvancedSectionProps = {
form: UseFormReturn<InternalFormValues>;
};
export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
return (
<FormField
control={form.control}
name="customResticParamsText"
render={({ field }) => (
<FormItem>
<FormLabel>Custom restic parameters</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="--exclude-larger-than 500M&#10;--no-scan&#10;--read-concurrency 8"
className="font-mono text-sm min-h-24"
/>
</FormControl>
<FormDescription>
Advanced: enter one restic flag per line (e.g.{" "}
<code className="bg-muted px-1 rounded">--exclude-larger-than 500M</code>). Only the supported flag list is
accepted.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
};

View file

@ -1,17 +1,19 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { zodResolver } from "@hookform/resolvers/zod";
import { useCallback, useState } from "react";
import { useForm } from "react-hook-form";
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
import { Form } from "~/client/components/ui/form";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/client/components/ui/collapsible";
import type { BackupSchedule, Volume } from "~/client/lib/types";
import { AdvancedSection } from "./advanced-section";
import { BasicInfoSection } from "./basic-info-section";
import { ExcludeSection } from "./exclude-section";
import { FrequencySection } from "./frequency-section";
import { PathsSection } from "./paths-section";
import { RetentionSection } from "./retention-section";
import { SummarySection } from "./summary-section";
import { cleanSchema, type BackupScheduleFormValues, type InternalFormValues } from "./types";
import { internalFormSchema, type BackupScheduleFormValues, type InternalFormValues } from "./types";
import { backupScheduleToFormValues } from "./utils";
export type { BackupScheduleFormValues };
@ -27,7 +29,7 @@ type Props = {
export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Props) => {
const form = useForm<InternalFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof import("./types").internalFormSchema),
resolver: zodResolver(internalFormSchema),
defaultValues: backupScheduleToFormValues(initialValues),
});
@ -39,6 +41,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
excludePatternsText,
excludeIfPresentText,
includePatternsText,
customResticParamsText,
includePatterns: fileBrowserPatterns,
cronExpression,
...rest
@ -65,12 +68,20 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
: [];
const includePatterns = [...(fileBrowserPatterns || []), ...textPatterns];
const customResticParams = customResticParamsText
? customResticParamsText
.split("\n")
.map((p) => p.trim())
.filter(Boolean)
: [];
onSubmit({
...rest,
cronExpression,
includePatterns: includePatterns.length > 0 ? includePatterns : [],
excludePatterns,
excludeIfPresent,
customResticParams,
});
},
[onSubmit],
@ -164,6 +175,17 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
<RetentionSection form={form} />
</CardContent>
</Card>
<Card className="min-w-0 @container">
<CardContent>
<Collapsible>
<CollapsibleTrigger>Advanced</CollapsibleTrigger>
<CollapsibleContent className="pb-4 space-y-4">
<AdvancedSection form={form} />
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
</div>
<div className="xl:sticky xl:top-6 xl:self-start">
<SummarySection volume={volume} frequency={frequency} formValues={formValues} />

View file

@ -22,7 +22,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
type="number"
min={0}
placeholder="Optional"
onChange={(v) => field.onChange(Number(v.target.value))}
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
/>
</FormControl>
<FormDescription>Keep the N most recent snapshots.</FormDescription>
@ -43,7 +43,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
min={0}
placeholder="Optional"
{...field}
onChange={(v) => field.onChange(Number(v.target.value))}
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
/>
</FormControl>
<FormDescription>Keep the last N hourly snapshots.</FormDescription>
@ -64,7 +64,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
min={0}
placeholder="e.g., 7"
{...field}
onChange={(v) => field.onChange(Number(v.target.value))}
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
/>
</FormControl>
<FormDescription>Keep the last N daily snapshots.</FormDescription>
@ -85,7 +85,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
min={0}
placeholder="e.g., 4"
{...field}
onChange={(v) => field.onChange(Number(v.target.value))}
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
/>
</FormControl>
<FormDescription>Keep the last N weekly snapshots.</FormDescription>
@ -106,7 +106,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
min={0}
placeholder="e.g., 6"
{...field}
onChange={(v) => field.onChange(Number(v.target.value))}
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
/>
</FormControl>
<FormDescription>Keep the last N monthly snapshots.</FormDescription>
@ -127,7 +127,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
min={0}
placeholder="Optional"
{...field}
onChange={(v) => field.onChange(Number(v.target.value))}
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
/>
</FormControl>
<FormDescription>Keep the last N yearly snapshots.</FormDescription>

View file

@ -1,29 +1,27 @@
import { type } from "arktype";
import { deepClean } from "~/utils/object";
import { z } from "zod";
export const internalFormSchema = type({
name: "1 <= string <= 128",
repositoryId: "string",
excludePatternsText: "string?",
excludeIfPresentText: "string?",
includePatternsText: "string?",
includePatterns: "string[]?",
frequency: "string",
dailyTime: "string?",
weeklyDay: "string?",
monthlyDays: "string[]?",
cronExpression: "string?",
keepLast: "number?",
keepHourly: "number?",
keepDaily: "number?",
keepWeekly: "number?",
keepMonthly: "number?",
keepYearly: "number?",
oneFileSystem: "boolean?",
export const internalFormSchema = z.object({
name: z.string().min(1).max(128),
repositoryId: z.string(),
excludePatternsText: z.string().optional(),
excludeIfPresentText: z.string().optional(),
includePatternsText: z.string().optional(),
includePatterns: z.array(z.string()).optional(),
frequency: z.string(),
dailyTime: z.string().optional(),
weeklyDay: z.string().optional(),
monthlyDays: z.array(z.string()).optional(),
cronExpression: z.string().optional(),
keepLast: z.number().optional(),
keepHourly: z.number().optional(),
keepDaily: z.number().optional(),
keepWeekly: z.number().optional(),
keepMonthly: z.number().optional(),
keepYearly: z.number().optional(),
oneFileSystem: z.boolean().optional(),
customResticParamsText: z.string().optional(),
});
export const cleanSchema = type.pipe((d) => internalFormSchema(deepClean(d)));
export const weeklyDays = [
{ label: "Monday", value: "1" },
{ label: "Tuesday", value: "2" },
@ -34,12 +32,13 @@ export const weeklyDays = [
{ label: "Sunday", value: "0" },
];
export type InternalFormValues = typeof internalFormSchema.infer;
export type InternalFormValues = z.infer<typeof internalFormSchema>;
export type BackupScheduleFormValues = Omit<
InternalFormValues,
"excludePatternsText" | "excludeIfPresentText" | "includePatternsText"
"excludePatternsText" | "excludeIfPresentText" | "includePatternsText" | "customResticParamsText"
> & {
excludePatterns?: string[];
excludeIfPresent?: string[];
customResticParams?: string[];
};

View file

@ -22,6 +22,7 @@ export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalF
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined,
oneFileSystem: schedule.oneFileSystem ?? false,
customResticParamsText: schedule.customResticParams?.join("\n") ?? "",
...cronValues,
...schedule.retentionPolicy,
};

View file

@ -1,3 +1,4 @@
import { useRef } from "react";
import type { ListSnapshotsResponse } from "~/client/api-client";
import { ByteSize } from "~/client/components/bytes-size";
import { Card, CardContent } from "~/client/components/ui/card";
@ -15,6 +16,7 @@ interface Props {
export const SnapshotTimeline = (props: Props) => {
const { snapshots, snapshotId, loading, onSnapshotSelect, error } = props;
const selectedRef = useRef<HTMLButtonElement>(null);
if (error) {
return (
@ -51,13 +53,14 @@ export const SnapshotTimeline = (props: Props) => {
<div className="w-full bg-card">
<div className="relative flex items-center">
<div className="flex-1 overflow-hidden">
<div className="flex gap-4 overflow-x-auto pb-2 *:first:ml-2 *:last:mr-2">
<div className="snapshot-scrollable flex gap-4 overflow-x-auto pb-2 *:first:ml-2 *:last:mr-2">
{snapshots.map((snapshot) => {
const date = new Date(snapshot.time);
const isSelected = snapshotId === snapshot.short_id;
return (
<button
ref={isSelected ? selectedRef : undefined}
type="button"
key={snapshot.short_id}
onClick={() => onSnapshotSelect(snapshot.short_id)}

View file

@ -65,7 +65,9 @@ export function ScheduleDetailsPage(props: Props) {
const searchParams = useSearch({ from: "/(dashboard)/backups/$backupId/" });
const [isEditMode, setIsEditMode] = useState(false);
const formId = useId();
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string | undefined>(initialSnapshotId);
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string | undefined>(
initialSnapshotId ?? loaderData.snapshots?.at(-1)?.short_id,
);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
@ -183,6 +185,7 @@ export function ScheduleDetailsPage(props: Props) {
excludePatterns: formValues.excludePatterns,
excludeIfPresent: formValues.excludeIfPresent,
oneFileSystem: formValues.oneFileSystem,
customResticParams: formValues.customResticParams,
},
});
};
@ -191,6 +194,7 @@ export function ScheduleDetailsPage(props: Props) {
updateSchedule.mutate({
path: { shortId: schedule.shortId },
body: {
name: schedule.name,
repositoryId: schedule.repositoryId,
enabled,
cronExpression: schedule.cronExpression,
@ -199,6 +203,7 @@ export function ScheduleDetailsPage(props: Props) {
excludePatterns: schedule.excludePatterns || [],
excludeIfPresent: schedule.excludeIfPresent || [],
oneFileSystem: schedule.oneFileSystem,
customResticParams: schedule.customResticParams || [],
},
});
};

View file

@ -110,9 +110,9 @@ export function BackupsPage() {
);
})}
<Link to="/backups/create" className="h-full">
<div className="group flex flex-col items-center justify-center h-full min-h-50 border border-dashed border-border/80 bg-card hover:bg-card/50 transition-colors cursor-pointer rounded-s shadow-sm hover:border-border">
<div className="group flex flex-col items-center justify-center h-full min-h-50 border-2 border-dashed border-border/60 bg-muted/20 dark:bg-card hover:bg-muted/40 dark:hover:bg-card/50 transition-all cursor-pointer rounded-xl hover:shadow-[0_8px_30px_-15px_rgba(0,0,0,0.05)] dark:hover:shadow-sm hover:border-border hover:-translate-y-[1px] active:scale-[0.98] duration-300">
<div className="flex flex-col items-center justify-center gap-3">
<div className="p-3 rounded-full bg-muted/20 group-hover:bg-muted/50 transition-all group-hover:scale-110 duration-300">
<div className="p-3 rounded-full bg-background/50 dark:bg-muted/20 group-hover:bg-background dark:group-hover:bg-muted/50 transition-all group-hover:scale-110 duration-300 shadow-xs dark:shadow-none">
<Plus className="h-6 w-6 text-muted-foreground group-hover:text-foreground transition-colors" />
</div>
<span className="text-sm font-medium text-muted-foreground group-hover:text-foreground transition-colors">

View file

@ -73,6 +73,7 @@ export function CreateBackupPage() {
excludePatterns: formValues.excludePatterns,
excludeIfPresent: formValues.excludeIfPresent,
oneFileSystem: formValues.oneFileSystem,
customResticParams: formValues.customResticParams,
},
});
};

View file

@ -1,8 +1,7 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { type } from "arktype";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { cn } from "~/client/lib/utils";
import { deepClean } from "~/utils/object";
import {
Form,
FormControl,
@ -14,7 +13,17 @@ import {
} from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { notificationConfigSchemaBase } from "~/schemas/notifications";
import {
customNotificationConfigSchema,
discordNotificationConfigSchema,
emailNotificationConfigSchema,
genericNotificationConfigSchema,
gotifyNotificationConfigSchema,
ntfyNotificationConfigSchema,
pushoverNotificationConfigSchema,
slackNotificationConfigSchema,
telegramNotificationConfigSchema,
} from "~/schemas/notifications";
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
import {
CustomForm,
@ -28,12 +37,21 @@ import {
TelegramForm,
} from "./notification-forms";
export const formSchema = type({
name: "2<=string<=32",
}).and(notificationConfigSchemaBase);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
const baseFields = { name: z.string().min(2).max(32) };
export type NotificationFormValues = typeof formSchema.inferIn;
export const formSchema = z.discriminatedUnion("type", [
emailNotificationConfigSchema.extend(baseFields),
slackNotificationConfigSchema.extend(baseFields),
discordNotificationConfigSchema.extend(baseFields),
gotifyNotificationConfigSchema.extend(baseFields),
ntfyNotificationConfigSchema.extend(baseFields),
pushoverNotificationConfigSchema.extend(baseFields),
telegramNotificationConfigSchema.extend(baseFields),
genericNotificationConfigSchema.extend(baseFields),
customNotificationConfigSchema.extend(baseFields),
]);
export type NotificationFormValues = z.input<typeof formSchema>;
type Props = {
onSubmit: (values: NotificationFormValues) => void;
@ -106,7 +124,7 @@ const defaultValuesForType = {
export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValues, formId, className }: Props) => {
const form = useForm<NotificationFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
resolver: zodResolver(formSchema, undefined, { raw: true }),
defaultValues: initialValues || {
name: "",
},

View file

@ -97,7 +97,7 @@ export function NotificationDetailsPage({ notificationId }: { notificationId: st
<div className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
<span
className={cn("inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", {
"bg-green-500/10 text-emerald-500": data.enabled,
"bg-success/10 text-success": data.enabled,
"bg-red-500/10 text-red-500": !data.enabled,
})}
>

View file

@ -1,9 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { Archive } from "lucide-react";
import { getRepositoryStatsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { ByteSize } from "~/client/components/bytes-size";
import { Card, CardContent, CardTitle } from "~/client/components/ui/card";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Archive, RefreshCw } from "lucide-react";
import {
getRepositoryStatsOptions,
refreshRepositoryStatsMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import type { GetRepositoryStatsResponse } from "~/client/api-client/types.gen";
import { ByteSize } from "~/client/components/bytes-size";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardTitle } from "~/client/components/ui/card";
import { parseError } from "~/client/lib/errors";
import { cn } from "~/client/lib/utils";
import { toast } from "sonner";
type Props = {
repositoryShortId: string;
@ -19,6 +26,18 @@ const toSafeNumber = (value: number | undefined) => {
};
export function CompressionStatsChart({ repositoryShortId, initialStats }: Props) {
const refreshStats = useMutation({
...refreshRepositoryStatsMutation(),
onSuccess: () => {
toast.success("Repository stats refreshed");
},
onError: (mutationError) => {
toast.error("Failed to refresh repository stats", {
description: parseError(mutationError)?.message,
});
},
});
const {
data: stats,
isPending,
@ -43,89 +62,84 @@ export function CompressionStatsChart({ repositoryShortId, initialStats }: Props
const hasStats = !!stats && (storedSize > 0 || uncompressedSize > 0 || snapshotsCount > 0);
if (isPending) {
return (
<Card className="p-6">
<p className="text-sm text-muted-foreground">Loading compression statistics...</p>
</Card>
);
}
if (error) {
return (
<Card className="p-6 border-red-500/20 bg-red-500/5">
<p className="text-sm font-medium text-red-500">Failed to load compression statistics</p>
<p className="mt-2 text-sm text-red-500/80 wrap-break-word">{error.message}</p>
</Card>
);
}
if (!hasStats) {
return (
<Card className="p-6">
<p className="text-sm text-muted-foreground">
No compression statistics available yet. Run a backup to populate repository stats.
</p>
</Card>
);
}
return (
<Card className="flex flex-col px-6 py-6">
<div className="pb-4">
<CardTitle className="flex items-center gap-2">
<Archive className="h-5 w-5 text-muted-foreground" />
Compression Statistics
<div className="flex items-start justify-between gap-3 pb-4">
<CardTitle className="flex items-center gap-2 w-full">
<div className="flex items-center gap-2 flex-1">
<Archive className="h-5 w-5 text-muted-foreground" />
Compression Statistics
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => refreshStats.mutate({ path: { shortId: repositoryShortId } })}
disabled={refreshStats.isPending}
title="Refresh statistics"
>
<RefreshCw className={refreshStats.isPending ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
</Button>
</CardTitle>
</div>
<div>
<CardContent className="grid grid-cols-2 lg:grid-cols-3 gap-y-6 gap-x-4 px-0">
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Stored Size</div>
<div className="flex items-baseline gap-2">
<ByteSize base={1024} bytes={storedSize} className="text-xl font-semibold text-foreground font-mono" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Uncompressed</div>
<div className="flex items-baseline gap-2">
<ByteSize
base={1024}
bytes={uncompressedSize}
className="text-xl font-semibold text-foreground font-mono"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Space Saved</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-foreground font-mono">{spaceSavingPercent.toFixed(1)}%</span>
<ByteSize base={1024} bytes={savedSize} className="text-xs text-muted-foreground font-mono" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Ratio</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-foreground font-mono">
{compressionRatio > 0 ? `${compressionRatio.toFixed(2)}x` : "—"}
</span>
</div>
</div>
<div className="flex flex-col gap-1.5 lg:col-span-2">
<div className="text-sm font-medium text-muted-foreground">Snapshots</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-foreground font-mono">{snapshotsCount.toLocaleString()}</span>
<span className="text-xs text-muted-foreground font-mono">
{compressionProgressPercent.toFixed(1)}% compressed
</span>
</div>
</div>
</CardContent>
<p className={cn("text-sm text-muted-foreground", { hidden: !isPending })}>Loading compression statistics...</p>
<div className={cn("space-y-2", { hidden: !error || isPending })}>
<p className="text-sm font-medium text-destructive">Failed to load compression statistics</p>
<p className="text-sm text-muted-foreground wrap-break-word">{error?.message}</p>
</div>
<p className={cn("text-sm text-muted-foreground", { hidden: isPending || !!error || hasStats })}>
Stats will be populated after your first backup. You can also refresh them manually.
</p>
<CardContent
className={cn("grid grid-cols-2 lg:grid-cols-3 gap-y-6 gap-x-4 px-0", {
hidden: isPending || !!error || !hasStats,
})}
>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Stored Size</div>
<div className="flex items-baseline gap-2">
<ByteSize base={1024} bytes={storedSize} className="text-xl font-semibold text-foreground font-mono" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Uncompressed</div>
<div className="flex items-baseline gap-2">
<ByteSize
base={1024}
bytes={uncompressedSize}
className="text-xl font-semibold text-foreground font-mono"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Space Saved</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-foreground font-mono">{spaceSavingPercent.toFixed(1)}%</span>
<ByteSize base={1024} bytes={savedSize} className="text-xs text-muted-foreground font-mono" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Ratio</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-foreground font-mono">
{compressionRatio > 0 ? `${compressionRatio.toFixed(2)}x` : "-"}
</span>
</div>
</div>
<div className="flex flex-col gap-1.5 lg:col-span-2">
<div className="text-sm font-medium text-muted-foreground">Snapshots</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-foreground font-mono">{snapshotsCount.toLocaleString()}</span>
<span className="text-xs text-muted-foreground font-mono">
{compressionProgressPercent.toFixed(1)}% compressed
</span>
</div>
</div>
</CardContent>
</Card>
);
}

View file

@ -1,10 +1,9 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { type } from "arktype";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Save } from "lucide-react";
import { z } from "zod";
import { cn } from "~/client/lib/utils";
import { deepClean } from "~/utils/object";
import { Button } from "../../../components/ui/button";
import {
Form,
@ -21,7 +20,17 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info";
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
import { COMPRESSION_MODES, repositoryConfigSchemaBase } from "~/schemas/restic";
import {
COMPRESSION_MODES,
azureRepositoryConfigSchema,
gcsRepositoryConfigSchema,
localRepositoryConfigSchema,
r2RepositoryConfigSchema,
rcloneRepositoryConfigSchema,
restRepositoryConfigSchema,
s3RepositoryConfigSchema,
sftpRepositoryConfigSchema,
} from "~/schemas/restic";
import { Checkbox } from "../../../components/ui/checkbox";
import {
LocalRepositoryForm,
@ -38,13 +47,23 @@ import { useServerFn } from "@tanstack/react-start";
import { getServerConstants } from "~/server/lib/functions/server-constants";
import { useSuspenseQuery } from "@tanstack/react-query";
export const formSchema = type({
name: "2<=string<=32",
compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
}).and(repositoryConfigSchemaBase);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
const formBaseFields = {
name: z.string().min(2).max(32),
compressionMode: z.enum(COMPRESSION_MODES).optional(),
};
export type RepositoryFormValues = typeof formSchema.inferIn;
export const formSchema = z.discriminatedUnion("backend", [
localRepositoryConfigSchema.extend(formBaseFields),
s3RepositoryConfigSchema.extend(formBaseFields),
r2RepositoryConfigSchema.extend(formBaseFields),
gcsRepositoryConfigSchema.extend(formBaseFields),
azureRepositoryConfigSchema.extend(formBaseFields),
rcloneRepositoryConfigSchema.extend(formBaseFields),
restRepositoryConfigSchema.extend(formBaseFields),
sftpRepositoryConfigSchema.extend(formBaseFields),
]);
export type RepositoryFormValues = z.input<typeof formSchema>;
type Props = {
onSubmit: (values: RepositoryFormValues) => void;
@ -81,7 +100,7 @@ export const CreateRepositoryForm = ({
});
const form = useForm<RepositoryFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
resolver: zodResolver(formSchema, undefined, { raw: true }),
defaultValues: initialValues,
resetOptions: {
keepDefaultValues: true,

View file

@ -41,7 +41,7 @@ export const DoctorReport = ({ result, repositoryStatus }: Props) => {
<CollapsibleTrigger className="w-full flex items-center justify-between p-3 hover:bg-muted/50 transition-colors">
<span className="text-sm font-medium capitalize">{step.step.replaceAll("_", " ")}</span>
{step.success ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
<CheckCircle2 className="h-4 w-4 text-success" />
) : (
<AlertCircle className="h-4 w-4 text-red-500" />
)}

View file

@ -5,6 +5,7 @@ import { toast } from "sonner";
import { createRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
import {
CreateRepositoryForm,
formSchema,
type RepositoryFormValues,
} from "~/client/modules/repositories/components/create-repository-form";
import { Button } from "~/client/components/ui/button";
@ -26,11 +27,13 @@ export function CreateRepositoryPage() {
});
const handleSubmit = (values: RepositoryFormValues) => {
const { name, compressionMode, ...config } = formSchema.parse(values);
createRepository.mutate({
body: {
config: values,
name: values.name,
compressionMode: values.compressionMode,
config,
name,
compressionMode,
},
});
};

View file

@ -5,6 +5,7 @@ import { toast } from "sonner";
import { getRepositoryOptions, updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
import {
CreateRepositoryForm,
formSchema,
type RepositoryFormValues,
} from "~/client/modules/repositories/components/create-repository-form";
import {
@ -35,11 +36,11 @@ const riskyLocationFieldsByBackend = {
rclone: ["remote", "path"],
} as const;
const hasRiskyLocationChange = (initialConfig: RepositoryConfig, nextConfig: RepositoryFormValues): boolean => {
const hasRiskyLocationChange = (initialConfig: RepositoryConfig, nextConfig: RepositoryConfig): boolean => {
const fields = riskyLocationFieldsByBackend[initialConfig.backend] ?? [];
return fields.some(
(field) => initialConfig[field as keyof RepositoryConfig] !== nextConfig[field as keyof RepositoryFormValues],
(field) => initialConfig[field as keyof RepositoryConfig] !== nextConfig[field as keyof RepositoryConfig],
);
};
@ -75,18 +76,20 @@ export function EditRepositoryPage({ repositoryId }: { repositoryId: string }) {
};
const submitUpdate = (values: RepositoryFormValues) => {
const { name, compressionMode, ...config } = formSchema.parse(values);
updateRepository.mutate({
path: { shortId: repositoryId },
body: {
name: values.name,
compressionMode: values.compressionMode,
config: values,
name,
compressionMode,
config,
},
});
};
const handleSubmit = (values: RepositoryFormValues) => {
const nextConfig = values;
const { name: _name, compressionMode: _compressionMode, ...nextConfig } = formSchema.parse(values);
if (hasRiskyLocationChange(initialConfig, nextConfig)) {
setPendingValues(values);

View file

@ -143,7 +143,7 @@ export function RepositoriesPage() {
className={cn(
"inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500",
{
"bg-green-500/10 text-emerald-500": repository.status === "healthy",
"bg-success/10 text-success": repository.status === "healthy",
"bg-red-500/10 text-red-500": repository.status === "error",
},
)}

View file

@ -4,6 +4,7 @@ import {
getSnapshotDetailsOptions,
listBackupSchedulesOptions,
} from "~/client/api-client/@tanstack/react-query.gen";
import type { GetSnapshotDetailsResponse } from "~/client/api-client/types.gen";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
@ -49,7 +50,13 @@ const SnapshotFileBrowserSkeleton = () => (
</div>
);
export function SnapshotDetailsPage({ repositoryId, snapshotId }: { repositoryId: string; snapshotId: string }) {
type Props = {
repositoryId: string;
snapshotId: string;
initialSnapshot?: GetSnapshotDetailsResponse;
};
export function SnapshotDetailsPage({ repositoryId, snapshotId, initialSnapshot }: Props) {
const [showAllPaths, setShowAllPaths] = useState(false);
const { data: repository } = useSuspenseQuery({
@ -62,6 +69,7 @@ export function SnapshotDetailsPage({ repositoryId, snapshotId }: { repositoryId
const { data, error } = useQuery({
...getSnapshotDetailsOptions({ path: { shortId: repositoryId, snapshotId: snapshotId } }),
initialData: initialSnapshot,
});
const backupSchedule = schedules?.find((s) => data?.tags?.includes(s.shortId));

View file

@ -187,7 +187,7 @@ export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) =>
<p className="text-sm flex items-center gap-2">
<span
className={cn("w-2 h-2 rounded-full", {
"bg-emerald-500": repository.status === "healthy",
"bg-success": repository.status === "healthy",
"bg-red-500": repository.status === "error",
"bg-amber-500": repository.status !== "healthy" && repository.status !== "error",
"animate-pulse": repository.status === "doctor",
@ -209,7 +209,7 @@ export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) =>
{hasCaCert && (
<div className="flex flex-col gap-1">
<div className="text-sm font-medium text-muted-foreground">CA Certificate</div>
<p className="text-sm text-green-500">Configured</p>
<p className="text-sm text-success">Configured</p>
</div>
)}
{hasInsecureTlsConfig && (
@ -217,7 +217,7 @@ export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) =>
<div className="text-sm font-medium text-muted-foreground">TLS Validation</div>
<p className="text-sm">
<span className={cn("text-red-500", { hidden: !isTlsValidationDisabled })}>Disabled</span>
<span className={cn("text-green-500", { hidden: isTlsValidationDisabled })}>Enabled</span>
<span className={cn("text-success", { hidden: isTlsValidationDisabled })}>Enabled</span>
</p>
</div>
)}

View file

@ -1,10 +1,10 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { type } from "arktype";
import { Plus, UserPlus } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "~/client/components/ui/button";
import {
Dialog,
@ -20,15 +20,15 @@ import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { authClient } from "~/client/lib/auth-client";
const createUserSchema = type({
name: "string>=1",
username: "string>=1",
email: "string",
password: "string>=8",
role: "'user'|'admin'",
const createUserSchema = z.object({
name: z.string().min(1),
username: z.string().min(1),
email: z.string().email(),
password: z.string().min(8),
role: z.enum(["user", "admin"]),
});
type CreateUserFormValues = typeof createUserSchema.infer;
type CreateUserFormValues = z.infer<typeof createUserSchema>;
interface CreateUserDialogProps {
onUserCreated?: () => void;
@ -38,7 +38,7 @@ export function CreateUserDialog({ onUserCreated }: CreateUserDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const form = useForm<CreateUserFormValues>({
resolver: arktypeResolver(createUserSchema),
resolver: zodResolver(createUserSchema),
defaultValues: {
name: "",
username: "",

View file

@ -2,12 +2,12 @@ import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { Shield, ShieldAlert, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import type { GetOrgMembersResponse } from "~/client/api-client";
import {
getOrgMembersOptions,
removeOrgMemberMutation,
updateMemberRoleMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import type { GetOrgMembersResponse } from "~/client/api-client/types.gen";
import {
AlertDialog,
AlertDialogAction,
@ -25,11 +25,11 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~
import { useOrganizationContext } from "~/client/hooks/use-org-context";
import { cn } from "~/client/lib/utils";
type OrgMembersSectionProps = {
type Props = {
initialMembers?: GetOrgMembersResponse;
};
export function OrgMembersSection({ initialMembers }: OrgMembersSectionProps) {
export function OrgMembersSection({ initialMembers }: Props) {
const { activeMember } = useOrganizationContext();
const [memberToRemove, setMemberToRemove] = useState<{ id: string; name: string } | null>(null);

View file

@ -34,7 +34,7 @@ export const TwoFactorSection = ({ twoFactorEnabled }: TwoFactorSectionProps) =>
<p className="text-sm font-medium">
Status:&nbsp;
{twoFactorEnabled ? (
<span className="text-green-500">Enabled</span>
<span className="text-success">Enabled</span>
) : (
<span className="text-muted-foreground">Disabled</span>
)}

View file

@ -154,7 +154,7 @@ export function UserManagement({ currentUser }: { currentUser: { id: string } |
<Badge variant="outline" className={cn("text-red-500 border-red-500", { hidden: !user.banned })}>
Banned
</Badge>
<Badge variant="outline" className={cn("bg-green-500/10 text-emerald-500", { hidden: user.banned })}>
<Badge variant="outline" className={cn("bg-success/10 text-success", { hidden: user.banned })}>
Active
</Badge>
</TableCell>

View file

@ -3,6 +3,7 @@ import { Download, KeyRound, User, X, Settings as SettingsIcon, Building2 } from
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 {
@ -21,18 +22,18 @@ import { authClient } from "~/client/lib/auth-client";
import { type AppContext } from "~/context";
import { TwoFactorSection } from "../components/two-factor-section";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { SsoSettingsSection } from "../components/sso/sso-settings-section";
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 type { GetOrgMembersResponse, GetSsoSettingsResponse } from "~/client/api-client";
type Props = {
appContext: AppContext;
initialMembers?: GetOrgMembersResponse;
initialSsoSettings?: GetSsoSettingsResponse;
initialOrigin?: string;
};
export function SettingsPage({ appContext, initialMembers, initialSsoSettings }: Props) {
export function SettingsPage({ appContext, initialMembers, initialSsoSettings, initialOrigin }: Props) {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
@ -163,8 +164,12 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings }:
</div>
<CardContent className="p-6 space-y-4">
<div className="space-y-2">
<Label>Username</Label>
<Input value={appContext.user?.username || ""} disabled className="max-w-md" />
<Label htmlFor="username">Username</Label>
<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" />
</div>
</CardContent>
@ -312,7 +317,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings }:
<CardDescription className="mt-1.5">Configure OIDC provider settings</CardDescription>
</div>
<CardContent className="p-6">
<SsoSettingsSection initialSettings={initialSsoSettings} />
<SsoSettingsSection initialSettings={initialSsoSettings} initialOrigin={initialOrigin} />
</CardContent>
</Card>
</TabsContent>

View file

@ -0,0 +1,57 @@
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 { 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) => {
console.error(error);
toast.error("SSO Login failed", { description: error.message });
},
});
if (ssoProviders.providers.length === 0) {
return null;
}
return (
<div className="pt-4 border-t border-border/60 space-y-3">
<p className="text-sm font-medium">Alternative Sign-in</p>
<div className="flex flex-col gap-2">
{ssoProviders.providers.map((provider) => (
<Button
key={provider.providerId}
type="button"
variant="outline"
className="w-full"
loading={ssoLoginMutation.isPending}
disabled={ssoLoginMutation.isPending}
onClick={() => ssoLoginMutation.mutate(provider.providerId)}
>
Log in with {provider.providerId}
</Button>
))}
</div>
</div>
);
}

View file

@ -9,6 +9,7 @@ import {
getSsoSettingsOptions,
updateSsoProviderAutoLinkingMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import type { GetSsoSettingsResponse } from "~/client/api-client/types.gen";
import {
AlertDialog,
AlertDialogAction,
@ -33,17 +34,17 @@ import { getOrigin } from "~/client/functions/get-origin";
import { authClient } from "~/client/lib/auth-client";
import { cn } from "~/client/lib/utils";
import { useServerFn } from "@tanstack/react-start";
import type { GetSsoSettingsResponse } from "~/client/api-client";
type InvitationRole = "member" | "admin" | "owner";
type SsoSettingsSectionProps = {
type Props = {
initialSettings?: GetSsoSettingsResponse;
initialOrigin?: string;
};
export function SsoSettingsSection({ initialSettings }: SsoSettingsSectionProps) {
export function SsoSettingsSection({ initialSettings, initialOrigin }: Props) {
const originQuery = useServerFn(getOrigin);
const { data } = useSuspenseQuery({ queryKey: ["app-origin"], queryFn: originQuery });
const { data } = useSuspenseQuery({ queryKey: ["app-origin"], queryFn: originQuery, initialData: initialOrigin });
const navigate = useNavigate();
const { activeOrganization } = useOrganizationContext();

View file

@ -1,11 +1,11 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { type } from "arktype";
import { ShieldCheck, Plus } from "lucide-react";
import { useId } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { updateSsoProviderAutoLinkingMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
@ -24,17 +24,17 @@ import { authClient } from "~/client/lib/auth-client";
import { parseError } from "~/client/lib/errors";
import { useOrganizationContext } from "~/client/hooks/use-org-context";
const ssoProviderSchema = type({
providerId: "string>=1",
issuer: "string>=1",
domain: "string>=1",
clientId: "string>=1",
clientSecret: "string>=1",
discoveryEndpoint: "string>=1",
linkMatchingEmails: "boolean",
const ssoProviderSchema = z.object({
providerId: z.string().min(1),
issuer: z.string().min(1),
domain: z.string().min(1),
clientId: z.string().min(1),
clientSecret: z.string().min(1),
discoveryEndpoint: z.string().min(1),
linkMatchingEmails: z.boolean(),
});
type ProviderForm = typeof ssoProviderSchema.infer;
type ProviderForm = z.infer<typeof ssoProviderSchema>;
export function CreateSsoProviderPage() {
const navigate = useNavigate();
@ -42,7 +42,7 @@ export function CreateSsoProviderPage() {
const { activeOrganization } = useOrganizationContext();
const form = useForm<ProviderForm>({
resolver: arktypeResolver(ssoProviderSchema),
resolver: zodResolver(ssoProviderSchema),
defaultValues: {
providerId: "",
issuer: "",

View file

@ -1,11 +1,10 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { type } from "arktype";
import { CheckCircle, Loader2, Plug, Save, XCircle } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { cn } from "~/client/lib/utils";
import { deepClean } from "~/utils/object";
import { Button } from "../../../components/ui/button";
import {
Form,
@ -18,19 +17,31 @@ import {
} from "../../../components/ui/form";
import { Input } from "../../../components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
import { volumeConfigSchemaBase } from "~/schemas/volumes";
import {
directoryConfigSchema,
nfsConfigSchema,
rcloneConfigSchema,
sftpConfigSchema,
smbConfigSchema,
volumeConfigSchema,
webdavConfigSchema,
} from "~/schemas/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";
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm, SFTPForm } from "./volume-forms";
export const formSchema = type({
name: "2<=string<=32",
}).and(volumeConfigSchemaBase);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export const formSchema = z.discriminatedUnion("backend", [
directoryConfigSchema.extend({ name: z.string().min(2).max(32) }),
nfsConfigSchema.extend({ name: z.string().min(2).max(32) }),
smbConfigSchema.extend({ name: z.string().min(2).max(32) }),
webdavConfigSchema.extend({ name: z.string().min(2).max(32) }),
rcloneConfigSchema.extend({ name: z.string().min(2).max(32) }),
sftpConfigSchema.extend({ name: z.string().min(2).max(32) }),
]);
export type FormValues = typeof formSchema.inferIn;
export type FormValues = z.input<typeof formSchema>;
type Props = {
onSubmit: (values: FormValues) => void;
@ -52,7 +63,7 @@ const defaultValuesForType = {
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
const form = useForm<FormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
resolver: zodResolver(formSchema, undefined, { raw: true }),
defaultValues: initialValues || {
name: "",
backend: "directory",
@ -89,15 +100,22 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
const handleTestConnection = async () => {
const formValues = getValues();
const { name: _, ...configCandidate } = formValues;
const parsedConfig = volumeConfigSchema.safeParse(configCandidate);
if (!parsedConfig.success) {
setTestMessage({ success: false, message: "Please fix validation errors before testing the connection." });
return;
}
if (
formValues.backend === "nfs" ||
formValues.backend === "smb" ||
formValues.backend === "webdav" ||
formValues.backend === "sftp"
parsedConfig.data.backend === "nfs" ||
parsedConfig.data.backend === "smb" ||
parsedConfig.data.backend === "webdav" ||
parsedConfig.data.backend === "sftp"
) {
testBackendConnection.mutate({
body: { config: formValues },
body: { config: parsedConfig.data },
});
}
};
@ -237,7 +255,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
>
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{!testBackendConnection.isPending && testMessage?.success && (
<CheckCircle className="mr-2 h-4 w-4 text-emerald-500" />
<CheckCircle className="mr-2 h-4 w-4 text-success" />
)}
{!testBackendConnection.isPending && testMessage && !testMessage.success && (
<XCircle className="mr-2 h-4 w-4 text-red-500" />
@ -255,7 +273,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
{testMessage && (
<div
className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", {
"bg-green-50 text-emerald-700 border border-green-200": testMessage.success,
"bg-success/10 text-success border border-success/30": testMessage.success,
"bg-red-50 text-red-700 border border-red-200": !testMessage.success,
})}
>

View file

@ -51,7 +51,7 @@ export const HealthchecksCard = ({ volume }: Props) => {
<CardContent>
<div className="flex flex-col flex-1 justify-start">
{volume.lastError && <span className="text-sm text-red-500 wrap-break-word">{volume.lastError}</span>}
{volume.status === "mounted" && <span className="text-md text-green-500">Healthy</span>}
{volume.status === "mounted" && <span className="text-md text-success">Healthy</span>}
{volume.status !== "unmounted" && (
<span className="text-xs text-muted-foreground mb-4">Checked {formatTimeAgo(volume.lastHealthCheck)}</span>
)}

View file

@ -18,7 +18,7 @@ export function StorageChart({ statfs }: Props) {
{
name: "Used",
value: statfs.used,
fill: "#ff543a",
fill: "var(--strong-accent)",
},
{
name: "Free",

View file

@ -3,7 +3,7 @@ import { HardDrive, Plus } from "lucide-react";
import { useId } from "react";
import { toast } from "sonner";
import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
import { CreateVolumeForm, formSchema, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { parseError } from "~/client/lib/errors";
@ -23,10 +23,12 @@ export function CreateVolumePage() {
});
const handleSubmit = (values: FormValues) => {
const { name, ...config } = formSchema.parse(values);
createVolume.mutate({
body: {
config: values,
name: values.name,
config,
name,
},
});
};

View file

@ -134,7 +134,7 @@ export function VolumesPage() {
{filteredVolumes.map((volume) => (
<TableRow
key={volume.name}
className="hover:bg-white/2 hover:cursor-pointer transition-colors h-12"
className="hover:bg-muted/50 hover:cursor-pointer transition-colors h-12"
onClick={() => navigate({ to: `/volumes/${volume.shortId}` })}
>
<TableCell className="font-medium font-mono text-strong-accent">{volume.name}</TableCell>

View file

@ -2,7 +2,7 @@ import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { Check, Plug, Trash2, Unplug } from "lucide-react";
import { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
import { CreateVolumeForm, formSchema, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
import {
AlertDialog,
AlertDialogAction,
@ -102,9 +102,11 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
const confirmUpdate = () => {
if (pendingValues) {
const { name, ...config } = formSchema.parse(pendingValues);
updateMutation.mutate({
path: { shortId: volume.shortId },
body: { name: pendingValues.name, config: pendingValues },
body: { name, config },
});
}
};

View file

@ -0,0 +1 @@
ALTER TABLE `backup_schedules_table` ADD `custom_restic_params` text DEFAULT '[]';

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,2 @@
ALTER TABLE `repositories_table` ADD `stats` text;--> statement-breakpoint
ALTER TABLE `repositories_table` ADD `stats_updated_at` integer;

File diff suppressed because it is too large Load diff

24
app/lib/sso-errors.ts Normal file
View file

@ -0,0 +1,24 @@
export const LOGIN_ERROR_CODES = [
"ACCOUNT_LINK_REQUIRED",
"EMAIL_NOT_VERIFIED",
"INVITE_REQUIRED",
"BANNED_USER",
"SSO_LOGIN_FAILED",
] as const;
export type LoginErrorCode = (typeof LOGIN_ERROR_CODES)[number];
export function getLoginErrorDescription(errorCode: LoginErrorCode): string {
switch (errorCode) {
case "ACCOUNT_LINK_REQUIRED":
return "SSO sign-in was blocked because this email already belongs to another user in this instance. Contact your administrator to resolve the account conflict.";
case "EMAIL_NOT_VERIFIED":
return "Your identity provider did not mark your email as verified.";
case "INVITE_REQUIRED":
return "Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
case "BANNED_USER":
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.";
}
}

View file

@ -1,11 +1,11 @@
import { createFileRoute } from "@tanstack/react-router";
import { type } from "arktype";
import { z } from "zod";
import { LoginPage } from "~/client/modules/auth/routes/login";
export const Route = createFileRoute("/(auth)/login/error")({
component: RouteComponent,
errorComponent: () => <div>Failed to load login error</div>,
validateSearch: type({ error: "string?" }),
validateSearch: z.object({ error: z.string().optional() }),
head: () => ({
meta: [
{ title: "Zerobyte - Login Error" },

View file

@ -1,11 +1,11 @@
import { Outlet, createFileRoute, useRouterState } from "@tanstack/react-router";
import { type } from "arktype";
import { z } from "zod";
import { LoginPage } from "~/client/modules/auth/routes/login";
export const Route = createFileRoute("/(auth)/login")({
component: RouteComponent,
errorComponent: () => <div>Failed to load login</div>,
validateSearch: type({ error: "string?" }),
validateSearch: z.object({ error: z.string().optional() }),
head: () => ({
meta: [
{ title: "Zerobyte - Login" },

View file

@ -1,12 +1,12 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { type } from "arktype";
import { z } from "zod";
import { fetchUser } from "../route";
import type { AppContext } from "~/context";
import { AdminPage } from "~/client/modules/admin/routes/admin-page";
import { getAdminUsersOptions, getRegistrationStatusOptions } from "~/client/api-client/@tanstack/react-query.gen";
export const Route = createFileRoute("/(dashboard)/admin/")({
validateSearch: type({ tab: "string?" }),
validateSearch: z.object({ tab: z.string().optional() }),
component: RouteComponent,
errorComponent: () => <div>Failed to load admin</div>,
loader: async ({ context }) => {

View file

@ -1,6 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { type } from "arktype";
import { z } from "zod";
import {
getBackupProgressOptions,
getBackupScheduleOptions,
getScheduleMirrorsOptions,
getScheduleNotificationsOptions,
@ -14,7 +15,7 @@ import { prefetchOrSkip } from "~/utils/prefetch";
export const Route = createFileRoute("/(dashboard)/backups/$backupId/")({
component: RouteComponent,
errorComponent: () => <div>Failed to load backup</div>,
validateSearch: type({ snapshot: "string?" }),
validateSearch: z.object({ snapshot: z.string().optional() }),
loader: async ({ params, context }) => {
const { backupId } = params;
@ -24,6 +25,7 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/")({
context.queryClient.ensureQueryData({ ...listRepositoriesOptions() }),
context.queryClient.ensureQueryData({ ...getScheduleNotificationsOptions({ path: { shortId: backupId } }) }),
context.queryClient.ensureQueryData({ ...getScheduleMirrorsOptions({ path: { shortId: backupId } }) }),
context.queryClient.ensureQueryData({ ...getBackupProgressOptions({ path: { shortId: backupId } }) }),
]);
const snapshotOptions = listSnapshotsOptions({

View file

@ -1,16 +1,30 @@
import { createFileRoute } from "@tanstack/react-router";
import { getRepositoryOptions } from "~/client/api-client/@tanstack/react-query.gen";
import {
getRepositoryOptions,
getSnapshotDetailsOptions,
listBackupSchedulesOptions,
} from "~/client/api-client/@tanstack/react-query.gen";
import { SnapshotDetailsPage } from "~/client/modules/repositories/routes/snapshot-details";
import { prefetchOrSkip } from "~/utils/prefetch";
export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$snapshotId/")({
component: RouteComponent,
errorComponent: (e) => <div>{e.error.message}</div>,
loader: async ({ params, context }) => {
const res = await context.queryClient.ensureQueryData({
...getRepositoryOptions({ path: { shortId: params.repositoryId } }),
});
const [res] = await Promise.all([
context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { shortId: params.repositoryId } }) }),
context.queryClient.ensureQueryData({ ...listBackupSchedulesOptions() }),
]);
return res;
const snapshotOptions = getSnapshotDetailsOptions({
path: { shortId: params.repositoryId, snapshotId: params.snapshotId },
});
await prefetchOrSkip(context.queryClient, snapshotOptions);
return {
...res,
snapshot: context.queryClient.getQueryData(snapshotOptions.queryKey),
};
},
staticData: {
breadcrumb: (match) => [
@ -32,6 +46,7 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s
function RouteComponent() {
const { repositoryId, snapshotId } = Route.useParams();
const { snapshot } = Route.useLoaderData();
return <SnapshotDetailsPage repositoryId={repositoryId} snapshotId={snapshotId} />;
return <SnapshotDetailsPage repositoryId={repositoryId} snapshotId={snapshotId} initialSnapshot={snapshot} />;
}

View file

@ -1,5 +1,5 @@
import { createFileRoute } from "@tanstack/react-router";
import { type } from "arktype";
import { z } from "zod";
import {
getRepositoryOptions,
getRepositoryStatsOptions,
@ -14,24 +14,23 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/")
errorComponent: (e) => <div>{e.error.message}</div>,
loader: async ({ params, context }) => {
const snapshotOptions = listSnapshotsOptions({ path: { shortId: params.repositoryId } });
const schedulesOptions = listBackupSchedulesOptions();
const statsOptions = getRepositoryStatsOptions({ path: { shortId: params.repositoryId } });
const [res] = await Promise.all([
const [res, schedules] = await Promise.all([
context.queryClient.ensureQueryData(getRepositoryOptions({ path: { shortId: params.repositoryId } })),
context.queryClient.ensureQueryData(listBackupSchedulesOptions()),
prefetchOrSkip(context.queryClient, snapshotOptions),
prefetchOrSkip(context.queryClient, schedulesOptions),
prefetchOrSkip(context.queryClient, statsOptions),
]);
return {
...res,
snapshots: context.queryClient.getQueryData(snapshotOptions.queryKey),
backupSchedules: context.queryClient.getQueryData(schedulesOptions.queryKey),
backupSchedules: schedules,
stats: context.queryClient.getQueryData(statsOptions.queryKey),
};
},
validateSearch: type({ tab: "string?" }),
validateSearch: z.object({ tab: z.string().optional() }),
staticData: {
breadcrumb: (match) => [
{ label: "Repositories", href: "/repositories" },

View file

@ -6,6 +6,7 @@ import { SIDEBAR_COOKIE_NAME } from "~/client/components/ui/sidebar";
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 { authService } from "~/server/modules/auth/auth.service";
export const fetchUser = createServerFn({ method: "GET" }).handler(async () => {
@ -32,6 +33,10 @@ export const Route = createFileRoute("/(dashboard)")({
queryKey: ["organization-context"],
queryFn: () => getOrganizationContext(),
}),
context.queryClient.ensureQueryData({
queryKey: ["server-constants"],
queryFn: () => getServerConstants(),
}),
]);
return authContext;

View file

@ -2,33 +2,42 @@ import { createFileRoute } from "@tanstack/react-router";
import { fetchUser } from "../route";
import type { AppContext } from "~/context";
import { SettingsPage } from "~/client/modules/settings/routes/settings";
import { type } from "arktype";
import { z } from "zod";
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
import { getOrgMembersOptions, getSsoSettingsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { getOrigin } from "~/client/functions/get-origin";
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
export const Route = createFileRoute("/(dashboard)/settings/")({
component: RouteComponent,
validateSearch: type({ tab: "string?" }),
validateSearch: z.object({ tab: z.string().optional() }),
errorComponent: () => <div>Failed to load settings</div>,
loader: async ({ context }) => {
const authContext = await fetchUser();
const orgContext = await getOrganizationContext();
const [authContext, orgContext] = await Promise.all([
fetchUser(),
context.queryClient.ensureQueryData({
queryKey: ["organization-context"],
queryFn: () => getOrganizationContext(),
}),
]);
const orgRole = orgContext.activeMember?.role;
const shouldPrefetchOrgQueries = orgRole === "owner" || orgRole === "admin";
let org, members;
if (authContext.user?.role === "admin" || orgRole === "owner" || orgRole === "admin") {
const promises = await Promise.all([
if (shouldPrefetchOrgQueries) {
const [org, members, appOrigin] = await Promise.all([
context.queryClient.ensureQueryData({ ...getSsoSettingsOptions() }),
context.queryClient.ensureQueryData({ ...getOrgMembersOptions() }),
context.queryClient.ensureQueryData({ queryKey: ["app-origin"], queryFn: () => getOrigin() }),
]);
org = promises[0];
members = promises[1];
return {
authContext: authContext as AppContext,
org,
members,
appOrigin,
};
}
return { authContext: authContext as AppContext, org, members };
return { authContext: authContext as AppContext };
},
staticData: {
breadcrumb: () => [{ label: "Settings" }],
@ -36,7 +45,14 @@ export const Route = createFileRoute("/(dashboard)/settings/")({
});
function RouteComponent() {
const { authContext, org, members } = Route.useLoaderData();
const { authContext, members, org, appOrigin } = Route.useLoaderData();
return <SettingsPage appContext={authContext} initialMembers={members} initialSsoSettings={org} />;
return (
<SettingsPage
appContext={authContext}
initialMembers={members}
initialSsoSettings={org}
initialOrigin={appOrigin}
/>
);
}

View file

@ -1,5 +1,5 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { CreateSsoProviderPage } from "~/client/modules/settings/routes/create-sso-provider";
import { CreateSsoProviderPage } from "~/client/modules/sso/routes/create-sso-provider";
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
export const Route = createFileRoute("/(dashboard)/settings/sso/new")({

View file

@ -1,5 +1,5 @@
import { createFileRoute } from "@tanstack/react-router";
import { type } from "arktype";
import { z } from "zod";
import { getVolumeOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { VolumeDetails } from "~/client/modules/volumes/routes/volume-details";
@ -13,7 +13,7 @@ export const Route = createFileRoute("/(dashboard)/volumes/$volumeId")({
return res;
},
validateSearch: type({ tab: "string?" }),
validateSearch: z.object({ tab: z.string().optional() }),
staticData: {
breadcrumb: (match) => [
{ label: "Volumes", href: "/volumes" },

View file

@ -2,14 +2,27 @@ import { Outlet, HeadContent, Scripts, createRootRouteWithContext } from "@tanst
import appCss from "../app.css?url";
import { apiClientMiddleware } from "~/middleware/api-client";
import type { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { Toaster } from "~/client/components/ui/sonner";
import { useServerEvents } from "~/client/hooks/use-server-events";
import { useEffect } from "react";
import { ThemeProvider, THEME_COOKIE_NAME } from "~/client/components/theme-provider";
import { createServerFn } from "@tanstack/react-start";
import { getCookie } from "@tanstack/react-start/server";
const fetchTheme = createServerFn({ method: "GET" }).handler(async () => {
const themeCookie = getCookie(THEME_COOKIE_NAME);
return (themeCookie === "light" ? "light" : "dark") as "light" | "dark";
});
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
server: {
middleware: [apiClientMiddleware],
},
loader: async () => {
const theme = await fetchTheme();
return { theme };
},
head: () => ({
meta: [{ title: "Zerobyte - Open Source Backup Solution" }],
links: [
@ -34,6 +47,7 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
});
function RootLayout() {
const { theme } = Route.useLoaderData();
useServerEvents();
useEffect(() => {
document.body.setAttribute("data-app-ready", "true");
@ -43,7 +57,7 @@ function RootLayout() {
}, []);
return (
<html lang="en">
<html lang="en" className={theme === "dark" ? "dark" : undefined} style={{ colorScheme: theme }}>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
@ -55,9 +69,12 @@ function RootLayout() {
<link rel="manifest" href="/images/favicon/site.webmanifest" />
<HeadContent />
</head>
<body className="dark">
<Outlet />
<Toaster />
<body>
<ThemeProvider initialTheme={theme}>
<Outlet />
<Toaster />
<ReactQueryDevtools buttonPosition="bottom-right" />
</ThemeProvider>
<Scripts />
</body>
</html>

View file

@ -1,9 +1,26 @@
import { createFileRoute } from "@tanstack/react-router";
import { createApp } from "~/server/app";
import { config } from "~/server/core/config";
const app = createApp();
const handle = ({ request }: { request: Request }) => app.fetch(request.clone());
type NodeRuntimeRequest = Request & {
runtime?: {
node?: {
res?: { setTimeout: (timeoutMs: number) => void };
};
};
};
export const prepareApiRequest = (request: Request, timeoutMs: number) => {
const nodeRequest = request as NodeRuntimeRequest;
nodeRequest.runtime?.node?.res?.setTimeout(timeoutMs);
return request.clone();
};
const handle = ({ request }: { request: Request }) =>
app.fetch(prepareApiRequest(request, config.serverIdleTimeout * 1000));
export const Route = createFileRoute("/api/$")({
server: {

View file

@ -1,85 +1,84 @@
import { type } from "arktype";
import { z } from "zod";
import { resticBackupProgressMetricsSchema, resticBackupRunSummarySchema } from "~/schemas/restic-dto";
export const backupEventStatusSchema = type("'success' | 'error' | 'stopped' | 'warning'");
export const restoreEventStatusSchema = type("'success' | 'error'");
export const backupEventStatusSchema = z.enum(["success", "error", "stopped", "warning"]);
export const restoreEventStatusSchema = z.enum(["success", "error"]);
const backupEventBaseSchema = type({
scheduleId: "string",
volumeName: "string",
repositoryName: "string",
const backupEventBaseSchema = z.object({
scheduleId: z.string(),
volumeName: z.string(),
repositoryName: z.string(),
});
const organizationScopedSchema = type({
organizationId: "string",
const organizationScopedSchema = z.object({
organizationId: z.string(),
});
const restoreEventBaseSchema = type({
repositoryId: "string",
snapshotId: "string",
const restoreEventBaseSchema = z.object({
repositoryId: z.string(),
snapshotId: z.string(),
});
const dumpStartedEventSchema = type({
repositoryId: "string",
snapshotId: "string",
path: "string",
filename: "string",
const dumpStartedEventSchema = z.object({
repositoryId: z.string(),
snapshotId: z.string(),
path: z.string(),
filename: z.string(),
});
const restoreProgressMetricsSchema = type({
seconds_elapsed: "number",
percent_done: "number",
total_files: "number",
files_restored: "number",
total_bytes: "number",
bytes_restored: "number",
const restoreProgressMetricsSchema = z.object({
seconds_elapsed: z.number(),
percent_done: z.number(),
total_files: z.number(),
files_restored: z.number(),
total_bytes: z.number(),
bytes_restored: z.number(),
});
export const backupStartedEventSchema = backupEventBaseSchema;
export const backupProgressEventSchema = backupEventBaseSchema.and(resticBackupProgressMetricsSchema);
export const backupProgressEventSchema = backupEventBaseSchema.extend(resticBackupProgressMetricsSchema.shape);
export const backupCompletedEventSchema = backupEventBaseSchema.and(
type({
status: backupEventStatusSchema,
summary: resticBackupRunSummarySchema.optional(),
}),
);
export const backupCompletedEventSchema = backupEventBaseSchema.extend({
status: backupEventStatusSchema,
summary: resticBackupRunSummarySchema.optional(),
});
export const restoreStartedEventSchema = restoreEventBaseSchema;
export const restoreProgressEventSchema = restoreEventBaseSchema.and(restoreProgressMetricsSchema);
export const restoreProgressEventSchema = restoreEventBaseSchema.extend(restoreProgressMetricsSchema.shape);
export const restoreCompletedEventSchema = restoreEventBaseSchema.and(
type({ status: restoreEventStatusSchema, error: "string?" }),
);
export const restoreCompletedEventSchema = restoreEventBaseSchema.extend({
status: restoreEventStatusSchema,
error: z.string().optional(),
});
export const serverBackupStartedEventSchema = organizationScopedSchema.and(backupStartedEventSchema);
export const serverBackupStartedEventSchema = organizationScopedSchema.extend(backupStartedEventSchema.shape);
export const serverBackupProgressEventSchema = organizationScopedSchema.and(backupProgressEventSchema);
export const serverBackupProgressEventSchema = organizationScopedSchema.extend(backupProgressEventSchema.shape);
export const serverBackupCompletedEventSchema = organizationScopedSchema.and(backupCompletedEventSchema);
export const serverBackupCompletedEventSchema = organizationScopedSchema.extend(backupCompletedEventSchema.shape);
export const serverRestoreStartedEventSchema = organizationScopedSchema.and(restoreStartedEventSchema);
export const serverRestoreStartedEventSchema = organizationScopedSchema.extend(restoreStartedEventSchema.shape);
export const serverRestoreProgressEventSchema = organizationScopedSchema.and(restoreProgressEventSchema);
export const serverRestoreProgressEventSchema = organizationScopedSchema.extend(restoreProgressEventSchema.shape);
export const serverRestoreCompletedEventSchema = organizationScopedSchema.and(restoreCompletedEventSchema);
export const serverRestoreCompletedEventSchema = organizationScopedSchema.extend(restoreCompletedEventSchema.shape);
export const serverDumpStartedEventSchema = organizationScopedSchema.and(dumpStartedEventSchema);
export const serverDumpStartedEventSchema = organizationScopedSchema.extend(dumpStartedEventSchema.shape);
export type BackupEventStatusDto = typeof backupEventStatusSchema.infer;
export type BackupStartedEventDto = typeof backupStartedEventSchema.infer;
export type BackupProgressEventDto = typeof backupProgressEventSchema.infer;
export type BackupCompletedEventDto = typeof backupCompletedEventSchema.infer;
export type RestoreStartedEventDto = typeof restoreStartedEventSchema.infer;
export type RestoreProgressEventDto = typeof restoreProgressEventSchema.infer;
export type RestoreCompletedEventDto = typeof restoreCompletedEventSchema.infer;
export type DumpStartedEventDto = typeof dumpStartedEventSchema.infer;
export type ServerBackupStartedEventDto = typeof serverBackupStartedEventSchema.infer;
export type ServerBackupProgressEventDto = typeof serverBackupProgressEventSchema.infer;
export type ServerBackupCompletedEventDto = typeof serverBackupCompletedEventSchema.infer;
export type ServerRestoreStartedEventDto = typeof serverRestoreStartedEventSchema.infer;
export type ServerRestoreProgressEventDto = typeof serverRestoreProgressEventSchema.infer;
export type ServerRestoreCompletedEventDto = typeof serverRestoreCompletedEventSchema.infer;
export type ServerDumpStartedEventDto = typeof serverDumpStartedEventSchema.infer;
export type BackupEventStatusDto = z.infer<typeof backupEventStatusSchema>;
export type BackupStartedEventDto = z.infer<typeof backupStartedEventSchema>;
export type BackupProgressEventDto = z.infer<typeof backupProgressEventSchema>;
export type BackupCompletedEventDto = z.infer<typeof backupCompletedEventSchema>;
export type RestoreStartedEventDto = z.infer<typeof restoreStartedEventSchema>;
export type RestoreProgressEventDto = z.infer<typeof restoreProgressEventSchema>;
export type RestoreCompletedEventDto = z.infer<typeof restoreCompletedEventSchema>;
export type DumpStartedEventDto = z.infer<typeof dumpStartedEventSchema>;
export type ServerBackupStartedEventDto = z.infer<typeof serverBackupStartedEventSchema>;
export type ServerBackupProgressEventDto = z.infer<typeof serverBackupProgressEventSchema>;
export type ServerBackupCompletedEventDto = z.infer<typeof serverBackupCompletedEventSchema>;
export type ServerRestoreStartedEventDto = z.infer<typeof serverRestoreStartedEventSchema>;
export type ServerRestoreProgressEventDto = z.infer<typeof serverRestoreProgressEventSchema>;
export type ServerRestoreCompletedEventDto = z.infer<typeof serverRestoreCompletedEventSchema>;
export type ServerDumpStartedEventDto = z.infer<typeof serverDumpStartedEventSchema>;

View file

@ -1,4 +1,4 @@
import { type } from "arktype";
import { z } from "zod";
export const NOTIFICATION_TYPES = {
email: "email",
@ -14,96 +14,98 @@ export const NOTIFICATION_TYPES = {
export type NotificationType = keyof typeof NOTIFICATION_TYPES;
export const emailNotificationConfigSchema = type({
type: "'email'",
smtpHost: "string",
smtpPort: "1 <= number <= 65535",
username: "string?",
password: "string?",
from: "string",
fromName: "string?",
to: "string[]",
useTLS: "boolean",
export const emailNotificationConfigSchema = z.object({
type: z.literal("email"),
smtpHost: z.string().min(1),
smtpPort: z.number().int().min(1).max(65535),
username: z.string().optional(),
password: z.string().optional(),
from: z.string().min(1),
fromName: z.string().optional(),
to: z.array(z.string()),
useTLS: z.boolean(),
});
export const slackNotificationConfigSchema = type({
type: "'slack'",
webhookUrl: "string",
channel: "string?",
username: "string?",
iconEmoji: "string?",
export const slackNotificationConfigSchema = z.object({
type: z.literal("slack"),
webhookUrl: z.string().min(1),
channel: z.string().optional(),
username: z.string().optional(),
iconEmoji: z.string().optional(),
});
export const discordNotificationConfigSchema = type({
type: "'discord'",
webhookUrl: "string",
username: "string?",
avatarUrl: "string?",
threadId: "string?",
export const discordNotificationConfigSchema = z.object({
type: z.literal("discord"),
webhookUrl: z.string().min(1),
username: z.string().optional(),
avatarUrl: z.string().optional(),
threadId: z.string().optional(),
});
export const gotifyNotificationConfigSchema = type({
type: "'gotify'",
serverUrl: "string",
token: "string",
path: "string?",
priority: "0 <= number <= 10",
export const gotifyNotificationConfigSchema = z.object({
type: z.literal("gotify"),
serverUrl: z.string().min(1),
token: z.string().min(1),
path: z.string().optional(),
priority: z.number().min(0).max(10),
});
export const ntfyNotificationConfigSchema = type({
type: "'ntfy'",
serverUrl: "string?",
topic: "string",
priority: "'max' | 'high' | 'default' | 'low' | 'min'",
username: "string?",
password: "string?",
accessToken: "string?",
export const ntfyNotificationConfigSchema = z.object({
type: z.literal("ntfy"),
serverUrl: z.string().optional(),
topic: z.string().min(1),
priority: z.enum(["max", "high", "default", "low", "min"]),
username: z.string().optional(),
password: z.string().optional(),
accessToken: z.string().optional(),
});
export const pushoverNotificationConfigSchema = type({
type: "'pushover'",
userKey: "string",
apiToken: "string",
devices: "string?",
priority: "-1 | 0 | 1",
export const pushoverNotificationConfigSchema = z.object({
type: z.literal("pushover"),
userKey: z.string().min(1),
apiToken: z.string().min(1),
devices: z.string().optional(),
priority: z.union([z.literal(-1), z.literal(0), z.literal(1)]),
});
export const telegramNotificationConfigSchema = type({
type: "'telegram'",
botToken: "string",
chatId: "string",
threadId: "string?",
export const telegramNotificationConfigSchema = z.object({
type: z.literal("telegram"),
botToken: z.string().min(1),
chatId: z.string().min(1),
threadId: z.string().optional(),
});
export const genericNotificationConfigSchema = type({
type: "'generic'",
url: "string",
method: "'GET' | 'POST'",
contentType: "string?",
headers: "string[]?",
useJson: "boolean?",
titleKey: "string?",
messageKey: "string?",
export const genericNotificationConfigSchema = z.object({
type: z.literal("generic"),
url: z.string().min(1),
method: z.enum(["GET", "POST"]),
contentType: z.string().optional(),
headers: z.array(z.string()).optional(),
useJson: z.boolean().optional(),
titleKey: z.string().optional(),
messageKey: z.string().optional(),
});
export const customNotificationConfigSchema = type({
type: "'custom'",
shoutrrrUrl: "string",
export const customNotificationConfigSchema = z.object({
type: z.literal("custom"),
shoutrrrUrl: z.string().min(1),
});
export const notificationConfigSchemaBase = emailNotificationConfigSchema
.or(slackNotificationConfigSchema)
.or(discordNotificationConfigSchema)
.or(gotifyNotificationConfigSchema)
.or(ntfyNotificationConfigSchema)
.or(pushoverNotificationConfigSchema)
.or(telegramNotificationConfigSchema)
.or(genericNotificationConfigSchema)
.or(customNotificationConfigSchema);
export const notificationConfigSchemaBase = z.discriminatedUnion("type", [
emailNotificationConfigSchema,
slackNotificationConfigSchema,
discordNotificationConfigSchema,
gotifyNotificationConfigSchema,
ntfyNotificationConfigSchema,
pushoverNotificationConfigSchema,
telegramNotificationConfigSchema,
genericNotificationConfigSchema,
customNotificationConfigSchema,
]);
export const notificationConfigSchema = notificationConfigSchemaBase.onUndeclaredKey("delete");
export const notificationConfigSchema = notificationConfigSchemaBase;
export type NotificationConfig = typeof notificationConfigSchema.infer;
export type NotificationConfig = z.infer<typeof notificationConfigSchema>;
export const NOTIFICATION_EVENTS = {
start: "start",

View file

@ -1,82 +1,73 @@
import { type } from "arktype";
import { z } from "zod";
export const resticSummaryBaseSchema = type({
files_new: "number",
files_changed: "number",
files_unmodified: "number",
dirs_new: "number",
dirs_changed: "number",
dirs_unmodified: "number",
data_blobs: "number",
tree_blobs: "number",
data_added: "number",
data_added_packed: "number?",
total_files_processed: "number",
total_bytes_processed: "number",
export const resticSummaryBaseSchema = z.object({
files_new: z.number(),
files_changed: z.number(),
files_unmodified: z.number(),
dirs_new: z.number(),
dirs_changed: z.number(),
dirs_unmodified: z.number(),
data_blobs: z.number(),
tree_blobs: z.number(),
data_added: z.number(),
data_added_packed: z.number().optional(),
total_files_processed: z.number(),
total_bytes_processed: z.number(),
});
export const resticSnapshotSummarySchema = resticSummaryBaseSchema.and(
type({
backup_start: "string",
backup_end: "string",
}),
);
export const resticBackupRunSummarySchema = resticSummaryBaseSchema.and(
type({
total_duration: "number",
snapshot_id: "string",
}),
);
export const resticBackupOutputSchema = resticBackupRunSummarySchema.and(
type({
message_type: "'summary'",
}),
);
export const resticBackupProgressMetricsSchema = type({
seconds_elapsed: "number",
percent_done: "number",
total_files: "number",
files_done: "number",
total_bytes: "number",
bytes_done: "number",
current_files: type("string")
.array()
.default(() => []),
export const resticSnapshotSummarySchema = resticSummaryBaseSchema.extend({
backup_start: z.string(),
backup_end: z.string(),
});
export const resticBackupProgressSchema = resticBackupProgressMetricsSchema.and(
type({
message_type: "'status'",
}),
);
export const resticRestoreOutputSchema = type({
message_type: "'summary'",
total_files: "number?",
files_restored: "number",
files_skipped: "number",
total_bytes: "number?",
bytes_restored: "number?",
bytes_skipped: "number",
export const resticBackupRunSummarySchema = resticSummaryBaseSchema.extend({
total_duration: z.number(),
snapshot_id: z.string(),
});
export const resticStatsSchema = type({
total_size: "number = 0",
total_uncompressed_size: "number = 0",
compression_ratio: "number = 0",
compression_progress: "number = 0",
compression_space_saving: "number = 0",
snapshots_count: "number = 0",
export const resticBackupOutputSchema = resticBackupRunSummarySchema.extend({
message_type: z.literal("summary"),
});
export type ResticSnapshotSummaryDto = typeof resticSnapshotSummarySchema.infer;
export type ResticBackupRunSummaryDto = typeof resticBackupRunSummarySchema.infer;
export type ResticBackupOutputDto = typeof resticBackupOutputSchema.infer;
export type ResticBackupProgressMetricsDto = typeof resticBackupProgressMetricsSchema.infer;
export type ResticBackupProgressDto = typeof resticBackupProgressSchema.infer;
export const resticBackupProgressMetricsSchema = z.object({
seconds_elapsed: z.number(),
seconds_remaining: z.number().default(0),
percent_done: z.number(),
total_files: z.number(),
files_done: z.number(),
total_bytes: z.number(),
bytes_done: z.number(),
current_files: z.array(z.string()).default([]),
});
export type ResticRestoreOutputDto = typeof resticRestoreOutputSchema.infer;
export type ResticStatsDto = typeof resticStatsSchema.infer;
export const resticBackupProgressSchema = resticBackupProgressMetricsSchema.extend({
message_type: z.literal("status"),
});
export const resticRestoreOutputSchema = z.object({
message_type: z.literal("summary"),
total_files: z.number().optional(),
files_restored: z.number(),
files_skipped: z.number(),
total_bytes: z.number().optional(),
bytes_restored: z.number().optional(),
bytes_skipped: z.number(),
});
export const resticStatsSchema = z.object({
total_size: z.number().default(0),
total_uncompressed_size: z.number().default(0),
compression_ratio: z.number().default(0),
compression_progress: z.number().default(0),
compression_space_saving: z.number().default(0),
snapshots_count: z.number().default(0),
});
export type ResticSnapshotSummaryDto = z.infer<typeof resticSnapshotSummarySchema>;
export type ResticBackupRunSummaryDto = z.infer<typeof resticBackupRunSummarySchema>;
export type ResticBackupOutputDto = z.infer<typeof resticBackupOutputSchema>;
export type ResticBackupProgressMetricsDto = z.infer<typeof resticBackupProgressMetricsSchema>;
export type ResticBackupProgressDto = z.infer<typeof resticBackupProgressSchema>;
export type ResticRestoreOutputDto = z.infer<typeof resticRestoreOutputSchema>;
export type ResticStatsDto = z.infer<typeof resticStatsSchema>;

View file

@ -1,4 +1,4 @@
import { type } from "arktype";
import { z } from "zod";
export const REPOSITORY_BACKENDS = {
local: "local",
@ -21,98 +21,120 @@ export const BANDWIDTH_UNITS = {
export type BandwidthUnit = keyof typeof BANDWIDTH_UNITS;
export const bandwidthLimitSchema = type({
enabled: "boolean = false",
value: "number > 0 = 1",
unit: type.valueOf(BANDWIDTH_UNITS).default("Mbps"),
const bandwidthUnitSchema = z.enum(["Kbps", "Mbps", "Gbps"]);
export const bandwidthLimitSchema = z.object({
enabled: z.boolean().default(false),
value: z.number().positive().default(1),
unit: bandwidthUnitSchema.default("Mbps"),
});
export type BandwidthLimit = typeof bandwidthLimitSchema.infer;
export type BandwidthLimit = z.infer<typeof bandwidthLimitSchema>;
// Common fields for all repository configs
const baseRepositoryConfigSchema = type({
isExistingRepository: "boolean?",
customPassword: "string?",
cacert: "string?",
insecureTls: "boolean?",
// Bandwidth controls
const baseRepositoryConfigSchema = z.object({
isExistingRepository: z.boolean().optional(),
customPassword: z.string().optional(),
cacert: z.string().optional(),
insecureTls: z.boolean().optional(),
uploadLimit: bandwidthLimitSchema.optional(),
downloadLimit: bandwidthLimitSchema.optional(),
});
export const s3RepositoryConfigSchema = type({
backend: "'s3'",
endpoint: "string",
bucket: "string",
accessKeyId: "string",
secretAccessKey: "string",
}).and(baseRepositoryConfigSchema);
export const s3RepositoryConfigSchema = z
.object({
backend: z.literal("s3"),
endpoint: z.string().min(1),
bucket: z.string().min(1),
accessKeyId: z.string().min(1),
secretAccessKey: z.string().min(1),
})
.extend(baseRepositoryConfigSchema.shape);
export const r2RepositoryConfigSchema = type({
backend: "'r2'",
endpoint: "string",
bucket: "string",
accessKeyId: "string",
secretAccessKey: "string",
}).and(baseRepositoryConfigSchema);
export const r2RepositoryConfigSchema = z
.object({
backend: z.literal("r2"),
endpoint: z.string().min(1),
bucket: z.string().min(1),
accessKeyId: z.string().min(1),
secretAccessKey: z.string().min(1),
})
.extend(baseRepositoryConfigSchema.shape);
export const localRepositoryConfigSchema = type({
backend: "'local'",
path: "string",
}).and(baseRepositoryConfigSchema);
export const localRepositoryConfigSchema = z
.object({
backend: z.literal("local"),
path: z.string().min(1),
})
.extend(baseRepositoryConfigSchema.shape);
export const gcsRepositoryConfigSchema = type({
backend: "'gcs'",
bucket: "string",
projectId: "string",
credentialsJson: "string",
}).and(baseRepositoryConfigSchema);
export const gcsRepositoryConfigSchema = z
.object({
backend: z.literal("gcs"),
bucket: z.string().min(1),
projectId: z.string().min(1),
credentialsJson: z.string().min(1),
})
.extend(baseRepositoryConfigSchema.shape);
export const azureRepositoryConfigSchema = type({
backend: "'azure'",
container: "string",
accountName: "string",
accountKey: "string",
endpointSuffix: "string?",
}).and(baseRepositoryConfigSchema);
export const azureRepositoryConfigSchema = z
.object({
backend: z.literal("azure"),
container: z.string().min(1),
accountName: z.string().min(1),
accountKey: z.string().min(1),
endpointSuffix: z.string().optional(),
})
.extend(baseRepositoryConfigSchema.shape);
export const rcloneRepositoryConfigSchema = type({
backend: "'rclone'",
remote: "string",
path: "string",
}).and(baseRepositoryConfigSchema);
export const rcloneRepositoryConfigSchema = z
.object({
backend: z.literal("rclone"),
remote: z.string().min(1),
path: z.string().min(1),
})
.extend(baseRepositoryConfigSchema.shape);
export const restRepositoryConfigSchema = type({
backend: "'rest'",
url: "string",
username: "string?",
password: "string?",
path: "string?",
}).and(baseRepositoryConfigSchema);
export const restRepositoryConfigSchema = z
.object({
backend: z.literal("rest"),
url: z.string().min(1),
username: z.string().optional(),
password: z.string().optional(),
path: z.string().optional(),
})
.extend(baseRepositoryConfigSchema.shape);
export const sftpRepositoryConfigSchema = type({
backend: "'sftp'",
host: "string",
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22),
user: "string",
path: "string",
privateKey: "string",
skipHostKeyCheck: "boolean = true",
knownHosts: "string?",
}).and(baseRepositoryConfigSchema);
export const sftpRepositoryConfigSchema = z
.object({
backend: z.literal("sftp"),
host: z.string().min(1),
port: z
.union([z.string(), z.number()])
.transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
.pipe(z.number().int().min(1).max(65535))
.default(22),
user: z.string().min(1),
path: z.string().min(1),
privateKey: z.string().min(1),
skipHostKeyCheck: z.boolean().default(true),
knownHosts: z.string().optional(),
})
.extend(baseRepositoryConfigSchema.shape);
export const repositoryConfigSchemaBase = s3RepositoryConfigSchema
.or(r2RepositoryConfigSchema)
.or(localRepositoryConfigSchema)
.or(gcsRepositoryConfigSchema)
.or(azureRepositoryConfigSchema)
.or(rcloneRepositoryConfigSchema)
.or(restRepositoryConfigSchema)
.or(sftpRepositoryConfigSchema);
export const repositoryConfigSchemaBase = z.discriminatedUnion("backend", [
s3RepositoryConfigSchema,
r2RepositoryConfigSchema,
localRepositoryConfigSchema,
gcsRepositoryConfigSchema,
azureRepositoryConfigSchema,
rcloneRepositoryConfigSchema,
restRepositoryConfigSchema,
sftpRepositoryConfigSchema,
]);
export const repositoryConfigSchema = repositoryConfigSchemaBase.onUndeclaredKey("delete");
export const repositoryConfigSchema = repositoryConfigSchemaBase;
export type RepositoryConfig = typeof repositoryConfigSchema.infer;
export type RepositoryConfig = z.infer<typeof repositoryConfigSchema>;
export const COMPRESSION_MODES = {
off: "off",
@ -132,22 +154,22 @@ export const REPOSITORY_STATUS = {
export type RepositoryStatus = keyof typeof REPOSITORY_STATUS;
export const doctorStepSchema = type({
step: "string",
success: "boolean",
output: "string | null",
error: "string | null",
export const doctorStepSchema = z.object({
step: z.string(),
success: z.boolean(),
output: z.string().nullable(),
error: z.string().nullable(),
});
export type DoctorStep = typeof doctorStepSchema.infer;
export type DoctorStep = z.infer<typeof doctorStepSchema>;
export const doctorResultSchema = type({
success: "boolean",
export const doctorResultSchema = z.object({
success: z.boolean(),
steps: doctorStepSchema.array(),
completedAt: "number",
completedAt: z.number(),
});
export type DoctorResult = typeof doctorResultSchema.infer;
export type DoctorResult = z.infer<typeof doctorResultSchema>;
export const OVERWRITE_MODES = {
always: "always",

View file

@ -1,4 +1,4 @@
import { type } from "arktype";
import { z } from "zod";
export const BACKEND_TYPES = {
nfs: "nfs",
@ -11,75 +11,93 @@ export const BACKEND_TYPES = {
export type BackendType = keyof typeof BACKEND_TYPES;
export const nfsConfigSchema = type({
backend: "'nfs'",
server: "string",
exportPath: "string",
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(2049),
version: "'3' | '4' | '4.1'",
readOnly: "boolean?",
export const nfsConfigSchema = z.object({
backend: z.literal("nfs"),
server: z.string().min(1),
exportPath: z.string().min(1),
port: z
.union([z.string(), z.number()])
.transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
.pipe(z.number().int().min(1).max(65535))
.default(2049),
version: z.enum(["3", "4", "4.1"]),
readOnly: z.boolean().optional(),
});
export const smbConfigSchema = type({
backend: "'smb'",
server: "string",
share: "string",
username: "string?",
password: "string?",
guest: "boolean?",
vers: type("'1.0' | '2.0' | '2.1' | '3.0' | 'auto'").default("auto"),
domain: "string?",
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(445),
readOnly: "boolean?",
export const smbConfigSchema = z.object({
backend: z.literal("smb"),
server: z.string().min(1),
share: z.string().min(1),
username: z.string().optional(),
password: z.string().optional(),
guest: z.boolean().optional(),
vers: z.enum(["1.0", "2.0", "2.1", "3.0", "auto"]).default("auto"),
domain: z.string().optional(),
port: z
.union([z.string(), z.number()])
.transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
.pipe(z.number().int().min(1).max(65535))
.default(445),
readOnly: z.boolean().optional(),
});
export const directoryConfigSchema = type({
backend: "'directory'",
path: "string",
readOnly: "false?",
export const directoryConfigSchema = z.object({
backend: z.literal("directory"),
path: z.string().min(1),
readOnly: z.literal(false).optional(),
});
export const webdavConfigSchema = type({
backend: "'webdav'",
server: "string",
path: "string",
username: "string?",
password: "string?",
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(80),
readOnly: "boolean?",
ssl: "boolean?",
export const webdavConfigSchema = z.object({
backend: z.literal("webdav"),
server: z.string().min(1),
path: z.string().min(1),
username: z.string().optional(),
password: z.string().optional(),
port: z
.union([z.string(), z.number()])
.transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
.pipe(z.number().int().min(1).max(65535))
.default(80),
readOnly: z.boolean().optional(),
ssl: z.boolean().optional(),
});
export const rcloneConfigSchema = type({
backend: "'rclone'",
remote: "string",
path: "string",
readOnly: "boolean?",
export const rcloneConfigSchema = z.object({
backend: z.literal("rclone"),
remote: z.string().min(1),
path: z.string().min(1),
readOnly: z.boolean().optional(),
});
export const sftpConfigSchema = type({
backend: "'sftp'",
host: "string",
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22),
username: "string",
password: "string?",
privateKey: "string?",
path: "string",
readOnly: "boolean?",
skipHostKeyCheck: "boolean = true",
knownHosts: "string?",
export const sftpConfigSchema = z.object({
backend: z.literal("sftp"),
host: z.string().min(1),
port: z
.union([z.string(), z.number()])
.transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
.pipe(z.number().int().min(1).max(65535))
.default(22),
username: z.string().min(1),
password: z.string().optional(),
privateKey: z.string().optional(),
path: z.string().min(1),
readOnly: z.boolean().optional(),
skipHostKeyCheck: z.boolean().default(true),
knownHosts: z.string().optional(),
});
export const volumeConfigSchemaBase = nfsConfigSchema
.or(smbConfigSchema)
.or(webdavConfigSchema)
.or(directoryConfigSchema)
.or(rcloneConfigSchema)
.or(sftpConfigSchema);
export const volumeConfigSchemaBase = z.discriminatedUnion("backend", [
nfsConfigSchema,
smbConfigSchema,
webdavConfigSchema,
directoryConfigSchema,
rcloneConfigSchema,
sftpConfigSchema,
]);
export const volumeConfigSchema = volumeConfigSchemaBase.onUndeclaredKey("delete");
export const volumeConfigSchema = volumeConfigSchemaBase;
export type BackendConfig = typeof volumeConfigSchema.infer;
export type BackendConfig = z.infer<typeof volumeConfigSchema>;
export const BACKEND_STATUS = {
mounted: "mounted",

View file

@ -7,6 +7,7 @@ import { secureHeaders } from "hono/secure-headers";
import { rateLimiter } from "hono-rate-limiter";
import { openAPIRouteHandler } from "hono-openapi";
import { authController } from "./modules/auth/auth.controller";
import { ssoController } from "./modules/sso/sso.controller";
import { requireAuth } from "./modules/auth/auth.middleware";
import { repositoriesController } from "./modules/repositories/repositories.controller";
import { systemController } from "./modules/system/system.controller";
@ -74,6 +75,7 @@ export const createApp = () => {
app
.get("/api/healthcheck", (c) => c.json({ status: "ok" }))
.route("/api/v1/auth", authController)
.route("/api/v1/auth", ssoController)
.route("/api/v1/volumes", volumeController)
.route("/api/v1/repositories", repositoriesController)
.route("/api/v1/backups", backupScheduleController)

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