From c843a60131b813177b1e270c4f5d97613c700efa Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Fri, 18 Apr 2025 10:38:50 -0500 Subject: [PATCH] feat: disable/enable users (#437) Co-authored-by: Elias Schneider --- .../internal/bootstrap/router_bootstrap.go | 2 +- backend/internal/common/errors.go | 24 ++++--- backend/internal/dto/app_config_dto.go | 1 + backend/internal/dto/user_dto.go | 2 + backend/internal/middleware/api_key_auth.go | 5 +- .../internal/middleware/auth_middleware.go | 12 ++-- backend/internal/middleware/jwt_auth.go | 19 +++-- backend/internal/model/app_config.go | 1 + backend/internal/model/user.go | 1 + .../internal/service/app_config_service.go | 1 + backend/internal/service/ldap_service.go | 38 ++++++++-- backend/internal/service/user_service.go | 47 ++++++++++-- backend/internal/service/webauthn_service.go | 4 ++ ...412000000_add_user_disabled_field.down.sql | 4 ++ ...50412000000_add_user_disabled_field.up.sql | 2 + ...412000000_add_user_disabled_field.down.sql | 4 ++ ...50412000000_add_user_disabled_field.up.sql | 2 + frontend/messages/en-US.json | 11 ++- .../lib/types/application-configuration.ts | 1 + frontend/src/lib/types/user.type.ts | 1 + frontend/src/lib/utils/error-util.ts | 8 ++- .../forms/app-config-ldap-form.svelte | 22 ++++-- .../settings/admin/users/user-form.svelte | 17 +++-- .../settings/admin/users/user-list.svelte | 72 +++++++++++++++++-- 24 files changed, 245 insertions(+), 56 deletions(-) create mode 100644 backend/resources/migrations/postgres/20250412000000_add_user_disabled_field.down.sql create mode 100644 backend/resources/migrations/postgres/20250412000000_add_user_disabled_field.up.sql create mode 100644 backend/resources/migrations/sqlite/20250412000000_add_user_disabled_field.down.sql create mode 100644 backend/resources/migrations/sqlite/20250412000000_add_user_disabled_field.up.sql diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 4cec75b5..6edbfbab 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -63,7 +63,7 @@ func initRouter(ctx context.Context, db *gorm.DB, appConfigService *service.AppC job.RegisterFileCleanupJobs(ctx, db) // Initialize middleware for specific routes - authMiddleware := middleware.NewAuthMiddleware(apiKeyService, jwtService) + authMiddleware := middleware.NewAuthMiddleware(apiKeyService, userService, jwtService) fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware() // Set up API routes diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index 8dd28b1a..b0b85006 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -82,11 +82,6 @@ type FileTypeNotSupportedError struct{} func (e *FileTypeNotSupportedError) Error() string { return "file type not supported" } func (e *FileTypeNotSupportedError) HttpStatusCode() int { return 400 } -type InvalidCredentialsError struct{} - -func (e *InvalidCredentialsError) Error() string { return "no user found with provided credentials" } -func (e *InvalidCredentialsError) HttpStatusCode() int { return 400 } - type FileTooLargeError struct { MaxSize string } @@ -229,8 +224,7 @@ type InvalidUUIDError struct{} func (e *InvalidUUIDError) Error() string { return "Invalid UUID" } - -type InvalidEmailError struct{} +func (e *InvalidUUIDError) HttpStatusCode() int { return http.StatusBadRequest } type OneTimeAccessDisabledError struct{} @@ -244,31 +238,34 @@ type InvalidAPIKeyError struct{} func (e *InvalidAPIKeyError) Error() string { return "Invalid Api Key" } +func (e *InvalidAPIKeyError) HttpStatusCode() int { return http.StatusUnauthorized } type NoAPIKeyProvidedError struct{} func (e *NoAPIKeyProvidedError) Error() string { return "No API Key Provided" } +func (e *NoAPIKeyProvidedError) HttpStatusCode() int { return http.StatusUnauthorized } type APIKeyNotFoundError struct{} func (e *APIKeyNotFoundError) Error() string { return "API Key Not Found" } +func (e *APIKeyNotFoundError) HttpStatusCode() int { return http.StatusUnauthorized } type APIKeyExpirationDateError struct{} func (e *APIKeyExpirationDateError) Error() string { return "API Key expiration time must be in the future" } +func (e *APIKeyExpirationDateError) HttpStatusCode() int { return http.StatusBadRequest } type OidcInvalidRefreshTokenError struct{} func (e *OidcInvalidRefreshTokenError) Error() string { return "refresh token is invalid or expired" } - func (e *OidcInvalidRefreshTokenError) HttpStatusCode() int { return http.StatusBadRequest } @@ -278,7 +275,6 @@ type OidcMissingRefreshTokenError struct{} func (e *OidcMissingRefreshTokenError) Error() string { return "refresh token is required" } - func (e *OidcMissingRefreshTokenError) HttpStatusCode() int { return http.StatusBadRequest } @@ -288,7 +284,15 @@ type OidcMissingAuthorizationCodeError struct{} func (e *OidcMissingAuthorizationCodeError) Error() string { return "authorization code is required" } - func (e *OidcMissingAuthorizationCodeError) HttpStatusCode() int { return http.StatusBadRequest } + +type UserDisabledError struct{} + +func (e *UserDisabledError) Error() string { + return "User account is disabled" +} +func (e *UserDisabledError) HttpStatusCode() int { + return http.StatusForbidden +} diff --git a/backend/internal/dto/app_config_dto.go b/backend/internal/dto/app_config_dto.go index 151b5e7e..91be6dd9 100644 --- a/backend/internal/dto/app_config_dto.go +++ b/backend/internal/dto/app_config_dto.go @@ -42,6 +42,7 @@ type AppConfigUpdateDto struct { LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"` LdapAttributeGroupName string `json:"ldapAttributeGroupName"` LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"` + LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers"` EmailOneTimeAccessEnabled string `json:"emailOneTimeAccessEnabled" binding:"required"` EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"` } diff --git a/backend/internal/dto/user_dto.go b/backend/internal/dto/user_dto.go index 82a7d555..da984743 100644 --- a/backend/internal/dto/user_dto.go +++ b/backend/internal/dto/user_dto.go @@ -13,6 +13,7 @@ type UserDto struct { CustomClaims []CustomClaimDto `json:"customClaims"` UserGroups []UserGroupDto `json:"userGroups"` LdapID *string `json:"ldapId"` + Disabled bool `json:"disabled"` } type UserCreateDto struct { @@ -22,6 +23,7 @@ type UserCreateDto struct { LastName string `json:"lastName" binding:"required,min=1,max=50"` IsAdmin bool `json:"isAdmin"` Locale *string `json:"locale"` + Disabled bool `json:"disabled"` LdapID string `json:"-"` } diff --git a/backend/internal/middleware/api_key_auth.go b/backend/internal/middleware/api_key_auth.go index 0741cf34..fa35dd6f 100644 --- a/backend/internal/middleware/api_key_auth.go +++ b/backend/internal/middleware/api_key_auth.go @@ -41,7 +41,10 @@ func (m *ApiKeyAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userI return "", false, &common.NotSignedInError{} } - // Check if the user is an admin + if user.Disabled { + return "", false, &common.UserDisabledError{} + } + if adminRequired && !user.IsAdmin { return "", false, &common.MissingPermissionError{} } diff --git a/backend/internal/middleware/auth_middleware.go b/backend/internal/middleware/auth_middleware.go index 7b0ac61b..a5fbeb07 100644 --- a/backend/internal/middleware/auth_middleware.go +++ b/backend/internal/middleware/auth_middleware.go @@ -19,11 +19,12 @@ type AuthOptions struct { func NewAuthMiddleware( apiKeyService *service.ApiKeyService, + userService *service.UserService, jwtService *service.JwtService, ) *AuthMiddleware { return &AuthMiddleware{ apiKeyMiddleware: NewApiKeyAuthMiddleware(apiKeyService, jwtService), - jwtMiddleware: NewJwtAuthMiddleware(jwtService), + jwtMiddleware: NewJwtAuthMiddleware(jwtService, userService), options: AuthOptions{ AdminRequired: true, SuccessOptional: false, @@ -57,12 +58,13 @@ func (m *AuthMiddleware) WithSuccessOptional() *AuthMiddleware { func (m *AuthMiddleware) Add() gin.HandlerFunc { return func(c *gin.Context) { - // First try JWT auth userID, isAdmin, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired) if err == nil { - // JWT auth succeeded, continue with the request c.Set("userID", userID) c.Set("userIsAdmin", isAdmin) + if c.IsAborted() { + return + } c.Next() return } @@ -70,9 +72,11 @@ func (m *AuthMiddleware) Add() gin.HandlerFunc { // JWT auth failed, try API key auth userID, isAdmin, err = m.apiKeyMiddleware.Verify(c, m.options.AdminRequired) if err == nil { - // API key auth succeeded, continue with the request c.Set("userID", userID) c.Set("userIsAdmin", isAdmin) + if c.IsAborted() { + return + } c.Next() return } diff --git a/backend/internal/middleware/jwt_auth.go b/backend/internal/middleware/jwt_auth.go index a3149008..69d1728e 100644 --- a/backend/internal/middleware/jwt_auth.go +++ b/backend/internal/middleware/jwt_auth.go @@ -10,11 +10,12 @@ import ( ) type JwtAuthMiddleware struct { - jwtService *service.JwtService + userService *service.UserService + jwtService *service.JwtService } -func NewJwtAuthMiddleware(jwtService *service.JwtService) *JwtAuthMiddleware { - return &JwtAuthMiddleware{jwtService: jwtService} +func NewJwtAuthMiddleware(jwtService *service.JwtService, userService *service.UserService) *JwtAuthMiddleware { + return &JwtAuthMiddleware{jwtService: jwtService, userService: userService} } func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc { @@ -55,12 +56,16 @@ func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject return } - // Check if the user is an admin - isAdmin, err = service.GetIsAdmin(token) + user, err := m.userService.GetUser(c, subject) if err != nil { - return "", false, &common.TokenInvalidError{} + return "", false, &common.NotSignedInError{} } - if adminRequired && !isAdmin { + + if user.Disabled { + return "", false, &common.UserDisabledError{} + } + + if adminRequired && !user.IsAdmin { return "", false, &common.MissingPermissionError{} } diff --git a/backend/internal/model/app_config.go b/backend/internal/model/app_config.go index 38149c0e..075e9996 100644 --- a/backend/internal/model/app_config.go +++ b/backend/internal/model/app_config.go @@ -69,6 +69,7 @@ type AppConfig struct { LdapAttributeGroupUniqueIdentifier AppConfigVariable `key:"ldapAttributeGroupUniqueIdentifier"` LdapAttributeGroupName AppConfigVariable `key:"ldapAttributeGroupName"` LdapAttributeAdminGroup AppConfigVariable `key:"ldapAttributeAdminGroup"` + LdapSoftDeleteUsers AppConfigVariable `key:"ldapSoftDeleteUsers"` } func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable { diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index db333535..4a8d4bf8 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -19,6 +19,7 @@ type User struct { IsAdmin bool `sortable:"true"` Locale *string LdapID *string + Disabled bool `sortable:"true"` CustomClaims []CustomClaim UserGroups []UserGroup `gorm:"many2many:user_groups_users;"` diff --git a/backend/internal/service/app_config_service.go b/backend/internal/service/app_config_service.go index 7a442d94..a5953a03 100644 --- a/backend/internal/service/app_config_service.go +++ b/backend/internal/service/app_config_service.go @@ -93,6 +93,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig { LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{}, LdapAttributeGroupName: model.AppConfigVariable{}, LdapAttributeAdminGroup: model.AppConfigVariable{}, + LdapSoftDeleteUsers: model.AppConfigVariable{Value: "true"}, } } diff --git a/backend/internal/service/ldap_service.go b/backend/internal/service/ldap_service.go index ce878cf0..1c080eaf 100644 --- a/backend/internal/service/ldap_service.go +++ b/backend/internal/service/ldap_service.go @@ -279,6 +279,22 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C Where("ldap_id = ?", ldapId). First(&databaseUser). Error + + // If a user is found (even if disabled), enable them since they're now back in LDAP + if databaseUser.ID != "" && databaseUser.Disabled { + // Use the transaction instead of the direct context + err = tx. + WithContext(ctx). + Model(&model.User{}). + Where("id = ?", databaseUser.ID). + Update("disabled", false). + Error + + if err != nil { + log.Printf("Failed to enable user %s: %v", databaseUser.Username, err) + } + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { // This could error with ErrRecordNotFound and we want to ignore that here return fmt.Errorf("failed to query for LDAP user ID '%s': %w", ldapId, err) @@ -336,24 +352,32 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C err = tx. WithContext(ctx). Find(&ldapUsersInDb, "ldap_id IS NOT NULL"). - Select("ldap_id"). + Select("id, username, ldap_id, disabled"). Error if err != nil { return fmt.Errorf("failed to fetch users from database: %w", err) } - // Delete users that no longer exist in LDAP + // Mark users as disabled or delete users that no longer exist in LDAP for _, user := range ldapUsersInDb { + // Skip if the user ID exists in the fetched LDAP results if _, exists := ldapUserIDs[*user.LdapID]; exists { continue } - err = s.userService.deleteUserInternal(ctx, user.ID, true, tx) - if err != nil { - return fmt.Errorf("failed to delete user '%s': %w", user.Username, err) + if dbConfig.LdapSoftDeleteUsers.IsTrue() { + err = s.userService.DisableUser(ctx, user.ID, tx) + if err != nil { + log.Printf("Failed to disable user %s: %v", user.Username, err) + continue + } + } else { + err = s.userService.DeleteUser(ctx, user.ID, true) + if err != nil { + log.Printf("Failed to delete user %s: %v", user.Username, err) + continue + } } - - log.Printf("Deleted user '%s'", user.Username) } return nil diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index d2764d1d..63211de4 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -38,14 +38,19 @@ func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditL func (s *UserService) ListUsers(ctx context.Context, searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) { var users []model.User - query := s.db.WithContext(ctx).Model(&model.User{}) + query := s.db.WithContext(ctx). + Model(&model.User{}). + Preload("UserGroups"). + Preload("CustomClaims") if searchTerm != "" { searchPattern := "%" + searchTerm + "%" - query = query.Where("email LIKE ? OR first_name LIKE ? OR username LIKE ?", searchPattern, searchPattern, searchPattern) + query = query.Where("email LIKE ? OR first_name LIKE ? OR last_name LIKE ? OR username LIKE ?", + searchPattern, searchPattern, searchPattern, searchPattern) } pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &users) + return users, pagination, err } @@ -170,9 +175,28 @@ func (s *UserService) UpdateProfilePicture(userID string, file io.Reader) error } func (s *UserService) DeleteUser(ctx context.Context, userID string, allowLdapDelete bool) error { - return s.db.Transaction(func(tx *gorm.DB) error { - return s.deleteUserInternal(ctx, userID, allowLdapDelete, tx) - }) + tx := s.db.Begin() + + var user model.User + if err := tx.WithContext(ctx).First(&user, "id = ?", userID).Error; err != nil { + tx.Rollback() + return err + } + + // Only soft-delete if user is LDAP and soft-delete is enabled and not allowing hard delete + if user.LdapID != nil && s.appConfigService.GetDbConfig().LdapSoftDeleteUsers.IsTrue() && !allowLdapDelete { + if !user.Disabled { + tx.Rollback() + return fmt.Errorf("LDAP user must be disabled before deletion") + } + } + + // Otherwise, hard delete (local users or LDAP users when allowed) + if err := s.deleteUserInternal(ctx, userID, allowLdapDelete, tx); err != nil { + tx.Rollback() + return err + } + return tx.Commit().Error } func (s *UserService) deleteUserInternal(ctx context.Context, userID string, allowLdapDelete bool, tx *gorm.DB) error { @@ -187,8 +211,8 @@ func (s *UserService) deleteUserInternal(ctx context.Context, userID string, all return fmt.Errorf("failed to load user to delete: %w", err) } - // Disallow deleting the user if it is an LDAP user and LDAP is enabled - if !allowLdapDelete && user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() { + // Disallow deleting the user if it is an LDAP user, LDAP is enabled, and the user is not disabled + if !allowLdapDelete && !user.Disabled && user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() { return &common.LdapUserUpdateError{} } @@ -299,6 +323,7 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd user.Locale = updatedUser.Locale if !updateOwnUser { user.IsAdmin = updatedUser.IsAdmin + user.Disabled = updatedUser.Disabled } err = tx. @@ -606,3 +631,11 @@ func (s *UserService) ResetProfilePicture(userID string) error { return nil } + +func (s *UserService) DisableUser(ctx context.Context, userID string, tx *gorm.DB) error { + return tx.WithContext(ctx). + Model(&model.User{}). + Where("id = ?", userID). + Update("disabled", true). + Error +} diff --git a/backend/internal/service/webauthn_service.go b/backend/internal/service/webauthn_service.go index cdc7768d..50dd3c62 100644 --- a/backend/internal/service/webauthn_service.go +++ b/backend/internal/service/webauthn_service.go @@ -244,6 +244,10 @@ func (s *WebAuthnService) VerifyLogin(ctx context.Context, sessionID string, cre return model.User{}, "", err } + if user.Disabled { + return model.User{}, "", &common.UserDisabledError{} + } + token, err := s.jwtService.GenerateAccessToken(*user) if err != nil { return model.User{}, "", err diff --git a/backend/resources/migrations/postgres/20250412000000_add_user_disabled_field.down.sql b/backend/resources/migrations/postgres/20250412000000_add_user_disabled_field.down.sql new file mode 100644 index 00000000..3a020820 --- /dev/null +++ b/backend/resources/migrations/postgres/20250412000000_add_user_disabled_field.down.sql @@ -0,0 +1,4 @@ +DROP INDEX idx_users_disabled; + +ALTER TABLE users +DROP COLUMN disabled; \ No newline at end of file diff --git a/backend/resources/migrations/postgres/20250412000000_add_user_disabled_field.up.sql b/backend/resources/migrations/postgres/20250412000000_add_user_disabled_field.up.sql new file mode 100644 index 00000000..6a70389d --- /dev/null +++ b/backend/resources/migrations/postgres/20250412000000_add_user_disabled_field.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN IF NOT EXISTS disabled BOOLEAN DEFAULT FALSE NOT NULL; \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20250412000000_add_user_disabled_field.down.sql b/backend/resources/migrations/sqlite/20250412000000_add_user_disabled_field.down.sql new file mode 100644 index 00000000..3a020820 --- /dev/null +++ b/backend/resources/migrations/sqlite/20250412000000_add_user_disabled_field.down.sql @@ -0,0 +1,4 @@ +DROP INDEX idx_users_disabled; + +ALTER TABLE users +DROP COLUMN disabled; \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20250412000000_add_user_disabled_field.up.sql b/backend/resources/migrations/sqlite/20250412000000_add_user_disabled_field.up.sql new file mode 100644 index 00000000..989a4772 --- /dev/null +++ b/backend/resources/migrations/sqlite/20250412000000_add_user_disabled_field.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN disabled NUMERIC DEFAULT FALSE NOT NULL; \ No newline at end of file diff --git a/frontend/messages/en-US.json b/frontend/messages/en-US.json index 2e46aa04..20e0e2fb 100644 --- a/frontend/messages/en-US.json +++ b/frontend/messages/en-US.json @@ -324,5 +324,14 @@ "client_authorization": "Client Authorization", "new_client_authorization": "New Client Authorization", "disable_animations": "Disable Animations", - "turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI" + "turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI.", + "user_disabled": "Account Disabled", + "disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.", + "user_disabled_successfully": "User has been disabled successfully.", + "user_enabled_successfully": "User has been enabled successfully.", + "status": "Status", + "disable_firstname_lastname": "Disable {firstName} {lastName}", + "are_you_sure_you_want_to_disable_this_user": "Are you sure you want to disable this user? They will not be able to log in or access any services.", + "ldap_soft_delete_users": "Keep disabled users from LDAP.", + "ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system." } diff --git a/frontend/src/lib/types/application-configuration.ts b/frontend/src/lib/types/application-configuration.ts index aaf8cf1d..2678d49e 100644 --- a/frontend/src/lib/types/application-configuration.ts +++ b/frontend/src/lib/types/application-configuration.ts @@ -37,6 +37,7 @@ export type AllAppConfig = AppConfig & { ldapAttributeGroupUniqueIdentifier: string; ldapAttributeGroupName: string; ldapAttributeAdminGroup: string; + ldapSoftDeleteUsers: boolean; }; export type AppConfigRawResponse = { diff --git a/frontend/src/lib/types/user.type.ts b/frontend/src/lib/types/user.type.ts index 39b3da86..f040599b 100644 --- a/frontend/src/lib/types/user.type.ts +++ b/frontend/src/lib/types/user.type.ts @@ -13,6 +13,7 @@ export type User = { customClaims: CustomClaim[]; locale?: Locale; ldapId?: string; + disabled: boolean; }; export type UserCreate = Omit; diff --git a/frontend/src/lib/utils/error-util.ts b/frontend/src/lib/utils/error-util.ts index 09f1d372..66c3a221 100644 --- a/frontend/src/lib/utils/error-util.ts +++ b/frontend/src/lib/utils/error-util.ts @@ -14,7 +14,10 @@ export function getAxiosErrorMessage( return message; } -export function axiosErrorToast(e: unknown, defaultMessage: string = m.an_unknown_error_occurred()) { +export function axiosErrorToast( + e: unknown, + defaultMessage: string = m.an_unknown_error_occurred() +) { const message = getAxiosErrorMessage(e, defaultMessage); toast.error(message); } @@ -29,7 +32,8 @@ export function getWebauthnErrorMessage(e: unknown) { m.authenticator_does_not_support_resident_keys(), ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED: m.passkey_was_previously_registered(), ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG: - m.authenticator_does_not_support_any_of_the_requested_algorithms() + m.authenticator_does_not_support_any_of_the_requested_algorithms(), + ERROR_USER_DISABLED_MSG: m.user_disabled() }; let message = m.an_unknown_error_occurred(); diff --git a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-ldap-form.svelte b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-ldap-form.svelte index 58c8303d..b87795d2 100644 --- a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-ldap-form.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-ldap-form.svelte @@ -43,7 +43,8 @@ ldapAttributeGroupMember: appConfig.ldapAttributeGroupMember, ldapAttributeGroupUniqueIdentifier: appConfig.ldapAttributeGroupUniqueIdentifier, ldapAttributeGroupName: appConfig.ldapAttributeGroupName, - ldapAttributeAdminGroup: appConfig.ldapAttributeAdminGroup + ldapAttributeAdminGroup: appConfig.ldapAttributeAdminGroup, + ldapSoftDeleteUsers: appConfig.ldapSoftDeleteUsers || true }; const formSchema = z.object({ @@ -63,7 +64,8 @@ ldapAttributeGroupMember: z.string(), ldapAttributeGroupUniqueIdentifier: z.string().min(1), ldapAttributeGroupName: z.string().min(1), - ldapAttributeAdminGroup: z.string() + ldapAttributeAdminGroup: z.string(), + ldapSoftDeleteUsers: z.boolean() }); const { inputs, ...form } = createForm(formSchema, updatedAppConfig); @@ -116,7 +118,11 @@ placeholder="cn=people,dc=example,dc=com" bind:input={$inputs.ldapBindDn} /> - + +

{m.attribute_mapping()}

@@ -203,7 +215,9 @@
{#if ldapEnabled} - + {:else} diff --git a/frontend/src/routes/settings/admin/users/user-form.svelte b/frontend/src/routes/settings/admin/users/user-form.svelte index 48d3dba2..5b35659c 100644 --- a/frontend/src/routes/settings/admin/users/user-form.svelte +++ b/frontend/src/routes/settings/admin/users/user-form.svelte @@ -24,7 +24,8 @@ lastName: existingUser?.lastName || '', email: existingUser?.email || '', username: existingUser?.username || '', - isAdmin: existingUser?.isAdmin || false + isAdmin: existingUser?.isAdmin || false, + disabled: existingUser?.disabled || false }; const formSchema = z.object({ @@ -34,12 +35,10 @@ .string() .min(2) .max(30) - .regex( - /^[a-z0-9_@.-]+$/, - m.username_can_only_contain() - ), + .regex(/^[a-z0-9_@.-]+$/, m.username_can_only_contain()), email: z.string().email(), - isAdmin: z.boolean() + isAdmin: z.boolean(), + disabled: z.boolean() }); type FormSchema = typeof formSchema; @@ -68,6 +67,12 @@ description={m.admins_have_full_access_to_the_admin_panel()} bind:checked={$inputs.isAdmin.value} /> +
diff --git a/frontend/src/routes/settings/admin/users/user-list.svelte b/frontend/src/routes/settings/admin/users/user-list.svelte index 820e28a8..2211237c 100644 --- a/frontend/src/routes/settings/admin/users/user-list.svelte +++ b/frontend/src/routes/settings/admin/users/user-list.svelte @@ -2,20 +2,26 @@ import { goto } from '$app/navigation'; import AdvancedTable from '$lib/components/advanced-table.svelte'; import { openConfirmDialog } from '$lib/components/confirm-dialog/'; + import OneTimeLinkModal from '$lib/components/one-time-link-modal.svelte'; import { Badge } from '$lib/components/ui/badge/index'; import { buttonVariants } from '$lib/components/ui/button'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as Table from '$lib/components/ui/table'; + import { m } from '$lib/paraglide/messages'; import UserService from '$lib/services/user-service'; import appConfigStore from '$lib/stores/application-configuration-store'; import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; import type { User } from '$lib/types/user.type'; import { axiosErrorToast } from '$lib/utils/error-util'; - import { LucideLink, LucidePencil, LucideTrash } from 'lucide-svelte'; + import { + LucideLink, + LucidePencil, + LucideTrash, + LucideUserCheck, + LucideUserX + } from 'lucide-svelte'; import Ellipsis from 'lucide-svelte/icons/ellipsis'; import { toast } from 'svelte-sonner'; - import OneTimeLinkModal from '$lib/components/one-time-link-modal.svelte'; - import { m } from '$lib/paraglide/messages'; let { users = $bindable(), @@ -28,7 +34,7 @@ async function deleteUser(user: User) { openConfirmDialog({ - title: m.delete_firstname_lastname({firstName: user.firstName, lastName: user.lastName}), + title: m.delete_firstname_lastname({ firstName: user.firstName, lastName: user.lastName }), message: m.are_you_sure_you_want_to_delete_this_user(), confirm: { label: m.delete(), @@ -45,6 +51,42 @@ } }); } + + async function enableUser(user: User) { + await userService + .update(user.id, { + ...user, + disabled: false + }) + .then(() => { + toast.success(m.user_enabled_successfully()); + userService.list(requestOptions!).then((updatedUsers) => (users = updatedUsers)); + }) + .catch(axiosErrorToast); + } + + async function disableUser(user: User) { + openConfirmDialog({ + title: m.disable_firstname_lastname({ firstName: user.firstName, lastName: user.lastName }), + message: m.are_you_sure_you_want_to_disable_this_user(), + confirm: { + label: m.disable(), + destructive: true, + action: async () => { + try { + await userService.update(user.id, { + ...user, + disabled: true + }); + users = await userService.list(requestOptions!); + toast.success(m.user_disabled_successfully()); + } catch (e) { + axiosErrorToast(e); + } + } + } + }); + } @@ -69,9 +112,15 @@ {item.isAdmin ? m.admin() : m.user()} + + + {item.disabled ? m.disabled() : m.enabled()} + + {#if $appConfigStore.ldapEnabled} - {item.ldapId ? m.ldap() : m.local()}{item.ldapId ? m.ldap() : m.local()} {/if} @@ -89,6 +138,17 @@ > {m.edit()} {#if !item.ldapId || !$appConfigStore.ldapEnabled} + {#if item.disabled} + enableUser(item)} + >{m.enable()} + {:else} + disableUser(item)} + >{m.disable()} + {/if} + {/if} + {#if !item.ldapId || (item.ldapId && item.disabled)} deleteUser(item)}