mirror of
https://github.com/nicotsx/zerobyte.git
synced 2026-06-15 03:50:37 -04:00
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:
@@ -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);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
});
|
||||
|
||||
311
app/client/modules/settings/components/api-keys-section.tsx
Normal file
311
app/client/modules/settings/components/api-keys-section.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
28
app/drizzle/20260605101319_known_tinkerer/migration.sql
Normal file
28
app/drizzle/20260605101319_known_tinkerer/migration.sql
Normal 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`);
|
||||
3488
app/drizzle/20260605101319_known_tinkerer/snapshot.json
Normal file
3488
app/drizzle/20260605101319_known_tinkerer/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
35
app/drizzle/20260612175108_deep_chronomancer/migration.sql
Normal file
35
app/drizzle/20260612175108_deep_chronomancer/migration.sql
Normal 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`);
|
||||
3499
app/drizzle/20260612175108_deep_chronomancer/snapshot.json
Normal file
3499
app/drizzle/20260612175108_deep_chronomancer/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()] : []),
|
||||
],
|
||||
|
||||
12
app/server/modules/api-keys/api-key-metadata.ts
Normal file
12
app/server/modules/api-keys/api-key-metadata.ts
Normal 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;
|
||||
};
|
||||
86
app/server/modules/api-keys/api-keys.controller.ts
Normal file
86
app/server/modules/api-keys/api-keys.controller.ts
Normal 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 });
|
||||
});
|
||||
84
app/server/modules/api-keys/api-keys.dto.ts
Normal file
84
app/server/modules/api-keys/api-keys.dto.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
});
|
||||
62
app/server/modules/api-keys/api-keys.service.ts
Normal file
62
app/server/modules/api-keys/api-keys.service.ts
Normal 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);
|
||||
};
|
||||
435
app/server/modules/auth/__tests__/auth.api-keys.test.ts
Normal file
435
app/server/modules/auth/__tests__/auth.api-keys.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"');
|
||||
|
||||
3
bun.lock
3
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user