From e1eecc3b6ce8e8130bd14c11e5a25bcf620ea2c6 Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 5 Oct 2026 14:10:07 +0200 Subject: [PATCH] feat(auth): add username sign-in mode and recovery kits (#3215) --- deploy/self-hosting/.env.example | 5 + deploy/self-hosting/docker-compose.yml | 2 + fluxer_admin/openapi-admin.json | 182 +- fluxer_admin/src/acl.rs | 4 + fluxer_admin/src/api/instance_config.rs | 16 +- fluxer_admin/src/api/types/instance_config.rs | 75 + fluxer_admin/src/api/types/user_detail.rs | 6 + fluxer_admin/src/api/users.rs | 23 +- .../src/middleware/account_identity.rs | 25 + fluxer_admin/src/middleware/mod.rs | 1 + fluxer_admin/src/routes/bans.rs | 51 +- fluxer_admin/src/routes/bans_actions.rs | 12 +- fluxer_admin/src/routes/guilds.rs | 4 + fluxer_admin/src/routes/messages.rs | 9 +- fluxer_admin/src/routes/mod.rs | 4 + fluxer_admin/src/routes/system_actions.rs | 35 +- fluxer_admin/src/routes/user_actions.rs | 5 + fluxer_admin/src/routes/user_tabs.rs | 69 +- fluxer_admin/src/routes/users.rs | 114 +- fluxer_admin/src/state.rs | 47 +- .../src/templates/components/message_list.rs | 1 + .../src/templates/components/user_display.rs | 33 +- fluxer_admin/src/templates/layout_header.rs | 8 +- .../src/templates/pages/application_detail.rs | 2 + .../src/templates/pages/applications_list.rs | 9 +- .../src/templates/pages/audit_logs_table.rs | 24 +- fluxer_admin/src/templates/pages/bans.rs | 8 +- .../src/templates/pages/bulk_actions.rs | 31 +- fluxer_admin/src/templates/pages/discovery.rs | 3 +- .../src/templates/pages/guild_detail.rs | 30 +- .../pages/guild_detail_tabs/audit_log.rs | 6 +- .../pages/guild_detail_tabs/members.rs | 14 +- .../templates/pages/guild_detail_tabs/mod.rs | 4 +- .../pages/guild_detail_tabs/reports.rs | 3 +- .../pages/guild_detail_tabs/settings.rs | 46 +- .../src/templates/pages/guilds_list.rs | 4 +- .../src/templates/pages/instance_config.rs | 272 ++- .../src/templates/pages/report_detail.rs | 6 +- .../src/templates/pages/reports_list.rs | 5 +- .../src/templates/pages/user_detail.rs | 4 +- .../pages/user_detail_tabs/account.rs | 147 +- .../templates/pages/user_detail_tabs/mod.rs | 12 +- .../pages/user_detail_tabs/moderation.rs | 62 +- .../pages/user_detail_tabs/overview.rs | 73 +- .../pages/user_detail_tabs/reports.rs | 5 +- .../pages/user_detail_tabs/settings.rs | 7 +- fluxer_admin/src/templates/pages/user_peek.rs | 6 +- .../src/templates/pages/users_list.rs | 65 +- fluxer_admin/src/utils/forms.rs | 4 + fluxer_admin/src/utils/mod.rs | 1 + fluxer_admin/src/utils/user_tag.rs | 69 + fluxer_admin/tests/api_deserialization.rs | 15 + fluxer_admin/tests/password_reset_link.rs | 446 ++++ fluxer_admin/tests/username_tags.rs | 241 +++ fluxer_api/src/api/Config.ts | 3 + fluxer_api/src/api/Tables.ts | 7 + fluxer_api/src/api/Validator.ts | 9 +- .../InstanceConfigAdminController.ts | 24 +- .../admin/controllers/UserAdminController.ts | 68 + .../api/admin/services/AdminReportService.ts | 14 +- .../admin/services/AdminUserLookupService.ts | 12 +- .../admin/services/AdminUserProfileService.ts | 48 +- .../services/AdminUserSecurityService.ts | 74 + .../UserWriteAdminAuditCases.ts | 54 +- fluxer_api/src/api/auth/AuthController.ts | 73 +- fluxer_api/src/api/auth/AuthLogin.ts | 169 +- fluxer_api/src/api/auth/AuthPassword.ts | 81 +- fluxer_api/src/api/auth/AuthRecoveryKit.ts | 271 +++ fluxer_api/src/api/auth/AuthRegistration.ts | 92 +- fluxer_api/src/api/auth/AuthRequestService.ts | 33 +- .../src/api/auth/EmailVerificationUtils.ts | 3 +- fluxer_api/src/api/auth/InstanceAddress.ts | 53 + .../auth/services/RecoveryKitRepository.ts | 41 + .../src/api/auth/services/SsoService.ts | 42 +- .../api/auth/tests/CaptchaBypassFlags.test.ts | 20 + .../src/api/auth/tests/RecoveryKit.test.ts | 691 +++++++ .../src/api/auth/tests/UsernameSignIn.test.ts | 1449 +++++++++++++ fluxer_api/src/api/config/APIConfig.ts | 4 + .../src/api/database/CassandraTableDsl.ts | 2 + fluxer_api/src/api/database/CassandraTypes.ts | 2 + .../api/database/PostgresKvQueryExecutor.ts | 18 + .../database/PostgresKvUnorderedScan.test.ts | 72 + .../src/api/database/types/AuthTypes.ts | 11 +- .../api/instance/AccountIdentityModeCache.ts | 56 + .../api/instance/AccountIdentitySetupLock.ts | 25 + .../api/instance/InstanceConfigRepository.ts | 205 +- .../src/api/instance/InstanceController.ts | 72 +- .../tests/AccountIdentityMode.test.ts | 596 ++++++ .../middleware/AccountIdentityMiddleware.ts | 40 + .../src/api/middleware/CaptchaMiddleware.ts | 4 +- .../src/api/middleware/ServiceSingletons.ts | 2 + .../src/api/models/PasswordResetToken.ts | 4 +- fluxer_api/src/api/openapi/openapi.json | 612 +++++- .../rate_limit_configs/AuthRateLimitConfig.ts | 8 + .../rate_limit_configs/MiscRateLimitConfig.ts | 4 + .../rate_limit_configs/UserRateLimitConfig.ts | 12 + fluxer_api/src/api/report/ReportController.ts | 3 + .../src/api/rpc/RpcSessionStartService.ts | 8 +- fluxer_api/src/api/types/HonoEnv.ts | 1 + fluxer_api/src/api/user/UniqueUsernames.ts | 133 ++ fluxer_api/src/api/user/UserHelpers.ts | 4 + fluxer_api/src/api/user/UserTag.ts | 24 + .../user/controllers/UserAccountController.ts | 67 +- .../user/controllers/UserAuthController.ts | 65 + .../repositories/IUserAccountRepository.ts | 1 + .../repositories/UserAccountRepository.ts | 4 + .../api/user/repositories/UserRepository.ts | 4 + .../account/UserLookupRepository.ts | 9 + .../user/repositories/auth/TokenRepository.ts | 10 +- .../user/services/PasswordChangeService.ts | 16 + .../user/services/UserAccountLookupService.ts | 13 +- .../services/UserAccountRequestService.ts | 7 + .../services/UserAccountSecurityService.ts | 37 +- .../api/user/services/UserAccountService.ts | 2 + .../services/UserContactChangeLogService.ts | 4 +- .../api/user/services/UserDeletionService.ts | 2 + .../src/api/utils/GuildVerificationUtils.ts | 19 +- .../src/api/utils/UsernameSuggestionUtils.ts | 13 +- .../src/api/worker/WorkerDependencies.ts | 1 + .../api/worker/tasks/HarvestUserData.test.ts | 67 +- .../src/api/worker/tasks/HarvestUserData.ts | 7 + fluxer_app/package.json | 1 + fluxer_app/src/app/Routes.ts | 1 + .../app/router/components/RootComponent.tsx | 1 + .../src/app/router/routes/AuthRoutes.tsx | 29 +- .../components/FeatureComparisonTable.tsx | 10 +- .../components/UnclaimedAccountAlert.tsx | 16 +- .../components/floating/UserAreaPopout.tsx | 12 +- .../app/components/layout/AppLayout.tsx | 2 + .../components/layout/KeyboardModeListener.ts | 1 + .../features/app/components/pages/YouPage.tsx | 2 +- .../SelfHostedSetupWizardGate.module.css | 15 + .../setup/SelfHostedSetupWizardGate.tsx | 186 +- .../app/components/setup/SetupWizardClient.ts | 13 + .../setup/SetupWizardStateMachine.ts | 51 +- .../app/components/setup/SetupWizardSteps.tsx | 243 ++- .../app/config/I18nDisplayConstants.ts | 1 + .../src/features/app/constants/Endpoints.ts | 6 + .../src/features/app/state/RuntimeConfig.ts | 57 + .../auth/commands/AuthenticationCommands.ts | 69 +- .../components/accounts/AccountListItem.tsx | 12 +- .../auth/components/accounts/AccountRow.tsx | 12 +- .../components/modals/BackupCodesModal.tsx | 2 +- .../modals/BackupCodesViewModal.tsx | 35 +- .../components/modals/ClaimAccountModal.tsx | 133 +- .../components/modals/MfaTotpEnableModal.tsx | 2 +- .../modals/RecoveryKitModal.module.css | 159 ++ .../components/modals/RecoveryKitModal.tsx | 464 +++++ .../modals/SudoVerificationModal.tsx | 25 + .../auth/components/pages/LoginPage.tsx | 5 +- .../pages/RecoverAccountPage.module.css | 78 + .../components/pages/RecoverAccountPage.tsx | 269 +++ .../auth/components/pages/RegisterPage.tsx | 6 +- .../components/pages/ResetPasswordPage.tsx | 44 +- .../features/auth/flow/AuthLoginLayout.tsx | 86 +- .../auth/flow/AuthRegisterFormCore.tsx | 105 +- .../auth/flow/BrowserLoginHandoffModal.tsx | 17 +- .../AuthLoginEmailPasswordForm.tsx | 20 +- .../src/features/auth/hooks/useLoginFlow.ts | 16 +- .../auth/hooks/useUsernameAvailability.ts | 41 + .../src/features/auth/state/AuthFlow.ts | 54 +- .../auth/utils/AccountSwitcherModalUtils.tsx | 8 +- .../features/auth/utils/RecoveryKitSheet.ts | 288 +++ .../direct_message/AddFriendForm.tsx | 14 +- .../guild_members_page/SearchApi.ts | 9 +- .../features/guild/commands/GuildCommands.ts | 1 + .../guild_tabs/BannedUserActionsSheet.tsx | 3 +- .../modals/guild_tabs/GuildBansTab.tsx | 4 +- .../modals/guild_tabs/GuildModerationTab.tsx | 24 +- .../src/features/i18n/locales/ar/messages.po | 1829 ++++++++++------ .../locales/auto-i18n-reviewed-unchanged.json | 270 +++ .../src/features/i18n/locales/bg/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/cs/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/da/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/de/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/el/messages.po | 1829 ++++++++++------ .../features/i18n/locales/en-GB/messages.po | 1831 +++++++++++------ .../features/i18n/locales/en-US/messages.po | 1829 ++++++++++------ .../features/i18n/locales/es-419/messages.po | 1829 ++++++++++------ .../features/i18n/locales/es-ES/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/fi/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/fr/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/he/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/hi/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/hr/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/hu/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/id/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/it/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/ja/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/ko/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/lt/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/nl/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/no/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/pl/messages.po | 1829 ++++++++++------ .../features/i18n/locales/pt-BR/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/ro/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/ru/messages.po | 1829 ++++++++++------ .../features/i18n/locales/sv-SE/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/th/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/tr/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/uk/messages.po | 1829 ++++++++++------ .../src/features/i18n/locales/vi/messages.po | 1829 ++++++++++------ .../features/i18n/locales/zh-CN/messages.po | 1829 ++++++++++------ .../features/i18n/locales/zh-TW/messages.po | 1829 ++++++++++------ .../invite/commands/InviteCommands.tsx | 12 +- .../src/features/member/state/MemberSearch.ts | 3 +- .../components/modals/BanDetailsModal.tsx | 3 +- .../components/report_modal/IARModalCopy.ts | 7 +- .../service_worker/WorkerNavigation.ts | 1 + .../features/platform/utils/DownloadFile.ts | 7 +- .../plutonium_page/PlutoniumPage.tsx | 32 +- .../PlutoniumPageComparison.tsx | 2 + .../plutonium_page/PlutoniumPageMessages.ts | 5 + .../plutonium_page/PlutoniumPageShowcase.tsx | 19 +- .../search/utils/DestinationSearchSources.ts | 2 +- .../search/utils/SearchQueryParser.ts | 5 +- .../user/commands/RecoveryKitCommands.ts | 114 + .../features/user/commands/UserCommands.ts | 33 +- .../components/RecoveryKitReminderGate.tsx | 81 + .../modals/FluxerTagChangeModal.tsx | 203 +- .../components/modals/PasswordChangeModal.tsx | 26 +- .../modals/UserProfileActionsSheet.tsx | 2 +- .../modals/UserProfileMobileSheet.tsx | 5 +- .../components/modals/UserProfileModal.tsx | 2 +- .../tabs/account_security_tab/AccountTab.tsx | 21 +- .../RecoveryKitSettings.tsx | 150 ++ .../tabs/my_profile_tab/AvatarUploader.tsx | 16 +- .../MyProfileTabUsernameSection.tsx | 3 +- .../tabs/privacy_safety_tab/DataExportTab.tsx | 141 +- .../data_request_modal/DataRequestModal.tsx | 38 +- .../search_index/AccountSecurityIndex.ts | 39 +- .../search_index/SearchIndexHelpers.ts | 9 + fluxer_app/src/features/user/models/User.ts | 6 +- .../features/user/state/RecoveryKitStatus.ts | 52 + fluxer_app/src/features/user/state/Users.ts | 9 +- .../features/user/utils/DisplayNameUtils.ts | 7 +- .../user/utils/FluxerTagDiscriminatorUtils.ts | 4 + .../src/features/user/utils/NicknameUtils.ts | 7 +- .../src/features/user/utils/UserTagUtils.ts | 23 + fluxer_docs/astro.config.ts | 8 +- fluxer_docs/scripts/VerifyDocsCoverage.ts | 8 + .../src/content/docs/admin-api/index.mdx | 4 + .../src/content/docs/admin-api/instance.mdx | 17 + .../src/content/docs/admin-api/reports.mdx | 6 +- .../src/content/docs/admin-api/users.mdx | 89 +- .../content/docs/http-api/authentication.mdx | 171 +- .../src/content/docs/http-api/billing.mdx | 2 +- .../docs/http-api/deployment-availability.md | 2 + .../src/content/docs/http-api/errors.md | 36 + .../src/content/docs/http-api/instance.mdx | 85 +- .../src/content/docs/http-api/reports.mdx | 4 +- .../src/content/docs/http-api/users.mdx | 8 +- .../docs/http-api/users/current-user.mdx | 6 +- .../http-api/users/email-and-password.mdx | 121 +- .../src/content/docs/http-api/users/mfa.mdx | 2 + .../docs/http-api/users/relationships.mdx | 2 +- .../content/docs/operator/configuration.mdx | 13 +- .../src/content/docs/operator/get-started.mdx | 24 +- .../src/content/docs/operator/sign-in.mdx | 117 ++ .../src/content/docs/topics/captcha.md | 3 +- fluxer_docs/src/installer/install.ps1 | 25 +- fluxer_docs/src/installer/install.sh | 37 +- packages/config/src/ConfigLoader.ts | 16 +- packages/config/src/MasterConfig.ts | 6 + .../config/src/__tests__/ConfigLoader.test.ts | 42 + .../src/config_loader/EnvironmentOverrides.ts | 14 + .../constants/src/AccountIdentityConstants.ts | 19 + packages/constants/src/AdminACLs.ts | 2 + packages/constants/src/ApiErrorCodes.ts | 3 + .../constants/src/RecoveryKeyUtils.test.ts | 91 + packages/constants/src/RecoveryKeyUtils.ts | 89 + .../constants/src/ValidationErrorCodes.ts | 6 + .../errors/src/__tests__/DomainErrors.test.ts | 12 + .../auth/AccountIdentityLockedError.ts | 10 + .../auth/EmailUnavailableOnInstanceError.ts | 10 + .../domains/auth/UsernameSignInOnlyError.ts | 10 + packages/errors/src/i18n/ErrorCodeMappings.ts | 10 + packages/errors/src/i18n/ErrorI18nMessages.ts | 12 + packages/errors/src/i18n/locales/ar.ts | 9 + packages/errors/src/i18n/locales/bg.ts | 9 + packages/errors/src/i18n/locales/cs.ts | 9 + packages/errors/src/i18n/locales/da.ts | 9 + packages/errors/src/i18n/locales/de.ts | 9 + packages/errors/src/i18n/locales/el.ts | 9 + packages/errors/src/i18n/locales/en-GB.ts | 9 + packages/errors/src/i18n/locales/es-419.ts | 9 + packages/errors/src/i18n/locales/es-ES.ts | 9 + packages/errors/src/i18n/locales/fi.ts | 9 + packages/errors/src/i18n/locales/fr.ts | 9 + packages/errors/src/i18n/locales/he.ts | 9 + packages/errors/src/i18n/locales/hi.ts | 9 + packages/errors/src/i18n/locales/hr.ts | 9 + packages/errors/src/i18n/locales/hu.ts | 9 + packages/errors/src/i18n/locales/id.ts | 9 + packages/errors/src/i18n/locales/it.ts | 9 + packages/errors/src/i18n/locales/ja.ts | 9 + packages/errors/src/i18n/locales/ko.ts | 9 + packages/errors/src/i18n/locales/lt.ts | 9 + packages/errors/src/i18n/locales/nl.ts | 9 + packages/errors/src/i18n/locales/no.ts | 9 + packages/errors/src/i18n/locales/pl.ts | 9 + packages/errors/src/i18n/locales/pt-BR.ts | 9 + packages/errors/src/i18n/locales/ro.ts | 9 + packages/errors/src/i18n/locales/ru.ts | 9 + packages/errors/src/i18n/locales/sv-SE.ts | 9 + packages/errors/src/i18n/locales/th.ts | 9 + packages/errors/src/i18n/locales/tr.ts | 9 + packages/errors/src/i18n/locales/uk.ts | 9 + packages/errors/src/i18n/locales/vi.ts | 9 + packages/errors/src/i18n/locales/zh-CN.ts | 9 + packages/errors/src/i18n/locales/zh-TW.ts | 9 + .../errors/src/i18n/weblate/locales/ar.json | 9 + .../errors/src/i18n/weblate/locales/bg.json | 9 + .../errors/src/i18n/weblate/locales/cs.json | 9 + .../errors/src/i18n/weblate/locales/da.json | 9 + .../errors/src/i18n/weblate/locales/de.json | 9 + .../errors/src/i18n/weblate/locales/el.json | 9 + .../src/i18n/weblate/locales/en-GB.json | 9 + .../src/i18n/weblate/locales/es-419.json | 9 + .../src/i18n/weblate/locales/es-ES.json | 9 + .../errors/src/i18n/weblate/locales/fi.json | 9 + .../errors/src/i18n/weblate/locales/fr.json | 9 + .../errors/src/i18n/weblate/locales/he.json | 9 + .../errors/src/i18n/weblate/locales/hi.json | 9 + .../errors/src/i18n/weblate/locales/hr.json | 9 + .../errors/src/i18n/weblate/locales/hu.json | 9 + .../errors/src/i18n/weblate/locales/id.json | 9 + .../errors/src/i18n/weblate/locales/it.json | 9 + .../errors/src/i18n/weblate/locales/ja.json | 9 + .../errors/src/i18n/weblate/locales/ko.json | 9 + .../errors/src/i18n/weblate/locales/lt.json | 9 + .../errors/src/i18n/weblate/locales/nl.json | 9 + .../errors/src/i18n/weblate/locales/no.json | 9 + .../errors/src/i18n/weblate/locales/pl.json | 9 + .../src/i18n/weblate/locales/pt-BR.json | 9 + .../errors/src/i18n/weblate/locales/ro.json | 9 + .../errors/src/i18n/weblate/locales/ru.json | 9 + .../src/i18n/weblate/locales/sv-SE.json | 9 + .../errors/src/i18n/weblate/locales/th.json | 9 + .../errors/src/i18n/weblate/locales/tr.json | 9 + .../errors/src/i18n/weblate/locales/uk.json | 9 + .../errors/src/i18n/weblate/locales/vi.json | 9 + .../src/i18n/weblate/locales/zh-CN.json | 9 + .../src/i18n/weblate/locales/zh-TW.json | 9 + .../errors/src/i18n/weblate/messages.json | 9 + .../schema/src/domains/admin/AdminSchemas.ts | 13 +- .../src/domains/admin/AdminUserSchemas.ts | 7 + .../schema/src/domains/auth/AuthSchemas.ts | 70 +- .../src/domains/instance/InstanceSchemas.ts | 43 + .../domains/tests/AuthLoginSchemas.test.ts | 116 ++ .../src/domains/user/UserRequestSchemas.ts | 8 + .../src/domains/user/UserResponseSchemas.ts | 23 + pnpm-lock.yaml | 35 +- pnpm-workspace.yaml | 1 + tools/dev/cassandra_target_schema.json | 19 + 355 files changed, 52138 insertions(+), 24097 deletions(-) create mode 100644 fluxer_admin/src/middleware/account_identity.rs create mode 100644 fluxer_admin/src/utils/user_tag.rs create mode 100644 fluxer_admin/tests/password_reset_link.rs create mode 100644 fluxer_admin/tests/username_tags.rs create mode 100644 fluxer_api/src/api/auth/AuthRecoveryKit.ts create mode 100644 fluxer_api/src/api/auth/InstanceAddress.ts create mode 100644 fluxer_api/src/api/auth/services/RecoveryKitRepository.ts create mode 100644 fluxer_api/src/api/auth/tests/RecoveryKit.test.ts create mode 100644 fluxer_api/src/api/auth/tests/UsernameSignIn.test.ts create mode 100644 fluxer_api/src/api/database/PostgresKvUnorderedScan.test.ts create mode 100644 fluxer_api/src/api/instance/AccountIdentityModeCache.ts create mode 100644 fluxer_api/src/api/instance/AccountIdentitySetupLock.ts create mode 100644 fluxer_api/src/api/instance/tests/AccountIdentityMode.test.ts create mode 100644 fluxer_api/src/api/middleware/AccountIdentityMiddleware.ts create mode 100644 fluxer_api/src/api/user/UniqueUsernames.ts create mode 100644 fluxer_api/src/api/user/UserTag.ts create mode 100644 fluxer_app/src/features/auth/components/modals/RecoveryKitModal.module.css create mode 100644 fluxer_app/src/features/auth/components/modals/RecoveryKitModal.tsx create mode 100644 fluxer_app/src/features/auth/components/pages/RecoverAccountPage.module.css create mode 100644 fluxer_app/src/features/auth/components/pages/RecoverAccountPage.tsx create mode 100644 fluxer_app/src/features/auth/hooks/useUsernameAvailability.ts create mode 100644 fluxer_app/src/features/auth/utils/RecoveryKitSheet.ts create mode 100644 fluxer_app/src/features/user/commands/RecoveryKitCommands.ts create mode 100644 fluxer_app/src/features/user/components/RecoveryKitReminderGate.tsx create mode 100644 fluxer_app/src/features/user/components/modals/tabs/account_security_tab/RecoveryKitSettings.tsx create mode 100644 fluxer_app/src/features/user/state/RecoveryKitStatus.ts create mode 100644 fluxer_app/src/features/user/utils/UserTagUtils.ts create mode 100644 fluxer_docs/src/content/docs/operator/sign-in.mdx create mode 100644 packages/constants/src/AccountIdentityConstants.ts create mode 100644 packages/constants/src/RecoveryKeyUtils.test.ts create mode 100644 packages/constants/src/RecoveryKeyUtils.ts create mode 100644 packages/errors/src/domains/auth/AccountIdentityLockedError.ts create mode 100644 packages/errors/src/domains/auth/EmailUnavailableOnInstanceError.ts create mode 100644 packages/errors/src/domains/auth/UsernameSignInOnlyError.ts create mode 100644 packages/schema/src/domains/tests/AuthLoginSchemas.test.ts diff --git a/deploy/self-hosting/.env.example b/deploy/self-hosting/.env.example index 39430593d..f937ccfea 100644 --- a/deploy/self-hosting/.env.example +++ b/deploy/self-hosting/.env.example @@ -306,6 +306,7 @@ FLUXER_KLIPY_API_KEY= # Hosts the api never unfurls, comma separated. #FLUXER_API_UNFURL_IGNORED_HOSTS= +# Email delivery. Only an instance where members sign in with email needs it. FLUXER_EMAIL_ENABLED=false FLUXER_EMAIL_PROVIDER=none FLUXER_EMAIL_FROM_EMAIL=noreply@example.com @@ -333,6 +334,10 @@ FLUXER_DISCOVERY_ENABLED=true #FLUXER_APP_STATUS_PAGE_URL= #FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL= #FLUXER_INSTANCE_SETUP_CONFIGURED=false +# How members sign in on a new instance, username or email. Unset means username. Read only on the first start. +#FLUXER_ACCOUNT_IDENTITY= +# Username tags on a new email instance. none gives unique names with no tag, random gives name#4821. Unset means none. A username instance always uses none. Read only on the first start. +#FLUXER_TAG_STYLE= #FLUXER_AUTO_JOIN_INVITE_CODE= #FLUXER_DELETION_GRACE_PERIOD_HOURS=336 diff --git a/deploy/self-hosting/docker-compose.yml b/deploy/self-hosting/docker-compose.yml index e1d6859dc..dfed1bd0e 100644 --- a/deploy/self-hosting/docker-compose.yml +++ b/deploy/self-hosting/docker-compose.yml @@ -152,6 +152,8 @@ x-fluxer-env: &fluxer-env FLUXER_APP_STATUS_PAGE_URL: ${FLUXER_APP_STATUS_PAGE_URL:-} FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL: ${FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL:-} FLUXER_INSTANCE_SETUP_CONFIGURED: ${FLUXER_INSTANCE_SETUP_CONFIGURED:-} + FLUXER_ACCOUNT_IDENTITY: ${FLUXER_ACCOUNT_IDENTITY:-} + FLUXER_TAG_STYLE: ${FLUXER_TAG_STYLE:-} FLUXER_AUTO_JOIN_INVITE_CODE: ${FLUXER_AUTO_JOIN_INVITE_CODE:-} FLUXER_DISCOVERY_ENABLED: ${FLUXER_DISCOVERY_ENABLED:-} FLUXER_DISCOVERY_MIN_MEMBER_COUNT: ${FLUXER_DISCOVERY_MIN_MEMBER_COUNT:-} diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index 7961dfee5..9e769ddab 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -7124,6 +7124,68 @@ ] } }, + "/admin/users/{user_id}/password-reset-link": { + "post": { + "operationId": "create_admin_user_password_reset_link", + "summary": "Create user password reset link", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminPasswordResetLinkResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Create a one-time password reset link on an instance where people sign in with a username. Hand the link to the user yourself. It works once and expires after an hour. Deletes the recovery kit of the account. Creates audit log entry. Requires USER_CREATE_PASSWORD_RESET_LINK permission and every ACL the target account holds. Fails with USERNAME_SIGN_IN_ONLY on email instances.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, + "description": "The ID of the user" + } + ] + } + }, "/admin/users/{user_id}/premium-flags": { "patch": { "operationId": "update_admin_user_premium_flags", @@ -7258,6 +7320,65 @@ } } }, + "/admin/users/{user_id}/recovery-kit": { + "delete": { + "operationId": "revoke_admin_user_recovery_kit", + "summary": "Revoke user recovery kit", + "tags": ["Admin"], + "responses": { + "204": {"description": "No Content"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Deletes the recovery kit of an account on an instance where people sign in with a username, so its key stops working. Creates audit log entry. Requires USER_DELETE_RECOVERY_KIT permission and every ACL the target account holds. Fails with USERNAME_SIGN_IN_ONLY on email instances.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, + "description": "The ID of the user" + } + ] + } + }, "/admin/users/{user_id}/relationships": { "get": { "operationId": "list_admin_user_relationships", @@ -9317,6 +9438,20 @@ } } }, + "AdminPasswordResetLinkResponse": { + "type": "object", + "properties": { + "url": {"type": "string", "description": "Password reset link to hand to the user. It is shown only once"}, + "expires_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$", + "description": "ISO 8601 timestamp when the link stops working" + } + }, + "required": ["url", "expires_at"], + "additionalProperties": false + }, "DeleteAllUserMessagesResponse": { "type": "object", "properties": { @@ -9727,7 +9862,7 @@ "type": "object", "properties": { "acls": { - "maxItems": 103, + "maxItems": 105, "type": "array", "items": {"$ref": "#/components/schemas/AdminAclType"}, "description": "List of access control permissions to assign" @@ -10536,6 +10671,19 @@ "additionalProperties": false }, "self_hosted": {"type": "boolean"}, + "account_identity": { + "type": "object", + "properties": { + "mode": { + "description": "Sign-in method in effect on this instance", + "allOf": [{"$ref": "#/components/schemas/AccountIdentityModeSchema"}] + }, + "locked": {"type": "boolean", "description": "Whether the sign-in method can no longer change"}, + "tag_style": {"allOf": [{"$ref": "#/components/schemas/TagStyleSchema"}]} + }, + "required": ["mode", "locked", "tag_style"], + "additionalProperties": false + }, "app_public": { "type": "object", "properties": { @@ -10843,6 +10991,7 @@ "experiment_delivery", "registration", "self_hosted", + "account_identity", "app_public", "policy", "integrations", @@ -12603,7 +12752,7 @@ }, "acls": { "description": "Replacement list of access control permissions for the key", - "maxItems": 103, + "maxItems": 105, "type": "array", "items": {"$ref": "#/components/schemas/AdminAclType"} } @@ -12621,7 +12770,7 @@ "type": "string" }, "acls": { - "maxItems": 103, + "maxItems": 105, "type": "array", "items": {"type": "string"}, "description": "List of access control permissions for the key" @@ -12651,7 +12800,7 @@ "maximum": 365 }, "acls": { - "maxItems": 103, + "maxItems": 105, "type": "array", "items": {"$ref": "#/components/schemas/AdminAclType"}, "description": "List of access control permissions for the key" @@ -12672,7 +12821,7 @@ "type": "string" }, "acls": { - "maxItems": 103, + "maxItems": 105, "type": "array", "items": {"type": "string"}, "description": "List of access control permissions for the key" @@ -12685,7 +12834,7 @@ "type": "object", "properties": { "acls": { - "maxItems": 103, + "maxItems": 105, "type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 64}, "description": "Every admin access control permission the admin API recognises" @@ -12773,6 +12922,8 @@ "report:view:reporter_pii", "system_dm:send", "user:cancel:bulk_message_deletion", + "user:create:password_reset_link", + "user:delete:recovery_kit", "user:delete", "user:list:dm_channels", "user:list:guilds", @@ -15232,6 +15383,23 @@ ], "additionalProperties": false }, + "TagStyleSchema": { + "description": "How usernames are tagged", + "x-enumNames": ["NONE", "RANDOM"], + "x-enumDescriptions": ["Usernames are unique and shown without a tag", "Every account gets a random tag"], + "enum": ["none", "random"], + "type": "string" + }, + "AccountIdentityModeSchema": { + "description": "How people identify themselves when they sign in", + "x-enumNames": ["EMAIL", "USERNAME"], + "x-enumDescriptions": [ + "People sign in with an email address", + "People sign in with a username and no email is collected" + ], + "enum": ["email", "username"], + "type": "string" + }, "ExperimentDeliveryConfigResponse": { "type": "object", "properties": { @@ -15515,7 +15683,7 @@ "description": "ISO 8601 timestamp when the pending deletion was scheduled", "type": "string" }, - "acls": {"maxItems": 103, "type": "array", "items": {"type": "string"}}, + "acls": {"maxItems": 105, "type": "array", "items": {"type": "string"}}, "traits": {"maxItems": 100, "type": "array", "items": {"type": "string"}}, "has_totp": {"type": "boolean"}, "authenticator_types": {"maxItems": 10, "type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}}, diff --git a/fluxer_admin/src/acl.rs b/fluxer_admin/src/acl.rs index ffa6cec63..9683da57d 100644 --- a/fluxer_admin/src/acl.rs +++ b/fluxer_admin/src/acl.rs @@ -76,6 +76,8 @@ pub const REPORT_VIEW: &str = "report:view"; pub const REPORT_VIEW_REPORTER_PII: &str = "report:view:reporter_pii"; pub const SYSTEM_DM_SEND: &str = "system_dm:send"; pub const USER_CANCEL_BULK_MESSAGE_DELETION: &str = "user:cancel:bulk_message_deletion"; +pub const USER_CREATE_PASSWORD_RESET_LINK: &str = "user:create:password_reset_link"; +pub const USER_DELETE_RECOVERY_KIT: &str = "user:delete:recovery_kit"; pub const USER_DELETE: &str = "user:delete"; pub const USER_LIST_DM_CHANNELS: &str = "user:list:dm_channels"; pub const USER_LIST_GUILDS: &str = "user:list:guilds"; @@ -180,6 +182,8 @@ pub const ALL_ACLS: &[&str] = &[ REPORT_VIEW_REPORTER_PII, SYSTEM_DM_SEND, USER_CANCEL_BULK_MESSAGE_DELETION, + USER_CREATE_PASSWORD_RESET_LINK, + USER_DELETE_RECOVERY_KIT, USER_DELETE, USER_LIST_DM_CHANNELS, USER_LIST_GUILDS, diff --git a/fluxer_admin/src/api/instance_config.rs b/fluxer_admin/src/api/instance_config.rs index ac52fd1f9..8651bd135 100644 --- a/fluxer_admin/src/api/instance_config.rs +++ b/fluxer_admin/src/api/instance_config.rs @@ -2,9 +2,9 @@ use super::client::{AdminApiClient, ApiResult}; use super::types::{ - CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, InstanceConfigResponse, - InstanceConfigUpdateRequest, InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse, - InstancePremiumDiscovery, + AccountIdentitySettings, CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, + InstanceAccountIdentityDiscovery, InstanceConfigResponse, InstanceConfigUpdateRequest, + InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse, InstancePremiumDiscovery, }; impl AdminApiClient { @@ -16,6 +16,16 @@ impl AdminApiClient { self.get("/.well-known/fluxer", None).await } + pub async fn get_instance_account_identity(&self) -> ApiResult { + let discovery: InstanceAccountIdentityDiscovery = + self.get("/.well-known/fluxer", None).await?; + let mode = discovery.features.account_identity; + Ok(AccountIdentitySettings { + mode, + tag_style: discovery.features.tag_style, + }) + } + pub async fn update_instance_config( &self, update: &InstanceConfigUpdateRequest, diff --git a/fluxer_admin/src/api/types/instance_config.rs b/fluxer_admin/src/api/types/instance_config.rs index a7684b12f..a34a1cfe7 100644 --- a/fluxer_admin/src/api/types/instance_config.rs +++ b/fluxer_admin/src/api/types/instance_config.rs @@ -13,6 +13,8 @@ pub struct InstanceConfigResponse { #[serde(default)] pub self_hosted: bool, #[serde(default)] + pub account_identity: AccountIdentityConfigResponse, + #[serde(default)] pub app_public: AppPublicConfigResponse, #[serde(default)] pub policy: InstancePolicyResponse, @@ -34,6 +36,79 @@ pub struct InstanceConfigResponse { pub billing: InstanceBillingResponse, } +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AccountIdentityMode { + #[default] + Email, + Username, +} + +impl AccountIdentityMode { + pub fn is_username(self) -> bool { + matches!(self, Self::Username) + } + + pub fn label(self) -> &'static str { + match self { + Self::Email => "Email", + Self::Username => "Username", + } + } +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum TagStyle { + None, + #[default] + #[serde(other)] + Random, +} + +impl TagStyle { + pub fn is_none(self) -> bool { + matches!(self, Self::None) + } + + pub fn label(self) -> &'static str { + match self { + Self::None => "No tags", + Self::Random => "Random tags", + } + } +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)] +pub struct AccountIdentityConfigResponse { + #[serde(default)] + pub mode: AccountIdentityMode, + #[serde(default)] + pub locked: Option, + #[serde(default)] + pub tag_style: TagStyle, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct AccountIdentitySettings { + pub mode: AccountIdentityMode, + pub tag_style: TagStyle, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct InstanceAccountIdentityDiscovery { + #[serde(default)] + pub features: InstanceAccountIdentityDiscoveryFeatures, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct InstanceAccountIdentityDiscoveryFeatures { + #[serde(default)] + pub account_identity: AccountIdentityMode, + #[serde(default)] + pub tag_style: TagStyle, +} + #[derive(Clone, Debug, Deserialize, Serialize)] pub struct InstancePolicyResponse { #[serde(default)] diff --git a/fluxer_admin/src/api/types/user_detail.rs b/fluxer_admin/src/api/types/user_detail.rs index 5deeae17b..5056a8429 100644 --- a/fluxer_admin/src/api/types/user_detail.rs +++ b/fluxer_admin/src/api/types/user_detail.rs @@ -232,3 +232,9 @@ pub struct WebAuthnCredential { } pub type WebAuthnCredentialListResponse = Vec; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PasswordResetLinkResponse { + pub url: String, + pub expires_at: String, +} diff --git a/fluxer_admin/src/api/users.rs b/fluxer_admin/src/api/users.rs index 223e88154..578f910ab 100644 --- a/fluxer_admin/src/api/users.rs +++ b/fluxer_admin/src/api/users.rs @@ -5,7 +5,8 @@ use crate::api::generated::{snowflake, types as generated_types}; use super::client::{AdminApiClient, ApiError, ApiResult}; use super::types::{ AdminUser, AdminUserMeResponse, GuildInfo, ListUserGuildsResponse, LookupUserResponse, - SearchUsersResponse, TerminateSessionsResponse, UserMutationResponse, + PasswordResetLinkResponse, SearchUsersResponse, TerminateSessionsResponse, + UserMutationResponse, }; impl AdminApiClient { @@ -473,6 +474,26 @@ impl AdminApiClient { Ok(()) } + pub async fn create_password_reset_link( + &self, + user_id: &str, + ) -> ApiResult { + let response = self + .generated() + .create_admin_user_password_reset_link(&snowflake(user_id)) + .await + .map_err(|e| self.generated_error(e))?; + self.generated_value(response.into_inner()) + } + + pub async fn revoke_recovery_kit(&self, user_id: &str) -> ApiResult<()> { + self.generated() + .revoke_admin_user_recovery_kit(&snowflake(user_id)) + .await + .map_err(|e| self.generated_error(e))?; + Ok(()) + } + pub async fn remove_relationship( &self, user_id: &str, diff --git a/fluxer_admin/src/middleware/account_identity.rs b/fluxer_admin/src/middleware/account_identity.rs new file mode 100644 index 000000000..3632599ae --- /dev/null +++ b/fluxer_admin/src/middleware/account_identity.rs @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + api::client::AdminApiClient, middleware::auth::AuthContext, state::AppState, + utils::user_tag::with_unique_usernames, +}; +use axum::{ + extract::{Request, State}, + middleware::Next, + response::Response, +}; + +pub async fn scope_account_identity( + State(state): State, + request: Request, + next: Next, +) -> Response { + let Some(auth) = request.extensions().get::() else { + return next.run(request).await; + }; + let client = AdminApiClient::new(state.http_client(), state.config(), &auth.session); + let settings = state.account_identity_settings(&client).await; + let unique_usernames = settings.mode.is_username() || settings.tag_style.is_none(); + with_unique_usernames(unique_usernames, next.run(request)).await +} diff --git a/fluxer_admin/src/middleware/mod.rs b/fluxer_admin/src/middleware/mod.rs index 3d1631445..7d6f139be 100644 --- a/fluxer_admin/src/middleware/mod.rs +++ b/fluxer_admin/src/middleware/mod.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +pub mod account_identity; pub mod auth; pub mod csrf; pub mod error_handler; diff --git a/fluxer_admin/src/routes/bans.rs b/fluxer_admin/src/routes/bans.rs index 85e8ebc99..e4d021995 100644 --- a/fluxer_admin/src/routes/bans.rs +++ b/fluxer_admin/src/routes/bans.rs @@ -48,17 +48,41 @@ pub fn router() -> Router { ) } -fn render_ban_page(state: &AppState, auth: &AuthContext, key: &str, req: &Request) -> Response { +async fn render_ban_page( + state: &AppState, + auth: &AuthContext, + key: &str, + csrf_token: String, +) -> Response { let config = state.config(); let ban_cfg = match templates::pages::bans::get_ban_config(key) { Some(c) => c, None => return axum::http::StatusCode::NOT_FOUND.into_response(), }; - let csrf_token = csrf::get_csrf_token(req); - let markup = templates::pages::bans::bans_page(config, auth, ban_cfg, None, &csrf_token); + let username_sign_in = email_bans_on_username_instance(state, auth, key).await; + let markup = templates::pages::bans::bans_page( + config, + auth, + ban_cfg, + None, + &csrf_token, + username_sign_in, + ); Html(markup.into_string()).into_response() } +async fn email_bans_on_username_instance(state: &AppState, auth: &AuthContext, key: &str) -> bool { + key == "email-bans" + && state + .account_identity(&AdminApiClient::new( + state.http_client(), + state.config(), + &auth.session, + )) + .await + .is_username() +} + macro_rules! ban_get { ($name:ident, $key:expr) => { async fn $name( @@ -66,7 +90,8 @@ macro_rules! ban_get { auth: axum::Extension, request: Request, ) -> Response { - render_ban_page(&state, &auth.0, $key, &request) + let csrf_token = csrf::get_csrf_token(&request); + render_ban_page(&state, &auth.0, $key, csrf_token).await } }; } @@ -96,7 +121,17 @@ async fn generic_ban_post( let value = extract_value(form, ban_cfg.input_name); let is_htmx = htmx::is_htmx_request(headers); let (level, msg) = execute_ban(&client, ban_key, action, &value, form).await; - flash_response(config, auth, is_htmx, level, &msg, ban_cfg, csrf_token) + let username_sign_in = !is_htmx && email_bans_on_username_instance(state, auth, ban_key).await; + flash_response( + config, + auth, + is_htmx, + level, + &msg, + ban_cfg, + csrf_token, + username_sign_in, + ) } macro_rules! ban_post { @@ -114,14 +149,18 @@ macro_rules! ban_post { let form: BanFormData = match Form::from_request(request, &state).await { Ok(Form(f)) => f, Err(_) => { + let is_htmx = htmx::is_htmx_request(&headers); + let username_sign_in = + !is_htmx && email_bans_on_username_instance(&state, &auth.0, $key).await; return flash_response( state.config(), &auth.0, - htmx::is_htmx_request(&headers), + is_htmx, "error", "Invalid form data", templates::pages::bans::get_ban_config($key).unwrap(), &csrf_token, + username_sign_in, ); } }; diff --git a/fluxer_admin/src/routes/bans_actions.rs b/fluxer_admin/src/routes/bans_actions.rs index ac6b23962..60ed37eb5 100644 --- a/fluxer_admin/src/routes/bans_actions.rs +++ b/fluxer_admin/src/routes/bans_actions.rs @@ -226,6 +226,7 @@ fn ban_action_result( } } +#[allow(clippy::too_many_arguments)] pub fn flash_response( config: &crate::config::AdminConfig, auth: &AuthContext, @@ -234,13 +235,20 @@ pub fn flash_response( message: &str, ban_cfg: &templates::pages::bans::BanConfig, csrf_token: &str, + username_sign_in: bool, ) -> Response { if is_htmx { render_inline_flash(level, message) } else { let flash = to_flash(level, message); - let markup = - templates::pages::bans::bans_page(config, auth, ban_cfg, Some(&flash), csrf_token); + let markup = templates::pages::bans::bans_page( + config, + auth, + ban_cfg, + Some(&flash), + csrf_token, + username_sign_in, + ); Html(markup.into_string()).into_response() } } diff --git a/fluxer_admin/src/routes/guilds.rs b/fluxer_admin/src/routes/guilds.rs index a3f41c46c..ed700b73f 100644 --- a/fluxer_admin/src/routes/guilds.rs +++ b/fluxer_admin/src/routes/guilds.rs @@ -134,6 +134,7 @@ async fn guild_detail( .as_ref() .map(|user| user.acls.as_slice()) .unwrap_or(&[]); + let username_sign_in = state.account_identity(&client).await.is_username(); let tab_body = if let Some(guild) = guild.as_ref() { guild_tabs::render( &client, @@ -152,6 +153,7 @@ async fn guild_detail( active_tab, &csrf_token, admin_acls, + username_sign_in, )) }) } else { @@ -166,6 +168,7 @@ async fn guild_detail( active_tab, tab_body, is_detail_fragment, + username_sign_in, ); Html(markup.into_string()).into_response() } @@ -508,6 +511,7 @@ async fn guild_tab( normalize_guild_tab(&tab), &csrf_token, admin_acls, + state.account_identity(&client).await.is_username(), ), None => maud::html! { div class="p-4 text-red-600 text-sm" { diff --git a/fluxer_admin/src/routes/messages.rs b/fluxer_admin/src/routes/messages.rs index a8207f0fd..0e1159e37 100644 --- a/fluxer_admin/src/routes/messages.rs +++ b/fluxer_admin/src/routes/messages.rs @@ -201,6 +201,13 @@ async fn bulk_actions_page( csrf: axum::Extension, ) -> Response { let config = state.config(); - let markup = templates::pages::bulk_actions::bulk_actions_page(config, &auth.0, &csrf.0.0); + let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); + let account_identity = state.account_identity(&client).await; + let markup = templates::pages::bulk_actions::bulk_actions_page( + config, + &auth.0, + &csrf.0.0, + account_identity.is_username(), + ); Html(markup.into_string()).into_response() } diff --git a/fluxer_admin/src/routes/mod.rs b/fluxer_admin/src/routes/mod.rs index 6e8de6139..9aa4e13c3 100644 --- a/fluxer_admin/src/routes/mod.rs +++ b/fluxer_admin/src/routes/mod.rs @@ -73,6 +73,10 @@ pub fn build_router(config: AdminConfig) -> Router { .merge(admin::router()) .route("/", get(dashboard)) .route("/dashboard", get(dashboard)) + .layer(from_fn_with_state( + state.clone(), + middleware::account_identity::scope_account_identity, + )) .layer(from_fn(middleware::htmx::flash_redirect_to_toast)) .layer(from_fn_with_state( state.clone(), diff --git a/fluxer_admin/src/routes/system_actions.rs b/fluxer_admin/src/routes/system_actions.rs index 87d11ab08..0723bfa09 100644 --- a/fluxer_admin/src/routes/system_actions.rs +++ b/fluxer_admin/src/routes/system_actions.rs @@ -838,7 +838,9 @@ fn build_integrations_update(form: &MultiValueForm) -> InstanceConfigUpdateReque youtube: Some(InstanceYoutubeIntegrationUpdateRequest { api_key: clean("integration_youtube_api_key"), }), - email: Some(InstanceEmailIntegrationUpdateRequest { + email: (form.has_key_starting_with("integration_email_") + || form.has_key_starting_with("integration_smtp_")) + .then(|| InstanceEmailIntegrationUpdateRequest { enabled: Some(form.bool_value("integration_email_enabled")), provider: Some("smtp".to_owned()), from_email: clean("integration_email_from_email"), @@ -1195,6 +1197,37 @@ pub async fn limit_config_post( mod tests { use super::*; + #[test] + fn build_integrations_update_leaves_email_alone_when_its_fields_are_hidden() { + let hidden = build_integrations_update(&MultiValueForm::parse( + b"integration_klipy_api_key=&integration_youtube_api_key=", + )); + let integrations = hidden.integrations.expect("integrations update"); + assert!(integrations.email.is_none()); + assert!(integrations.gif.is_some()); + + let shown = build_integrations_update(&MultiValueForm::parse( + b"integration_email_present=1&integration_smtp_host=smtp.example.com", + )); + let email = shown + .integrations + .and_then(|integrations| integrations.email) + .expect("email update"); + assert_eq!(email.enabled, Some(false)); + + let from_an_older_page = build_integrations_update(&MultiValueForm::parse( + b"integration_klipy_api_key=&integration_smtp_host=smtp.example.com", + )); + let email = from_an_older_page + .integrations + .and_then(|integrations| integrations.email) + .expect("email update from a page without the presence marker"); + assert_eq!( + email.smtp.and_then(|smtp| smtp.host).as_deref(), + Some("smtp.example.com") + ); + } + #[test] fn build_sso_update_keeps_repeated_allowed_domains() { let form = MultiValueForm::parse( diff --git a/fluxer_admin/src/routes/user_actions.rs b/fluxer_admin/src/routes/user_actions.rs index 8f9181bf2..59f2f1e1f 100644 --- a/fluxer_admin/src/routes/user_actions.rs +++ b/fluxer_admin/src/routes/user_actions.rs @@ -367,6 +367,11 @@ pub async fn dispatch( "Password reset sent successfully", "Failed to send password reset", ), + "revoke_recovery_kit" => DispatchOutcome::from_result( + client.revoke_recovery_kit(user_id).await, + "Recovery kit revoked", + "Failed to revoke recovery kit", + ), "remove_relationship" => { let Some(target_id) = get("target_user_id").or_else(|| get("target_id")) else { return DispatchOutcome::error("Target user ID is required"); diff --git a/fluxer_admin/src/routes/user_tabs.rs b/fluxer_admin/src/routes/user_tabs.rs index f76b9c965..0a087bb8a 100644 --- a/fluxer_admin/src/routes/user_tabs.rs +++ b/fluxer_admin/src/routes/user_tabs.rs @@ -5,6 +5,7 @@ use crate::{ api::{ audit::SearchAuditLogsParams, client::{AdminApiClient, ApiResultExt}, + types::AccountIdentityMode, }, config::AdminConfig, templates::{ @@ -26,6 +27,7 @@ pub struct TabQuery { pub delete_all_messages_message_count: Option, } +#[allow(clippy::too_many_arguments)] pub async fn render( client: &AdminApiClient, config: &AdminConfig, @@ -34,6 +36,7 @@ pub async fn render( tab: &str, query: &TabQuery, admin_acls: &[String], + account_identity: AccountIdentityMode, ) -> Option { match tab { "overview" => { @@ -60,31 +63,22 @@ pub async fn render( csrf_token, change_log.as_ref(), limit_config.as_ref(), + account_identity.is_username(), )) } "account" => { - let u = client - .get_user_by_id(user_id) - .await - .log_error("load user account")?; - let s = client - .list_user_sessions(user_id) - .await - .map(|r| r.sessions) - .map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list user sessions")) - .unwrap_or_default(); - let webauthn_credentials = client - .list_webauthn_credentials(user_id) - .await - .map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list webauthn credentials")) - .unwrap_or_default(); - Some(tabs::account::account_tab( + render_account( + client, config, - &u, - &s, - &webauthn_credentials, csrf_token, - )) + user_id, + &tabs::account::AccountTabOptions { + admin_acls, + account_identity, + password_reset_link: None, + }, + ) + .await } "moderation" => { let u = client @@ -145,6 +139,7 @@ pub async fn render( let context = tabs::moderation::ModerationContext { deletion_scheduler: deletion_scheduler.as_ref(), current_ban: tabs::moderation::find_current_ban(&u, &ban_logs), + username_sign_in: account_identity.is_username(), }; Some(tabs::moderation::moderation_tab( config, @@ -298,6 +293,40 @@ pub async fn render( } } +pub async fn render_account( + client: &AdminApiClient, + config: &AdminConfig, + csrf_token: &str, + user_id: &str, + options: &tabs::account::AccountTabOptions<'_>, +) -> Option { + let u = client + .get_user_by_id(user_id) + .await + .log_error("load user account")?; + let s = client + .list_user_sessions(user_id) + .await + .map(|r| r.sessions) + .map_err( + |error| tracing::warn!(%error, user_id, "admin API request failed: list user sessions"), + ) + .unwrap_or_default(); + let webauthn_credentials = client + .list_webauthn_credentials(user_id) + .await + .map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list webauthn credentials")) + .unwrap_or_default(); + Some(tabs::account::account_tab( + config, + &u, + &s, + &webauthn_credentials, + csrf_token, + options, + )) +} + fn parse_bool_flag(value: &str) -> Option { match value.trim().to_ascii_lowercase().as_str() { "1" | "true" => Some(true), diff --git a/fluxer_admin/src/routes/users.rs b/fluxer_admin/src/routes/users.rs index 32d97bb0a..857f26cdf 100644 --- a/fluxer_admin/src/routes/users.rs +++ b/fluxer_admin/src/routes/users.rs @@ -9,7 +9,12 @@ use crate::{ middleware::{auth::AuthContext, csrf::CsrfToken, flash, htmx}, routes::user_tabs, state::AppState, - templates, + templates::{ + self, + pages::user_detail_tabs::account::{ + PASSWORD_RESET_LINK_RESULT_ID, password_reset_link_result, + }, + }, utils::forms::MultiValueForm, }; use axum::{ @@ -86,8 +91,9 @@ async fn users_list( .as_ref() .map(|user| user.acls.as_slice()) .unwrap_or(&[]); - let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL); let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); + let username_sign_in = state.account_identity(&client).await.is_username(); + let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL) && !username_sign_in; let searching = params.has_id_lookup() || params.has_search(); let results = async { if params.has_id_lookup() { @@ -136,6 +142,7 @@ async fn users_list( result_users, has_more, can_view_email, + username_sign_in, premium_badge_name.as_deref(), is_results_fragment, ); @@ -204,8 +211,16 @@ async fn user_detail( .map(|user| user.acls.as_slice()) .unwrap_or(&[]); let tab_body = if user.is_some() { + let account_identity = state.account_identity(&client).await; user_tabs::render( - &client, config, &csrf.0.0, &user_id, active_tab, &tq, admin_acls, + &client, + config, + &csrf.0.0, + &user_id, + active_tab, + &tq, + admin_acls, + account_identity, ) .await } else { @@ -229,6 +244,7 @@ async fn user_detail_post( State(state): State, headers: HeaderMap, auth: axum::Extension, + csrf: axum::Extension, Path(user_id): Path, Query(aq): Query, request: Request, @@ -252,6 +268,10 @@ async fn user_detail_post( }; let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); let action = aq.action.as_deref().unwrap_or(""); + if action == "create_password_reset_link" { + return create_password_reset_link(&state, &headers, &auth.0, &csrf.0.0, &client, &user_id) + .await; + } let outcome = super::user_actions::dispatch(&client, &user_id, action, &form).await; let mut redirect = if tab.is_empty() { format!("{base}/users/{user_id}") @@ -268,6 +288,74 @@ async fn user_detail_post( flash::redirect_with_flash(&redirect, outcome.flash, config.secure_cookies()) } +async fn create_password_reset_link( + state: &AppState, + headers: &HeaderMap, + auth: &AuthContext, + csrf_token: &str, + client: &AdminApiClient, + user_id: &str, +) -> Response { + let config = state.config(); + let account_url = format!("{}/users/{user_id}?tab=account", config.base_path); + let link = match client.create_password_reset_link(user_id).await { + Ok(link) => link, + Err(error) => { + tracing::warn!(%error, user_id, "admin API request failed: create password reset link"); + let flash = flash::FlashData::error("Failed to create password reset link"); + if htmx::is_htmx_request(headers) + && (htmx::targets(headers, "flash-container") + || htmx::targets(headers, PASSWORD_RESET_LINK_RESULT_ID)) + { + return htmx::toast_response(&flash); + } + return flash::redirect_with_flash(&account_url, flash, config.secure_cookies()); + } + }; + if htmx::is_htmx_request(headers) && htmx::targets(headers, PASSWORD_RESET_LINK_RESULT_ID) { + return Html(password_reset_link_result(Some(&link)).into_string()).into_response(); + } + let admin_acls = auth + .admin_user + .as_ref() + .map(|user| user.acls.as_slice()) + .unwrap_or(&[]); + let (user, badge_name, account_identity) = tokio::join!( + async { + client + .get_user_by_id(user_id) + .await + .log_error("load user after creating password reset link") + }, + self_hosted_premium_badge_name(state, client), + state.account_identity(client) + ); + let tab_body = user_tabs::render_account( + client, + config, + csrf_token, + user_id, + &templates::pages::user_detail_tabs::account::AccountTabOptions { + admin_acls, + account_identity, + password_reset_link: Some(&link), + }, + ) + .await; + let premium_badge_name = user.as_ref().and(badge_name); + let markup = templates::pages::user_detail::user_detail_with_tab( + config, + auth, + user.as_ref(), + user_id, + "account", + tab_body, + premium_badge_name.as_deref(), + htmx::targets(headers, "main-content"), + ); + Html(markup.into_string()).into_response() +} + async fn user_tab( State(state): State, auth: axum::Extension, @@ -299,14 +387,20 @@ async fn user_tab( .as_ref() .map(|user| user.acls.as_slice()) .unwrap_or(&[]); + let account_identity = state.account_identity(&client).await; let markup = match user { - Some(ref u) => { - user_tabs::render(&client, config, &csrf.0.0, &user_id, &tab, &tq, admin_acls) - .await - .unwrap_or_else(|| { - templates::pages::user_detail::simple_tab_content(config, u, &tab) - }) - } + Some(ref u) => user_tabs::render( + &client, + config, + &csrf.0.0, + &user_id, + &tab, + &tq, + admin_acls, + account_identity, + ) + .await + .unwrap_or_else(|| templates::pages::user_detail::simple_tab_content(config, u, &tab)), None => maud::html! { div class="p-4 text-red-600 text-sm" { "Failed to load user data." } }, diff --git a/fluxer_admin/src/state.rs b/fluxer_admin/src/state.rs index 0400e27b4..0c1f2563e 100644 --- a/fluxer_admin/src/state.rs +++ b/fluxer_admin/src/state.rs @@ -3,7 +3,7 @@ use crate::{ api::{ client::{AdminApiClient, ApiResultExt}, - types::PremiumBranding, + types::{AccountIdentityMode, AccountIdentitySettings, PremiumBranding}, }, config::AdminConfig, }; @@ -13,6 +13,8 @@ use std::{ }; const PREMIUM_BRANDING_TTL: Duration = Duration::from_secs(60); +const ACCOUNT_IDENTITY_TTL: Duration = Duration::from_secs(60); +const ACCOUNT_IDENTITY_RETRY_TTL: Duration = Duration::from_secs(10); #[derive(Clone)] pub struct AppState { @@ -23,6 +25,7 @@ struct AppStateInner { pub config: AdminConfig, pub http_client: reqwest::Client, premium_branding: Mutex>, + account_identity: Mutex>, } impl AppState { @@ -36,6 +39,7 @@ impl AppState { config, http_client, premium_branding: Mutex::new(None), + account_identity: Mutex::new(None), }), } } @@ -81,6 +85,47 @@ impl AppState { self.remember_premium_branding(branding.clone()); Some(branding) } + + pub async fn account_identity(&self, client: &AdminApiClient) -> AccountIdentityMode { + self.account_identity_settings(client).await.mode + } + + pub async fn account_identity_settings( + &self, + client: &AdminApiClient, + ) -> AccountIdentitySettings { + if !self.config().self_hosted { + return AccountIdentitySettings::default(); + } + let previous = *self + .inner + .account_identity + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some((expires_at, settings)) = previous + && Instant::now() < expires_at + { + return settings; + } + let (settings, ttl) = match client + .get_instance_account_identity() + .await + .log_error("load account identity mode") + { + Some(settings) => (settings, ACCOUNT_IDENTITY_TTL), + None => ( + previous.map_or(AccountIdentitySettings::default(), |(_, settings)| settings), + ACCOUNT_IDENTITY_RETRY_TTL, + ), + }; + *self + .inner + .account_identity + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = + Some((Instant::now() + ttl, settings)); + settings + } } impl axum::extract::FromRef for AdminConfig { diff --git a/fluxer_admin/src/templates/components/message_list.rs b/fluxer_admin/src/templates/components/message_list.rs index cbdb95f6f..217e780dc 100644 --- a/fluxer_admin/src/templates/components/message_list.rs +++ b/fluxer_admin/src/templates/components/message_list.rs @@ -198,6 +198,7 @@ fn message_row( msg.author_global_name.as_deref(), Some(&msg.author_username), None, + false, ); let row_class = format!( "group relative mt-4 py-0.5 pr-4 pl-4 transition-colors first:mt-0{hover}{highlight}" diff --git a/fluxer_admin/src/templates/components/user_display.rs b/fluxer_admin/src/templates/components/user_display.rs index 9bdd0f680..38975ab77 100644 --- a/fluxer_admin/src/templates/components/user_display.rs +++ b/fluxer_admin/src/templates/components/user_display.rs @@ -1,15 +1,46 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use crate::utils::user_tag::user_tag; + pub fn format_user_display( global_name: Option<&str>, username: Option<&str>, discriminator: Option<&str>, + is_bot: bool, ) -> String { match (global_name, username, discriminator) { (Some(gn), Some(un), Some("0")) => format!("{gn} (@{un})"), (Some(gn), _, _) => gn.to_owned(), - (None, Some(un), Some(d)) if d != "0" => format!("{un}#{d}"), + (None, Some(un), Some(d)) if d != "0" => user_tag(un, d, is_bot), (None, Some(un), _) => format!("@{un}"), _ => "Unknown".to_owned(), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::user_tag::sync_with_unique_usernames; + + #[test] + fn username_instances_show_bare_human_names_and_keep_bot_tags() { + sync_with_unique_usernames(true, || { + assert_eq!( + format_user_display(None, Some("alice"), Some("0000"), false), + "alice" + ); + assert_eq!( + format_user_display(None, Some("helper"), Some("4363"), true), + "helper#4363" + ); + }); + } + + #[test] + fn email_instances_keep_the_zero_tag() { + assert_eq!( + format_user_display(None, Some("alice"), Some("0000"), false), + "alice#0000" + ); + } +} diff --git a/fluxer_admin/src/templates/layout_header.rs b/fluxer_admin/src/templates/layout_header.rs index dc4ba061a..0869f09fd 100644 --- a/fluxer_admin/src/templates/layout_header.rs +++ b/fluxer_admin/src/templates/layout_header.rs @@ -1,8 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::{ - config::AdminConfig, middleware::auth::AuthContext, - templates::components::media::user_avatar_url, utils::bigint::format_discriminator, + config::AdminConfig, + middleware::auth::AuthContext, + templates::components::media::user_avatar_url, + utils::{bigint::format_discriminator, user_tag::user_tag}, }; use maud::{Markup, html}; @@ -32,7 +34,7 @@ pub fn render_header(config: &AdminConfig, auth: &AuthContext, csrf_token: &str) (display) } div class="truncate text-neutral-500 text-xs" { - (admin.username) "#" (format_discriminator(&admin.discriminator)) + (user_tag(&admin.username, &format_discriminator(&admin.discriminator), admin.bot)) } } } diff --git a/fluxer_admin/src/templates/pages/application_detail.rs b/fluxer_admin/src/templates/pages/application_detail.rs index dfee51dd7..b1a871267 100644 --- a/fluxer_admin/src/templates/pages/application_detail.rs +++ b/fluxer_admin/src/templates/pages/application_detail.rs @@ -114,6 +114,7 @@ fn overview_card(config: &AdminConfig, app: &Application, can_list_by_owner: boo app.owner_global_name.as_deref(), app.owner_username.as_deref(), app.owner_discriminator.as_deref(), + false, ); section_card_simple( "Overview", @@ -157,6 +158,7 @@ fn bot_display_markup(config: &AdminConfig, app: &Application) -> Markup { app.bot_global_name.as_deref(), app.bot_username.as_deref(), app.bot_discriminator.as_deref(), + true, ); html! { div class="space-y-1" { diff --git a/fluxer_admin/src/templates/pages/applications_list.rs b/fluxer_admin/src/templates/pages/applications_list.rs index 080a5974a..1100bee83 100644 --- a/fluxer_admin/src/templates/pages/applications_list.rs +++ b/fluxer_admin/src/templates/pages/applications_list.rs @@ -189,7 +189,12 @@ fn render_application_card(config: &AdminConfig, base: &str, app: &Application) fn format_owner_display(app: &Application) -> String { if let (Some(un), Some(disc)) = (&app.owner_username, &app.owner_discriminator) { - format_user_display(app.owner_global_name.as_deref(), Some(un), Some(disc)) + format_user_display( + app.owner_global_name.as_deref(), + Some(un), + Some(disc), + false, + ) } else { app.owner_user_id.clone() } @@ -199,7 +204,7 @@ fn format_bot_display(app: &Application) -> String { if let (Some(_bid), Some(un), Some(disc)) = (&app.bot_user_id, &app.bot_username, &app.bot_discriminator) { - format_user_display(app.bot_global_name.as_deref(), Some(un), Some(disc)) + format_user_display(app.bot_global_name.as_deref(), Some(un), Some(disc), true) } else { app.bot_user_id.clone().unwrap_or_default() } diff --git a/fluxer_admin/src/templates/pages/audit_logs_table.rs b/fluxer_admin/src/templates/pages/audit_logs_table.rs index 57eed7646..d81a11447 100644 --- a/fluxer_admin/src/templates/pages/audit_logs_table.rs +++ b/fluxer_admin/src/templates/pages/audit_logs_table.rs @@ -9,7 +9,7 @@ use crate::{ resource_link::{ResourceType, resource_link}, table::{table_body, table_cell, table_head, table_header_cell, table_row}, }, - utils::bigint::format_discriminator, + utils::{bigint::format_discriminator, user_tag::user_tag}, }; use maud::{Markup, html}; @@ -42,10 +42,10 @@ fn type_label(target_type: &str) -> String { } fn user_label(user: &AuditLogUserSummary) -> String { - let tag = format!( - "{}#{}", - user.username, - format_discriminator(&user.discriminator) + let tag = user_tag( + &user.username, + &format_discriminator(&user.discriminator), + false, ); match user .global_name @@ -351,6 +351,20 @@ mod tests { assert!(!markup.contains("/admin/users/")); } + #[test] + fn admin_labels_drop_the_zero_tag_only_without_tags() { + let admin = AuditLogUserSummary { + id: "1500000000000000001".to_owned(), + username: "lilith".to_owned(), + discriminator: "0".to_owned(), + global_name: Some("Lilith".to_owned()), + }; + assert_eq!(user_label(&admin), "Lilith (lilith#0000)"); + crate::utils::user_tag::sync_with_unique_usernames(true, || { + assert_eq!(user_label(&admin), "Lilith (lilith)"); + }); + } + #[test] fn unknown_target_types_stay_unlinked() { let markup = target_cell("/admin", &entry("email_domain", "spam.example")).into_string(); diff --git a/fluxer_admin/src/templates/pages/bans.rs b/fluxer_admin/src/templates/pages/bans.rs index ab4fa6fb3..61f747315 100644 --- a/fluxer_admin/src/templates/pages/bans.rs +++ b/fluxer_admin/src/templates/pages/bans.rs @@ -4,7 +4,7 @@ use crate::{ config::AdminConfig, middleware::auth::AuthContext, templates::{ - components::{form::csrf_input, page_container::page_header}, + components::{alert::alert_info, form::csrf_input, page_container::page_header}, layout::admin_layout, }, }; @@ -149,10 +149,16 @@ pub fn bans_page( ban_cfg: &BanConfig, flash: Option<&crate::api::types::FlashMessage>, csrf_token: &str, + username_sign_in: bool, ) -> Markup { let base = &config.base_path; let content = html! { (page_header(ban_cfg.title, None)) + @if username_sign_in && ban_cfg.active_page == "email-bans" { + div class="mb-6" { + (alert_info(html! { "People sign in with a username on this instance. Accounts have no email address, so email bans have no effect." })) + } + } div class="grid gap-6 lg:grid-cols-2" { (ban_card(base, ban_cfg, csrf_token)) (check_ban_card(base, ban_cfg, csrf_token)) diff --git a/fluxer_admin/src/templates/pages/bulk_actions.rs b/fluxer_admin/src/templates/pages/bulk_actions.rs index 92fa8c817..218a74008 100644 --- a/fluxer_admin/src/templates/pages/bulk_actions.rs +++ b/fluxer_admin/src/templates/pages/bulk_actions.rs @@ -177,7 +177,12 @@ fn guild_feature_label(feature: &str) -> String { } } -pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &str) -> Markup { +pub fn bulk_actions_page( + config: &AdminConfig, + auth: &AuthContext, + csrf_token: &str, + username_sign_in: bool, +) -> Markup { let base = &config.base_path; let admin_acls = auth .admin_user @@ -198,7 +203,7 @@ pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: & (bulk_add_guild_members_section(base, csrf_token)) } @if acl::has_permission(admin_acls, acl::BULK_DELETE_USERS) { - (bulk_schedule_deletion_section(base, csrf_token)) + (bulk_schedule_deletion_section(base, csrf_token, username_sign_in)) } @if acl::has_permission(admin_acls, acl::BULK_DELETE_USER_MESSAGES) { (bulk_delete_user_messages_section(base, csrf_token)) @@ -331,7 +336,7 @@ fn bulk_add_guild_members_section(base: &str, csrf_token: &str) -> Markup { ) } -fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup { +fn bulk_schedule_deletion_section(base: &str, csrf_token: &str, username_sign_in: bool) -> Markup { section_card_simple( "Bulk Schedule User Deletion", html! { @@ -370,7 +375,11 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup { }, )) (text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation")) - (opt_out_checkbox("notify_user", "Email each user about the scheduled deletion")) + @if username_sign_in { + input type="hidden" name="notify_user_present" value="1"; + } @else { + (opt_out_checkbox("notify_user", "Email each user about the scheduled deletion")) + } (form_actions(html! { (danger_button("Schedule Deletion")) })) @@ -416,7 +425,7 @@ mod tests { #[test] fn deletion_form_has_no_preselected_reason() { - let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string(); + let markup = bulk_schedule_deletion_section("/admin", "csrf", false).into_string(); assert!(markup.contains(r#""#)); for (value, _) in DELETION_REASONS { assert!(!markup.contains(&format!(r#"