diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index 9051293e..d5f66cff 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -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 = [ Pick & { @@ -1465,6 +1465,41 @@ export const downloadResticPasswordMutation = (options?: Partial) => createQueryKey('getPasswordLoginStatus', options); + +/** + * Get whether password-based login is disabled + */ +export const getPasswordLoginStatusOptions = (options?: Options) => queryOptions>({ + 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>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await setPasswordLoginStatus({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + export const getDevPanelQueryKey = (options?: Options) => createQueryKey('getDevPanel', options); /** diff --git a/app/client/api-client/index.ts b/app/client/api-client/index.ts index 23f7bae8..4e89a099 100644 --- a/app/client/api-client/index.ts +++ b/app/client/api-client/index.ts @@ -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, diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index d87ba2f0..c93c43e8 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -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 = Options2 & { /** @@ -570,6 +570,23 @@ export const downloadResticPassword = (opt } }); +/** + * Get whether password-based login is disabled + */ +export const getPasswordLoginStatus = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/system/password-login-status', ...options }); + +/** + * Disable or re-enable password-based login. Requires global admin role. + */ +export const setPasswordLoginStatus = (options: Options) => (options.client ?? client).put({ + url: '/api/v1/system/password-login-status', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + /** * Get the dev panel status */ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index bf76dbde..1002d0e3 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -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; diff --git a/app/client/modules/admin/routes/admin-page.tsx b/app/client/modules/admin/routes/admin-page.tsx index a4348f86..ac209ae2 100644 --- a/app/client/modules/admin/routes/admin-page.tsx +++ b/app/client/modules/admin/routes/admin-page.tsx @@ -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 (
@@ -58,7 +92,9 @@ export function AdminPage({ appContext }: Props) { User Management - Manage users, roles and permissions + + Manage users, roles and permissions +
@@ -73,13 +109,15 @@ export function AdminPage({ appContext }: Props) { Manage system-wide settings - +
-

When enabled, new users can sign up

+

+ When enabled, new users can sign up +

+
+
+ +

+ When disabled, the username and password form is hidden on the login page. + Users can still sign in via SSO or passkeys. +

+
+ { + if (checked) { + updatePasswordLoginDisabled(false); + return; + } + + setShowDisablePasswordLoginConfirm(true); + }} + disabled={updatePasswordLoginStatusMutation.isPending} + /> +
+ + + + Disable password login? + + 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  + docker exec -it zerobyte bun run cli enable-password-login on the server. + + + + Cancel + { + updatePasswordLoginDisabled(true); + }} + > + Disable password login + + + + ); } diff --git a/app/client/modules/auth/components/alternative-sign-in-section.tsx b/app/client/modules/auth/components/alternative-sign-in-section.tsx index ca4f97bc..3badeb19 100644 --- a/app/client/modules/auth/components/alternative-sign-in-section.tsx +++ b/app/client/modules/auth/components/alternative-sign-in-section.tsx @@ -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; }; -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

Alternative Sign-in

- {loginOptions.hasPasskeySignIn && } + {hasPasskeySignIn && }
diff --git a/app/client/modules/auth/routes/__tests__/login.test.tsx b/app/client/modules/auth/routes/__tests__/login.test.tsx index f9af712f..be46c1e4 100644 --- a/app/client/modules/auth/routes/__tests__/login.test.tsx +++ b/app/client/modules/auth/routes/__tests__/login.test.tsx @@ -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(, { 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(, { withSuspense: true }); + + expect(await screen.findByText(inviteOnlyMessage)).toBeTruthy(); + expect(screen.queryByLabelText("Username")).toBeNull(); + expect(screen.queryByLabelText("Password")).toBeNull(); + }); }); diff --git a/app/client/modules/auth/routes/login.tsx b/app/client/modules/auth/routes/login.tsx index a9081fe7..b0f90aef 100644 --- a/app/client/modules/auth/routes/login.tsx +++ b/app/client/modules/auth/routes/login.tsx @@ -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 ( -
- -
- {errorDescription} -
- ( - - Username - - - - - - )} - /> - ( - -
- Password - -
- - - - -
- )} - /> - - - +
+ {errorDescription} +
- + {loginOptions.passwordLoginEnabled && ( +
+ + ( + + Username + + + + + + )} + /> + ( + +
+ Password + +
+ + + + +
+ )} + /> + + + + )} + +
diff --git a/app/lib/__tests__/permission-policy.test.ts b/app/lib/__tests__/permission-policy.test.ts index a0dd41ca..d479ceaf 100644 --- a/app/lib/__tests__/permission-policy.test.ts +++ b/app/lib/__tests__/permission-policy.test.ts @@ -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", () => { diff --git a/app/lib/permission-policy.ts b/app/lib/permission-policy.ts index 55bd93b2..81151d97 100644 --- a/app/lib/permission-policy.ts +++ b/app/lib/permission-policy.ts @@ -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"], diff --git a/app/server/cli/commands/enable-password-login.ts b/app/server/cli/commands/enable-password-login.ts new file mode 100644 index 00000000..754101bf --- /dev/null +++ b/app/server/cli/commands/enable-password-login.ts @@ -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); + }); diff --git a/app/server/cli/index.ts b/app/server/cli/index.ts index 97560f0b..dd4966ea 100644 --- a/app/server/cli/index.ts +++ b/app/server/cli/index.ts @@ -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); diff --git a/app/server/core/constants.ts b/app/server/core/constants.ts index e83c8268..b8f08a81 100644 --- a/app/server/core/constants.ts +++ b/app/server/core/constants.ts @@ -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"; diff --git a/app/server/lib/auth.ts b/app/server/lib/auth.ts index 2cf72cec..30e650a2 100644 --- a/app/server/lib/auth.ts +++ b/app/server/lib/auth.ts @@ -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); }), }, diff --git a/app/server/lib/auth/middlewares/__tests__/password-login-policy.test.ts b/app/server/lib/auth/middlewares/__tests__/password-login-policy.test.ts new file mode 100644 index 00000000..6d78ecb4 --- /dev/null +++ b/app/server/lib/auth/middlewares/__tests__/password-login-policy.test.ts @@ -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(); + }); +}); diff --git a/app/server/lib/auth/middlewares/password-login-policy.ts b/app/server/lib/auth/middlewares/password-login-policy.ts new file mode 100644 index 00000000..738f2c9d --- /dev/null +++ b/app/server/lib/auth/middlewares/password-login-policy.ts @@ -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."); +}; diff --git a/app/server/lib/functions/login-options.ts b/app/server/lib/functions/login-options.ts index 66c98a6f..62331469 100644 --- a/app/server/lib/functions/login-options.ts +++ b/app/server/lib/functions/login-options.ts @@ -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()), })); diff --git a/app/server/modules/system/__tests__/system.controller.test.ts b/app/server/modules/system/__tests__/system.controller.test.ts index 44c22096..6c9dbf72 100644 --- a/app/server/modules/system/__tests__/system.controller.test.ts +++ b/app/server/modules/system/__tests__/system.controller.test.ts @@ -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 }); diff --git a/app/server/modules/system/system.controller.ts b/app/server/modules/system/system.controller.ts index 819b4d91..701b3f35 100644 --- a/app/server/modules/system/system.controller.ts +++ b/app/server/modules/system/system.controller.ts @@ -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({ 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({ disabled: body.disabled }, 200); + }, + ) .get("/dev-panel", getDevPanelDto, async (c) => { const enabled = systemService.isDevPanelEnabled(); diff --git a/app/server/modules/system/system.dto.ts b/app/server/modules/system/system.dto.ts index 661f6978..ec0bbae8 100644 --- a/app/server/modules/system/system.dto.ts +++ b/app/server/modules/system/system.dto.ts @@ -126,6 +126,48 @@ export const setRegistrationStatusDto = describeRoute({ }, }); +const passwordLoginStatusResponse = z.object({ + disabled: z.boolean(), +}); + +export type PasswordLoginStatusDto = z.infer; + +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(), }); diff --git a/app/server/modules/system/system.service.ts b/app/server/modules/system/system.service.ts index d987ebdd..d483d623 100644 --- a/app/server/modules/system/system.service.ts +++ b/app/server/modules/system/system.service.ts @@ -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, };