feat(authentication): api key (#966)

* feat(authentication): api key

Keeps selected UX pieces from b487b096.

Co-authored-by: Nguyen Quy Hy <nguyenquyhy@live.com.sg>

* refactor: pr feedbacks

* chore: bump @better-auth/api-key

* refactor: global limit of 50 api key instead of 10 per org

---------

Co-authored-by: Nguyen Quy Hy <nguyenquyhy@live.com.sg>
This commit is contained in:
Nico
2026-06-12 20:14:21 +02:00
committed by GitHub
parent bd46bd1156
commit 283de054ec
26 changed files with 8414 additions and 46 deletions

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, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getMirrorSyncStatus, getNotificationDestination, getOrgMembers, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getUserSsoInvitations, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshRepositoryStats, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, startInvitationSsoVerification, stopBackup, syncMirror, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetMirrorSyncStatusData, GetMirrorSyncStatusResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetUserSsoInvitationsData, GetUserSsoInvitationsResponse, 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, StartInvitationSsoVerificationData, StopBackupData, StopBackupResponse, SyncMirrorData, SyncMirrorResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
import { browseFilesystem, cancelDoctor, createApiKey, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteApiKey, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, downloadResticPassword, dumpSnapshot, getAdminUsers, getApiKeys, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getMirrorSyncStatus, getNotificationDestination, getOrgMembers, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getUserSsoInvitations, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshRepositoryStats, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, startInvitationSsoVerification, stopBackup, syncMirror, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateApiKeyData, CreateApiKeyResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteApiKeyData, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetApiKeysData, GetApiKeysResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetMirrorSyncStatusData, GetMirrorSyncStatusResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetUserSsoInvitationsData, GetUserSsoInvitationsResponse, 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, StartInvitationSsoVerificationData, StopBackupData, StopBackupResponse, SyncMirrorData, SyncMirrorResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
export type QueryKey<TOptions extends Options> = [
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
@@ -111,6 +111,58 @@ export const getUserDeletionImpactOptions = (options: Options<GetUserDeletionImp
queryKey: getUserDeletionImpactQueryKey(options)
});
export const getApiKeysQueryKey = (options?: Options<GetApiKeysData>) => createQueryKey('getApiKeys', options);
/**
* List API keys for the current user in the active organization
*/
export const getApiKeysOptions = (options?: Options<GetApiKeysData>) => queryOptions<GetApiKeysResponse, DefaultError, GetApiKeysResponse, ReturnType<typeof getApiKeysQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getApiKeys({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getApiKeysQueryKey(options)
});
/**
* Create an API key for the current user in the active organization
*/
export const createApiKeyMutation = (options?: Partial<Options<CreateApiKeyData>>): UseMutationOptions<CreateApiKeyResponse, DefaultError, Options<CreateApiKeyData>> => {
const mutationOptions: UseMutationOptions<CreateApiKeyResponse, DefaultError, Options<CreateApiKeyData>> = {
mutationFn: async (fnOptions) => {
const { data } = await createApiKey({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Revoke an API key for the current user in the active organization
*/
export const deleteApiKeyMutation = (options?: Partial<Options<DeleteApiKeyData>>): UseMutationOptions<unknown, DefaultError, Options<DeleteApiKeyData>> => {
const mutationOptions: UseMutationOptions<unknown, DefaultError, Options<DeleteApiKeyData>> = {
mutationFn: async (fnOptions) => {
const { data } = await deleteApiKey({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getOrgMembersQueryKey = (options?: Options<GetOrgMembersData>) => createQueryKey('getOrgMembers', options);
/**

View File

@@ -4,10 +4,12 @@
export {
browseFilesystem,
cancelDoctor,
createApiKey,
createBackupSchedule,
createNotificationDestination,
createRepository,
createVolume,
deleteApiKey,
deleteBackupSchedule,
deleteNotificationDestination,
deleteRepository,
@@ -21,6 +23,7 @@ export {
downloadResticPassword,
dumpSnapshot,
getAdminUsers,
getApiKeys,
getBackupProgress,
getBackupSchedule,
getBackupScheduleForVolume,
@@ -89,6 +92,10 @@ export type {
CancelDoctorResponse,
CancelDoctorResponses,
ClientOptions,
CreateApiKeyData,
CreateApiKeyErrors,
CreateApiKeyResponse,
CreateApiKeyResponses,
CreateBackupScheduleData,
CreateBackupScheduleResponse,
CreateBackupScheduleResponses,
@@ -101,6 +108,9 @@ export type {
CreateVolumeData,
CreateVolumeResponse,
CreateVolumeResponses,
DeleteApiKeyData,
DeleteApiKeyErrors,
DeleteApiKeyResponses,
DeleteBackupScheduleData,
DeleteBackupScheduleResponse,
DeleteBackupScheduleResponses,
@@ -142,6 +152,9 @@ export type {
GetAdminUsersData,
GetAdminUsersResponse,
GetAdminUsersResponses,
GetApiKeysData,
GetApiKeysResponse,
GetApiKeysResponses,
GetBackupProgressData,
GetBackupProgressResponse,
GetBackupProgressResponses,

View File

@@ -3,7 +3,7 @@
import type { Client, ClientMeta, Options as Options2, RequestResult, ServerSentEventsResult, 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, DevPanelExecResponse, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetMirrorSyncStatusData, GetMirrorSyncStatusResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetUserSsoInvitationsData, GetUserSsoInvitationsResponses, 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, StartInvitationSsoVerificationData, StartInvitationSsoVerificationErrors, StartInvitationSsoVerificationResponses, StopBackupData, StopBackupErrors, StopBackupResponses, SyncMirrorData, SyncMirrorErrors, SyncMirrorResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateApiKeyData, CreateApiKeyErrors, CreateApiKeyResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteApiKeyData, DeleteApiKeyErrors, DeleteApiKeyResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponse, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetApiKeysData, GetApiKeysResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetMirrorSyncStatusData, GetMirrorSyncStatusResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetUserSsoInvitationsData, GetUserSsoInvitationsResponses, 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, StartInvitationSsoVerificationData, StartInvitationSsoVerificationErrors, StartInvitationSsoVerificationResponses, StopBackupData, StopBackupErrors, StopBackupResponses, SyncMirrorData, SyncMirrorErrors, SyncMirrorResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/**
@@ -39,6 +39,28 @@ export const deleteUserAccount = <ThrowOnError extends boolean = false>(options:
*/
export const getUserDeletionImpact = <ThrowOnError extends boolean = false>(options: Options<GetUserDeletionImpactData, ThrowOnError>): RequestResult<GetUserDeletionImpactResponses, unknown, ThrowOnError> => (options.client ?? client).get<GetUserDeletionImpactResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/deletion-impact/{userId}', ...options });
/**
* List API keys for the current user in the active organization
*/
export const getApiKeys = <ThrowOnError extends boolean = false>(options?: Options<GetApiKeysData, ThrowOnError>) => (options?.client ?? client).get<GetApiKeysResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/api-keys', ...options });
/**
* Create an API key for the current user in the active organization
*/
export const createApiKey = <ThrowOnError extends boolean = false>(options: Options<CreateApiKeyData, ThrowOnError>) => (options.client ?? client).post<CreateApiKeyResponses, CreateApiKeyErrors, ThrowOnError>({
url: '/api/v1/auth/api-keys',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Revoke an API key for the current user in the active organization
*/
export const deleteApiKey = <ThrowOnError extends boolean = false>(options: Options<DeleteApiKeyData, ThrowOnError>) => (options.client ?? client).delete<DeleteApiKeyResponses, DeleteApiKeyErrors, ThrowOnError>({ url: '/api/v1/auth/api-keys/{keyId}', ...options });
/**
* Get members of the active organization
*/

View File

@@ -108,6 +108,96 @@ export type GetUserDeletionImpactResponses = {
export type GetUserDeletionImpactResponse = GetUserDeletionImpactResponses[keyof GetUserDeletionImpactResponses];
export type GetApiKeysData = {
body?: never;
path?: never;
query?: never;
url: '/api/v1/auth/api-keys';
};
export type GetApiKeysResponses = {
/**
* List of API keys
*/
200: {
apiKeys: Array<{
id: string;
name: string | null;
createdAt: string;
expiresAt: string | null;
lastRequestAt: string | null;
}>;
limit: number;
};
};
export type GetApiKeysResponse = GetApiKeysResponses[keyof GetApiKeysResponses];
export type CreateApiKeyData = {
body: {
name: string;
password: string;
expiresIn?: number | null;
};
path?: never;
query?: never;
url: '/api/v1/auth/api-keys';
};
export type CreateApiKeyErrors = {
/**
* Invalid password
*/
401: unknown;
/**
* Local credential password required
*/
403: unknown;
/**
* API key limit reached
*/
409: unknown;
};
export type CreateApiKeyResponses = {
/**
* API key created
*/
200: {
id: string;
name: string | null;
createdAt: string;
expiresAt: string | null;
lastRequestAt: string | null;
key: string;
};
};
export type CreateApiKeyResponse = CreateApiKeyResponses[keyof CreateApiKeyResponses];
export type DeleteApiKeyData = {
body?: never;
path: {
keyId: string;
};
query?: never;
url: '/api/v1/auth/api-keys/{keyId}';
};
export type DeleteApiKeyErrors = {
/**
* API key not found
*/
404: unknown;
};
export type DeleteApiKeyResponses = {
/**
* API key revoked
*/
200: unknown;
};
export type GetOrgMembersData = {
body?: never;
path?: never;

View File

@@ -8,6 +8,7 @@ import {
} from "better-auth/client/plugins";
import { ssoClient } from "@better-auth/sso/client";
import { passkeyClient } from "@better-auth/passkey/client";
import { apiKeyClient } from "@better-auth/api-key/client";
import type { auth } from "~/server/lib/auth";
export const authClient = createAuthClient({
@@ -19,5 +20,6 @@ export const authClient = createAuthClient({
ssoClient(),
twoFactorClient(),
passkeyClient(),
apiKeyClient(),
],
});

View File

@@ -0,0 +1,311 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { KeyRound, Plus, Trash2, X } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
createApiKeyMutation,
deleteApiKeyMutation,
getApiKeysOptions,
} from "~/client/api-client/@tanstack/react-query.gen";
import type { GetApiKeysResponse } from "~/client/api-client/types.gen";
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import { Button } from "~/client/components/ui/button";
import { CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/client/components/ui/dialog";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { useTimeFormat } from "~/client/lib/datetime";
import { cn } from "~/client/lib/utils";
type Props = {
hasCredentialPassword: boolean;
};
type ApiKey = GetApiKeysResponse["apiKeys"][number];
const EXPIRATION_OPTIONS = [
{ value: "30", label: "30 days" },
{ value: "90", label: "90 days" },
{ value: "365", label: "1 year" },
{ value: "never", label: "No expiration" },
] as const;
type ExpirationValue = (typeof EXPIRATION_OPTIONS)[number]["value"];
export function ApiKeysSection({ hasCredentialPassword }: Props) {
const { formatDateTime } = useTimeFormat();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [newKeyName, setNewKeyName] = useState("");
const [newKeyExpiration, setNewKeyExpiration] = useState<ExpirationValue>("365");
const [password, setPassword] = useState("");
const [createdKey, setCreatedKey] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ApiKey | null>(null);
const { data, isPending } = useQuery(getApiKeysOptions());
const apiKeys = data?.apiKeys ?? [];
const limit = data?.limit ?? 50;
const createKey = useMutation({
...createApiKeyMutation(),
onSuccess: (apiKey) => {
toast.success("API key created");
setCreatedKey(apiKey.key);
setNewKeyName("");
setPassword("");
},
onError: (error) => {
toast.error("Failed to create API key", { description: error.message });
},
});
const deleteKey = useMutation({
...deleteApiKeyMutation(),
onSuccess: () => {
toast.success("API key revoked");
setDeleteTarget(null);
},
onError: (error) => {
toast.error("Failed to revoke API key", { description: error.message });
},
});
const closeCreateDialog = () => {
setCreateDialogOpen(false);
setNewKeyName("");
setNewKeyExpiration("365");
setPassword("");
setCreatedKey(null);
};
const handleCreate = (event: React.SubmitEvent<HTMLFormElement>) => {
event.preventDefault();
const name = newKeyName.trim();
if (!name) {
toast.error("Name is required");
return;
}
if (!password) {
toast.error("Password is required");
return;
}
const expiresIn = newKeyExpiration === "never" ? null : Number(newKeyExpiration) * 24 * 60 * 60;
createKey.mutate({ body: { name, password, expiresIn } });
};
return (
<>
<div className="border-t border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<KeyRound className="size-5" />
API Keys
</CardTitle>
<CardDescription className="mt-1.5">
Create keys for API access to the active organization.
</CardDescription>
</div>
<CardContent className="p-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<p className="text-sm font-medium">{apiKeys.length} active keys for this organization</p>
<p className="text-xs text-muted-foreground">Limit {limit} active keys per user.</p>
</div>
<Button type="button" disabled={!hasCredentialPassword} onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Create key
</Button>
</div>
<Alert variant="warning" className={cn({ hidden: hasCredentialPassword })}>
<KeyRound className="size-5" />
<AlertTitle>Local password required</AlertTitle>
<AlertDescription>
A local credential password is required before API keys can be created.
</AlertDescription>
</Alert>
<p className={cn("text-sm text-muted-foreground", { hidden: !isPending })}>Loading API keys...</p>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Created</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Last used</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className={cn({ hidden: isPending || apiKeys.length > 0 })}>
<TableCell colSpan={5} className="text-center text-sm text-muted-foreground">
No API keys yet.
</TableCell>
</TableRow>
{apiKeys.map((apiKey) => (
<TableRow key={apiKey.id}>
<TableCell className="font-medium">{apiKey.name ?? "Unnamed key"}</TableCell>
<TableCell>{formatDateTime(new Date(apiKey.createdAt))}</TableCell>
<TableCell>
{apiKey.expiresAt ? formatDateTime(new Date(apiKey.expiresAt)) : "Never"}
</TableCell>
<TableCell>
{apiKey.lastRequestAt
? formatDateTime(new Date(apiKey.lastRequestAt))
: "Never"}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
title="Revoke API key"
aria-label={`Revoke API key ${apiKey.name ?? "Unnamed key"}`}
onClick={() => setDeleteTarget(apiKey)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
<Dialog
open={createDialogOpen}
onOpenChange={(open) => {
if (open) setCreateDialogOpen(true);
else closeCreateDialog();
}}
>
<DialogContent>
<form onSubmit={handleCreate} className="min-w-0">
<DialogHeader>
<DialogTitle>{createdKey ? "API key created" : "Create API key"}</DialogTitle>
<DialogDescription>
{createdKey
? "Save this key now. For security reasons it will not be shown again."
: "The key is shown once after creation and cannot be revealed later."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className={cn("space-y-2", { hidden: Boolean(createdKey) })}>
<Label htmlFor="api-key-name">Name</Label>
<Input
id="api-key-name"
value={newKeyName}
onChange={(event) => setNewKeyName(event.target.value)}
maxLength={32}
required
/>
</div>
<div className={cn("space-y-2", { hidden: Boolean(createdKey) })}>
<Label htmlFor="api-key-expiration">Expiration</Label>
<Select
value={newKeyExpiration}
onValueChange={(value) => setNewKeyExpiration(value as ExpirationValue)}
>
<SelectTrigger id="api-key-expiration">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXPIRATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className={cn("space-y-2", { hidden: Boolean(createdKey) })}>
<Label htmlFor="api-key-password">Current password</Label>
<Input
id="api-key-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</div>
<div className={cn("min-w-0", { hidden: !createdKey })}>
<div className="min-w-0 space-y-2">
<Label htmlFor="created-api-key">API key</Label>
<Input
id="created-api-key"
type="text"
readOnly
value={createdKey ?? ""}
className="font-mono text-sm"
onClick={(e) => e.currentTarget.select()}
/>
</div>
</div>
</div>
<DialogFooter>
<Button type="button" onClick={closeCreateDialog} className={cn({ hidden: !createdKey })}>
Done
</Button>
<span className={cn("flex items-center gap-2", { hidden: Boolean(createdKey) })}>
<Button type="button" variant="outline" onClick={closeCreateDialog}>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button type="submit" loading={createKey.isPending}>
<KeyRound className="h-4 w-4 mr-2" />
Create
</Button>
</span>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog open={Boolean(deleteTarget)} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke API key?</AlertDialogTitle>
<AlertDialogDescription>
This will revoke "{deleteTarget?.name ?? "this key"}". Future requests using it will fail.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteKey.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(event) => {
event.preventDefault();
if (deleteTarget) {
deleteKey.mutate({ path: { keyId: deleteTarget.id } });
}
}}
disabled={deleteKey.isPending}
>
Revoke
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -47,6 +47,7 @@ import { parseError } from "~/client/lib/errors";
import { type AppContext } from "~/context";
import { TwoFactorSection } from "../components/two-factor-section";
import { PasskeysSection } from "../components/passkeys-section";
import { ApiKeysSection } from "../components/api-keys-section";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section";
import { OrgMembersSection } from "../components/org-members-section";
@@ -475,6 +476,8 @@ export function SettingsPage({
</Dialog>
</CardContent>
<ApiKeysSection hasCredentialPassword={hasCredentialPassword} />
<TwoFactorSection twoFactorEnabled={appContext.user?.twoFactorEnabled} />
<PasskeysSection />

View File

@@ -0,0 +1,28 @@
CREATE TABLE `apikey` (
`id` text PRIMARY KEY,
`config_id` text DEFAULT 'default' NOT NULL,
`name` text,
`start` text,
`reference_id` text NOT NULL,
`prefix` text,
`key` text NOT NULL,
`refill_interval` integer,
`refill_amount` integer,
`last_refill_at` integer,
`enabled` integer DEFAULT true,
`rate_limit_enabled` integer DEFAULT false,
`rate_limit_time_window` integer,
`rate_limit_max` integer,
`request_count` integer DEFAULT 0,
`remaining` integer,
`last_request` integer,
`expires_at` integer,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`permissions` text,
`metadata` text
);
--> statement-breakpoint
CREATE INDEX `apikey_configId_idx` ON `apikey` (`config_id`);--> statement-breakpoint
CREATE INDEX `apikey_referenceId_idx` ON `apikey` (`reference_id`);--> statement-breakpoint
CREATE INDEX `apikey_key_idx` ON `apikey` (`key`);

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_apikey` (
`id` text PRIMARY KEY,
`config_id` text DEFAULT 'default' NOT NULL,
`name` text,
`start` text,
`reference_id` text NOT NULL,
`prefix` text,
`key` text NOT NULL,
`refill_interval` integer,
`refill_amount` integer,
`last_refill_at` integer,
`enabled` integer DEFAULT true,
`rate_limit_enabled` integer DEFAULT false,
`rate_limit_time_window` integer,
`rate_limit_max` integer,
`request_count` integer DEFAULT 0,
`remaining` integer,
`last_request` integer,
`expires_at` integer,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`permissions` text,
`metadata` text,
CONSTRAINT `fk_apikey_reference_id_users_table_id_fk` FOREIGN KEY (`reference_id`) REFERENCES `users_table`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
INSERT INTO `__new_apikey`(`id`, `config_id`, `name`, `start`, `reference_id`, `prefix`, `key`, `refill_interval`, `refill_amount`, `last_refill_at`, `enabled`, `rate_limit_enabled`, `rate_limit_time_window`, `rate_limit_max`, `request_count`, `remaining`, `last_request`, `expires_at`, `created_at`, `updated_at`, `permissions`, `metadata`) SELECT `id`, `config_id`, `name`, `start`, `reference_id`, `prefix`, `key`, `refill_interval`, `refill_amount`, `last_refill_at`, `enabled`, `rate_limit_enabled`, `rate_limit_time_window`, `rate_limit_max`, `request_count`, `remaining`, `last_request`, `expires_at`, `created_at`, `updated_at`, `permissions`, `metadata` FROM `apikey`;--> statement-breakpoint
DROP TABLE `apikey`;--> statement-breakpoint
ALTER TABLE `__new_apikey` RENAME TO `apikey`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
DROP INDEX IF EXISTS `apikey_key_idx`;--> statement-breakpoint
CREATE INDEX `apikey_configId_idx` ON `apikey` (`config_id`);--> statement-breakpoint
CREATE INDEX `apikey_referenceId_idx` ON `apikey` (`reference_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `apikey_key_unique` ON `apikey` (`key`);

View File

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,7 @@ import { volumeController } from "./modules/volumes/volume.controller";
import { backupScheduleController } from "./modules/backups/backups.controller";
import { eventsController } from "./modules/events/events.controller";
import { notificationsController } from "./modules/notifications/notifications.controller";
import { apiKeysController } from "./modules/api-keys/api-keys.controller";
import { handleServiceError } from "./utils/errors";
import { logger } from "@zerobyte/core/node";
import { config } from "./core/config";
@@ -84,9 +85,9 @@ export const createApp = () => {
}),
);
app
.get("/api/healthcheck", (c) => c.json({ status: "ok" }))
app.get("/api/healthcheck", (c) => c.json({ status: "ok" }))
.route("/api/v1/auth", authController)
.route("/api/v1/auth", apiKeysController)
.route("/api/v1/auth", ssoController)
.route("/api/v1/volumes", volumeController)
.route("/api/v1/repositories", repositoriesController)
@@ -95,7 +96,18 @@ export const createApp = () => {
.route("/api/v1/system", systemController)
.route("/api/v1/events", eventsController);
app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw));
app.on(["POST", "GET"], "/api/auth/*", (c) => {
const pathname = new URL(c.req.url).pathname;
if (pathname.startsWith("/api/auth/api-key/")) {
return c.json({ message: "API key management is only supported through API v1 routes" }, 404);
}
if (c.req.header("x-api-key")) {
return c.json({ message: "API key authentication is only supported for API v1 routes" }, 401);
}
return auth.handler(c.req.raw);
});
app.get("/api/v1/openapi.json", generalDescriptor(app));
app.get("/api/v1/docs", requireAuth, scalarDescriptor);

View File

@@ -574,3 +574,48 @@ export const passkey = sqliteTable(
(table) => [index("passkey_userId_idx").on(table.userId), index("passkey_credentialID_idx").on(table.credentialID)],
);
export type Passkey = typeof passkey.$inferSelect;
export type ApiKeyMetadata = {
organizationId: string;
};
export const apikey = sqliteTable(
"apikey",
{
id: text("id").primaryKey(),
configId: text("config_id").notNull().default("default"),
name: text("name"),
start: text("start"),
referenceId: text("reference_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
prefix: text("prefix"),
key: text("key").notNull(),
refillInterval: integer("refill_interval"),
refillAmount: integer("refill_amount"),
lastRefillAt: integer("last_refill_at", { mode: "timestamp_ms" }),
enabled: integer("enabled", { mode: "boolean" }).default(true),
rateLimitEnabled: integer("rate_limit_enabled", { mode: "boolean" }).default(false),
rateLimitTimeWindow: integer("rate_limit_time_window"),
rateLimitMax: integer("rate_limit_max"),
requestCount: integer("request_count").default(0),
remaining: integer("remaining"),
lastRequest: integer("last_request", { mode: "timestamp_ms" }),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.notNull()
.$onUpdate(() => new Date())
.default(sql`(unixepoch() * 1000)`),
permissions: text("permissions"),
metadata: text("metadata"),
},
(table) => [
index("apikey_configId_idx").on(table.configId),
index("apikey_referenceId_idx").on(table.referenceId),
uniqueIndex("apikey_key_unique").on(table.key),
],
);
export type ApiKey = typeof apikey.$inferSelect;

View File

@@ -8,6 +8,7 @@ import {
import { APIError } from "better-auth/api";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin, twoFactor, username, organization, testUtils } from "better-auth/plugins";
import { apiKey } from "@better-auth/api-key";
import { passkey } from "@better-auth/passkey";
import { createAuthMiddleware } from "better-auth/api";
import { config } from "../core/config";
@@ -196,6 +197,10 @@ export const auth = betterAuth({
},
},
}),
apiKey({
defaultPrefix: "zb_",
enableMetadata: true,
}),
tanstackStartCookies(),
...(process.env.NODE_ENV === "test" ? [testUtils()] : []),
],

View File

@@ -0,0 +1,12 @@
import { safeJsonParse } from "@zerobyte/core/utils";
import { z } from "zod";
const apiKeyMetadataSchema = z.object({
organizationId: z.string(),
});
export const parseApiKeyOrganizationId = (metadata: string | null | undefined) => {
const parsed = apiKeyMetadataSchema.safeParse(safeJsonParse(metadata));
return parsed.success ? parsed.data.organizationId : null;
};

View File

@@ -0,0 +1,86 @@
import { Hono } from "hono";
import { validator } from "hono-openapi";
import {
createApiKeyBody,
createApiKeyDto,
deleteApiKeyDto,
getApiKeysDto,
type CreateApiKeyDto,
type ListApiKeysDto,
} from "./api-keys.dto";
import { MAX_API_KEYS_PER_USER, countActiveApiKeys, hasApiKey, listApiKeys } from "./api-keys.service";
import { requireAuth, requireBrowserSession } from "../auth/auth.middleware";
import { auth } from "~/server/lib/auth";
import { userHasCredentialPassword, verifyUserPassword } from "../auth/helpers";
export const apiKeysController = new Hono()
.get("/api-keys", requireAuth, requireBrowserSession, getApiKeysDto, async (c) => {
const user = c.get("user");
const organizationId = c.get("organizationId");
const apiKeys = await listApiKeys(user.id, organizationId);
return c.json<ListApiKeysDto>({ apiKeys, limit: MAX_API_KEYS_PER_USER });
})
.post(
"/api-keys",
requireAuth,
requireBrowserSession,
createApiKeyDto,
validator("json", createApiKeyBody),
async (c) => {
const user = c.get("user");
const organizationId = c.get("organizationId");
const { expiresIn, name, password } = c.req.valid("json");
const hasCredentialPassword = await userHasCredentialPassword(user.id);
if (!hasCredentialPassword) {
return c.json({ message: "A local credential password is required to create API keys" }, 403);
}
const isPasswordValid = await verifyUserPassword({ userId: user.id, password });
if (!isPasswordValid) {
return c.json({ message: "Invalid password" }, 401);
}
const apiKeyCount = await countActiveApiKeys(user.id);
if (apiKeyCount >= MAX_API_KEYS_PER_USER) {
return c.json({ message: "API key limit reached" }, 409);
}
const apiKey = await auth.api.createApiKey({
body: {
name,
expiresIn: expiresIn ?? undefined,
userId: user.id,
metadata: { organizationId },
rateLimitEnabled: false,
},
});
return c.json<CreateApiKeyDto>({
id: apiKey.id,
name: apiKey.name,
key: apiKey.key,
createdAt: apiKey.createdAt.toISOString(),
expiresAt: apiKey.expiresAt?.toISOString() ?? null,
lastRequestAt: apiKey.lastRequest?.toISOString() ?? null,
});
},
)
.delete("/api-keys/:keyId", requireAuth, requireBrowserSession, deleteApiKeyDto, async (c) => {
const user = c.get("user");
const organizationId = c.get("organizationId");
const keyId = c.req.param("keyId");
const belongsToUserOrganization = await hasApiKey(user.id, organizationId, keyId);
if (!belongsToUserOrganization) {
return c.json({ message: "API key not found" }, 404);
}
await auth.api.deleteApiKey({
headers: c.req.raw.headers,
body: { keyId },
});
return c.json({ success: true });
});

View File

@@ -0,0 +1,84 @@
import { z } from "zod";
import { describeRoute, resolver } from "hono-openapi";
const apiKeyResponse = z.object({
id: z.string(),
name: z.string().nullable(),
createdAt: z.string(),
expiresAt: z.string().nullable(),
lastRequestAt: z.string().nullable(),
});
const listApiKeysResponse = z.object({
apiKeys: apiKeyResponse.array(),
limit: z.number(),
});
export type ListApiKeysDto = z.infer<typeof listApiKeysResponse>;
export const getApiKeysDto = describeRoute({
description: "List API keys for the current user in the active organization",
operationId: "getApiKeys",
tags: ["API Keys"],
responses: {
200: {
description: "List of API keys",
content: {
"application/json": {
schema: resolver(listApiKeysResponse),
},
},
},
},
});
export const createApiKeyBody = z.object({
name: z.string().trim().min(1).max(32),
password: z.string(),
expiresIn: z.number().int().min(1).nullable().optional(),
});
const createApiKeyResponse = apiKeyResponse.extend({
key: z.string(),
});
export type CreateApiKeyDto = z.infer<typeof createApiKeyResponse>;
export const createApiKeyDto = describeRoute({
description: "Create an API key for the current user in the active organization",
operationId: "createApiKey",
tags: ["API Keys"],
responses: {
200: {
description: "API key created",
content: {
"application/json": {
schema: resolver(createApiKeyResponse),
},
},
},
401: {
description: "Invalid password",
},
403: {
description: "Local credential password required",
},
409: {
description: "API key limit reached",
},
},
});
export const deleteApiKeyDto = describeRoute({
description: "Revoke an API key for the current user in the active organization",
operationId: "deleteApiKey",
tags: ["API Keys"],
responses: {
200: {
description: "API key revoked",
},
404: {
description: "API key not found",
},
},
});

View File

@@ -0,0 +1,62 @@
import { db } from "~/server/db/db";
import { parseApiKeyOrganizationId } from "./api-key-metadata";
export const MAX_API_KEYS_PER_USER = 50;
export const listApiKeys = async (userId: string, organizationId: string) => {
const rows = await db.query.apikey.findMany({
where: {
AND: [
{ referenceId: userId },
{ OR: [{ enabled: true }, { enabled: { isNull: true } }] },
{ OR: [{ expiresAt: { isNull: true } }, { expiresAt: { gt: new Date() } }] },
],
},
orderBy: (table, { desc }) => [desc(table.createdAt)],
});
return rows
.filter((row) => parseApiKeyOrganizationId(row.metadata) === organizationId)
.map((row) => ({
id: row.id,
name: row.name,
createdAt: row.createdAt.toISOString(),
expiresAt: row.expiresAt?.toISOString() ?? null,
lastRequestAt: row.lastRequest?.toISOString() ?? null,
}));
};
export const countActiveApiKeys = async (userId: string) => {
const rows = await db.query.apikey.findMany({
where: {
AND: [
{ referenceId: userId },
{ OR: [{ enabled: true }, { enabled: { isNull: true } }] },
{ OR: [{ expiresAt: { isNull: true } }, { expiresAt: { gt: new Date() } }] },
],
},
columns: { id: true },
});
return rows.length;
};
export const hasApiKey = async (userId: string, organizationId: string, apiKeyId: string) => {
const row = await db.query.apikey.findFirst({
where: {
AND: [{ id: apiKeyId }, { referenceId: userId }],
},
columns: { metadata: true },
});
return parseApiKeyOrganizationId(row?.metadata ?? null) === organizationId;
};
export const getApiKeyOrganizationId = async (apiKeyId: string) => {
const apiKeyRecord = await db.query.apikey.findFirst({
where: { id: apiKeyId },
columns: { metadata: true },
});
return parseApiKeyOrganizationId(apiKeyRecord?.metadata);
};

View File

@@ -0,0 +1,435 @@
import { beforeEach, describe, expect, test } from "vitest";
import { and, eq } from "drizzle-orm";
import { hashPassword } from "better-auth/crypto";
import { createApp } from "~/server/app";
import { auth } from "~/server/lib/auth";
import { db } from "~/server/db/db";
import { account, apikey, member, organization, sessionsTable } from "~/server/db/schema";
import {
createTestSession,
createTestSessionWithGlobalAdmin,
createTestSessionWithOrgAdmin,
} from "~/test/helpers/auth";
import { randomId, randomSlug } from "~/test/helpers/user-org";
const app = createApp();
type TestSession = {
headers: Record<string, string>;
user: { id: string };
organizationId: string;
};
type CreatedApiKey = {
id: string;
name: string | null;
key: string;
createdAt: string;
expiresAt: string | null;
lastRequestAt: string | null;
};
beforeEach(async () => {
await db.delete(apikey);
});
async function addCredentialPassword(session: TestSession, password = "correct-password") {
await db.insert(account).values({
id: randomId(),
accountId: randomSlug("credential"),
providerId: "credential",
userId: session.user.id,
password: await hashPassword(password),
});
}
async function createApiKey(session: TestSession, name = randomSlug("api-key"), expiresIn?: number | null) {
const body =
expiresIn === undefined
? { name, password: "correct-password" }
: { name, password: "correct-password", expiresIn };
const res = await app.request("/api/v1/auth/api-keys", {
method: "POST",
headers: {
...session.headers,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
expect(res.status).toBe(200);
return (await res.json()) as CreatedApiKey;
}
async function createStoredApiKey(session: TestSession, organizationId = session.organizationId) {
return auth.api.createApiKey({
body: {
name: randomSlug("api-key"),
userId: session.user.id,
metadata: { organizationId },
rateLimitEnabled: false,
},
});
}
describe("API keys", () => {
test("creates and lists API keys for the current organization after password confirmation", async () => {
const session = await createTestSession();
await addCredentialPassword(session);
const created = await createApiKey(session, "Nightly automation");
expect(created.key).toEqual(expect.any(String));
expect(created.key.startsWith("zb_")).toBe(true);
expect(created.name).toBe("Nightly automation");
expect(created.expiresAt).toBe(null);
const listRes = await app.request("/api/v1/auth/api-keys", { headers: session.headers });
expect(listRes.status).toBe(200);
const body = (await listRes.json()) as {
apiKeys: Array<{
id: string;
name: string | null;
createdAt: string;
expiresAt: string | null;
lastRequestAt: string | null;
key?: string;
}>;
limit: number;
};
expect(body.limit).toBe(50);
expect(body.apiKeys).toEqual([
{
id: created.id,
name: "Nightly automation",
createdAt: created.createdAt,
expiresAt: null,
lastRequestAt: null,
},
]);
expect(body.apiKeys[0]).not.toHaveProperty("key");
});
test("creates API keys with an optional expiration", async () => {
const session = await createTestSession();
await addCredentialPassword(session);
const expiresIn = 30 * 24 * 60 * 60;
const created = await createApiKey(session, "Monthly automation", expiresIn);
expect(created.expiresAt).toEqual(expect.any(String));
const listRes = await app.request("/api/v1/auth/api-keys", { headers: session.headers });
expect(listRes.status).toBe(200);
const body = (await listRes.json()) as { apiKeys: Array<{ id: string; expiresAt: string | null }> };
expect(body.apiKeys).toContainEqual(expect.objectContaining({ id: created.id, expiresAt: created.expiresAt }));
});
test("rejects API key creation when the same request has the wrong password", async () => {
const session = await createTestSession();
await addCredentialPassword(session);
const res = await app.request("/api/v1/auth/api-keys", {
method: "POST",
headers: {
...session.headers,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "Wrong password", password: "wrong-password" }),
});
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ message: "Invalid password" });
expect(await db.query.apikey.findMany({ where: { referenceId: session.user.id } })).toHaveLength(0);
});
test("blocks API key creation for users without a local credential password", async () => {
const session = await createTestSession();
const res = await app.request("/api/v1/auth/api-keys", {
method: "POST",
headers: {
...session.headers,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "SSO-only key", password: "correct-password" }),
});
expect(res.status).toBe(403);
expect(await res.json()).toEqual({
message: "A local credential password is required to create API keys",
});
});
test("enforces the per-user API key limit", async () => {
const session = await createTestSession();
await addCredentialPassword(session);
for (let index = 0; index < 50; index++) {
await createStoredApiKey(session);
}
const res = await app.request("/api/v1/auth/api-keys", {
method: "POST",
headers: {
...session.headers,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "Over limit", password: "correct-password" }),
});
expect(res.status).toBe(409);
expect(await res.json()).toEqual({ message: "API key limit reached" });
});
test("does not list expired or disabled API keys", async () => {
const session = await createTestSession();
await addCredentialPassword(session);
const storedKeys: Array<Awaited<ReturnType<typeof createStoredApiKey>>> = [];
for (let index = 0; index < 10; index++) {
storedKeys.push(await createStoredApiKey(session));
}
for (const key of storedKeys.slice(0, 5)) {
await db
.update(apikey)
.set({ expiresAt: new Date(Date.now() - 60_000) })
.where(eq(apikey.id, key.id));
}
for (const key of storedKeys.slice(5)) {
await db.update(apikey).set({ enabled: false }).where(eq(apikey.id, key.id));
}
const created = await createApiKey(session, "Replacement key");
expect(created.name).toBe("Replacement key");
const listRes = await app.request("/api/v1/auth/api-keys", { headers: session.headers });
expect(listRes.status).toBe(200);
const body = (await listRes.json()) as { apiKeys: Array<{ id: string }> };
expect(body.apiKeys.map((apiKey) => apiKey.id)).toEqual([created.id]);
});
test("does not allow direct Better Auth session lookup with API keys", async () => {
const session = await createTestSession();
await addCredentialPassword(session);
const created = await createApiKey(session);
const directSession = await auth.api.getSession({
headers: new Headers({ "x-api-key": created.key }),
});
expect(directSession).toBeNull();
});
test("does not allow API keys to access global admin endpoints", async () => {
const session = await createTestSessionWithGlobalAdmin();
await addCredentialPassword(session);
const created = await createApiKey(session);
const res = await app.request("/api/v1/auth/admin-users", {
headers: { "x-api-key": created.key },
});
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ message: "Browser session required" });
});
test("does not allow API keys to execute dev panel commands", async () => {
const session = await createTestSessionWithOrgAdmin();
await addCredentialPassword(session);
const created = await createApiKey(session);
const res = await app.request("/api/v1/repositories/test-repo/exec", {
method: "POST",
headers: {
"x-api-key": created.key,
"Content-Type": "application/json",
},
body: JSON.stringify({ command: "version" }),
});
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ message: "Browser session required" });
});
test("does not allow API keys to access SSO settings", async () => {
const session = await createTestSessionWithOrgAdmin();
await addCredentialPassword(session);
const created = await createApiKey(session);
const res = await app.request("/api/v1/auth/sso-settings", {
headers: { "x-api-key": created.key },
});
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ message: "Browser session required" });
});
test("does not allow API keys to mutate SSO admin resources", async () => {
const session = await createTestSessionWithOrgAdmin();
await addCredentialPassword(session);
const created = await createApiKey(session);
const routes = [
{ method: "DELETE", path: "/api/v1/auth/sso-providers/test-provider" },
{
method: "PATCH",
path: "/api/v1/auth/sso-providers/test-provider/auto-linking",
body: { enabled: true },
},
{ method: "DELETE", path: "/api/v1/auth/sso-invitations/test-invitation" },
];
for (const route of routes) {
const res = await app.request(route.path, {
method: route.method,
headers: {
"x-api-key": created.key,
"Content-Type": "application/json",
},
body: route.body ? JSON.stringify(route.body) : undefined,
});
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ message: "Browser session required" });
}
});
test("authenticates API v1 requests with the key's bound organization", async () => {
const session = await createTestSession();
await addCredentialPassword(session);
await db
.update(member)
.set({ role: "owner" })
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, session.organizationId)));
const created = await createApiKey(session);
const otherOrgId = randomId();
await db.insert(organization).values({
id: otherOrgId,
name: "Other Org",
slug: randomSlug("other-org"),
createdAt: new Date(),
});
await db.insert(member).values({
id: randomId(),
organizationId: otherOrgId,
userId: session.user.id,
role: "owner",
createdAt: new Date(),
});
const otherSession = await createTestSession();
await db.insert(member).values({
id: randomId(),
organizationId: otherOrgId,
userId: otherSession.user.id,
role: "member",
createdAt: new Date(),
});
await db
.update(sessionsTable)
.set({ activeOrganizationId: otherOrgId })
.where(eq(sessionsTable.userId, session.user.id));
const res = await app.request("/api/v1/auth/org-members", {
headers: { "x-api-key": created.key },
});
expect(res.status).toBe(200);
const body = (await res.json()) as { members: Array<{ userId: string }> };
expect(body.members.map((m) => m.userId)).toEqual([session.user.id]);
});
test("does not allow API keys on Better Auth endpoints", async () => {
const session = await createTestSession();
await addCredentialPassword(session);
const created = await createApiKey(session);
const res = await app.request("/api/auth/get-session", {
headers: { "x-api-key": created.key },
});
expect(res.status).toBe(401);
expect(await res.json()).toEqual({
message: "API key authentication is only supported for API v1 routes",
});
});
test("does not expose Better Auth's direct API key management endpoints", async () => {
const session = await createTestSession();
const res = await app.request("/api/auth/api-key/create", {
method: "POST",
headers: {
...session.headers,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "Bypass attempt" }),
});
expect(res.status).toBe(404);
expect(await res.json()).toEqual({
message: "API key management is only supported through API v1 routes",
});
expect(await db.query.apikey.findMany({ where: { referenceId: session.user.id } })).toHaveLength(0);
});
test("does not allow API keys to download the recovery key", async () => {
const session = await createTestSession();
await addCredentialPassword(session);
const created = await createApiKey(session);
const res = await app.request("/api/v1/system/restic-password", {
method: "POST",
headers: {
"x-api-key": created.key,
"Content-Type": "application/json",
},
body: JSON.stringify({ password: "correct-password" }),
});
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ message: "Browser session required" });
});
test("revoked API keys fail future requests", async () => {
const session = await createTestSession();
await addCredentialPassword(session);
const created = await createApiKey(session);
const deleteRes = await app.request(`/api/v1/auth/api-keys/${created.id}`, {
method: "DELETE",
headers: session.headers,
});
expect(deleteRes.status).toBe(200);
const res = await app.request("/api/v1/system/info", {
headers: { "x-api-key": created.key },
});
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ message: "Invalid or expired session" });
});
test("API keys fail after the user is removed from the bound organization", async () => {
const session = await createTestSession();
await addCredentialPassword(session);
const created = await createApiKey(session);
await db
.delete(member)
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, session.organizationId)));
const res = await app.request("/api/v1/system/info", {
headers: { "x-api-key": created.key },
});
expect(res.status).toBe(403);
expect(await res.json()).toEqual({ message: "Invalid organization context" });
});
});

View File

@@ -2,18 +2,25 @@ import { createMiddleware } from "hono/factory";
import { auth } from "~/server/lib/auth";
import { db } from "~/server/db/db";
import { withContext } from "~/server/core/request-context";
import { getApiKeyOrganizationId } from "../api-keys/api-keys.service";
const API_KEY_HEADER = "x-api-key";
type AuthSource = "browser-session" | "api-key";
type AuthenticatedUser = {
id: string;
email: string;
username: string;
hasDownloadedResticPassword: boolean;
role?: string | null | undefined;
banned?: boolean | null | undefined;
};
declare module "hono" {
interface ContextVariableMap {
user: {
id: string;
email: string;
username: string;
hasDownloadedResticPassword: boolean;
role?: string | null | undefined;
};
user: AuthenticatedUser;
organizationId: string;
membership: { role: string };
authSource: AuthSource;
}
}
@@ -22,14 +29,43 @@ declare module "hono" {
* Verifies the session cookie and attaches user to context
*/
export const requireAuth = createMiddleware(async (c, next) => {
const sess = await auth.api.getSession({
headers: c.req.raw.headers,
});
const apiKeyValue = c.req.header(API_KEY_HEADER);
const authSource: AuthSource = apiKeyValue ? "api-key" : "browser-session";
let user: AuthenticatedUser | undefined;
let activeOrganizationId: string | null | undefined;
const { user, session } = sess ?? {};
const { activeOrganizationId } = session ?? {};
if (apiKeyValue && !c.req.path.startsWith("/api/v1")) {
return c.json<unknown>({ message: "API key authentication is only supported for API v1 routes" }, 401);
}
if (!user || !session || !activeOrganizationId) {
if (apiKeyValue) {
const verification = await auth.api.verifyApiKey({ body: { key: apiKeyValue } });
const apiKey = verification?.valid ? verification.key : null;
if (!apiKey) {
return c.json<unknown>({ message: "Invalid or expired session" }, 401);
}
user = await db.query.usersTable.findFirst({ where: { id: apiKey.referenceId } });
activeOrganizationId = await getApiKeyOrganizationId(apiKey.id);
} else {
const sess = await auth.api.getSession({ headers: c.req.raw.headers });
if (sess) {
user = sess.user;
activeOrganizationId = sess.session.activeOrganizationId;
}
}
if (!user) {
return c.json<unknown>({ message: "Invalid or expired session" }, 401);
}
if (authSource === "api-key" && user.banned) {
return c.json<unknown>({ message: "Invalid or expired session" }, 401);
}
if (!activeOrganizationId) {
return c.json<unknown>({ message: "Invalid or expired session" }, 401);
}
@@ -46,12 +82,21 @@ export const requireAuth = createMiddleware(async (c, next) => {
c.set("user", user);
c.set("organizationId", activeOrganizationId);
c.set("membership", membership);
c.set("authSource", authSource);
await withContext({ organizationId: activeOrganizationId, userId: user.id }, async () => {
await next();
});
});
export const requireBrowserSession = createMiddleware(async (c, next) => {
if (c.get("authSource") === "api-key") {
return c.json({ message: "Browser session required" }, 401);
}
await next();
});
/**
* Middleware to require organization owner or admin role
* Verifies the user has the required role in the current organization
@@ -67,6 +112,10 @@ export const requireOrgAdmin = createMiddleware(async (c, next) => {
});
export const requireAdmin = createMiddleware(async (c, next) => {
if (c.get("authSource") === "api-key") {
return c.json({ message: "Browser session required" }, 401);
}
const user = c.get("user");
if (!user || user.role !== "admin") {

View File

@@ -53,7 +53,10 @@ class AuthService {
if (org) {
const [volumes, repos, schedules] = await Promise.all([
db.select({ count: count() }).from(volumesTable).where(eq(volumesTable.organizationId, org.id)),
db.select({ count: count() }).from(repositoriesTable).where(eq(repositoriesTable.organizationId, org.id)),
db
.select({ count: count() })
.from(repositoriesTable)
.where(eq(repositoriesTable.organizationId, org.id)),
db
.select({ count: count() })
.from(backupSchedulesTable)
@@ -120,7 +123,12 @@ class AuthService {
for (const [affectedUserId, fallbackOrgId] of fallbackOrgByUser) {
tx.update(sessionsTable)
.set({ activeOrganizationId: fallbackOrgId })
.where(and(eq(sessionsTable.userId, affectedUserId), inArray(sessionsTable.activeOrganizationId, orgIds)))
.where(
and(
eq(sessionsTable.userId, affectedUserId),
inArray(sessionsTable.activeOrganizationId, orgIds),
),
)
.run();
}
}
@@ -238,7 +246,10 @@ class AuthService {
activeOrganizationId: fallbackMembership.organizationId,
})
.where(
and(eq(sessionsTable.userId, targetMember.userId), eq(sessionsTable.activeOrganizationId, organizationId)),
and(
eq(sessionsTable.userId, targetMember.userId),
eq(sessionsTable.activeOrganizationId, organizationId),
),
)
.run();
return;

View File

@@ -54,7 +54,7 @@ import {
} from "./repositories.dto";
import { repositoriesService } from "./repositories.service";
import { getRcloneRemoteInfo, listRcloneRemotes } from "../../utils/rclone";
import { requireAuth, requireOrgAdmin } from "../auth/auth.middleware";
import { requireAuth, requireBrowserSession, requireOrgAdmin } from "../auth/auth.middleware";
import { toMessage } from "~/server/utils/errors";
import { requireDevPanel } from "../auth/dev-panel.middleware";
import { getSnapshotDuration } from "../../utils/snapshots";
@@ -291,6 +291,7 @@ export const repositoriesController = new Hono()
})
.post(
"/:shortId/exec",
requireBrowserSession,
requireDevPanel,
requireOrgAdmin,
devPanelExecDto,

View File

@@ -15,7 +15,7 @@ import {
updateSsoProviderAutoLinkingDto,
} from "./sso.dto";
import { SSO_INVITATION_INTENT_COOKIE, ssoService } from "./sso.service";
import { requireAuth, requireOrgAdmin } from "../auth/auth.middleware";
import { requireAuth, requireBrowserSession, requireOrgAdmin } from "../auth/auth.middleware";
import { auth } from "~/server/lib/auth";
import { mapAuthErrorToCode } from "./sso.errors";
import { config } from "~/server/core/config";
@@ -27,7 +27,7 @@ export const ssoController = new Hono()
const providers = await ssoService.getPublicSsoProviders();
return c.json<PublicSsoProvidersDto>(providers);
})
.get("/sso-settings", requireAuth, requireOrgAdmin, getSsoSettingsDto, async (c) => {
.get("/sso-settings", requireAuth, requireBrowserSession, requireOrgAdmin, getSsoSettingsDto, async (c) => {
const headers = c.req.raw.headers;
const activeOrganizationId = c.get("organizationId");
@@ -106,21 +106,29 @@ export const ssoController = new Hono()
return c.json({ success: true });
},
)
.delete("/sso-providers/:providerId", requireAuth, requireOrgAdmin, deleteSsoProviderDto, async (c) => {
const providerId = c.req.param("providerId");
const organizationId = c.get("organizationId");
.delete(
"/sso-providers/:providerId",
requireAuth,
requireBrowserSession,
requireOrgAdmin,
deleteSsoProviderDto,
async (c) => {
const providerId = c.req.param("providerId");
const organizationId = c.get("organizationId");
const deleted = await ssoService.deleteSsoProvider(providerId, organizationId);
const deleted = await ssoService.deleteSsoProvider(providerId, organizationId);
if (!deleted) {
return c.json({ message: "Provider not found" }, 404);
}
if (!deleted) {
return c.json({ message: "Provider not found" }, 404);
}
return c.json({ success: true });
})
return c.json({ success: true });
},
)
.patch(
"/sso-providers/:providerId/auto-linking",
requireAuth,
requireBrowserSession,
requireOrgAdmin,
updateSsoProviderAutoLinkingDto,
validator("json", updateSsoProviderAutoLinkingBody),
@@ -138,19 +146,26 @@ export const ssoController = new Hono()
return c.json({ success: true });
},
)
.delete("/sso-invitations/:invitationId", requireAuth, requireOrgAdmin, deleteSsoInvitationDto, async (c) => {
const invitationId = c.req.param("invitationId");
const organizationId = c.get("organizationId");
.delete(
"/sso-invitations/:invitationId",
requireAuth,
requireBrowserSession,
requireOrgAdmin,
deleteSsoInvitationDto,
async (c) => {
const invitationId = c.req.param("invitationId");
const organizationId = c.get("organizationId");
const invitation = await ssoService.getSsoInvitationById(invitationId);
if (!invitation || invitation.organizationId !== organizationId) {
return c.json({ message: "Invitation not found" }, 404);
}
const invitation = await ssoService.getSsoInvitationById(invitationId);
if (!invitation || invitation.organizationId !== organizationId) {
return c.json({ message: "Invitation not found" }, 404);
}
await ssoService.deleteSsoInvitation(invitationId);
await ssoService.deleteSsoInvitation(invitationId);
return c.json({ success: true });
})
return c.json({ success: true });
},
)
.get("/login-error", async (c) => {
const error = c.req.query("error");
const errorCode = error ? mapAuthErrorToCode(error) : "SSO_LOGIN_FAILED";

View File

@@ -15,7 +15,7 @@ import {
type DevPanelDto,
} from "./system.dto";
import { systemService } from "./system.service";
import { requireAdmin, requireAuth, requireOrgAdmin } from "../auth/auth.middleware";
import { requireAdmin, requireAuth, requireBrowserSession, requireOrgAdmin } from "../auth/auth.middleware";
import { db } from "../../db/db";
import { usersTable } from "../../db/schema";
import { eq } from "drizzle-orm";
@@ -56,6 +56,7 @@ export const systemController = new Hono()
)
.post(
"/restic-password",
requireBrowserSession,
requireOrgAdmin,
downloadResticPasswordDto,
validator("json", downloadResticPasswordBodySchema),
@@ -78,7 +79,10 @@ export const systemController = new Hono()
const content = await cryptoUtils.resolveSecret(org.metadata.resticPassword);
await db.update(usersTable).set({ hasDownloadedResticPassword: true }).where(eq(usersTable.id, user.id));
await db
.update(usersTable)
.set({ hasDownloadedResticPassword: true })
.where(eq(usersTable.id, user.id));
c.header("Content-Type", "text/plain");
c.header("Content-Disposition", 'attachment; filename="restic.pass"');

View File

@@ -5,6 +5,7 @@
"": {
"name": "zerobyte",
"dependencies": {
"@better-auth/api-key": "1.6.15",
"@better-auth/passkey": "^1.6.15",
"@better-auth/sso": "^1.6.15",
"@dnd-kit/core": "^6.3.1",
@@ -270,6 +271,8 @@
"@base-ui/utils": ["@base-ui/utils@0.2.9", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-x/PDDCYzoqPpjrdyb3VcyylTI2IjUXEtYDGi5foh7KsnmNJIIaVwA2GLgDH1dps1GgXiJbA60hM+AyuTfQzIvw=="],
"@better-auth/api-key": ["@better-auth/api-key@1.6.15", "", { "dependencies": { "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.15", "@better-auth/utils": "0.4.1", "better-auth": "^1.6.15", "better-call": "1.3.5" } }, "sha512-+jFLVBbu4uTjo1JE8ET0SCAKz5FnTmI5CIBzY40IvITuWVTC8nxrWBUbEkmqXFgAJcxrK3/eqcWwlJ99Wve7dA=="],
"@better-auth/core": ["@better-auth/core@1.6.15", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.5", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-gPgeEn+2t2cohSKj1CJFCJLo4jNqvtvhKP/y6Uwuurg4ReEf2Y9PUCYffvbp7DDc+GevWTuD0Cv9x0+e83+HpA=="],
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.15", "", { "peerDependencies": { "@better-auth/core": "^1.6.15", "@better-auth/utils": "0.4.1", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-+ho2RozN6cZmCN+6XkZd/ec5iolVlefgInQ7znJ18qRPbgllHxdyu9tGrgKZyEsXSCyqAa6wi5Yb4hd3WZmTNQ=="],

View File

@@ -39,6 +39,7 @@
"dependencies": {
"@better-auth/passkey": "^1.6.15",
"@better-auth/sso": "^1.6.15",
"@better-auth/api-key": "1.6.15",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",