diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index e07f2b1f..acdb6fdc 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -46,7 +46,6 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (sv return nil, fmt.Errorf("failed to create JWT service: %w", err) } - svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService) svc.customClaimService = service.NewCustomClaimService(db) svc.webauthnService, err = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService) if err != nil { @@ -59,6 +58,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (sv } svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService) + svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService) svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService) svc.apiKeyService = service.NewApiKeyService(db, svc.emailService) diff --git a/backend/internal/dto/app_config_dto.go b/backend/internal/dto/app_config_dto.go index 7787a82d..3a73e810 100644 --- a/backend/internal/dto/app_config_dto.go +++ b/backend/internal/dto/app_config_dto.go @@ -18,6 +18,8 @@ type AppConfigUpdateDto struct { DisableAnimations string `json:"disableAnimations" binding:"required"` AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"` AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"` + SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json"` + SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json"` AccentColor string `json:"accentColor"` SmtpHost string `json:"smtpHost"` SmtpPort string `json:"smtpPort"` diff --git a/backend/internal/model/app_config.go b/backend/internal/model/app_config.go index deba901e..2b5227f0 100644 --- a/backend/internal/model/app_config.go +++ b/backend/internal/model/app_config.go @@ -34,13 +34,15 @@ func (a *AppConfigVariable) AsDurationMinutes() time.Duration { type AppConfig struct { // General - AppName AppConfigVariable `key:"appName,public"` // Public - SessionDuration AppConfigVariable `key:"sessionDuration"` - EmailsVerified AppConfigVariable `key:"emailsVerified"` - AccentColor AppConfigVariable `key:"accentColor,public"` // Public - DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public - AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public - AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public + AppName AppConfigVariable `key:"appName,public"` // Public + SessionDuration AppConfigVariable `key:"sessionDuration"` + EmailsVerified AppConfigVariable `key:"emailsVerified"` + AccentColor AppConfigVariable `key:"accentColor,public"` // Public + DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public + AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public + AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public + SignupDefaultUserGroupIDs AppConfigVariable `key:"signupDefaultUserGroupIDs"` + SignupDefaultCustomClaims AppConfigVariable `key:"signupDefaultCustomClaims"` // Internal BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal diff --git a/backend/internal/service/app_config_service.go b/backend/internal/service/app_config_service.go index 2846bb5d..9a4fe7ee 100644 --- a/backend/internal/service/app_config_service.go +++ b/backend/internal/service/app_config_service.go @@ -60,13 +60,15 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig { // Values are the default ones return &model.AppConfig{ // General - AppName: model.AppConfigVariable{Value: "Pocket ID"}, - SessionDuration: model.AppConfigVariable{Value: "60"}, - EmailsVerified: model.AppConfigVariable{Value: "false"}, - DisableAnimations: model.AppConfigVariable{Value: "false"}, - AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"}, - AllowUserSignups: model.AppConfigVariable{Value: "disabled"}, - AccentColor: model.AppConfigVariable{Value: "default"}, + AppName: model.AppConfigVariable{Value: "Pocket ID"}, + SessionDuration: model.AppConfigVariable{Value: "60"}, + EmailsVerified: model.AppConfigVariable{Value: "false"}, + DisableAnimations: model.AppConfigVariable{Value: "false"}, + AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"}, + AllowUserSignups: model.AppConfigVariable{Value: "disabled"}, + SignupDefaultUserGroupIDs: model.AppConfigVariable{Value: "[]"}, + SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"}, + AccentColor: model.AppConfigVariable{Value: "default"}, // Internal BackgroundImageType: model.AppConfigVariable{Value: "jpg"}, LogoLightImageType: model.AppConfigVariable{Value: "svg"}, diff --git a/backend/internal/service/custom_claim_service.go b/backend/internal/service/custom_claim_service.go index 4d8f42bd..03cfb124 100644 --- a/backend/internal/service/custom_claim_service.go +++ b/backend/internal/service/custom_claim_service.go @@ -55,16 +55,46 @@ const ( // UpdateCustomClaimsForUser updates the custom claims for a user func (s *CustomClaimService) UpdateCustomClaimsForUser(ctx context.Context, userID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) { - return s.updateCustomClaims(ctx, UserID, userID, claims) + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + + updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserID, userID, claims, tx) + if err != nil { + return nil, err + } + + err = tx.Commit().Error + if err != nil { + return nil, err + } + + return updatedClaims, nil } // UpdateCustomClaimsForUserGroup updates the custom claims for a user group func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(ctx context.Context, userGroupID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) { - return s.updateCustomClaims(ctx, UserGroupID, userGroupID, claims) + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + + updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserGroupID, userGroupID, claims, tx) + if err != nil { + return nil, err + } + + err = tx.Commit().Error + if err != nil { + return nil, err + } + + return updatedClaims, nil } -// updateCustomClaims updates the custom claims for a user or user group -func (s *CustomClaimService) updateCustomClaims(ctx context.Context, idType idType, value string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) { +// updateCustomClaimsInternal updates the custom claims for a user or user group within a transaction +func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idType idType, value string, claims []dto.CustomClaimCreateDto, tx *gorm.DB) ([]model.CustomClaim, error) { // Check for duplicate keys in the claims slice seenKeys := make(map[string]struct{}) for _, claim := range claims { @@ -74,11 +104,6 @@ func (s *CustomClaimService) updateCustomClaims(ctx context.Context, idType idTy seenKeys[claim.Key] = struct{}{} } - tx := s.db.Begin() - defer func() { - tx.Rollback() - }() - var existingClaims []model.CustomClaim err := tx. WithContext(ctx). @@ -150,11 +175,6 @@ func (s *CustomClaimService) updateCustomClaims(ctx context.Context, idType idTy return nil, err } - err = tx.Commit().Error - if err != nil { - return nil, err - } - return updatedClaims, nil } diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index b791e1f1..cec7dc7e 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -3,6 +3,7 @@ package service import ( "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -26,20 +27,22 @@ import ( ) type UserService struct { - db *gorm.DB - jwtService *JwtService - auditLogService *AuditLogService - emailService *EmailService - appConfigService *AppConfigService + db *gorm.DB + jwtService *JwtService + auditLogService *AuditLogService + emailService *EmailService + appConfigService *AppConfigService + customClaimService *CustomClaimService } -func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService) *UserService { +func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService) *UserService { return &UserService{ - db: db, - jwtService: jwtService, - auditLogService: auditLogService, - emailService: emailService, - appConfigService: appConfigService, + db: db, + jwtService: jwtService, + auditLogService: auditLogService, + emailService: emailService, + appConfigService: appConfigService, + customClaimService: customClaimService, } } @@ -268,9 +271,53 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea } else if err != nil { return model.User{}, err } + + // Apply default groups and claims for new non-LDAP users + if !isLdapSync { + if err := s.applySignupDefaults(ctx, &user, tx); err != nil { + return model.User{}, err + } + } + return user, nil } +func (s *UserService) applySignupDefaults(ctx context.Context, user *model.User, tx *gorm.DB) error { + config := s.appConfigService.GetDbConfig() + + // Apply default user groups + var groupIDs []string + if v := config.SignupDefaultUserGroupIDs.Value; v != "" && v != "[]" { + if err := json.Unmarshal([]byte(v), &groupIDs); err != nil { + return fmt.Errorf("invalid SignupDefaultUserGroupIDs JSON: %w", err) + } + if len(groupIDs) > 0 { + var groups []model.UserGroup + if err := tx.WithContext(ctx).Where("id IN ?", groupIDs).Find(&groups).Error; err != nil { + return fmt.Errorf("failed to find default user groups: %w", err) + } + if err := tx.WithContext(ctx).Model(user).Association("UserGroups").Replace(groups); err != nil { + return fmt.Errorf("failed to associate default user groups: %w", err) + } + } + } + + // Apply default custom claims + var claims []dto.CustomClaimCreateDto + if v := config.SignupDefaultCustomClaims.Value; v != "" && v != "[]" { + if err := json.Unmarshal([]byte(v), &claims); err != nil { + return fmt.Errorf("invalid SignupDefaultCustomClaims JSON: %w", err) + } + if len(claims) > 0 { + if _, err := s.customClaimService.updateCustomClaimsInternal(ctx, UserID, user.ID, claims, tx); err != nil { + return fmt.Errorf("failed to apply default custom claims: %w", err) + } + } + } + + return nil +} + func (s *UserService) UpdateUser(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool) (model.User, error) { tx := s.db.Begin() defer func() { @@ -504,7 +551,7 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup // Fetch the groups based on userGroupIds var groups []model.UserGroup if len(userGroupIds) > 0 { - err = tx. + err := tx. WithContext(ctx). Where("id IN (?)", userGroupIds). Find(&groups). diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 243d7810..919ad851 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -387,6 +387,12 @@ "number_of_times_token_can_be_used": "Number of times the signup token can be used.", "expires": "Expires", "signup": "Sign Up", + "user_creation": "User Creation", + "configure_user_creation": "Manage user creation settings, including signup methods and default permissions for new users.", + "user_creation_groups_description": "Assign these groups automatically to new users upon signup.", + "user_creation_claims_description": "Assign these custom claims automatically to new users upon signup.", + "user_creation_updated_successfully": "User creation settings updated successfully.", + "signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.", "signup_requires_valid_token": "A valid signup token is required to create an account", "validating_signup_token": "Validating signup token", "go_to_login": "Go to login", @@ -398,7 +404,7 @@ "skip_for_now": "Skip for now", "account_created": "Account Created", "enable_user_signups": "Enable User Signups", - "enable_user_signups_description": "Whether the User Signup functionality should be enabled.", + "enable_user_signups_description": "Decide how users can sign up for new accounts in Pocket ID.", "user_signups_are_disabled": "User signups are currently disabled", "create_signup_token": "Create Signup Token", "view_active_signup_tokens": "View Active Signup Tokens", @@ -414,7 +420,6 @@ "loading": "Loading", "delete_signup_token": "Delete Signup Token", "are_you_sure_you_want_to_delete_this_signup_token": "Are you sure you want to delete this signup token? This action cannot be undone.", - "signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.", "signup_with_token": "Signup with token", "signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.", "signup_open": "Open Signup", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index bcad624c..ad301d27 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -385,6 +385,12 @@ "number_of_times_token_can_be_used": "Número de veces que se puede utilizar el token de registro.", "expires": "Caduca", "signup": "Regístrate", + "user_creation": "Registro", + "configure_user_creation": "Gestiona la configuración de registro de usuarios, incluyendo los métodos de registro y los permisos por defecto para nuevos usuarios.", + "user_creation_groups_description": "Asigna estos grupos automáticamente a los nuevos usuarios al registrarse.", + "user_creation_claims_description": "Asigna estas reclamaciones personalizadas automáticamente a los nuevos usuarios al registrarse.", + "user_creation_updated_successfully": "Configuración de registro actualizada correctamente.", + "signup_disabled_description": "El registro de usuarios está completamente desactivado. Solo los administradores pueden crear nuevas cuentas de usuario.", "signup_requires_valid_token": "Se requiere un token de registro válido para crear una cuenta.", "validating_signup_token": "Validación del token de registro", "go_to_login": "Ir al inicio de sesión", @@ -412,7 +418,6 @@ "loading": "Cargando", "delete_signup_token": "Eliminar token de registro", "are_you_sure_you_want_to_delete_this_signup_token": "¿Estás seguro de que deseas eliminar este token de registro? Esta acción no se puede deshacer.", - "signup_disabled_description": "El registro de usuarios está completamente desactivado. Solo los administradores pueden crear nuevas cuentas de usuario.", "signup_with_token": "Regístrate con token", "signup_with_token_description": "Los usuarios solo pueden registrarse utilizando un token de registro válido creado por un administrador.", "signup_open": "Inscripción abierta", diff --git a/frontend/src/lib/components/form/searchable-multi-select.svelte b/frontend/src/lib/components/form/searchable-multi-select.svelte new file mode 100644 index 00000000..6dcea818 --- /dev/null +++ b/frontend/src/lib/components/form/searchable-multi-select.svelte @@ -0,0 +1,140 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + + { + filterItems(e.currentTarget.value); + oninput?.(e); + }} + /> + + {#if isLoading} +
+ +
+ {:else} + {noItemsText} + {/if} +
+ + {#each filteredItems as item} + { + handleItemSelect(item.value); + }} + > + + {item.label} + + {/each} + +
+
+
diff --git a/frontend/src/lib/services/app-config-service.ts b/frontend/src/lib/services/app-config-service.ts index b3505314..cf178fdc 100644 --- a/frontend/src/lib/services/app-config-service.ts +++ b/frontend/src/lib/services/app-config-service.ts @@ -14,10 +14,15 @@ export default class AppConfigService extends APIService { } async update(appConfig: AllAppConfig) { - // Convert all values to string - const appConfigConvertedToString = {}; + // Convert all values to string, stringifying JSON where needed + const appConfigConvertedToString: Record = {}; for (const key in appConfig) { - (appConfigConvertedToString as any)[key] = (appConfig as any)[key].toString(); + const value = (appConfig as any)[key]; + if (typeof value === 'object' && value !== null) { + appConfigConvertedToString[key] = JSON.stringify(value); + } else { + appConfigConvertedToString[key] = String(value); + } } const res = await this.api.put('/application-configuration', appConfigConvertedToString); return this.parseConfigList(res.data); @@ -66,6 +71,16 @@ export default class AppConfigService extends APIService { } private parseValue(value: string) { + // Try to parse JSON first + try { + const parsed = JSON.parse(value); + if (typeof parsed === 'object' && parsed !== null) { + return parsed; + } + value = String(parsed); + } catch {} + + // Handle rest of the types if (value === 'true') { return true; } else if (value === 'false') { diff --git a/frontend/src/lib/types/application-configuration.ts b/frontend/src/lib/types/application-configuration.ts index 69ad6acc..a6b68dcd 100644 --- a/frontend/src/lib/types/application-configuration.ts +++ b/frontend/src/lib/types/application-configuration.ts @@ -1,3 +1,5 @@ +import type { CustomClaim } from './custom-claim.type'; + export type AppConfig = { appName: string; allowOwnAccountEdit: boolean; @@ -14,6 +16,8 @@ export type AllAppConfig = AppConfig & { // General sessionDuration: number; emailsVerified: boolean; + signupDefaultUserGroupIDs: string[]; + signupDefaultCustomClaims: CustomClaim[]; // Email smtpHost: string; smtpPort: number; diff --git a/frontend/src/routes/settings/admin/application-configuration/+page.svelte b/frontend/src/routes/settings/admin/application-configuration/+page.svelte index a2d5592f..33059711 100644 --- a/frontend/src/routes/settings/admin/application-configuration/+page.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/+page.svelte @@ -5,11 +5,12 @@ import appConfigStore from '$lib/stores/application-configuration-store'; import type { AllAppConfig } from '$lib/types/application-configuration'; import { axiosErrorToast } from '$lib/utils/error-util'; - import { LucideImage, Mail, SlidersHorizontal, UserSearch } from '@lucide/svelte'; + import { LucideImage, Mail, SlidersHorizontal, UserSearch, Users } from '@lucide/svelte'; import { toast } from 'svelte-sonner'; import AppConfigEmailForm from './forms/app-config-email-form.svelte'; import AppConfigGeneralForm from './forms/app-config-general-form.svelte'; import AppConfigLdapForm from './forms/app-config-ldap-form.svelte'; + import AppConfigSignupDefaultsForm from './forms/app-config-signup-defaults-form.svelte'; import UpdateApplicationImages from './update-application-images.svelte'; let { data } = $props(); @@ -68,6 +69,17 @@ +
+ + + +
+
-
-
- -

- {m.enable_user_signups_description()} -

-
- - ($inputs.allowUserSignups.value = v as typeof $inputs.allowUserSignups.value)} - > - - {signupOptions[$inputs.allowUserSignups.value]?.label} - - - -
- {signupOptions.disabled.label} - - {signupOptions.disabled.description} - -
-
- -
- {signupOptions.withToken.label} - - {signupOptions.withToken.description} - -
-
- -
- {signupOptions.open.label} - - {signupOptions.open.description} - -
-
-
-
-
+ import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte'; + import SearchableMultiSelect from '$lib/components/form/searchable-multi-select.svelte'; + import { Button } from '$lib/components/ui/button'; + import { Label } from '$lib/components/ui/label'; + import * as Select from '$lib/components/ui/select'; + import { m } from '$lib/paraglide/messages'; + import UserGroupService from '$lib/services/user-group-service'; + import type { AllAppConfig } from '$lib/types/application-configuration'; + import { debounced } from '$lib/utils/debounce-util'; + import { preventDefault } from '$lib/utils/event-util'; + import { onMount } from 'svelte'; + import { toast } from 'svelte-sonner'; + + let { + appConfig, + callback + }: { + appConfig: AllAppConfig; + callback: (updatedConfig: Partial) => Promise; + } = $props(); + + const userGroupService = new UserGroupService(); + + let userGroups = $state<{ value: string; label: string }[]>([]); + let selectedGroups = $state<{ value: string; label: string }[]>([]); + let customClaims = $state(appConfig.signupDefaultCustomClaims || []); + let allowUserSignups = $state(appConfig.allowUserSignups); + let isLoading = $state(false); + let isUserSearchLoading = $state(false); + + const signupOptions = { + disabled: { + label: m.disabled(), + description: m.signup_disabled_description() + }, + withToken: { + label: m.signup_with_token(), + description: m.signup_with_token_description() + }, + open: { + label: m.signup_open(), + description: m.signup_open_description() + } + }; + + async function loadUserGroups(search?: string) { + userGroups = (await userGroupService.list({ search })).data.map((group) => ({ + value: group.id, + label: group.name + })); + + // Ensure selected groups are still in the list + for (const selectedGroup of selectedGroups) { + if (!userGroups.some((g) => g.value === selectedGroup.value)) { + userGroups.push(selectedGroup); + } + } + } + + async function loadSelectedGroups() { + selectedGroups = ( + await Promise.all( + appConfig.signupDefaultUserGroupIDs.map((groupId) => userGroupService.get(groupId)) + ) + ).map((group) => ({ + value: group.id, + label: group.name + })); + } + + const onUserGroupSearch = debounced( + async (search: string) => await loadUserGroups(search), + 300, + (loading) => (isUserSearchLoading = loading) + ); + + async function onSubmit() { + isLoading = true; + await callback({ + allowUserSignups: allowUserSignups, + signupDefaultUserGroupIDs: selectedGroups.map((g) => g.value), + signupDefaultCustomClaims: customClaims + }); + toast.success(m.user_creation_updated_successfully()); + isLoading = false; + } + + $effect(() => { + loadSelectedGroups(); + customClaims = appConfig.signupDefaultCustomClaims || []; + allowUserSignups = appConfig.allowUserSignups; + }); + + onMount(() => loadUserGroups()); + + +
+
+
+ +

+ {m.enable_user_signups_description()} +

+
+ (allowUserSignups = v as typeof allowUserSignups)} + > + + {signupOptions[allowUserSignups]?.label} + + + +
+ {signupOptions.disabled.label} + + {signupOptions.disabled.description} + +
+
+ +
+ {signupOptions.withToken.label} + + {signupOptions.withToken.description} + +
+
+ +
+ {signupOptions.open.label} + + {signupOptions.open.description} + +
+
+
+
+
+ +
+ +

+ {m.user_creation_groups_description()} +

+ onUserGroupSearch(e.currentTarget.value)} + selectedItems={selectedGroups.map((g) => g.value)} + onSelect={(selected) => { + selectedGroups = userGroups.filter((g) => selected.includes(g.value)); + }} + isLoading={isUserSearchLoading} + disableInternalSearch + /> +
+
+ +

+ {m.user_creation_claims_description()} +

+ +
+ +
+ +
+
diff --git a/tests/specs/application-configuration.spec.ts b/tests/specs/application-configuration.spec.ts index 94f46d1d..54c5b623 100644 --- a/tests/specs/application-configuration.spec.ts +++ b/tests/specs/application-configuration.spec.ts @@ -1,11 +1,12 @@ -import test, { expect } from '@playwright/test'; +import { expect, test } from '@playwright/test'; import { cleanupBackend } from '../utils/cleanup.util'; -test.beforeEach(async () => await cleanupBackend()); +test.beforeEach(async ({ page }) => { + await cleanupBackend(); + await page.goto('/settings/admin/application-configuration'); +}); test('Update general configuration', async ({ page }) => { - await page.goto('/settings/admin/application-configuration'); - await page.getByLabel('Application Name', { exact: true }).fill('Updated Name'); await page.getByLabel('Session Duration').fill('30'); await page.getByRole('button', { name: 'Save' }).first().click(); @@ -21,10 +22,70 @@ test('Update general configuration', async ({ page }) => { await expect(page.getByLabel('Session Duration')).toHaveValue('30'); }); -test('Update email configuration', async ({ page }) => { - await page.goto('/settings/admin/application-configuration'); +test.describe('Update user creation configuration', () => { + test.beforeEach(async ({ page }) => { + await page.getByRole('button', { name: 'Expand card' }).nth(1).click(); + }); - await page.getByRole('button', { name: 'Expand card' }).nth(1).click(); + test('should save sign up mode', async ({ page }) => { + await page.getByRole('button', { name: 'Enable User Signups' }).click(); + await page.getByRole('option', { name: 'Open Signup' }).click(); + + await page.getByRole('button', { name: 'Save' }).nth(1).click(); + + await expect(page.locator('[data-type="success"]').last()).toHaveText( + 'User creation settings updated successfully.' + ); + + await page.reload(); + + await expect(page.getByRole('button', { name: 'Enable User Signups' })).toBeVisible(); + }); + + test('should save default user groups for new signups', async ({ page }) => { + await page.getByRole('combobox', { name: 'User Groups' }).click(); + await page.getByRole('option', { name: 'Developers' }).click(); + await page.getByRole('option', { name: 'Designers' }).click(); + + await page.getByRole('button', { name: 'Save' }).nth(1).click(); + + await expect(page.locator('[data-type="success"]').last()).toHaveText( + 'User creation settings updated successfully.' + ); + + await page.reload(); + + await page.getByRole('combobox', { name: 'User Groups' }).click(); + + await expect(page.getByRole('option', { name: 'Developers' })).toBeChecked(); + await expect(page.getByRole('option', { name: 'Designers' })).toBeChecked(); + }); + + test('should save default custom claims for new signups', async ({ page }) => { + await page.getByRole('button', { name: 'Add custom claim' }).click(); + await page.getByPlaceholder('Key').fill('test-claim'); + await page.getByPlaceholder('Value').fill('test-value'); + await page.getByRole('button', { name: 'Add another' }).click(); + await page.getByPlaceholder('Key').nth(1).fill('another-claim'); + await page.getByPlaceholder('Value').nth(1).fill('another-value'); + + await page.getByRole('button', { name: 'Save' }).nth(1).click(); + + await expect(page.locator('[data-type="success"]').last()).toHaveText( + 'User creation settings updated successfully.' + ); + + await page.reload(); + + await expect(page.getByPlaceholder('Key').first()).toHaveValue('test-claim'); + await expect(page.getByPlaceholder('Value').first()).toHaveValue('test-value'); + await expect(page.getByPlaceholder('Key').nth(1)).toHaveValue('another-claim'); + await expect(page.getByPlaceholder('Value').nth(1)).toHaveValue('another-value'); + }); +}); + +test('Update email configuration', async ({ page }) => { + await page.getByRole('button', { name: 'Expand card' }).nth(2).click(); await page.getByLabel('SMTP Host').fill('smtp.gmail.com'); await page.getByLabel('SMTP Port').fill('587'); @@ -56,15 +117,13 @@ test('Update email configuration', async ({ page }) => { }); test('Update application images', async ({ page }) => { - await page.goto('/settings/admin/application-configuration'); - - await page.getByRole('button', { name: 'Expand card' }).nth(3).click(); + await page.getByRole('button', { name: 'Expand card' }).nth(4).click(); await page.getByLabel('Favicon').setInputFiles('assets/w3-schools-favicon.ico'); await page.getByLabel('Light Mode Logo').setInputFiles('assets/pingvin-share-logo.png'); await page.getByLabel('Dark Mode Logo').setInputFiles('assets/nextcloud-logo.png'); await page.getByLabel('Background Image').setInputFiles('assets/clouds.jpg'); - await page.getByRole('button', { name: 'Save' }).nth(1).click(); + await page.getByRole('button', { name: 'Save' }).last().click(); await expect(page.locator('[data-type="success"]')).toHaveText('Images updated successfully'); diff --git a/tests/specs/ldap.spec.ts b/tests/specs/ldap.spec.ts index 21ac47da..b1445632 100644 --- a/tests/specs/ldap.spec.ts +++ b/tests/specs/ldap.spec.ts @@ -12,7 +12,7 @@ test.describe('LDAP Integration', () => { test('LDAP configuration is working properly', async ({ page }) => { await page.goto('/settings/admin/application-configuration'); - await page.getByRole('button', { name: 'Expand card' }).nth(2).click(); + await page.getByRole('button', { name: 'Expand card' }).nth(3).click(); await expect(page.getByRole('button', { name: 'Disable', exact: true })).toBeVisible(); await expect(page.getByLabel('LDAP URL')).toHaveValue(/ldap:\/\/.*/); diff --git a/tests/specs/user-group.spec.ts b/tests/specs/user-group.spec.ts index 866b5442..fc3d3bbc 100644 --- a/tests/specs/user-group.spec.ts +++ b/tests/specs/user-group.spec.ts @@ -96,6 +96,7 @@ test('Update user group custom claims', async ({ page }) => { ); await page.reload(); + await page.waitForLoadState('networkidle'); // Check if custom claims are saved await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim1'); @@ -107,7 +108,12 @@ test('Update user group custom claims', async ({ page }) => { await page.getByLabel('Remove custom claim').first().click(); await page.getByRole('button', { name: 'Save' }).nth(2).click(); + await expect(page.locator('[data-type="success"]')).toHaveText( + 'Custom claims updated successfully' + ); + await page.reload(); + await page.waitForLoadState('networkidle'); // Check if custom claim is removed await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim2'); diff --git a/tests/specs/user-signup.spec.ts b/tests/specs/user-signup.spec.ts index 1dcd0315..a86a5848 100644 --- a/tests/specs/user-signup.spec.ts +++ b/tests/specs/user-signup.spec.ts @@ -1,215 +1,213 @@ -import test, { expect } from '@playwright/test'; -import { signupTokens, users } from 'data'; +import test, { expect, type Page } from '@playwright/test'; +import { signupTokens, users } from '../data'; import { cleanupBackend } from '../utils/cleanup.util'; import passkeyUtil from '../utils/passkey.util'; -test.beforeEach(async () => await cleanupBackend()); +async function setSignupMode(page: Page, mode: 'Disabled' | 'Signup with token' | 'Open Signup') { + await page.goto('/settings/admin/application-configuration'); -test.describe('User Signup', () => { - async function setSignupMode(page: any, mode: 'Disabled' | 'Signup with token' | 'Open Signup') { - await page.goto('/settings/admin/application-configuration'); + await page.getByRole('button', { name: 'Expand card' }).nth(1).click(); + await page.getByRole('button', { name: 'Enable User Signups' }).click(); + await page.getByRole('option', { name: mode }).click(); + await page.getByRole('button', { name: 'Save' }).nth(1).click(); - await page.getByLabel('Enable user signups').click(); - await page.getByRole('option', { name: mode }).click(); + await expect(page.locator('[data-type="success"]').last()).toHaveText( + 'User creation settings updated successfully.' + ); - await page.getByRole('button', { name: 'Save' }).first().click(); - await expect(page.locator('[data-type="success"]')).toHaveText( - 'Application configuration updated successfully' - ); - await page.waitForLoadState('networkidle'); - - await page.context().clearCookies(); - await page.goto('/login'); - } - - test('Signup is disabled - shows error message', async ({ page }) => { - await setSignupMode(page, 'Disabled'); - - await page.goto('/signup'); - - await expect(page.getByText('User signups are currently disabled')).toBeVisible(); - }); - - test('Signup with token - success flow', async ({ page }) => { - await setSignupMode(page, 'Signup with token'); - - await page.goto(`/st/${signupTokens.valid.token}`); - - await page.getByLabel('First name').fill('John'); - await page.getByLabel('Last name').fill('Doe'); - await page.getByLabel('Username').fill('johndoe'); - await page.getByLabel('Email').fill('john.doe@test.com'); - - await page.getByRole('button', { name: 'Sign Up' }).click(); - - await page.waitForURL('/signup/add-passkey'); - await expect(page.getByText('Set up your passkey')).toBeVisible(); - }); - - test('Signup with token - invalid token shows error', async ({ page }) => { - await setSignupMode(page, 'Signup with token'); - - await page.goto('/st/invalid-token-123'); - await page.getByLabel('First name').fill('Complete'); - await page.getByLabel('Last name').fill('User'); - await page.getByLabel('Username').fill('completeuser'); - await page.getByLabel('Email').fill('complete.user@test.com'); - await page.getByRole('button', { name: 'Sign Up' }).click(); - - await expect(page.getByText('Token is invalid or expired.')).toBeVisible(); - }); - - test('Signup with token - no token in URL shows error', async ({ page }) => { - await setSignupMode(page, 'Signup with token'); - - await page.goto('/signup'); - - await expect( - page.getByText('A valid signup token is required to create an account.') - ).toBeVisible(); - }); - - test('Open signup - success flow', async ({ page }) => { - await setSignupMode(page, 'Open Signup'); - - await page.goto('/signup'); - - await expect(page.getByText('Create your account to get started')).toBeVisible(); - - await page.getByLabel('First name').fill('Jane'); - await page.getByLabel('Last name').fill('Smith'); - await page.getByLabel('Username').fill('janesmith'); - await page.getByLabel('Email').fill('jane.smith@test.com'); - - await page.getByRole('button', { name: 'Sign Up' }).click(); - - await page.waitForURL('/signup/add-passkey'); - await expect(page.getByText('Set up your passkey')).toBeVisible(); - }); - - test('Open signup - validation errors', async ({ page }) => { - await setSignupMode(page, 'Open Signup'); - - await page.goto('/signup'); - - await page.getByRole('button', { name: 'Sign Up' }).click(); - - await expect(page.getByText('Invalid input').first()).toBeVisible(); - }); - - test('Open signup - duplicate email shows error', async ({ page }) => { - await setSignupMode(page, 'Open Signup'); - - await page.goto('/signup'); - - await page.getByLabel('First name').fill('Test'); - await page.getByLabel('Last name').fill('User'); - await page.getByLabel('Username').fill('testuser123'); - await page.getByLabel('Email').fill(users.tim.email); - - await page.getByRole('button', { name: 'Sign Up' }).click(); - - await expect(page.getByText('Email is already in use.')).toBeVisible(); - }); - - test('Open signup - duplicate username shows error', async ({ page }) => { - await setSignupMode(page, 'Open Signup'); - - await page.goto('/signup'); - - await page.getByLabel('First name').fill('Test'); - await page.getByLabel('Last name').fill('User'); - await page.getByLabel('Username').fill(users.tim.username); - await page.getByLabel('Email').fill('newuser@test.com'); - - await page.getByRole('button', { name: 'Sign Up' }).click(); - - await expect(page.getByText('Username is already in use.')).toBeVisible(); - }); - - test('Complete signup flow with passkey creation', async ({ page }) => { - await setSignupMode(page, 'Open Signup'); - - await page.goto('/signup'); - await page.getByLabel('First name').fill('Complete'); - await page.getByLabel('Last name').fill('User'); - await page.getByLabel('Username').fill('completeuser'); - await page.getByLabel('Email').fill('complete.user@test.com'); - await page.getByRole('button', { name: 'Sign Up' }).click(); - - await page.waitForURL('/signup/add-passkey'); - - await (await passkeyUtil.init(page)).addPasskey('timNew'); - await page.getByRole('button', { name: 'Add Passkey' }).click(); - - await page.waitForURL('/settings/account'); - await expect(page.getByText('Single Passkey Configured')).toBeVisible(); - }); - - test('Skip passkey creation during signup', async ({ page }) => { - await setSignupMode(page, 'Open Signup'); - - await page.goto('/signup'); - await page.getByLabel('First name').fill('Skip'); - await page.getByLabel('Last name').fill('User'); - await page.getByLabel('Username').fill('skipuser'); - await page.getByLabel('Email').fill('skip.user@test.com'); - await page.getByRole('button', { name: 'Sign Up' }).click(); - - await page.waitForURL('/signup/add-passkey'); - - await page.getByRole('button', { name: 'Skip for now' }).click(); - - await expect(page.getByText('Skip Passkey Setup')).toBeVisible(); - await page.getByRole('button', { name: 'Skip for now' }).nth(1).click(); - - await page.waitForURL('/settings/account'); - await expect(page.getByText('Passkey missing')).toBeVisible(); - }); - - test('Token usage limit is enforced', async ({ page }) => { - await setSignupMode(page, 'Signup with token'); - - await page.goto(`/st/${signupTokens.fullyUsed.token}`); - await page.getByLabel('First name').fill('Complete'); - await page.getByLabel('Last name').fill('User'); - await page.getByLabel('Username').fill('completeuser'); - await page.getByLabel('Email').fill('complete.user@test.com'); - await page.getByRole('button', { name: 'Sign Up' }).click(); - - await expect(page.getByText('Token is invalid or expired.')).toBeVisible(); - }); -}); + await page.context().clearCookies(); + await page.goto('/login'); +} test.describe('Initial User Signup', () => { test.beforeEach(async ({ page }) => { await page.context().clearCookies(); }); + test('Initial Signup - success flow', async ({ page }) => { await cleanupBackend(true); await page.goto('/setup'); - await page.getByLabel('First name').fill('Jane'); await page.getByLabel('Last name').fill('Smith'); await page.getByLabel('Username').fill('janesmith'); await page.getByLabel('Email').fill('jane.smith@test.com'); - await page.getByRole('button', { name: 'Sign Up' }).click(); - await page.waitForURL('/signup/add-passkey'); await expect(page.getByText('Set up your passkey')).toBeVisible(); }); test('Initial Signup - setup already completed', async ({ page }) => { + await cleanupBackend(); await page.goto('/setup'); - await page.getByLabel('First name').fill('Test'); await page.getByLabel('Last name').fill('User'); await page.getByLabel('Username').fill('testuser123'); await page.getByLabel('Email').fill(users.tim.email); - await page.getByRole('button', { name: 'Sign Up' }).click(); - await expect(page.getByText('Setup already completed')).toBeVisible(); }); }); + +test.describe('User Signup', () => { + test.beforeEach(async () => await cleanupBackend()); + + test.describe('Signup Flows', () => { + test('Signup is disabled - shows error message', async ({ page }) => { + await setSignupMode(page, 'Disabled'); + + await page.goto('/signup'); + + await expect(page.getByText('User signups are currently disabled')).toBeVisible(); + }); + + test('Signup with token - success flow', async ({ page }) => { + await setSignupMode(page, 'Signup with token'); + + await page.goto(`/st/${signupTokens.valid.token}`); + + await page.getByLabel('First name').fill('John'); + await page.getByLabel('Last name').fill('Doe'); + await page.getByLabel('Username').fill('johndoe'); + await page.getByLabel('Email').fill('john.doe@test.com'); + + await page.getByRole('button', { name: 'Sign Up' }).click(); + + await page.waitForURL('/signup/add-passkey'); + await expect(page.getByText('Set up your passkey')).toBeVisible(); + }); + + test('Signup with token - invalid token shows error', async ({ page }) => { + await setSignupMode(page, 'Signup with token'); + + await page.goto('/st/invalid-token-123'); + await page.getByLabel('First name').fill('Complete'); + await page.getByLabel('Last name').fill('User'); + await page.getByLabel('Username').fill('completeuser'); + await page.getByLabel('Email').fill('complete.user@test.com'); + await page.getByRole('button', { name: 'Sign Up' }).click(); + + await expect(page.getByText('Token is invalid or expired.')).toBeVisible(); + }); + + test('Signup with token - no token in URL shows error', async ({ page }) => { + await setSignupMode(page, 'Signup with token'); + + await page.goto('/signup'); + + await expect( + page.getByText('A valid signup token is required to create an account.') + ).toBeVisible(); + }); + + test('Open signup - success flow', async ({ page }) => { + await setSignupMode(page, 'Open Signup'); + + await page.goto('/signup'); + + await expect(page.getByText('Create your account to get started')).toBeVisible(); + + await page.getByLabel('First name').fill('Jane'); + await page.getByLabel('Last name').fill('Smith'); + await page.getByLabel('Username').fill('janesmith'); + await page.getByLabel('Email').fill('jane.smith@test.com'); + + await page.getByRole('button', { name: 'Sign Up' }).click(); + + await page.waitForURL('/signup/add-passkey'); + await expect(page.getByText('Set up your passkey')).toBeVisible(); + }); + + test('Open signup - validation errors', async ({ page }) => { + await setSignupMode(page, 'Open Signup'); + + await page.goto('/signup'); + + await page.getByRole('button', { name: 'Sign Up' }).click(); + + await expect(page.getByText('Invalid input').first()).toBeVisible(); + }); + + test('Open signup - duplicate email shows error', async ({ page }) => { + await setSignupMode(page, 'Open Signup'); + + await page.goto('/signup'); + + await page.getByLabel('First name').fill('Test'); + await page.getByLabel('Last name').fill('User'); + await page.getByLabel('Username').fill('testuser123'); + await page.getByLabel('Email').fill(users.tim.email); + + await page.getByRole('button', { name: 'Sign Up' }).click(); + + await expect(page.getByText('Email is already in use.')).toBeVisible(); + }); + + test('Open signup - duplicate username shows error', async ({ page }) => { + await setSignupMode(page, 'Open Signup'); + + await page.goto('/signup'); + + await page.getByLabel('First name').fill('Test'); + await page.getByLabel('Last name').fill('User'); + await page.getByLabel('Username').fill(users.tim.username); + await page.getByLabel('Email').fill('newuser@test.com'); + + await page.getByRole('button', { name: 'Sign Up' }).click(); + + await expect(page.getByText('Username is already in use.')).toBeVisible(); + }); + + test('Complete signup flow with passkey creation', async ({ page }) => { + await setSignupMode(page, 'Open Signup'); + + await page.goto('/signup'); + await page.getByLabel('First name').fill('Complete'); + await page.getByLabel('Last name').fill('User'); + await page.getByLabel('Username').fill('completeuser'); + await page.getByLabel('Email').fill('complete.user@test.com'); + await page.getByRole('button', { name: 'Sign Up' }).click(); + + await page.waitForURL('/signup/add-passkey'); + + await (await passkeyUtil.init(page)).addPasskey('timNew'); + await page.getByRole('button', { name: 'Add Passkey' }).click(); + + await page.waitForURL('/settings/account'); + await expect(page.getByText('Single Passkey Configured')).toBeVisible(); + }); + + test('Skip passkey creation during signup', async ({ page }) => { + await setSignupMode(page, 'Open Signup'); + + await page.goto('/signup'); + await page.getByLabel('First name').fill('Skip'); + await page.getByLabel('Last name').fill('User'); + await page.getByLabel('Username').fill('skipuser'); + await page.getByLabel('Email').fill('skip.user@test.com'); + await page.getByRole('button', { name: 'Sign Up' }).click(); + + await page.waitForURL('/signup/add-passkey'); + + await page.getByRole('button', { name: 'Skip for now' }).click(); + + await expect(page.getByText('Skip Passkey Setup')).toBeVisible(); + await page.getByRole('button', { name: 'Skip for now' }).nth(1).click(); + + await page.waitForURL('/settings/account'); + await expect(page.getByText('Passkey missing')).toBeVisible(); + }); + + test('Token usage limit is enforced', async ({ page }) => { + await setSignupMode(page, 'Signup with token'); + + await page.goto(`/st/${signupTokens.fullyUsed.token}`); + await page.getByLabel('First name').fill('Complete'); + await page.getByLabel('Last name').fill('User'); + await page.getByLabel('Username').fill('completeuser'); + await page.getByLabel('Email').fill('complete.user@test.com'); + await page.getByRole('button', { name: 'Sign Up' }).click(); + + await expect(page.getByText('Token is invalid or expired.')).toBeVisible(); + }); + }); +});