mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-18 01:11:26 +03:00
feat(signup): add default user groups and claims for new users (#812)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us> Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
140
frontend/src/lib/components/form/searchable-multi-select.svelte
Normal file
140
frontend/src/lib/components/form/searchable-multi-select.svelte
Normal file
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Command from '$lib/components/ui/command';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { cn } from '$lib/utils/style';
|
||||
import { LoaderCircle, LucideCheck, LucideChevronDown } from '@lucide/svelte';
|
||||
import type { FormEventHandler } from 'svelte/elements';
|
||||
|
||||
type Item = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
let {
|
||||
items,
|
||||
selectedItems = $bindable(),
|
||||
onSelect,
|
||||
oninput,
|
||||
isLoading = false,
|
||||
placeholder = 'Select items...',
|
||||
searchText = 'Search...',
|
||||
noItemsText = 'No items found.',
|
||||
disableInternalSearch = false,
|
||||
id
|
||||
}: {
|
||||
items: Item[];
|
||||
selectedItems: string[];
|
||||
onSelect?: (value: string[]) => void;
|
||||
oninput?: FormEventHandler<HTMLInputElement>;
|
||||
isLoading?: boolean;
|
||||
placeholder?: string;
|
||||
searchText?: string;
|
||||
noItemsText?: string;
|
||||
disableInternalSearch?: boolean;
|
||||
id?: string;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let searchValue = $state('');
|
||||
let filteredItems = $state(items);
|
||||
|
||||
const selectedLabels = $derived(
|
||||
items.filter((item) => selectedItems.includes(item.value)).map((item) => item.label)
|
||||
);
|
||||
|
||||
function handleItemSelect(value: string) {
|
||||
let newSelectedItems: string[];
|
||||
if (selectedItems.includes(value)) {
|
||||
newSelectedItems = selectedItems.filter((item) => item !== value);
|
||||
} else {
|
||||
newSelectedItems = [...selectedItems, value];
|
||||
}
|
||||
selectedItems = newSelectedItems;
|
||||
onSelect?.(newSelectedItems);
|
||||
}
|
||||
|
||||
function filterItems(search: string) {
|
||||
if (disableInternalSearch) return;
|
||||
searchValue = search;
|
||||
if (!search) {
|
||||
filteredItems = items;
|
||||
} else {
|
||||
filteredItems = items.filter((item) =>
|
||||
item.label.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset search value when the popover is closed
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
filterItems('');
|
||||
}
|
||||
|
||||
filteredItems = items;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger {id}>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
class="h-auto min-h-10 w-full justify-between"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
{#if selectedItems.length > 0}
|
||||
{#each selectedLabels as label}
|
||||
<Badge variant="secondary">{label}</Badge>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="text-muted-foreground font-normal">{placeholder}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<LucideChevronDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="p-0" sameWidth>
|
||||
<Command.Root shouldFilter={false}>
|
||||
<Command.Input
|
||||
placeholder={searchText}
|
||||
value={searchValue}
|
||||
oninput={(e) => {
|
||||
filterItems(e.currentTarget.value);
|
||||
oninput?.(e);
|
||||
}}
|
||||
/>
|
||||
<Command.Empty>
|
||||
{#if isLoading}
|
||||
<div class="flex w-full items-center justify-center py-2">
|
||||
<LoaderCircle class="size-4 animate-spin" />
|
||||
</div>
|
||||
{:else}
|
||||
{noItemsText}
|
||||
{/if}
|
||||
</Command.Empty>
|
||||
<Command.Group class="max-h-60 overflow-y-auto">
|
||||
{#each filteredItems as item}
|
||||
<Command.Item
|
||||
aria-checked={selectedItems.includes(item.value)}
|
||||
value={item.value}
|
||||
onSelect={() => {
|
||||
handleItemSelect(item.value);
|
||||
}}
|
||||
>
|
||||
<LucideCheck
|
||||
class={cn('mr-2 size-4', !selectedItems.includes(item.value) && 'text-transparent')}
|
||||
/>
|
||||
{item.label}
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Command.Group>
|
||||
</Command.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -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<string, string> = {};
|
||||
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') {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 @@
|
||||
</CollapsibleCard>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<CollapsibleCard
|
||||
id="application-configuration-signup-defaults"
|
||||
icon={Users}
|
||||
title={m.user_creation()}
|
||||
description={m.configure_user_creation()}
|
||||
>
|
||||
<AppConfigSignupDefaultsForm {appConfig} callback={updateAppConfig} />
|
||||
</CollapsibleCard>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<CollapsibleCard
|
||||
id="application-configuration-email"
|
||||
|
||||
@@ -23,27 +23,11 @@
|
||||
|
||||
let isLoading = $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()
|
||||
}
|
||||
};
|
||||
|
||||
const updatedAppConfig = {
|
||||
appName: appConfig.appName,
|
||||
sessionDuration: appConfig.sessionDuration,
|
||||
emailsVerified: appConfig.emailsVerified,
|
||||
allowOwnAccountEdit: appConfig.allowOwnAccountEdit,
|
||||
allowUserSignups: appConfig.allowUserSignups,
|
||||
disableAnimations: appConfig.disableAnimations,
|
||||
accentColor: appConfig.accentColor
|
||||
};
|
||||
@@ -53,7 +37,6 @@
|
||||
sessionDuration: z.number().min(1).max(43200),
|
||||
emailsVerified: z.boolean(),
|
||||
allowOwnAccountEdit: z.boolean(),
|
||||
allowUserSignups: z.enum(['disabled', 'withToken', 'open']),
|
||||
disableAnimations: z.boolean(),
|
||||
accentColor: z.string()
|
||||
});
|
||||
@@ -80,55 +63,6 @@
|
||||
description={m.the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again()}
|
||||
bind:input={$inputs.sessionDuration}
|
||||
/>
|
||||
<div class="grid gap-2">
|
||||
<div>
|
||||
<Label class="mb-0" for="enable-user-signup">{m.enable_user_signups()}</Label>
|
||||
<p class="text-muted-foreground text-[0.8rem]">
|
||||
{m.enable_user_signups_description()}
|
||||
</p>
|
||||
</div>
|
||||
<Select.Root
|
||||
disabled={$appConfigStore.uiConfigDisabled}
|
||||
type="single"
|
||||
value={$inputs.allowUserSignups.value}
|
||||
onValueChange={(v) =>
|
||||
($inputs.allowUserSignups.value = v as typeof $inputs.allowUserSignups.value)}
|
||||
>
|
||||
<Select.Trigger
|
||||
class="w-full"
|
||||
aria-label={m.enable_user_signups()}
|
||||
placeholder={m.enable_user_signups()}
|
||||
>
|
||||
{signupOptions[$inputs.allowUserSignups.value]?.label}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="disabled">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span class="font-medium">{signupOptions.disabled.label}</span>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{signupOptions.disabled.description}
|
||||
</span>
|
||||
</div>
|
||||
</Select.Item>
|
||||
<Select.Item value="withToken">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span class="font-medium">{signupOptions.withToken.label}</span>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{signupOptions.withToken.description}
|
||||
</span>
|
||||
</div>
|
||||
</Select.Item>
|
||||
<Select.Item value="open">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span class="font-medium">{signupOptions.open.label}</span>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{signupOptions.open.description}
|
||||
</span>
|
||||
</div>
|
||||
</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<SwitchWithLabel
|
||||
id="self-account-editing"
|
||||
label={m.enable_self_account_editing()}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<script lang="ts">
|
||||
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<AllAppConfig>) => Promise<void>;
|
||||
} = $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());
|
||||
</script>
|
||||
|
||||
<form class="space-y-6" onsubmit={preventDefault(onSubmit)}>
|
||||
<div class="grid gap-2">
|
||||
<div>
|
||||
<Label class="mb-0" for="enable-user-signup">{m.enable_user_signups()}</Label>
|
||||
<p class="text-muted-foreground text-[0.8rem]">
|
||||
{m.enable_user_signups_description()}
|
||||
</p>
|
||||
</div>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={allowUserSignups}
|
||||
onValueChange={(v) => (allowUserSignups = v as typeof allowUserSignups)}
|
||||
>
|
||||
<Select.Trigger
|
||||
id="enable-user-signup"
|
||||
class="w-full"
|
||||
aria-label={m.enable_user_signups()}
|
||||
placeholder={m.enable_user_signups()}
|
||||
>
|
||||
{signupOptions[allowUserSignups]?.label}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="disabled">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span class="font-medium">{signupOptions.disabled.label}</span>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{signupOptions.disabled.description}
|
||||
</span>
|
||||
</div>
|
||||
</Select.Item>
|
||||
<Select.Item value="withToken">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span class="font-medium">{signupOptions.withToken.label}</span>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{signupOptions.withToken.description}
|
||||
</span>
|
||||
</div>
|
||||
</Select.Item>
|
||||
<Select.Item value="open">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span class="font-medium">{signupOptions.open.label}</span>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{signupOptions.open.description}
|
||||
</span>
|
||||
</div>
|
||||
</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="default-groups" class="mb-0">{m.user_groups()}</Label>
|
||||
<p class="text-muted-foreground mt-1 mb-2 text-xs">
|
||||
{m.user_creation_groups_description()}
|
||||
</p>
|
||||
<SearchableMultiSelect
|
||||
id="default-groups"
|
||||
items={userGroups}
|
||||
oninput={(e) => onUserGroupSearch(e.currentTarget.value)}
|
||||
selectedItems={selectedGroups.map((g) => g.value)}
|
||||
onSelect={(selected) => {
|
||||
selectedGroups = userGroups.filter((g) => selected.includes(g.value));
|
||||
}}
|
||||
isLoading={isUserSearchLoading}
|
||||
disableInternalSearch
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="mb-0">{m.custom_claims()}</Label>
|
||||
<p class="text-muted-foreground mt-1 mb-2 text-xs">
|
||||
{m.user_creation_claims_description()}
|
||||
</p>
|
||||
<CustomClaimsInput bind:customClaims />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-2">
|
||||
<Button {isLoading} type="submit">{m.save()}</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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:\/\/.*/);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user