From 2b70267e0182726cfefad8ced628e7310a09c14e Mon Sep 17 00:00:00 2001 From: Parman Mohammadalizadeh Date: Fri, 5 Jun 2026 22:49:07 +0330 Subject: [PATCH] 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 --- .../api-client/@tanstack/react-query.gen.ts | 39 ++++- app/client/api-client/sdk.gen.ts | 19 ++- app/client/api-client/types.gen.ts | 38 +++++ .../modules/admin/routes/admin-page.tsx | 36 +++++ app/client/modules/auth/routes/login.tsx | 142 ++++++++++-------- app/server/core/constants.ts | 1 + app/server/lib/functions/login-options.ts | 2 + .../modules/system/system.controller.ts | 22 +++ app/server/modules/system/system.dto.ts | 42 ++++++ app/server/modules/system/system.service.ts | 24 ++- 10 files changed, 295 insertions(+), 70 deletions(-) diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index 8b99be1a..0640f4a2 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, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getMirrorSyncStatus, getNotificationDestination, getOrgMembers, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshRepositoryStats, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, syncMirror, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetMirrorSyncStatusData, GetMirrorSyncStatusResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, 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, 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, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getMirrorSyncStatus, getNotificationDestination, getOrgMembers, getPasswordLoginStatus, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshRepositoryStats, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setPasswordLoginStatus, setRegistrationStatus, startDoctor, stopBackup, syncMirror, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetMirrorSyncStatusData, GetMirrorSyncStatusResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetPasswordLoginStatusData, GetPasswordLoginStatusResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, 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, 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 & { @@ -1361,6 +1361,41 @@ export const setRegistrationStatusMutation = (options?: Partial) => createQueryKey('getPasswordLoginStatus', options); + +/** + * Get whether password-based login is enabled + */ +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) +}); + +/** + * Enable or disable 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; +}; + /** * Download the organization's Restic password for backup recovery. Requires organization owner or admin role and password re-authentication. */ diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index c2aedc4a..52149eda 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, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponse, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetMirrorSyncStatusData, GetMirrorSyncStatusResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, 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, 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, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponse, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetMirrorSyncStatusData, GetMirrorSyncStatusResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetPasswordLoginStatusData, GetPasswordLoginStatusResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, 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, 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 & { /** @@ -519,6 +519,23 @@ export const setRegistrationStatus = (opti } }); +/** + * Get whether password-based login is enabled + */ +export const getPasswordLoginStatus = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/system/password-login-status', ...options }); + +/** + * Enable or disable 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 + } +}); + /** * Download the organization's Restic password for backup recovery. Requires organization owner or admin role and password re-authentication. */ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 8aa1adf3..e04d93b8 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -4990,6 +4990,44 @@ export type SetRegistrationStatusResponses = { export type SetRegistrationStatusResponse = SetRegistrationStatusResponses[keyof SetRegistrationStatusResponses]; +export type GetPasswordLoginStatusData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/system/password-login-status'; +}; + +export type GetPasswordLoginStatusResponses = { + /** + * Password login status + */ + 200: { + enabled: boolean; + }; +}; + +export type GetPasswordLoginStatusResponse = GetPasswordLoginStatusResponses[keyof GetPasswordLoginStatusResponses]; + +export type SetPasswordLoginStatusData = { + body: { + enabled: boolean; + }; + path?: never; + query?: never; + url: '/api/v1/system/password-login-status'; +}; + +export type SetPasswordLoginStatusResponses = { + /** + * Password login status updated + */ + 200: { + enabled: boolean; + }; +}; + +export type SetPasswordLoginStatusResponse = SetPasswordLoginStatusResponses[keyof SetPasswordLoginStatusResponses]; + export type DownloadResticPasswordData = { body: { password: string; diff --git a/app/client/modules/admin/routes/admin-page.tsx b/app/client/modules/admin/routes/admin-page.tsx index a4348f86..7e28540a 100644 --- a/app/client/modules/admin/routes/admin-page.tsx +++ b/app/client/modules/admin/routes/admin-page.tsx @@ -3,7 +3,9 @@ import { useNavigate, useSearch } from "@tanstack/react-router"; import { Users, Settings as SettingsIcon } from "lucide-react"; import { toast } from "sonner"; import { + getPasswordLoginStatusOptions, getRegistrationStatusOptions, + setPasswordLoginStatusMutation, setRegistrationStatusMutation, } from "~/client/api-client/@tanstack/react-query.gen"; import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card"; @@ -38,6 +40,22 @@ 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 }) }); }; @@ -90,6 +108,24 @@ export function AdminPage({ appContext }: Props) { disabled={updateRegistrationStatusMutation.isPending} /> +
+
+ +

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

+
+ + updatePasswordLoginStatusMutation.mutate({ body: { enabled: checked } }) + } + disabled={updatePasswordLoginStatusMutation.isPending} + /> +
diff --git a/app/client/modules/auth/routes/login.tsx b/app/client/modules/auth/routes/login.tsx index a9081fe7..a940083c 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,66 +273,68 @@ export function LoginPage({ error }: LoginPageProps = {}) { return ( -
- -
- {errorDescription} -
- ( - - Username - - - - - - )} - /> - ( - -
- Password - -
- - - - -
- )} - /> - - - + {loginOptions.passwordLoginEnabled && ( +
+ +
+ {errorDescription} +
+ ( + + Username + + + + + + )} + /> + ( + +
+ Password + +
+ + + + +
+ )} + /> + + + + )} diff --git a/app/server/core/constants.ts b/app/server/core/constants.ts index 2a7aa784..fa1c503d 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]; export const REGISTRATION_ENABLED_KEY = "registrations_enabled"; +export const PASSWORD_LOGIN_ENABLED_KEY = "password_login_enabled"; diff --git a/app/server/lib/functions/login-options.ts b/app/server/lib/functions/login-options.ts index 66c98a6f..cb259205 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 { systemService } from "~/server/modules/system/system.service"; export const getLoginOptions = createServerFn({ method: "GET" }).handler(async () => ({ hasPasskeySignIn: await hasActivePasskeyUser(), + passwordLoginEnabled: await systemService.isPasswordLoginEnabled(), })); diff --git a/app/server/modules/system/system.controller.ts b/app/server/modules/system/system.controller.ts index 210606cb..ae921794 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"; @@ -89,6 +93,24 @@ export const systemController = new Hono() } }, ) + .get("/password-login-status", getPasswordLoginStatusDto, async (c) => { + const enabled = await systemService.isPasswordLoginEnabled(); + + return c.json({ enabled }, 200); + }) + .put( + "/password-login-status", + requireAdmin, + setPasswordLoginStatusDto, + validator("json", passwordLoginStatusBody), + async (c) => { + const body = c.req.valid("json"); + + await systemService.setPasswordLoginEnabled(body.enabled); + + return c.json({ enabled: body.enabled }, 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 10da861d..c2e11f98 100644 --- a/app/server/modules/system/system.dto.ts +++ b/app/server/modules/system/system.dto.ts @@ -123,6 +123,48 @@ export const setRegistrationStatusDto = describeRoute({ }, }); +const passwordLoginStatusResponse = z.object({ + enabled: z.boolean(), +}); + +export type PasswordLoginStatusDto = z.infer; + +export const passwordLoginStatusBody = z.object({ + enabled: z.boolean(), +}); + +export const getPasswordLoginStatusDto = describeRoute({ + description: "Get whether password-based login is enabled", + tags: ["System"], + operationId: "getPasswordLoginStatus", + responses: { + 200: { + description: "Password login status", + content: { + "application/json": { + schema: resolver(passwordLoginStatusResponse), + }, + }, + }, + }, +}); + +export const setPasswordLoginStatusDto = describeRoute({ + description: "Enable or disable 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 64ced883..28f22b43 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_ENABLED_KEY, REGISTRATION_ENABLED_KEY } from "~/server/core/constants"; const CACHE_TTL = 60 * 60; @@ -107,6 +107,26 @@ const setRegistrationEnabled = async (enabled: boolean) => { logger.info(`Registration enabled set to: ${enabled}`); }; +const isPasswordLoginEnabled = async () => { + const result = await db.query.appMetadataTable.findFirst({ + where: { key: PASSWORD_LOGIN_ENABLED_KEY }, + }); + + // Default to true so existing deployments are unaffected + return result === undefined || result.value === "true"; +}; + +const setPasswordLoginEnabled = async (enabled: boolean) => { + const now = Date.now(); + + await db + .insert(appMetadataTable) + .values({ key: PASSWORD_LOGIN_ENABLED_KEY, value: JSON.stringify(enabled), createdAt: now, updatedAt: now }) + .onConflictDoUpdate({ target: appMetadataTable.key, set: { value: JSON.stringify(enabled), updatedAt: now } }); + + logger.info(`Password login enabled set to: ${enabled}`); +}; + const isDevPanelEnabled = () => config.flags.enableDevPanel; export const systemService = { @@ -114,5 +134,7 @@ export const systemService = { getUpdates, isRegistrationEnabled, setRegistrationEnabled, + isPasswordLoginEnabled, + setPasswordLoginEnabled, isDevPanelEnabled, };