mirror of
https://github.com/nicotsx/zerobyte.git
synced 2026-07-31 10:06:05 -04:00
feat: add option to disable password login (#949)
* feat: add option to disable password login Adds a global admin setting to hide the username/password form on the login page, useful for deployments that rely solely on SSO/OIDC or passkeys. The form defaults to enabled so existing deployments are unaffected. Changes: - Add PASSWORD_LOGIN_ENABLED_KEY constant - Add isPasswordLoginEnabled / setPasswordLoginEnabled to system service (defaults to true when no DB record exists) - Add GET/PUT /api/v1/system/password-login-status endpoints (GET auth-gated for admin use; setting is exposed to the login page via the existing getLoginOptions server function) - Extend getLoginOptions to include passwordLoginEnabled - Conditionally render the password login form in LoginPage - Add "Enable password login" toggle in the admin System Settings tab, next to "Enable new user registrations" - Add corresponding entries to generated API client files (types.gen.ts, sdk.gen.ts, @tanstack/react-query.gen.ts) Closes #941 * fix(login): show errors even if password is disabled * feat(cli): add command to re-enable password --------- Co-authored-by: Nicolas Meienberger <github@thisprops.com>
This commit is contained in:
committed by
GitHub
parent
1f38d9241b
commit
34e8f89727
@@ -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, createApiKey, createBackupSchedule, createDesktopSession, 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, CreateDesktopSessionData, 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';
|
||||
import { browseFilesystem, cancelDoctor, createApiKey, createBackupSchedule, createDesktopSession, 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, getPasswordLoginStatus, 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, setPasswordLoginStatus, 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, CreateDesktopSessionData, 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, GetPasswordLoginStatusData, GetPasswordLoginStatusResponse, 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, SetPasswordLoginStatusData, SetPasswordLoginStatusResponse, 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'> & {
|
||||
@@ -1465,6 +1465,41 @@ export const downloadResticPasswordMutation = (options?: Partial<Options<Downloa
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const getPasswordLoginStatusQueryKey = (options?: Options<GetPasswordLoginStatusData>) => createQueryKey('getPasswordLoginStatus', options);
|
||||
|
||||
/**
|
||||
* Get whether password-based login is disabled
|
||||
*/
|
||||
export const getPasswordLoginStatusOptions = (options?: Options<GetPasswordLoginStatusData>) => queryOptions<GetPasswordLoginStatusResponse, DefaultError, GetPasswordLoginStatusResponse, ReturnType<typeof getPasswordLoginStatusQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getPasswordLoginStatus({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getPasswordLoginStatusQueryKey(options)
|
||||
});
|
||||
|
||||
/**
|
||||
* Disable or re-enable password-based login. Requires global admin role.
|
||||
*/
|
||||
export const setPasswordLoginStatusMutation = (options?: Partial<Options<SetPasswordLoginStatusData>>): UseMutationOptions<SetPasswordLoginStatusResponse, DefaultError, Options<SetPasswordLoginStatusData>> => {
|
||||
const mutationOptions: UseMutationOptions<SetPasswordLoginStatusResponse, DefaultError, Options<SetPasswordLoginStatusData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await setPasswordLoginStatus({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const getDevPanelQueryKey = (options?: Options<GetDevPanelData>) => createQueryKey('getDevPanel', options);
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,6 +33,7 @@ export {
|
||||
getMirrorSyncStatus,
|
||||
getNotificationDestination,
|
||||
getOrgMembers,
|
||||
getPasswordLoginStatus,
|
||||
getPublicSsoProviders,
|
||||
getRegistrationStatus,
|
||||
getRepository,
|
||||
@@ -65,6 +66,7 @@ export {
|
||||
restoreSnapshot,
|
||||
runBackupNow,
|
||||
runForget,
|
||||
setPasswordLoginStatus,
|
||||
setRegistrationStatus,
|
||||
startDoctor,
|
||||
startInvitationSsoVerification,
|
||||
@@ -183,6 +185,9 @@ export type {
|
||||
GetOrgMembersData,
|
||||
GetOrgMembersResponse,
|
||||
GetOrgMembersResponses,
|
||||
GetPasswordLoginStatusData,
|
||||
GetPasswordLoginStatusResponse,
|
||||
GetPasswordLoginStatusResponses,
|
||||
GetPublicSsoProvidersData,
|
||||
GetPublicSsoProvidersResponse,
|
||||
GetPublicSsoProvidersResponses,
|
||||
@@ -278,6 +283,9 @@ export type {
|
||||
RunForgetData,
|
||||
RunForgetResponse,
|
||||
RunForgetResponses,
|
||||
SetPasswordLoginStatusData,
|
||||
SetPasswordLoginStatusResponse,
|
||||
SetPasswordLoginStatusResponses,
|
||||
SetRegistrationStatusData,
|
||||
SetRegistrationStatusResponse,
|
||||
SetRegistrationStatusResponses,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import type { Client, Options as Options2, TDataShape } from './client';
|
||||
import { client } from './client.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateApiKeyData, CreateApiKeyErrors, CreateApiKeyResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateDesktopSessionData, CreateDesktopSessionResponses, 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';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateApiKeyData, CreateApiKeyErrors, CreateApiKeyResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateDesktopSessionData, CreateDesktopSessionResponses, 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, GetPasswordLoginStatusData, GetPasswordLoginStatusResponses, 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, SetPasswordLoginStatusData, SetPasswordLoginStatusResponses, 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> & {
|
||||
/**
|
||||
@@ -570,6 +570,23 @@ export const downloadResticPassword = <ThrowOnError extends boolean = false>(opt
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get whether password-based login is disabled
|
||||
*/
|
||||
export const getPasswordLoginStatus = <ThrowOnError extends boolean = false>(options?: Options<GetPasswordLoginStatusData, ThrowOnError>) => (options?.client ?? client).get<GetPasswordLoginStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/system/password-login-status', ...options });
|
||||
|
||||
/**
|
||||
* Disable or re-enable password-based login. Requires global admin role.
|
||||
*/
|
||||
export const setPasswordLoginStatus = <ThrowOnError extends boolean = false>(options: Options<SetPasswordLoginStatusData, ThrowOnError>) => (options.client ?? client).put<SetPasswordLoginStatusResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/system/password-login-status',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the dev panel status
|
||||
*/
|
||||
|
||||
@@ -5158,6 +5158,44 @@ export type DownloadResticPasswordResponses = {
|
||||
|
||||
export type DownloadResticPasswordResponse = DownloadResticPasswordResponses[keyof DownloadResticPasswordResponses];
|
||||
|
||||
export type GetPasswordLoginStatusData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/system/password-login-status';
|
||||
};
|
||||
|
||||
export type GetPasswordLoginStatusResponses = {
|
||||
/**
|
||||
* Password login status
|
||||
*/
|
||||
200: {
|
||||
disabled: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type GetPasswordLoginStatusResponse = GetPasswordLoginStatusResponses[keyof GetPasswordLoginStatusResponses];
|
||||
|
||||
export type SetPasswordLoginStatusData = {
|
||||
body: {
|
||||
disabled: boolean;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/system/password-login-status';
|
||||
};
|
||||
|
||||
export type SetPasswordLoginStatusResponses = {
|
||||
/**
|
||||
* Password login status updated
|
||||
*/
|
||||
200: {
|
||||
disabled: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type SetPasswordLoginStatusResponse = SetPasswordLoginStatusResponses[keyof SetPasswordLoginStatusResponses];
|
||||
|
||||
export type GetDevPanelData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { Users, Settings as SettingsIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getPasswordLoginStatusOptions,
|
||||
getRegistrationStatusOptions,
|
||||
setPasswordLoginStatusMutation,
|
||||
setRegistrationStatusMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import { Switch } from "~/client/components/ui/switch";
|
||||
@@ -21,6 +34,7 @@ export function AdminPage({ appContext }: Props) {
|
||||
const { tab } = useSearch({ from: "/(dashboard)/admin/" });
|
||||
const activeTab = tab || "users";
|
||||
const navigate = useNavigate();
|
||||
const [showDisablePasswordLoginConfirm, setShowDisablePasswordLoginConfirm] = useState(false);
|
||||
|
||||
const registrationStatus = useSuspenseQuery({
|
||||
...getRegistrationStatusOptions(),
|
||||
@@ -38,10 +52,30 @@ export function AdminPage({ appContext }: Props) {
|
||||
},
|
||||
});
|
||||
|
||||
const passwordLoginStatus = useSuspenseQuery({
|
||||
...getPasswordLoginStatusOptions(),
|
||||
});
|
||||
|
||||
const updatePasswordLoginStatusMutation = useMutation({
|
||||
...setPasswordLoginStatusMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Login settings updated");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to update login settings", {
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onTabChange = (value: string) => {
|
||||
void navigate({ to: ".", search: () => ({ tab: value }) });
|
||||
};
|
||||
|
||||
const updatePasswordLoginDisabled = (disabled: boolean) => {
|
||||
updatePasswordLoginStatusMutation.mutate({ body: { disabled } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Tabs value={activeTab} onValueChange={onTabChange} className="w-full">
|
||||
@@ -58,7 +92,9 @@ export function AdminPage({ appContext }: Props) {
|
||||
<Users className="size-5" />
|
||||
User Management
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5">Manage users, roles and permissions</CardDescription>
|
||||
<CardDescription className="mt-1.5">
|
||||
Manage users, roles and permissions
|
||||
</CardDescription>
|
||||
</div>
|
||||
<UserManagement currentUser={appContext.user} />
|
||||
</Card>
|
||||
@@ -73,13 +109,15 @@ export function AdminPage({ appContext }: Props) {
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5">Manage system-wide settings</CardDescription>
|
||||
</div>
|
||||
<CardContent className="p-6">
|
||||
<CardContent className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="enable-registrations" className="text-base">
|
||||
Enable new user registrations
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground max-w-2xl">When enabled, new users can sign up</p>
|
||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||
When enabled, new users can sign up
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enable-registrations"
|
||||
@@ -90,11 +128,57 @@ export function AdminPage({ appContext }: Props) {
|
||||
disabled={updateRegistrationStatusMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-4 border-t border-border/50">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="enable-password-login" className="text-base">
|
||||
Enable password login
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||
When disabled, the username and password form is hidden on the login page.
|
||||
Users can still sign in via SSO or passkeys.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enable-password-login"
|
||||
checked={!passwordLoginStatus.data.disabled}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
updatePasswordLoginDisabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowDisablePasswordLoginConfirm(true);
|
||||
}}
|
||||
disabled={updatePasswordLoginStatusMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
<AlertDialog open={showDisablePasswordLoginConfirm} onOpenChange={setShowDisablePasswordLoginConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Disable password login?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
If you do not have SSO or a passkey configured, disabling password login can lock you out of
|
||||
this instance. You can recover by running
|
||||
<code>docker exec -it zerobyte bun run cli enable-password-login</code> on the server.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
updatePasswordLoginDisabled(true);
|
||||
}}
|
||||
>
|
||||
Disable password login
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useServerFn } from "@tanstack/react-start";
|
||||
import { getPublicSsoProvidersOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { SsoLoginButtons } from "~/client/modules/sso/components/sso-login-buttons";
|
||||
import { getLoginOptions } from "~/server/lib/functions/login-options";
|
||||
import { PasskeySignInButton } from "./passkey-sign-in-button";
|
||||
|
||||
type AlternativeSignInSectionProps = {
|
||||
hasPasskeySignIn: boolean;
|
||||
onPasskeySignIn: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function AlternativeSignInSection({ onPasskeySignIn }: AlternativeSignInSectionProps) {
|
||||
const getOptions = useServerFn(getLoginOptions);
|
||||
export function AlternativeSignInSection({ hasPasskeySignIn, onPasskeySignIn }: AlternativeSignInSectionProps) {
|
||||
const { data: ssoProviders } = useSuspenseQuery({
|
||||
...getPublicSsoProvidersOptions(),
|
||||
});
|
||||
const { data: loginOptions } = useSuspenseQuery({
|
||||
queryKey: ["login-options"],
|
||||
queryFn: getOptions,
|
||||
});
|
||||
|
||||
if (ssoProviders.providers.length === 0 && !loginOptions.hasPasskeySignIn) {
|
||||
if (ssoProviders.providers.length === 0 && !hasPasskeySignIn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -27,7 +21,7 @@ export function AlternativeSignInSection({ onPasskeySignIn }: AlternativeSignInS
|
||||
<div className="pt-4 border-t border-border/60 space-y-3">
|
||||
<p className="text-sm font-medium">Alternative Sign-in</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{loginOptions.hasPasskeySignIn && <PasskeySignInButton onSignIn={onPasskeySignIn} />}
|
||||
{hasPasskeySignIn && <PasskeySignInButton onSignIn={onPasskeySignIn} />}
|
||||
<SsoLoginButtons providers={ssoProviders.providers} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "~/lib/sso-errors";
|
||||
|
||||
const { mockGetLoginOptions, mockNavigate, mockPasskeySignIn } = vi.hoisted(() => ({
|
||||
mockGetLoginOptions: vi.fn(async () => ({ hasPasskeySignIn: false })),
|
||||
mockGetLoginOptions: vi.fn(async () => ({ hasPasskeySignIn: false, passwordLoginEnabled: true })),
|
||||
mockNavigate: vi.fn(async () => {}),
|
||||
mockPasskeySignIn: vi.fn(
|
||||
async (): Promise<{
|
||||
@@ -67,7 +67,7 @@ const mockSsoProvidersRequest = (
|
||||
|
||||
afterEach(() => {
|
||||
mockGetLoginOptions.mockClear();
|
||||
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: false });
|
||||
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: false, passwordLoginEnabled: true });
|
||||
mockNavigate.mockClear();
|
||||
mockPasskeySignIn.mockClear();
|
||||
mockPasskeySignIn.mockResolvedValue({ data: null, error: null });
|
||||
@@ -147,7 +147,7 @@ describe("LoginPage", () => {
|
||||
|
||||
test("renders passkey sign-in when an active user has a passkey", async () => {
|
||||
mockSsoProvidersRequest();
|
||||
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
|
||||
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true, passwordLoginEnabled: true });
|
||||
|
||||
render(<LoginPage />, { withSuspense: true });
|
||||
|
||||
@@ -156,7 +156,7 @@ describe("LoginPage", () => {
|
||||
|
||||
test("redirects passkey verification failures to the login error box", async () => {
|
||||
mockSsoProvidersRequest();
|
||||
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
|
||||
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true, passwordLoginEnabled: true });
|
||||
mockPasskeySignIn.mockResolvedValue({
|
||||
data: null,
|
||||
error: { code: "AUTHENTICATION_FAILED", message: "Authentication failed" },
|
||||
@@ -178,7 +178,7 @@ describe("LoginPage", () => {
|
||||
|
||||
test("redirects unauthorized passkey failures to the login error box", async () => {
|
||||
mockSsoProvidersRequest();
|
||||
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
|
||||
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true, passwordLoginEnabled: true });
|
||||
mockPasskeySignIn.mockResolvedValue({
|
||||
data: null,
|
||||
error: { code: "UNAUTHORIZED", message: "Unauthorized" },
|
||||
@@ -200,7 +200,7 @@ describe("LoginPage", () => {
|
||||
|
||||
test("preserves specific passkey login error codes", async () => {
|
||||
mockSsoProvidersRequest();
|
||||
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
|
||||
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true, passwordLoginEnabled: true });
|
||||
mockPasskeySignIn.mockResolvedValue({
|
||||
data: null,
|
||||
error: { code: "ERROR_INVALID_RP_ID", message: "Auth cancelled" },
|
||||
@@ -274,4 +274,15 @@ describe("LoginPage", () => {
|
||||
expect(screen.queryByText("Alternative Sign-in")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Sign in with passkey" })).toBeNull();
|
||||
});
|
||||
|
||||
test("hides the password form when password login is disabled", async () => {
|
||||
mockSsoProvidersRequest();
|
||||
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: false, passwordLoginEnabled: false });
|
||||
|
||||
render(<LoginPage error="INVITE_REQUIRED" />, { withSuspense: true });
|
||||
|
||||
expect(await screen.findByText(inviteOnlyMessage)).toBeTruthy();
|
||||
expect(screen.queryByLabelText("Username")).toBeNull();
|
||||
expect(screen.queryByLabelText("Password")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useServerFn } from "@tanstack/react-start";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -10,13 +13,13 @@ import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/clie
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import { authClient } from "~/client/lib/auth-client";
|
||||
import { logger } from "~/client/lib/logger";
|
||||
import { RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME } from "~/lib/recovery-key-skip";
|
||||
import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/sso-errors";
|
||||
import { PASSKEY_LOGIN_FAILED_ERROR } from "~/lib/sso-errors";
|
||||
import { ResetPasswordDialog } from "../components/reset-password-dialog";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { normalizeUsername } from "~/lib/username";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/sso-errors";
|
||||
import { getLoginOptions } from "~/server/lib/functions/login-options";
|
||||
import { RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME } from "~/lib/recovery-key-skip";
|
||||
import { PASSKEY_LOGIN_FAILED_ERROR } from "~/lib/sso-errors";
|
||||
import { normalizeUsername } from "~/lib/username";
|
||||
import { ResetPasswordDialog } from "../components/reset-password-dialog";
|
||||
import { AlternativeSignInSection } from "../components/alternative-sign-in-section";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -53,6 +56,11 @@ function hasSkippedRecoveryKeyDownload(userId: string) {
|
||||
|
||||
export function LoginPage({ error }: LoginPageProps = {}) {
|
||||
const navigate = useNavigate();
|
||||
const getOptions = useServerFn(getLoginOptions);
|
||||
const { data: loginOptions } = useSuspenseQuery({
|
||||
queryKey: ["login-options"],
|
||||
queryFn: getOptions,
|
||||
});
|
||||
const [showResetDialog, setShowResetDialog] = useState(false);
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||
const [requires2FA, setRequires2FA] = useState(false);
|
||||
@@ -265,68 +273,74 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||
|
||||
return (
|
||||
<AuthLayout title="Login to your account" description="Enter your credentials below to login to your account">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div
|
||||
className={cn("rounded-md border border-destructive/50 p-3 text-sm", {
|
||||
hidden: !errorDescription,
|
||||
})}
|
||||
>
|
||||
{errorDescription}
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
placeholder="admin"
|
||||
disabled={isLoggingIn}
|
||||
autoComplete="username webauthn"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Password</FormLabel>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:underline"
|
||||
onClick={() => setShowResetDialog(true)}
|
||||
>
|
||||
Forgot your password?
|
||||
</button>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
disabled={isLoggingIn}
|
||||
autoComplete="current-password webauthn"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" className="w-full" loading={isLoggingIn}>
|
||||
Login
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<div
|
||||
className={cn("rounded-md border border-destructive/50 p-3 text-sm", {
|
||||
hidden: !errorDescription,
|
||||
})}
|
||||
>
|
||||
{errorDescription}
|
||||
</div>
|
||||
|
||||
<AlternativeSignInSection onPasskeySignIn={navigateAfterLogin} />
|
||||
{loginOptions.passwordLoginEnabled && (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
placeholder="admin"
|
||||
disabled={isLoggingIn}
|
||||
autoComplete="username webauthn"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Password</FormLabel>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:underline"
|
||||
onClick={() => setShowResetDialog(true)}
|
||||
>
|
||||
Forgot your password?
|
||||
</button>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
disabled={isLoggingIn}
|
||||
autoComplete="current-password webauthn"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" className="w-full" loading={isLoggingIn}>
|
||||
Login
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<AlternativeSignInSection
|
||||
hasPasskeySignIn={loginOptions.hasPasskeySignIn}
|
||||
onPasskeySignIn={navigateAfterLogin}
|
||||
/>
|
||||
|
||||
<ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} />
|
||||
</AuthLayout>
|
||||
|
||||
@@ -109,6 +109,14 @@ describe("permissions", () => {
|
||||
authSource: "browser-session",
|
||||
}),
|
||||
).toEqual({ allowed: false, reason: "instanceRole" });
|
||||
|
||||
expect(
|
||||
evaluatePermission("passwordLogin.manage", {
|
||||
runtime: "server",
|
||||
instanceRole: "admin",
|
||||
authSource: "browser-session",
|
||||
}).allowed,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("models runtime features independently from user roles", () => {
|
||||
|
||||
@@ -51,6 +51,11 @@ const PERMISSIONS = {
|
||||
instanceRoles: ["admin"],
|
||||
authSources: ["browser-session"],
|
||||
},
|
||||
"passwordLogin.manage": {
|
||||
feature: "instanceAdministration",
|
||||
instanceRoles: ["admin"],
|
||||
authSources: ["browser-session"],
|
||||
},
|
||||
"organizationSettings.view": {
|
||||
feature: "organizationAdministration",
|
||||
orgRoles: ["owner", "admin"],
|
||||
|
||||
19
app/server/cli/commands/enable-password-login.ts
Normal file
19
app/server/cli/commands/enable-password-login.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Command } from "commander";
|
||||
import { toMessage } from "~/server/utils/errors";
|
||||
import { systemService } from "~/server/modules/system/system.service";
|
||||
|
||||
export const enablePasswordLoginCommand = new Command("enable-password-login")
|
||||
.description("Re-enable password login for break-glass recovery")
|
||||
.action(async () => {
|
||||
console.info("\nZerobyte Password Login Recovery\n");
|
||||
|
||||
try {
|
||||
await systemService.setPasswordLoginDisabled(false);
|
||||
console.info("\nPassword login has been re-enabled.");
|
||||
} catch (error) {
|
||||
console.error(`\nFailed to re-enable password login: ${toMessage(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { assignOrganizationCommand } from "./commands/assign-organization";
|
||||
import { changeEmailCommand } from "./commands/change-email";
|
||||
import { changeUsernameCommand } from "./commands/change-username";
|
||||
import { disable2FACommand } from "./commands/disable-2fa";
|
||||
import { enablePasswordLoginCommand } from "./commands/enable-password-login";
|
||||
import { rekey2FACommand } from "./commands/rekey-2fa";
|
||||
import { resetPasswordCommand } from "./commands/reset-password";
|
||||
import { config } from "../core/config";
|
||||
@@ -16,6 +17,7 @@ program
|
||||
.version(config.appVersion);
|
||||
program.addCommand(resetPasswordCommand);
|
||||
program.addCommand(disable2FACommand);
|
||||
program.addCommand(enablePasswordLoginCommand);
|
||||
program.addCommand(changeUsernameCommand);
|
||||
program.addCommand(changeEmailCommand);
|
||||
program.addCommand(rekey2FACommand);
|
||||
|
||||
@@ -17,3 +17,4 @@ export const RESTORE_BLOCKED_ROOTS = [REPOSITORY_BASE, RESTIC_CACHE_DIR, RCLONE_
|
||||
export const DEFAULT_EXCLUDES = [RESTIC_PASS_FILE, REPOSITORY_BASE, RESTIC_CACHE_DIR];
|
||||
|
||||
export const REGISTRATION_ENABLED_KEY = "registrations_enabled";
|
||||
export const PASSWORD_LOGIN_DISABLED_KEY = "password_login_disabled";
|
||||
|
||||
@@ -19,6 +19,7 @@ import { tanstackStartCookies } from "better-auth/tanstack-start";
|
||||
import { isValidUsername, normalizeUsername } from "~/lib/username";
|
||||
import { ensureOnlyOneUser } from "./auth/middlewares/only-one-user";
|
||||
import { convertLegacyUserOnFirstLogin } from "./auth/middlewares/convert-legacy-user";
|
||||
import { enforcePasswordLoginPolicy } from "./auth/middlewares/password-login-policy";
|
||||
import { ensureDefaultOrg } from "./auth/helpers/create-default-org";
|
||||
import { ssoIntegration } from "../modules/sso/sso.integration";
|
||||
import { ACCOUNT_LINK_REQUIRED_DESCRIPTION } from "~/lib/sso-errors";
|
||||
@@ -53,6 +54,7 @@ export const auth = betterAuth({
|
||||
}
|
||||
|
||||
await ensureOnlyOneUser(ctx);
|
||||
await enforcePasswordLoginPolicy(ctx);
|
||||
await convertLegacyUserOnFirstLogin(ctx);
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { config } from "~/server/core/config";
|
||||
import type { AuthMiddlewareContext } from "~/server/lib/auth";
|
||||
import { systemService } from "~/server/modules/system/system.service";
|
||||
import { enforcePasswordLoginPolicy } from "../password-login-policy";
|
||||
|
||||
const createContext = (path: string) => ({ path }) as AuthMiddlewareContext;
|
||||
|
||||
describe("enforcePasswordLoginPolicy", () => {
|
||||
beforeEach(() => {
|
||||
config.runtime = "server";
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("skips non-password sign-in endpoints", async () => {
|
||||
const isPasswordLoginDisabled = vi.spyOn(systemService, "isPasswordLoginDisabled").mockResolvedValue(true);
|
||||
|
||||
await expect(enforcePasswordLoginPolicy(createContext("/sign-in/sso"))).resolves.toBeUndefined();
|
||||
|
||||
expect(isPasswordLoginDisabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("allows username password sign-in when password login is enabled", async () => {
|
||||
vi.spyOn(systemService, "isPasswordLoginDisabled").mockResolvedValue(false);
|
||||
|
||||
await expect(enforcePasswordLoginPolicy(createContext("/sign-in/username"))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("blocks username and email password sign-in when password login is disabled", async () => {
|
||||
vi.spyOn(systemService, "isPasswordLoginDisabled").mockResolvedValue(true);
|
||||
|
||||
await expect(enforcePasswordLoginPolicy(createContext("/sign-in/username"))).rejects.toThrow(
|
||||
"Password login is disabled",
|
||||
);
|
||||
await expect(enforcePasswordLoginPolicy(createContext("/sign-in/email"))).rejects.toThrow(
|
||||
"Password login is disabled",
|
||||
);
|
||||
});
|
||||
|
||||
test("blocks password sign-in when the runtime does not support password authentication", async () => {
|
||||
config.runtime = "desktop";
|
||||
const isPasswordLoginDisabled = vi.spyOn(systemService, "isPasswordLoginDisabled").mockResolvedValue(false);
|
||||
|
||||
await expect(enforcePasswordLoginPolicy(createContext("/sign-in/username"))).rejects.toThrow(
|
||||
"Password login is disabled",
|
||||
);
|
||||
expect(isPasswordLoginDisabled).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
18
app/server/lib/auth/middlewares/password-login-policy.ts
Normal file
18
app/server/lib/auth/middlewares/password-login-policy.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { ForbiddenError } from "http-errors-enhanced";
|
||||
import type { AuthMiddlewareContext } from "~/server/lib/auth";
|
||||
import { serverHasRuntimeFeature } from "~/server/lib/permission-service";
|
||||
import { systemService } from "~/server/modules/system/system.service";
|
||||
|
||||
export const enforcePasswordLoginPolicy = async (ctx: AuthMiddlewareContext) => {
|
||||
if (ctx.path !== "/sign-in/email" && ctx.path !== "/sign-in/username") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (serverHasRuntimeFeature("passwordAuthentication") && !(await systemService.isPasswordLoginDisabled())) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Password login attempt blocked: password login is not enabled.");
|
||||
throw new ForbiddenError("Password login is disabled. Please use another sign-in method.");
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { hasActivePasskeyUser } from "~/server/modules/auth/helpers";
|
||||
import { hasActivePasskeyUser, isPasswordAuthSupported } from "~/server/modules/auth/helpers";
|
||||
import { systemService } from "~/server/modules/system/system.service";
|
||||
|
||||
export const getLoginOptions = createServerFn({ method: "GET" }).handler(async () => ({
|
||||
hasPasskeySignIn: await hasActivePasskeyUser(),
|
||||
passwordLoginEnabled: isPasswordAuthSupported() && !(await systemService.isPasswordLoginDisabled()),
|
||||
}));
|
||||
|
||||
@@ -4,10 +4,11 @@ import { createTestSession, createTestSessionWithGlobalAdmin, getAuthHeaders } f
|
||||
import { systemService } from "../system.service";
|
||||
import * as authHelpers from "~/server/modules/auth/helpers";
|
||||
import { db } from "~/server/db/db";
|
||||
import { organization, sessionsTable, usersTable } from "~/server/db/schema";
|
||||
import { appMetadataTable, organization, sessionsTable, usersTable } from "~/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { cryptoUtils } from "~/server/utils/crypto";
|
||||
import { config } from "~/server/core/config";
|
||||
import { PASSWORD_LOGIN_DISABLED_KEY } from "~/server/core/constants";
|
||||
|
||||
const app = createApp();
|
||||
|
||||
@@ -87,6 +88,8 @@ describe("system security", () => {
|
||||
{ method: "GET", path: "/api/v1/system/updates" },
|
||||
{ method: "GET", path: "/api/v1/system/registration-status" },
|
||||
{ method: "PUT", path: "/api/v1/system/registration-status" },
|
||||
{ method: "GET", path: "/api/v1/system/password-login-status" },
|
||||
{ method: "PUT", path: "/api/v1/system/password-login-status" },
|
||||
{ method: "POST", path: "/api/v1/system/restic-password" },
|
||||
{ method: "GET", path: "/api/v1/system/dev-panel" },
|
||||
];
|
||||
@@ -132,12 +135,53 @@ describe("system security", () => {
|
||||
...globalAdminSession.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("password-login-status endpoint", () => {
|
||||
test("GET /api/v1/system/password-login-status should be accessible with valid session", async () => {
|
||||
await db.delete(appMetadataTable).where(eq(appMetadataTable.key, PASSWORD_LOGIN_DISABLED_KEY));
|
||||
|
||||
const res = await app.request("/api/v1/system/password-login-status", {
|
||||
headers: session.headers,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(typeof body.disabled).toBe("boolean");
|
||||
expect(body.disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("PUT /api/v1/system/password-login-status should return 403 for non-admin users", async () => {
|
||||
const res = await app.request("/api/v1/system/password-login-status", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
...session.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ disabled: true }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.message).toBe("Forbidden");
|
||||
});
|
||||
|
||||
test("PUT /api/v1/system/password-login-status should be accessible to global admin", async () => {
|
||||
const res = await app.request("/api/v1/system/password-login-status", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
...globalAdminSession.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ disabled: false }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ disabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("dev-panel endpoint", () => {
|
||||
test("GET /api/v1/system/dev-panel should be accessible with valid session", async () => {
|
||||
const res = await app.request("/api/v1/system/dev-panel", { headers: session.headers });
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
getRegistrationStatusDto,
|
||||
registrationStatusBody,
|
||||
type RegistrationStatusDto,
|
||||
getPasswordLoginStatusDto,
|
||||
setPasswordLoginStatusDto,
|
||||
passwordLoginStatusBody,
|
||||
type PasswordLoginStatusDto,
|
||||
getDevPanelDto,
|
||||
type DevPanelDto,
|
||||
} from "./system.dto";
|
||||
@@ -103,6 +107,24 @@ export const systemController = new Hono()
|
||||
}
|
||||
},
|
||||
)
|
||||
.get("/password-login-status", getPasswordLoginStatusDto, async (c) => {
|
||||
const disabled = await systemService.isPasswordLoginDisabled();
|
||||
|
||||
return c.json<PasswordLoginStatusDto>({ disabled }, 200);
|
||||
})
|
||||
.put(
|
||||
"/password-login-status",
|
||||
requirePermission("passwordLogin.manage"),
|
||||
setPasswordLoginStatusDto,
|
||||
validator("json", passwordLoginStatusBody),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
|
||||
await systemService.setPasswordLoginDisabled(body.disabled);
|
||||
|
||||
return c.json<PasswordLoginStatusDto>({ disabled: body.disabled }, 200);
|
||||
},
|
||||
)
|
||||
.get("/dev-panel", getDevPanelDto, async (c) => {
|
||||
const enabled = systemService.isDevPanelEnabled();
|
||||
|
||||
|
||||
@@ -126,6 +126,48 @@ export const setRegistrationStatusDto = describeRoute({
|
||||
},
|
||||
});
|
||||
|
||||
const passwordLoginStatusResponse = z.object({
|
||||
disabled: z.boolean(),
|
||||
});
|
||||
|
||||
export type PasswordLoginStatusDto = z.infer<typeof passwordLoginStatusResponse>;
|
||||
|
||||
export const passwordLoginStatusBody = z.object({
|
||||
disabled: z.boolean(),
|
||||
});
|
||||
|
||||
export const getPasswordLoginStatusDto = describeRoute({
|
||||
description: "Get whether password-based login is disabled",
|
||||
tags: ["System"],
|
||||
operationId: "getPasswordLoginStatus",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Password login status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(passwordLoginStatusResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const setPasswordLoginStatusDto = describeRoute({
|
||||
description: "Disable or re-enable password-based login. Requires global admin role.",
|
||||
tags: ["System"],
|
||||
operationId: "setPasswordLoginStatus",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Password login status updated",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(passwordLoginStatusResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const devPanelResponse = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cache, cacheKeys } from "../../utils/cache";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { db } from "../../db/db";
|
||||
import { appMetadataTable } from "../../db/schema";
|
||||
import { REGISTRATION_ENABLED_KEY } from "~/server/core/constants";
|
||||
import { PASSWORD_LOGIN_DISABLED_KEY, REGISTRATION_ENABLED_KEY } from "~/server/core/constants";
|
||||
import type { BackendType } from "@zerobyte/contracts/volumes";
|
||||
import type { RepositoryBackend } from "@zerobyte/core/restic";
|
||||
import { serverHasRuntimeFeature } from "~/server/lib/permission-service";
|
||||
@@ -134,6 +134,33 @@ const setRegistrationEnabled = async (enabled: boolean) => {
|
||||
logger.info(`Registration enabled set to: ${enabled}`);
|
||||
};
|
||||
|
||||
const isPasswordLoginDisabled = async () => {
|
||||
const result = await db.query.appMetadataTable.findFirst({
|
||||
where: { key: PASSWORD_LOGIN_DISABLED_KEY },
|
||||
});
|
||||
|
||||
return result?.value === "true";
|
||||
};
|
||||
|
||||
const setPasswordLoginDisabled = async (disabled: boolean) => {
|
||||
const now = Date.now();
|
||||
|
||||
await db
|
||||
.insert(appMetadataTable)
|
||||
.values({
|
||||
key: PASSWORD_LOGIN_DISABLED_KEY,
|
||||
value: JSON.stringify(disabled),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: appMetadataTable.key,
|
||||
set: { value: JSON.stringify(disabled), updatedAt: now },
|
||||
});
|
||||
|
||||
logger.info(`Password login disabled set to: ${disabled}`);
|
||||
};
|
||||
|
||||
const isDevPanelEnabled = () => config.flags.enableDevPanel;
|
||||
|
||||
export const systemService = {
|
||||
@@ -141,5 +168,7 @@ export const systemService = {
|
||||
getUpdates,
|
||||
isRegistrationEnabled,
|
||||
setRegistrationEnabled,
|
||||
isPasswordLoginDisabled,
|
||||
setPasswordLoginDisabled,
|
||||
isDevPanelEnabled,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user