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:
Parman Mohammadalizadeh
2026-06-16 23:56:56 +03:30
committed by GitHub
parent 1f38d9241b
commit 34e8f89727
21 changed files with 537 additions and 93 deletions

View File

@@ -4,8 +4,8 @@
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen';
import { browseFilesystem, cancelDoctor, 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);
/**

View File

@@ -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,

View File

@@ -3,7 +3,7 @@
import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, 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
*/

View File

@@ -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;

View File

@@ -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&nbsp;
<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>
);
}

View File

@@ -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>

View File

@@ -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();
});
});

View File

@@ -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>

View File

@@ -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", () => {

View File

@@ -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"],

View 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);
});

View File

@@ -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);

View File

@@ -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";

View File

@@ -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);
}),
},

View File

@@ -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();
});
});

View 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.");
};

View File

@@ -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()),
}));

View File

@@ -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 });

View File

@@ -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();

View File

@@ -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(),
});

View File

@@ -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,
};