mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-24 22:13:53 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd9b1d26ea | ||
|
|
4b829757b2 | ||
|
|
b5b01cb6dd | ||
|
|
287314f016 | ||
|
|
73e7e0b1c5 | ||
|
|
d070b9a778 | ||
|
|
d976bf5965 | ||
|
|
052ac008c3 | ||
|
|
57a2b2bc83 | ||
|
|
043f82ad79 | ||
|
|
ba61cdba4e | ||
|
|
dcd1ae96e0 | ||
|
|
1fdb058386 | ||
|
|
29cb5513a0 | ||
|
|
6db57d9f27 | ||
|
|
1a77bd9914 | ||
|
|
350335711b | ||
|
|
988c425150 | ||
|
|
23827ba1d1 | ||
|
|
7d36bda769 | ||
|
|
8c559ea067 | ||
|
|
88832d4bc9 |
27
CHANGELOG.md
27
CHANGELOG.md
@@ -1,3 +1,30 @@
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v1.4.1...v) (2025-06-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* improve initial admin creation workflow ([287314f](https://github.com/pocket-id/pocket-id/commit/287314f01644e42ddb2ce1b1115bd14f2f0c1768))
|
||||
* redact sensitive app config variables if set with env variable ([ba61cdb](https://github.com/pocket-id/pocket-id/commit/ba61cdba4eb3d5659f3ae6b6c21249985c0aa630))
|
||||
* self-service user signup ([#672](https://github.com/pocket-id/pocket-id/issues/672)) ([dcd1ae9](https://github.com/pocket-id/pocket-id/commit/dcd1ae96e048115be34b0cce275054e990462ebf))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* double double full stops for certain error messages ([d070b9a](https://github.com/pocket-id/pocket-id/commit/d070b9a778d7d1a51f2fa62d003f2331a96d6c91))
|
||||
* error page flickering after sign out ([1a77bd9](https://github.com/pocket-id/pocket-id/commit/1a77bd9914ea01e445ff3d6e116c9ed3bcfbf153))
|
||||
* improve accent color picker disabled state ([d976bf5](https://github.com/pocket-id/pocket-id/commit/d976bf5965eda10e3ecb71821c23e93e5d712a02))
|
||||
* less noisy logging for certain GET requests ([#681](https://github.com/pocket-id/pocket-id/issues/681)) ([043f82a](https://github.com/pocket-id/pocket-id/commit/043f82ad794eb64a5550d8b80703114a055701d9))
|
||||
* margin of user sign up description ([052ac00](https://github.com/pocket-id/pocket-id/commit/052ac008c3a8c910d1ce79ee99b2b2f75e4090f4))
|
||||
* remove duplicate request logging ([#678](https://github.com/pocket-id/pocket-id/issues/678)) ([988c425](https://github.com/pocket-id/pocket-id/commit/988c425150556b32cff1d341a21fcc9c69d9aaf8))
|
||||
* users can't be updated by admin if self account editing is disabled ([29cb551](https://github.com/pocket-id/pocket-id/commit/29cb5513a03d1a9571969c8a42deec9b2bdee037))
|
||||
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v1.4.0...v) (2025-06-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* app not starting if UI config is disabled and Postgres is used ([7d36bda](https://github.com/pocket-id/pocket-id/commit/7d36bda769e25497dec6b76206a4f7e151b0bd72))
|
||||
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v1.3.1...v) (2025-06-19)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/frontend"
|
||||
@@ -47,8 +48,26 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
r.Use(gin.Logger())
|
||||
// do not log these URLs
|
||||
loggerSkipPathsPrefix := []string{
|
||||
"GET /application-configuration/logo",
|
||||
"GET /application-configuration/background-image",
|
||||
"GET /application-configuration/favicon",
|
||||
"GET /_app",
|
||||
"GET /fonts",
|
||||
"GET /healthz",
|
||||
"HEAD /healthz",
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{Skip: func(c *gin.Context) bool {
|
||||
for _, prefix := range loggerSkipPathsPrefix {
|
||||
if strings.HasPrefix(c.Request.Method+" "+c.Request.URL.String(), prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}}))
|
||||
|
||||
if !common.EnvConfig.TrustProxy {
|
||||
_ = r.SetTrustedProxies(nil)
|
||||
|
||||
@@ -349,3 +349,13 @@ func (e *OidcAuthorizationPendingError) Error() string {
|
||||
func (e *OidcAuthorizationPendingError) HttpStatusCode() int {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
type OpenSignupDisabledError struct{}
|
||||
|
||||
func (e *OpenSignupDisabledError) Error() string {
|
||||
return "Open user signup is not enabled"
|
||||
}
|
||||
|
||||
func (e *OpenSignupDisabledError) HttpStatusCode() int {
|
||||
return http.StatusForbidden
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
skipLdap := c.Query("skip-ldap") == "true"
|
||||
skipSeed := c.Query("skip-seed") == "true"
|
||||
|
||||
if err := tc.TestService.ResetDatabase(); err != nil {
|
||||
_ = c.Error(err)
|
||||
@@ -44,9 +45,11 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := tc.TestService.SeedDatabase(baseURL); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if !skipSeed {
|
||||
if err := tc.TestService.SeedDatabase(baseURL); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tc.TestService.ResetAppConfig(c.Request.Context()); err != nil {
|
||||
|
||||
@@ -44,11 +44,17 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
||||
group.POST("/users/:id/one-time-access-token", authMiddleware.Add(), uc.createAdminOneTimeAccessTokenHandler)
|
||||
group.POST("/users/:id/one-time-access-email", authMiddleware.Add(), uc.RequestOneTimeAccessEmailAsAdminHandler)
|
||||
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
|
||||
group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler)
|
||||
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.RequestOneTimeAccessEmailAsUnauthenticatedUserHandler)
|
||||
|
||||
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
|
||||
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
|
||||
|
||||
group.POST("/signup-tokens", authMiddleware.Add(), uc.createSignupTokenHandler)
|
||||
group.GET("/signup-tokens", authMiddleware.Add(), uc.listSignupTokensHandler)
|
||||
group.DELETE("/signup-tokens/:id", authMiddleware.Add(), uc.deleteSignupTokenHandler)
|
||||
group.POST("/signup", rateLimitMiddleware.Add(rate.Every(1*time.Minute), 10), uc.signupHandler)
|
||||
group.POST("/signup/setup", uc.signUpInitialAdmin)
|
||||
|
||||
}
|
||||
|
||||
type UserController struct {
|
||||
@@ -440,14 +446,23 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
}
|
||||
|
||||
// getSetupAccessTokenHandler godoc
|
||||
// @Summary Setup initial admin
|
||||
// @Description Generate setup access token for initial admin user configuration
|
||||
// signUpInitialAdmin godoc
|
||||
// @Summary Sign up initial admin user
|
||||
// @Description Sign up and generate setup access token for initial admin user
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body dto.SignUpDto true "User information"
|
||||
// @Success 200 {object} dto.UserDto
|
||||
// @Router /api/one-time-access-token/setup [post]
|
||||
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
|
||||
user, token, err := uc.userService.SetupInitialAdmin(c.Request.Context())
|
||||
// @Router /api/signup/setup [post]
|
||||
func (uc *UserController) signUpInitialAdmin(c *gin.Context) {
|
||||
var input dto.SignUpDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
user, token, err := uc.userService.SignUpInitialAdmin(c.Request.Context(), input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -495,6 +510,128 @@ func (uc *UserController) updateUserGroups(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
}
|
||||
|
||||
// createSignupTokenHandler godoc
|
||||
// @Summary Create signup token
|
||||
// @Description Create a new signup token that allows user registration
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param token body dto.SignupTokenCreateDto true "Signup token information"
|
||||
// @Success 201 {object} dto.SignupTokenDto
|
||||
// @Router /api/signup-tokens [post]
|
||||
func (uc *UserController) createSignupTokenHandler(c *gin.Context) {
|
||||
var input dto.SignupTokenCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), input.ExpiresAt, input.UsageLimit)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var tokenDto dto.SignupTokenDto
|
||||
if err := dto.MapStruct(signupToken, &tokenDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, tokenDto)
|
||||
}
|
||||
|
||||
// listSignupTokensHandler godoc
|
||||
// @Summary List signup tokens
|
||||
// @Description Get a paginated list of signup tokens
|
||||
// @Tags Users
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.SignupTokenDto]
|
||||
// @Router /api/signup-tokens [get]
|
||||
func (uc *UserController) listSignupTokensHandler(c *gin.Context) {
|
||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
tokens, pagination, err := uc.userService.ListSignupTokens(c.Request.Context(), sortedPaginationRequest)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var tokensDto []dto.SignupTokenDto
|
||||
if err := dto.MapStructList(tokens, &tokensDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[dto.SignupTokenDto]{
|
||||
Data: tokensDto,
|
||||
Pagination: pagination,
|
||||
})
|
||||
}
|
||||
|
||||
// deleteSignupTokenHandler godoc
|
||||
// @Summary Delete signup token
|
||||
// @Description Delete a signup token by ID
|
||||
// @Tags Users
|
||||
// @Param id path string true "Token ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/signup-tokens/{id} [delete]
|
||||
func (uc *UserController) deleteSignupTokenHandler(c *gin.Context) {
|
||||
tokenID := c.Param("id")
|
||||
|
||||
err := uc.userService.DeleteSignupToken(c.Request.Context(), tokenID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// signupWithTokenHandler godoc
|
||||
// @Summary Sign up
|
||||
// @Description Create a new user account
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param user body dto.SignUpDto true "User information"
|
||||
// @Success 201 {object} dto.SignUpDto
|
||||
// @Router /api/signup [post]
|
||||
func (uc *UserController) signupHandler(c *gin.Context) {
|
||||
var input dto.SignUpDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ipAddress := c.ClientIP()
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
|
||||
user, accessToken, err := uc.userService.SignUp(c.Request.Context(), input, ipAddress, userAgent)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
|
||||
cookie.AddAccessTokenCookie(c, maxAge, accessToken)
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, userDto)
|
||||
}
|
||||
|
||||
// updateUser is an internal helper method, not exposed as an API endpoint
|
||||
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||
var input dto.UserCreateDto
|
||||
|
||||
@@ -17,6 +17,7 @@ type AppConfigUpdateDto struct {
|
||||
EmailsVerified string `json:"emailsVerified" binding:"required"`
|
||||
DisableAnimations string `json:"disableAnimations" binding:"required"`
|
||||
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
|
||||
AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"`
|
||||
AccentColor string `json:"accentColor"`
|
||||
SmtpHost string `json:"smtpHost"`
|
||||
SmtpPort string `json:"smtpPort"`
|
||||
|
||||
21
backend/internal/dto/signup_token_dto.go
Normal file
21
backend/internal/dto/signup_token_dto.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type SignupTokenCreateDto struct {
|
||||
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
||||
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
|
||||
}
|
||||
|
||||
type SignupTokenDto struct {
|
||||
ID string `json:"id"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||
UsageLimit int `json:"usageLimit"`
|
||||
UsageCount int `json:"usageCount"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
}
|
||||
@@ -44,3 +44,11 @@ type OneTimeAccessEmailAsAdminDto struct {
|
||||
type UserUpdateUserGroupDto struct {
|
||||
UserGroupIds []string `json:"userGroupIds" binding:"required"`
|
||||
}
|
||||
|
||||
type SignUpDto struct {
|
||||
Username string `json:"username" binding:"required,username,min=2,max=50"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
FirstName string `json:"firstName" binding:"required,min=1,max=50"`
|
||||
LastName string `json:"lastName" binding:"max=50"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
|
||||
return errors.Join(
|
||||
s.registerJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
|
||||
s.registerJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
|
||||
s.registerJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
|
||||
s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
|
||||
s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
|
||||
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
|
||||
@@ -60,6 +61,21 @@ func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearSignupTokens deletes signup tokens that have expired
|
||||
func (j *DbCleanupJobs) clearSignupTokens(ctx context.Context) error {
|
||||
// Delete tokens that are expired OR have reached their usage limit
|
||||
st := j.db.
|
||||
WithContext(ctx).
|
||||
Delete(&model.SignupToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||
if st.Error != nil {
|
||||
return fmt.Errorf("failed to clean expired tokens: %w", st.Error)
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Cleaned expired tokens", slog.Int64("count", st.RowsAffected))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
||||
func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error {
|
||||
st := j.db.
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
type AppConfigVariable struct {
|
||||
@@ -38,6 +40,7 @@ type AppConfig struct {
|
||||
AccentColor AppConfigVariable `key:"accentColor,public"` // Public
|
||||
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
|
||||
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
|
||||
AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public
|
||||
// Internal
|
||||
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
|
||||
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
|
||||
@@ -48,7 +51,7 @@ type AppConfig struct {
|
||||
SmtpPort AppConfigVariable `key:"smtpPort"`
|
||||
SmtpFrom AppConfigVariable `key:"smtpFrom"`
|
||||
SmtpUser AppConfigVariable `key:"smtpUser"`
|
||||
SmtpPassword AppConfigVariable `key:"smtpPassword"`
|
||||
SmtpPassword AppConfigVariable `key:"smtpPassword,sensitive"`
|
||||
SmtpTls AppConfigVariable `key:"smtpTls"`
|
||||
SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"`
|
||||
EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"`
|
||||
@@ -59,7 +62,7 @@ type AppConfig struct {
|
||||
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
|
||||
LdapUrl AppConfigVariable `key:"ldapUrl"`
|
||||
LdapBindDn AppConfigVariable `key:"ldapBindDn"`
|
||||
LdapBindPassword AppConfigVariable `key:"ldapBindPassword"`
|
||||
LdapBindPassword AppConfigVariable `key:"ldapBindPassword,sensitive"`
|
||||
LdapBase AppConfigVariable `key:"ldapBase"`
|
||||
LdapUserSearchFilter AppConfigVariable `key:"ldapUserSearchFilter"`
|
||||
LdapUserGroupSearchFilter AppConfigVariable `key:"ldapUserGroupSearchFilter"`
|
||||
@@ -77,7 +80,7 @@ type AppConfig struct {
|
||||
LdapSoftDeleteUsers AppConfigVariable `key:"ldapSoftDeleteUsers"`
|
||||
}
|
||||
|
||||
func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
|
||||
func (c *AppConfig) ToAppConfigVariableSlice(showAll bool, redactSensitiveValues bool) []AppConfigVariable {
|
||||
// Use reflection to iterate through all fields
|
||||
cfgValue := reflect.ValueOf(c).Elem()
|
||||
cfgType := cfgValue.Type()
|
||||
@@ -97,11 +100,16 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldValue := cfgValue.Field(i)
|
||||
value := cfgValue.Field(i).FieldByName("Value").String()
|
||||
|
||||
// Redact sensitive values if the value isn't empty, the UI config is disabled, and redactSensitiveValues is true
|
||||
if value != "" && common.EnvConfig.UiConfigDisabled && redactSensitiveValues && attrs == "sensitive" {
|
||||
value = "XXXXXXXXXX"
|
||||
}
|
||||
|
||||
appConfigVariable := AppConfigVariable{
|
||||
Key: key,
|
||||
Value: fieldValue.FieldByName("Value").String(),
|
||||
Value: value,
|
||||
}
|
||||
|
||||
res = append(res, appConfigVariable)
|
||||
|
||||
@@ -28,6 +28,7 @@ type AuditLogEvent string //nolint:recvcheck
|
||||
const (
|
||||
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
|
||||
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
|
||||
AuditLogEventAccountCreated AuditLogEvent = "ACCOUNT_CREATED"
|
||||
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
|
||||
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
|
||||
AuditLogEventDeviceCodeAuthorization AuditLogEvent = "DEVICE_CODE_AUTHORIZATION"
|
||||
|
||||
28
backend/internal/model/signup_token.go
Normal file
28
backend/internal/model/signup_token.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type SignupToken struct {
|
||||
Base
|
||||
|
||||
Token string `json:"token"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"`
|
||||
UsageLimit int `json:"usageLimit" sortable:"true"`
|
||||
UsageCount int `json:"usageCount" sortable:"true"`
|
||||
}
|
||||
|
||||
func (st *SignupToken) IsExpired() bool {
|
||||
return time.Time(st.ExpiresAt).Before(time.Now())
|
||||
}
|
||||
|
||||
func (st *SignupToken) IsUsageLimitReached() bool {
|
||||
return st.UsageCount >= st.UsageLimit
|
||||
}
|
||||
|
||||
func (st *SignupToken) IsValid() bool {
|
||||
return !st.IsExpired() && !st.IsUsageLimitReached()
|
||||
}
|
||||
@@ -68,6 +68,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
|
||||
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"},
|
||||
// Internal
|
||||
BackgroundImageType: model.AppConfigVariable{Value: "jpg"},
|
||||
@@ -233,7 +234,7 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon
|
||||
s.dbConfig.Store(cfg)
|
||||
|
||||
// Return the updated config
|
||||
res := cfg.ToAppConfigVariableSlice(true)
|
||||
res := cfg.ToAppConfigVariableSlice(true, false)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -318,7 +319,7 @@ func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndVal
|
||||
}
|
||||
|
||||
func (s *AppConfigService) ListAppConfig(showAll bool) []model.AppConfigVariable {
|
||||
return s.GetDbConfig().ToAppConfigVariableSlice(showAll)
|
||||
return s.GetDbConfig().ToAppConfigVariableSlice(showAll, true)
|
||||
}
|
||||
|
||||
func (s *AppConfigService) UpdateImage(ctx context.Context, uploadedFile *multipart.FileHeader, imageName string, oldImageType string) (err error) {
|
||||
@@ -369,7 +370,7 @@ func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) {
|
||||
func (s *AppConfigService) loadDbConfigInternal(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) {
|
||||
// If the UI config is disabled, only load from the env
|
||||
if common.EnvConfig.UiConfigDisabled {
|
||||
dest, err := s.loadDbConfigFromEnv(ctx, s.db)
|
||||
dest, err := s.loadDbConfigFromEnv(ctx, tx)
|
||||
return dest, err
|
||||
}
|
||||
|
||||
|
||||
@@ -310,6 +310,50 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
signupTokens := []model.SignupToken{
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
},
|
||||
Token: "VALID1234567890A",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
|
||||
UsageLimit: 1,
|
||||
UsageCount: 0,
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "b2c3d4e5-f6g7-8901-bcde-f12345678901",
|
||||
},
|
||||
Token: "PARTIAL567890ABC",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(7 * 24 * time.Hour)),
|
||||
UsageLimit: 5,
|
||||
UsageCount: 2,
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "c3d4e5f6-g7h8-9012-cdef-123456789012",
|
||||
},
|
||||
Token: "EXPIRED34567890B",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(-24 * time.Hour)), // Expired
|
||||
UsageLimit: 3,
|
||||
UsageCount: 1,
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "d4e5f6g7-h8i9-0123-def0-234567890123",
|
||||
},
|
||||
Token: "FULLYUSED567890C",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
|
||||
UsageLimit: 1,
|
||||
UsageCount: 1, // Usage limit reached
|
||||
},
|
||||
}
|
||||
for _, token := range signupTokens {
|
||||
if err := tx.Create(&token).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
|
||||
@@ -296,15 +296,21 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
|
||||
isLdapUser := user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue()
|
||||
allowOwnAccountEdit := s.appConfigService.GetDbConfig().AllowOwnAccountEdit.IsTrue()
|
||||
|
||||
// For LDAP users or if own account editing is not allowed, only allow updating the locale unless it's an LDAP sync
|
||||
if !isLdapSync && (isLdapUser || (!allowOwnAccountEdit && !updateOwnUser)) {
|
||||
if !isLdapSync && (isLdapUser || (!allowOwnAccountEdit && updateOwnUser)) {
|
||||
// Restricted update: Only locale can be changed when:
|
||||
// - User is from LDAP, OR
|
||||
// - User is editing their own account but global setting disallows self-editing
|
||||
// (Exception: LDAP sync operations can update everything)
|
||||
user.Locale = updatedUser.Locale
|
||||
} else {
|
||||
// Full update: Allow updating all personal fields
|
||||
user.FirstName = updatedUser.FirstName
|
||||
user.LastName = updatedUser.LastName
|
||||
user.Email = updatedUser.Email
|
||||
user.Username = updatedUser.Username
|
||||
user.Locale = updatedUser.Locale
|
||||
|
||||
// Admin-only fields: Only allow updates when not updating own account
|
||||
if !updateOwnUser {
|
||||
user.IsAdmin = updatedUser.IsAdmin
|
||||
user.Disabled = updatedUser.Disabled
|
||||
@@ -523,7 +529,7 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *UserService) SetupInitialAdmin(ctx context.Context) (model.User, string, error) {
|
||||
func (s *UserService) SignUpInitialAdmin(ctx context.Context, signUpData dto.SignUpDto) (model.User, string, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
@@ -533,25 +539,19 @@ func (s *UserService) SetupInitialAdmin(ctx context.Context) (model.User, string
|
||||
if err := tx.WithContext(ctx).Model(&model.User{}).Count(&userCount).Error; err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
if userCount > 1 {
|
||||
if userCount != 0 {
|
||||
return model.User{}, "", &common.SetupAlreadyCompletedError{}
|
||||
}
|
||||
|
||||
user := model.User{
|
||||
FirstName: "Admin",
|
||||
LastName: "Admin",
|
||||
Username: "admin",
|
||||
Email: "admin@admin.com",
|
||||
userToCreate := dto.UserCreateDto{
|
||||
FirstName: signUpData.FirstName,
|
||||
LastName: signUpData.LastName,
|
||||
Username: signUpData.Username,
|
||||
Email: signUpData.Email,
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
if err := tx.WithContext(ctx).Model(&model.User{}).Preload("Credentials").FirstOrCreate(&user).Error; err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
if len(user.Credentials) > 0 {
|
||||
return model.User{}, "", &common.SetupAlreadyCompletedError{}
|
||||
}
|
||||
user, err := s.createUserInternal(ctx, userToCreate, false, tx)
|
||||
|
||||
token, err := s.jwtService.GenerateAccessToken(user)
|
||||
if err != nil {
|
||||
@@ -630,6 +630,110 @@ func (s *UserService) disableUserInternal(ctx context.Context, userID string, tx
|
||||
Error
|
||||
}
|
||||
|
||||
func (s *UserService) CreateSignupToken(ctx context.Context, expiresAt time.Time, usageLimit int) (model.SignupToken, error) {
|
||||
return s.createSignupTokenInternal(ctx, expiresAt, usageLimit, s.db)
|
||||
}
|
||||
|
||||
func (s *UserService) createSignupTokenInternal(ctx context.Context, expiresAt time.Time, usageLimit int, tx *gorm.DB) (model.SignupToken, error) {
|
||||
signupToken, err := NewSignupToken(expiresAt, usageLimit)
|
||||
if err != nil {
|
||||
return model.SignupToken{}, err
|
||||
}
|
||||
|
||||
if err := tx.WithContext(ctx).Create(signupToken).Error; err != nil {
|
||||
return model.SignupToken{}, err
|
||||
}
|
||||
|
||||
return *signupToken, nil
|
||||
}
|
||||
|
||||
func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAddress, userAgent string) (model.User, string, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
tokenProvided := signupData.Token != ""
|
||||
|
||||
config := s.appConfigService.GetDbConfig()
|
||||
if config.AllowUserSignups.Value != "open" && !tokenProvided {
|
||||
return model.User{}, "", &common.OpenSignupDisabledError{}
|
||||
}
|
||||
|
||||
var signupToken model.SignupToken
|
||||
if tokenProvided {
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Where("token = ?", signupData.Token).
|
||||
First(&signupToken).
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||
}
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
if !signupToken.IsValid() {
|
||||
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||
}
|
||||
}
|
||||
|
||||
userToCreate := dto.UserCreateDto{
|
||||
Username: signupData.Username,
|
||||
Email: signupData.Email,
|
||||
FirstName: signupData.FirstName,
|
||||
LastName: signupData.LastName,
|
||||
}
|
||||
|
||||
user, err := s.createUserInternal(ctx, userToCreate, false, tx)
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
accessToken, err := s.jwtService.GenerateAccessToken(user)
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
if tokenProvided {
|
||||
s.auditLogService.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
|
||||
"signupToken": signupToken.Token,
|
||||
}, tx)
|
||||
|
||||
signupToken.UsageCount++
|
||||
|
||||
err = tx.WithContext(ctx).Save(&signupToken).Error
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
|
||||
}
|
||||
} else {
|
||||
s.auditLogService.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
|
||||
"method": "open_signup",
|
||||
}, tx)
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
return user, accessToken, nil
|
||||
}
|
||||
|
||||
func (s *UserService) ListSignupTokens(ctx context.Context, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.SignupToken, utils.PaginationResponse, error) {
|
||||
var tokens []model.SignupToken
|
||||
query := s.db.WithContext(ctx).Model(&model.SignupToken{})
|
||||
|
||||
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &tokens)
|
||||
return tokens, pagination, err
|
||||
}
|
||||
|
||||
func (s *UserService) DeleteSignupToken(ctx context.Context, tokenID string) error {
|
||||
return s.db.WithContext(ctx).Delete(&model.SignupToken{}, "id = ?", tokenID).Error
|
||||
}
|
||||
|
||||
func NewOneTimeAccessToken(userID string, expiresAt time.Time) (*model.OneTimeAccessToken, error) {
|
||||
// If expires at is less than 15 minutes, use a 6-character token instead of 16
|
||||
tokenLength := 16
|
||||
@@ -650,3 +754,20 @@ func NewOneTimeAccessToken(userID string, expiresAt time.Time) (*model.OneTimeAc
|
||||
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func NewSignupToken(expiresAt time.Time, usageLimit int) (*model.SignupToken, error) {
|
||||
// Generate a random token
|
||||
randomString, err := utils.GenerateRandomAlphanumericString(16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token := &model.SignupToken{
|
||||
Token: randomString,
|
||||
ExpiresAt: datatype.DateTime(expiresAt),
|
||||
UsageLimit: usageLimit,
|
||||
UsageCount: 0,
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP INDEX IF EXISTS idx_signup_tokens_expires_at;
|
||||
DROP INDEX IF EXISTS idx_signup_tokens_token;
|
||||
DROP TABLE IF EXISTS signup_tokens;
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE signup_tokens (
|
||||
id UUID NOT NULL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
token VARCHAR(255) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
usage_limit INTEGER NOT NULL DEFAULT 1,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX idx_signup_tokens_token ON signup_tokens(token);
|
||||
CREATE INDEX idx_signup_tokens_expires_at ON signup_tokens(expires_at);
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP INDEX IF EXISTS idx_signup_tokens_expires_at;
|
||||
DROP INDEX IF EXISTS idx_signup_tokens_token;
|
||||
DROP TABLE IF EXISTS signup_tokens;
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE signup_tokens (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at DATETIME NOT NULL,
|
||||
usage_limit INTEGER NOT NULL DEFAULT 1,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX idx_signup_tokens_token ON signup_tokens(token);
|
||||
CREATE INDEX idx_signup_tokens_expires_at ON signup_tokens(expires_at);
|
||||
@@ -65,11 +65,9 @@
|
||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Chcete se s účtem <b>{username}</b> odhlásit z Pocket ID?",
|
||||
"sign_in_to_appname": "Přihlásit se k {appName}",
|
||||
"please_try_to_sign_in_again": "Zkuste se prosím znovu přihlásit.",
|
||||
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Autentizujte se pomocí Vašeho přístupového klíče pro přístup k administrátorskému panelu.",
|
||||
"authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
|
||||
"authenticate": "Autentizovat",
|
||||
"appname_setup": "{appName} konfigurace",
|
||||
"please_try_again": "Prosím, zkuste znovu.",
|
||||
"you_are_about_to_sign_in_to_the_initial_admin_account": "Chystáte se přihlásit k počátečnímu účtu správce. Kdokoli s tímto odkazem může přistupovat k účtu, dokud nebude přidán přístupový účet. Prosím nastavte přístupový klíč co nejdříve, abyste zabránili neoprávněnému přístupu.",
|
||||
"continue": "Pokračovat",
|
||||
"alternative_sign_in": "Alternativní přihlášení",
|
||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Pokud nemáte přístup k Vašemu přístupovému klíči, můžete se přihlášit pomocí jedné z následujících metod.",
|
||||
@@ -320,6 +318,7 @@
|
||||
"all_users": "Všichni uživatelé",
|
||||
"all_events": "Všechny události",
|
||||
"all_clients": "Všichni klienti",
|
||||
"all_locations": "All Locations",
|
||||
"global_audit_log": "Globální protokol auditu",
|
||||
"see_all_account_activities_from_the_last_3_months": "Zobrazit veškerou aktivitu uživatele za poslední 3 měsíce.",
|
||||
"token_sign_in": "Přihlášení tokenem",
|
||||
@@ -378,5 +377,46 @@
|
||||
"custom_accent_color": "Custom Accent Color",
|
||||
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
|
||||
"color_value": "Color Value",
|
||||
"apply": "Apply"
|
||||
"apply": "Apply",
|
||||
"signup_token": "Signup Token",
|
||||
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
|
||||
"usage_limit": "Usage Limit",
|
||||
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
|
||||
"expires": "Expires",
|
||||
"signup": "Sign Up",
|
||||
"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",
|
||||
"signup_to_appname": "Sign Up to {appName}",
|
||||
"create_your_account_to_get_started": "Create your account to get started.",
|
||||
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
|
||||
"setup_your_passkey": "Set up your passkey",
|
||||
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
|
||||
"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.",
|
||||
"user_signups_are_disabled": "User signups are currently disabled",
|
||||
"create_signup_token": "Create Signup Token",
|
||||
"view_active_signup_tokens": "View Active Signup Tokens",
|
||||
"manage_signup_tokens": "Manage Signup Tokens",
|
||||
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
|
||||
"signup_token_deleted_successfully": "Signup token deleted successfully.",
|
||||
"expired": "Expired",
|
||||
"used_up": "Used Up",
|
||||
"active": "Active",
|
||||
"usage": "Usage",
|
||||
"created": "Created",
|
||||
"token": "Token",
|
||||
"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",
|
||||
"signup_open_description": "Anyone can create a new account without restrictions.",
|
||||
"of": "of",
|
||||
"skip_passkey_setup": "Skip Passkey Setup",
|
||||
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
|
||||
}
|
||||
|
||||
@@ -65,11 +65,9 @@
|
||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Vil du logge ud af {appName} med kontoen <b>{username}</b>?",
|
||||
"sign_in_to_appname": "Log ind på {appName}",
|
||||
"please_try_to_sign_in_again": "Prøv at logge ind igen.",
|
||||
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Bekræft din identitet med din adgangsnøgle for at få adgang til administrationspanelet.",
|
||||
"authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
|
||||
"authenticate": "Bekræft identitet",
|
||||
"appname_setup": "Opsætning af {appName}",
|
||||
"please_try_again": "Prøv venligst igen.",
|
||||
"you_are_about_to_sign_in_to_the_initial_admin_account": "Du er ved at logge ind på den oprindelige administrator-konto. Enhver med dette link kan få adgang, indtil en adgangsnøgle tilføjes. Opsæt en adgangsnøgle hurtigst muligt for at forhindre uautoriseret adgang.",
|
||||
"continue": "Fortsæt",
|
||||
"alternative_sign_in": "Andre loginmetoder",
|
||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Hvis du ikke har adgang til din adgangsnøgle, kan du logge ind med en af følgende metoder.",
|
||||
@@ -320,6 +318,7 @@
|
||||
"all_users": "Alle brugere",
|
||||
"all_events": "Alle hændelser",
|
||||
"all_clients": "Alle klienter",
|
||||
"all_locations": "All Locations",
|
||||
"global_audit_log": "Global aktivitetslog",
|
||||
"see_all_account_activities_from_the_last_3_months": "Se al brugeraktivitet for de seneste 3 måneder.",
|
||||
"token_sign_in": "Token-login",
|
||||
@@ -378,5 +377,46 @@
|
||||
"custom_accent_color": "Brugerdefineret accentfarve",
|
||||
"custom_accent_color_description": "Indtast en brugerdefineret farve i et gyldigt CSS-format (f.eks. hex, rgb, hsl).",
|
||||
"color_value": "Farveværdi",
|
||||
"apply": "Anvend"
|
||||
"apply": "Anvend",
|
||||
"signup_token": "Signup Token",
|
||||
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
|
||||
"usage_limit": "Usage Limit",
|
||||
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
|
||||
"expires": "Expires",
|
||||
"signup": "Sign Up",
|
||||
"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",
|
||||
"signup_to_appname": "Sign Up to {appName}",
|
||||
"create_your_account_to_get_started": "Create your account to get started.",
|
||||
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
|
||||
"setup_your_passkey": "Set up your passkey",
|
||||
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
|
||||
"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.",
|
||||
"user_signups_are_disabled": "User signups are currently disabled",
|
||||
"create_signup_token": "Create Signup Token",
|
||||
"view_active_signup_tokens": "View Active Signup Tokens",
|
||||
"manage_signup_tokens": "Manage Signup Tokens",
|
||||
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
|
||||
"signup_token_deleted_successfully": "Signup token deleted successfully.",
|
||||
"expired": "Expired",
|
||||
"used_up": "Used Up",
|
||||
"active": "Active",
|
||||
"usage": "Usage",
|
||||
"created": "Created",
|
||||
"token": "Token",
|
||||
"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",
|
||||
"signup_open_description": "Anyone can create a new account without restrictions.",
|
||||
"of": "of",
|
||||
"skip_passkey_setup": "Skip Passkey Setup",
|
||||
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
|
||||
}
|
||||
|
||||
@@ -65,11 +65,9 @@
|
||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Möchtest du dich mit deinem Konto <b>{username}</b> von Pocket ID abmelden?",
|
||||
"sign_in_to_appname": "Bei {appName} anmelden",
|
||||
"please_try_to_sign_in_again": "Bitte versuche dich erneut anzumelden.",
|
||||
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Authentifiziere dich mit deinem Passkey, um auf das Admin Panel zugreifen zu können.",
|
||||
"authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
|
||||
"authenticate": "Authentifizieren",
|
||||
"appname_setup": "{appName} Einrichtung",
|
||||
"please_try_again": "Bitte versuche es noch einmal.",
|
||||
"you_are_about_to_sign_in_to_the_initial_admin_account": "Du bist dabei, dich beim initialen Administratorkonto anzumelden. Jeder, der diesen Link hat, kann auf das Konto zugreifen, bis ein Passkey hinzugefügt wird. Bitte richte so schnell wie möglich einen Passkey ein, um unbefugten Zugriff zu verhindern.",
|
||||
"continue": "Fortsetzen",
|
||||
"alternative_sign_in": "Alternative Anmeldemethoden",
|
||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Wenn du keinen Zugang zu deinem Passkey hast, kannst du dich mit einer der folgenden Methoden anmelden.",
|
||||
@@ -320,6 +318,7 @@
|
||||
"all_users": "Alle Benutzer",
|
||||
"all_events": "Alle Ereignisse",
|
||||
"all_clients": "Alle Clients",
|
||||
"all_locations": "Alle Orte",
|
||||
"global_audit_log": "Globaler Aktivitäts-Log",
|
||||
"see_all_account_activities_from_the_last_3_months": "Sieh dir alle Benutzeraktivitäten der letzten 3 Monate an.",
|
||||
"token_sign_in": "Token-Anmeldung",
|
||||
@@ -356,7 +355,7 @@
|
||||
"show_advanced_options": "Erweiterte Optionen anzeigen",
|
||||
"hide_advanced_options": "Erweiterte Optionen ausblenden",
|
||||
"oidc_data_preview": "OIDC Daten-Vorschau",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Vorschau der OIDC-Daten, die für verschiedene Nutzer gesendet werden sollen",
|
||||
"id_token": "ID Token",
|
||||
"access_token": "Access Token",
|
||||
"userinfo": "Userinfo",
|
||||
@@ -374,9 +373,50 @@
|
||||
"select_user": "Benutzer auswählen",
|
||||
"error": "Fehler",
|
||||
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Select an accent color to customize the appearance of Pocket ID.",
|
||||
"accent_color": "Accent Color",
|
||||
"custom_accent_color": "Custom Accent Color",
|
||||
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
|
||||
"color_value": "Color Value",
|
||||
"apply": "Apply"
|
||||
"accent_color": "Akzentfarbe",
|
||||
"custom_accent_color": "Benutzerdefinierte Akzentfarbe",
|
||||
"custom_accent_color_description": "Geben Sie eine benutzerdefinierte Farbe mit gültigen CSS-Farbformaten ein (z.B. hex, rgb, hsl).",
|
||||
"color_value": "Farbwert",
|
||||
"apply": "Übernehmen",
|
||||
"signup_token": "Signup Token",
|
||||
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
|
||||
"usage_limit": "Usage Limit",
|
||||
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
|
||||
"expires": "Expires",
|
||||
"signup": "Sign Up",
|
||||
"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",
|
||||
"signup_to_appname": "Sign Up to {appName}",
|
||||
"create_your_account_to_get_started": "Create your account to get started.",
|
||||
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
|
||||
"setup_your_passkey": "Set up your passkey",
|
||||
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
|
||||
"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.",
|
||||
"user_signups_are_disabled": "User signups are currently disabled",
|
||||
"create_signup_token": "Create Signup Token",
|
||||
"view_active_signup_tokens": "View Active Signup Tokens",
|
||||
"manage_signup_tokens": "Manage Signup Tokens",
|
||||
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
|
||||
"signup_token_deleted_successfully": "Signup token deleted successfully.",
|
||||
"expired": "Expired",
|
||||
"used_up": "Used Up",
|
||||
"active": "Active",
|
||||
"usage": "Usage",
|
||||
"created": "Created",
|
||||
"token": "Token",
|
||||
"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",
|
||||
"signup_open_description": "Anyone can create a new account without restrictions.",
|
||||
"of": "of",
|
||||
"skip_passkey_setup": "Skip Passkey Setup",
|
||||
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
|
||||
}
|
||||
|
||||
@@ -65,11 +65,9 @@
|
||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Do you want to sign out of {appName} with the account <b>{username}</b>?",
|
||||
"sign_in_to_appname": "Sign in to {appName}",
|
||||
"please_try_to_sign_in_again": "Please try to sign in again.",
|
||||
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Authenticate yourself with your passkey to access the admin panel.",
|
||||
"authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
|
||||
"authenticate": "Authenticate",
|
||||
"appname_setup": "{appName} Setup",
|
||||
"please_try_again": "Please try again.",
|
||||
"you_are_about_to_sign_in_to_the_initial_admin_account": "You're about to sign in to the initial admin account. Anyone with this link can access the account until a passkey is added. Please set up a passkey as soon as possible to prevent unauthorized access.",
|
||||
"continue": "Continue",
|
||||
"alternative_sign_in": "Alternative Sign In",
|
||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.",
|
||||
@@ -379,5 +377,46 @@
|
||||
"custom_accent_color": "Custom Accent Color",
|
||||
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
|
||||
"color_value": "Color Value",
|
||||
"apply": "Apply"
|
||||
"apply": "Apply",
|
||||
"signup_token": "Signup Token",
|
||||
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
|
||||
"usage_limit": "Usage Limit",
|
||||
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
|
||||
"expires": "Expires",
|
||||
"signup": "Sign Up",
|
||||
"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",
|
||||
"signup_to_appname": "Sign Up to {appName}",
|
||||
"create_your_account_to_get_started": "Create your account to get started.",
|
||||
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
|
||||
"setup_your_passkey": "Set up your passkey",
|
||||
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
|
||||
"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.",
|
||||
"user_signups_are_disabled": "User signups are currently disabled",
|
||||
"create_signup_token": "Create Signup Token",
|
||||
"view_active_signup_tokens": "View Active Signup Tokens",
|
||||
"manage_signup_tokens": "Manage Signup Tokens",
|
||||
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
|
||||
"signup_token_deleted_successfully": "Signup token deleted successfully.",
|
||||
"expired": "Expired",
|
||||
"used_up": "Used Up",
|
||||
"active": "Active",
|
||||
"usage": "Usage",
|
||||
"created": "Created",
|
||||
"token": "Token",
|
||||
"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",
|
||||
"signup_open_description": "Anyone can create a new account without restrictions.",
|
||||
"of": "of",
|
||||
"skip_passkey_setup": "Skip Passkey Setup",
|
||||
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
|
||||
}
|
||||
|
||||
@@ -65,11 +65,9 @@
|
||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "¿Quieres cerrar sesión de Pocket ID con la cuenta <b>{username}</b>?",
|
||||
"sign_in_to_appname": "Iniciar sesión en {appName}",
|
||||
"please_try_to_sign_in_again": "Por favor, intente iniciar sesión de nuevo.",
|
||||
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Autenticar con tu Passkey para acceder al panel de administración.",
|
||||
"authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
|
||||
"authenticate": "Autenticar",
|
||||
"appname_setup": "Configuración de {appName}",
|
||||
"please_try_again": "Por favor intente nuevamente.",
|
||||
"you_are_about_to_sign_in_to_the_initial_admin_account": "Estás a punto de iniciar sesión en la cuenta de administrador inicial. Cualquiera con este enlace puede acceder a la cuenta hasta que se agregue un Passkey. Por favor, configure un Passkey lo antes posible para evitar acceso no autorizado.",
|
||||
"continue": "Continuar",
|
||||
"alternative_sign_in": "Inicio de sesión alternativa",
|
||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Si no tiene acceso a su Passkey, puede iniciar sesión usando uno de los siguientes métodos.",
|
||||
@@ -320,6 +318,7 @@
|
||||
"all_users": "All Users",
|
||||
"all_events": "All Events",
|
||||
"all_clients": "All Clients",
|
||||
"all_locations": "All Locations",
|
||||
"global_audit_log": "Global Audit Log",
|
||||
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
|
||||
"token_sign_in": "Token Sign In",
|
||||
@@ -378,5 +377,46 @@
|
||||
"custom_accent_color": "Custom Accent Color",
|
||||
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
|
||||
"color_value": "Color Value",
|
||||
"apply": "Apply"
|
||||
"apply": "Apply",
|
||||
"signup_token": "Signup Token",
|
||||
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
|
||||
"usage_limit": "Usage Limit",
|
||||
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
|
||||
"expires": "Expires",
|
||||
"signup": "Sign Up",
|
||||
"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",
|
||||
"signup_to_appname": "Sign Up to {appName}",
|
||||
"create_your_account_to_get_started": "Create your account to get started.",
|
||||
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
|
||||
"setup_your_passkey": "Set up your passkey",
|
||||
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
|
||||
"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.",
|
||||
"user_signups_are_disabled": "User signups are currently disabled",
|
||||
"create_signup_token": "Create Signup Token",
|
||||
"view_active_signup_tokens": "View Active Signup Tokens",
|
||||
"manage_signup_tokens": "Manage Signup Tokens",
|
||||
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
|
||||
"signup_token_deleted_successfully": "Signup token deleted successfully.",
|
||||
"expired": "Expired",
|
||||
"used_up": "Used Up",
|
||||
"active": "Active",
|
||||
"usage": "Usage",
|
||||
"created": "Created",
|
||||
"token": "Token",
|
||||
"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",
|
||||
"signup_open_description": "Anyone can create a new account without restrictions.",
|
||||
"of": "of",
|
||||
"skip_passkey_setup": "Skip Passkey Setup",
|
||||
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"my_account": "Mon compte",
|
||||
"logout": "Déconnexion",
|
||||
"confirm": "Confirmer",
|
||||
"docs": "Docs",
|
||||
"docs": "Documentation",
|
||||
"key": "Clé",
|
||||
"value": "Valeur",
|
||||
"remove_custom_claim": "Remove custom claim",
|
||||
@@ -37,7 +37,7 @@
|
||||
"generate_code": "Générer un code",
|
||||
"name": "Nom",
|
||||
"browser_unsupported": "Navigateur non pris en charge",
|
||||
"this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please use an alternative sign in method.",
|
||||
"this_browser_does_not_support_passkeys": "Ce navigateur ne supporte pas les clés d'accès. Veuillez utiliser une autre méthode d'authentification.",
|
||||
"an_unknown_error_occurred": "Une erreur inconnue est survenue",
|
||||
"authentication_process_was_aborted": "Le processus d'authentification a été interrompu",
|
||||
"error_occurred_with_authenticator": "Une erreur est survenue pendant l'authentification",
|
||||
@@ -65,14 +65,12 @@
|
||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Voulez-vous vous déconnecter de Pocket ID avec le compte <b>{username}</b>?",
|
||||
"sign_in_to_appname": "Se connecter à {appName}",
|
||||
"please_try_to_sign_in_again": "Veuillez essayer de vous connecter à nouveau.",
|
||||
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Authentifiez-vous avec votre clé d'accès pour accéder au panneau d'administration.",
|
||||
"authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
|
||||
"authenticate": "S'authentifier",
|
||||
"appname_setup": "Configuration {appName}",
|
||||
"please_try_again": "Veuillez réessayer.",
|
||||
"you_are_about_to_sign_in_to_the_initial_admin_account": "Vous êtes sur le point de vous connecter au compte administrateur initial. N'importe qui avec ce lien peut accéder au compte jusqu'à ce qu'une clé d'accès soit ajouté. Veuillez configurer une clé d'accès dès que possible pour éviter tout accès non autorisé.",
|
||||
"continue": "Continuer",
|
||||
"alternative_sign_in": "Connexion alternative",
|
||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.",
|
||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Si vous n'avez pas accès à votre clé d'accès, vous pouvez vous authentifier en utilisant une des méthodes suivantes.",
|
||||
"use_your_passkey_instead": "Utiliser votre clé d'accès à la place ?",
|
||||
"email_login": "Connexion par e-mail",
|
||||
"enter_a_login_code_to_sign_in": "Entrez un code de connexion pour vous connecter.",
|
||||
@@ -108,7 +106,7 @@
|
||||
"account_settings": "Paramètres du compte",
|
||||
"passkey_missing": "Clé d'accès manquante",
|
||||
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Veuillez ajouter une clé d'accès pour éviter de perdre l'accès à votre compte.",
|
||||
"single_passkey_configured": "Une seul clé d'accès configuré",
|
||||
"single_passkey_configured": "Une seule clé d'accès configurée",
|
||||
"it_is_recommended_to_add_more_than_one_passkey": "Il est recommandé d'ajouter plus d'une clé d'accès pour éviter de perdre l'accès à votre compte.",
|
||||
"account_details": "Paramètres du compte",
|
||||
"passkeys": "Clés d'accès",
|
||||
@@ -155,7 +153,7 @@
|
||||
"actions": "Actions",
|
||||
"images_updated_successfully": "Image mise à jour avec succès",
|
||||
"general": "Général",
|
||||
"configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
|
||||
"configure_smtp_to_send_emails": "Activer les notifications par e-mail pour alerter les utilisateurs lorsqu'une connexion est détectée à partir d'un nouvel appareil ou d'un nouvel emplacement.",
|
||||
"ldap": "LDAP",
|
||||
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configurer les paramètres LDAP pour synchroniser les utilisateurs et les groupes à partir d'un serveur LDAP.",
|
||||
"images": "Images",
|
||||
@@ -179,11 +177,11 @@
|
||||
"enabled_emails": "Emails activés",
|
||||
"email_login_notification": "Notification de connexion par e-mail",
|
||||
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Envoyer un email à l'utilisateur lorsqu'il se connecte à partir d'un nouvel appareil.",
|
||||
"emai_login_code_requested_by_user": "Email Login Code Requested by User",
|
||||
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This significantly reduces security as anyone with access to the user's email can gain entry.",
|
||||
"email_login_code_from_admin": "Email Login Code from Admin",
|
||||
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
|
||||
"send_test_email": "Send test email",
|
||||
"emai_login_code_requested_by_user": "Code de connexion reçu par e-mail à la demande de l'utilisateur.",
|
||||
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Permet aux utilisateurs de contourner les clés d'accès en demandant un code de connexion envoyé à leur adresse e-mail. Cela réduit considérablement la sécurité car toute personne ayant accès à l'e-mail de l'utilisateur peut récupérer la clé d'accès.",
|
||||
"email_login_code_from_admin": "Code de connexion reçu par e-mail envoyé par l'administrateur.",
|
||||
"allows_an_admin_to_send_a_login_code_to_the_user": "Permet à un administrateur d'envoyer un code de connexion à l'utilisateur par e-mail.",
|
||||
"send_test_email": "Envoyer un e-mail de test",
|
||||
"application_configuration_updated_successfully": "Mise à jour de l'application avec succès",
|
||||
"application_name": "Nom de l'application",
|
||||
"session_duration": "Durée de la session",
|
||||
@@ -270,7 +268,7 @@
|
||||
"add_oidc_client": "Ajouter un client OIDC",
|
||||
"manage_oidc_clients": "Gérer les clients OIDC",
|
||||
"one_time_link": "Lien de connexion unique",
|
||||
"use_this_link_to_sign_in_once": "Use this link to sign in once. This is needed for users who haven't added a passkey yet or have lost it.",
|
||||
"use_this_link_to_sign_in_once": "Utilisez ce lien pour vous connecter. Ceci est nécessaire pour les utilisateurs qui n'ont pas encore ajouté de clé d'accès ou l'ont perdu.",
|
||||
"add": "Ajouter",
|
||||
"callback_urls": "URL de callback",
|
||||
"logout_callback_urls": "URL de callback de déconnexion",
|
||||
@@ -310,73 +308,115 @@
|
||||
"background_image": "Image d'arrière-plan",
|
||||
"language": "Langue",
|
||||
"reset_profile_picture_question": "Réinitialiser la photo de profil ?",
|
||||
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "This will remove the uploaded image and reset the profile picture to default. Do you want to continue?",
|
||||
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "Cela supprimera l’image téléchargée et réinitialisera la photo de profil par défaut. Voulez-vous continuer ?",
|
||||
"reset": "Réinitialiser",
|
||||
"reset_to_default": "Valeurs par défaut",
|
||||
"profile_picture_has_been_reset": "La photo de profil a été réinitialisée. La mise à jour peut prendre quelques minutes.",
|
||||
"select_the_language_you_want_to_use": "Sélectionnez la langue que vous souhaitez utiliser. Certaines langues peuvent ne pas être entièrement traduites.",
|
||||
"personal": "Personal",
|
||||
"personal": "Personnel",
|
||||
"global": "Global",
|
||||
"all_users": "All Users",
|
||||
"all_events": "All Events",
|
||||
"all_clients": "All Clients",
|
||||
"global_audit_log": "Global Audit Log",
|
||||
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
|
||||
"token_sign_in": "Token Sign In",
|
||||
"client_authorization": "Client Authorization",
|
||||
"new_client_authorization": "New Client Authorization",
|
||||
"disable_animations": "Disable Animations",
|
||||
"turn_off_ui_animations": "Turn off animations throughout the UI.",
|
||||
"user_disabled": "Account Disabled",
|
||||
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
|
||||
"user_disabled_successfully": "User has been disabled successfully.",
|
||||
"user_enabled_successfully": "User has been enabled successfully.",
|
||||
"status": "Status",
|
||||
"disable_firstname_lastname": "Disable {firstName} {lastName}",
|
||||
"are_you_sure_you_want_to_disable_this_user": "Are you sure you want to disable this user? They will not be able to log in or access any services.",
|
||||
"ldap_soft_delete_users": "Keep disabled users from LDAP.",
|
||||
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.",
|
||||
"login_code_email_success": "The login code has been sent to the user.",
|
||||
"send_email": "Send Email",
|
||||
"show_code": "Show Code",
|
||||
"callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
|
||||
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
|
||||
"api_key_expiration": "API Key Expiration",
|
||||
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
|
||||
"authorize_device": "Authorize Device",
|
||||
"the_device_has_been_authorized": "The device has been authorized.",
|
||||
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
|
||||
"authorize": "Authorize",
|
||||
"federated_client_credentials": "Federated Client Credentials",
|
||||
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
|
||||
"add_federated_client_credential": "Add Federated Client Credential",
|
||||
"add_another_federated_client_credential": "Add another federated client credential",
|
||||
"oidc_allowed_group_count": "Allowed Group Count",
|
||||
"unrestricted": "Unrestricted",
|
||||
"show_advanced_options": "Show Advanced Options",
|
||||
"hide_advanced_options": "Hide Advanced Options",
|
||||
"oidc_data_preview": "OIDC Data Preview",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
|
||||
"id_token": "ID Token",
|
||||
"access_token": "Access Token",
|
||||
"userinfo": "Userinfo",
|
||||
"id_token_payload": "ID Token Payload",
|
||||
"access_token_payload": "Access Token Payload",
|
||||
"userinfo_endpoint_response": "Userinfo Endpoint Response",
|
||||
"copy": "Copy",
|
||||
"no_preview_data_available": "No preview data available",
|
||||
"copy_all": "Copy All",
|
||||
"preview": "Preview",
|
||||
"preview_for_user": "Preview for {name} ({email})",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
|
||||
"show": "Show",
|
||||
"select_an_option": "Select an option",
|
||||
"select_user": "Select User",
|
||||
"error": "Error",
|
||||
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Select an accent color to customize the appearance of Pocket ID.",
|
||||
"accent_color": "Accent Color",
|
||||
"custom_accent_color": "Custom Accent Color",
|
||||
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
|
||||
"color_value": "Color Value",
|
||||
"apply": "Apply"
|
||||
"all_users": "Tous les utilisateurs",
|
||||
"all_events": "Tous les événements",
|
||||
"all_clients": "Tous les clients",
|
||||
"all_locations": "Tous les emplacements",
|
||||
"global_audit_log": "Journal d'audit global",
|
||||
"see_all_account_activities_from_the_last_3_months": "Voir toutes les activités des utilisateurs des 3 derniers mois.",
|
||||
"token_sign_in": "Connexion par jeton",
|
||||
"client_authorization": "Autorisation client",
|
||||
"new_client_authorization": "Nouvelle autorisation client",
|
||||
"disable_animations": "Désactiver les animations",
|
||||
"turn_off_ui_animations": "Désactiver les animations dans toute l'interface.",
|
||||
"user_disabled": "Compte désactivé",
|
||||
"disabled_users_cannot_log_in_or_use_services": "Les utilisateurs désactivés ne peuvent pas se connecter ni utiliser les services.",
|
||||
"user_disabled_successfully": "L'utilisateur a été désactivé avec succès.",
|
||||
"user_enabled_successfully": "L'utilisateur a été activé avec succès.",
|
||||
"status": "Statut",
|
||||
"disable_firstname_lastname": "Désactiver {firstName} {lastName}",
|
||||
"are_you_sure_you_want_to_disable_this_user": "Êtes-vous sûr de vouloir désactiver cet utilisateur ? Il ne pourra plus se connecter ni accéder aux services.",
|
||||
"ldap_soft_delete_users": "Conserver les utilisateurs désactivés de LDAP.",
|
||||
"ldap_soft_delete_users_description": "Quand activé, les utilisateurs retirés de LDAP seront désactivés plutôt que supprimés du système.",
|
||||
"login_code_email_success": "Le code de connexion a été envoyé à l'utilisateur.",
|
||||
"send_email": "Envoyer un email",
|
||||
"show_code": "Afficher le code",
|
||||
"callback_url_description": "URL(s) fournies par votre client. Sera automatiquement ajoutée si laissée vide. Les jokers (*) sont supportés, mais il est préférable de les éviter pour plus de sécurité.",
|
||||
"logout_callback_url_description": "URL(s) fournies par votre client pour la déconnexion. Les jokers (*) sont supportés, mais il est préférable de les éviter pour plus de sécurité.",
|
||||
"api_key_expiration": "Expiration de la clé API",
|
||||
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Envoyer un email à l'utilisateur lorsque sa clé API est sur le point d'expirer.",
|
||||
"authorize_device": "Autoriser l'appareil",
|
||||
"the_device_has_been_authorized": "L'appareil a été autorisé.",
|
||||
"enter_code_displayed_in_previous_step": "Entrez le code affiché à l'étape précédente.",
|
||||
"authorize": "Autoriser",
|
||||
"federated_client_credentials": "Identifiants client fédérés",
|
||||
"federated_client_credentials_description": "Avec des identifiants clients fédérés, vous pouvez authentifier des clients OIDC avec des tokens JWT émis par des autorités tierces.",
|
||||
"add_federated_client_credential": "Ajouter un identifiant client fédéré",
|
||||
"add_another_federated_client_credential": "Ajouter un autre identifiant client fédéré",
|
||||
"oidc_allowed_group_count": "Nombre de groupes autorisés",
|
||||
"unrestricted": "Illimité",
|
||||
"show_advanced_options": "Afficher les options avancées",
|
||||
"hide_advanced_options": "Masquer les options avancées",
|
||||
"oidc_data_preview": "Aperçu des données OIDC",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Aperçu des données OIDC qui seraient envoyées pour différents utilisateurs",
|
||||
"id_token": "Jeton ID",
|
||||
"access_token": "Jeton d'accès",
|
||||
"userinfo": "Informations utilisateur",
|
||||
"id_token_payload": "Charge utile du jeton ID",
|
||||
"access_token_payload": "Charge utile du jeton d'accès",
|
||||
"userinfo_endpoint_response": "Réponse du point d'accès Userinfo",
|
||||
"copy": "Copier",
|
||||
"no_preview_data_available": "Aucune donnée d'aperçu disponible",
|
||||
"copy_all": "Tout copier",
|
||||
"preview": "Aperçu",
|
||||
"preview_for_user": "Aperçu pour {name} ({email})",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Aperçu des données OIDC qui seraient envoyées pour cet utilisateur",
|
||||
"show": "Afficher",
|
||||
"select_an_option": "Sélectionner une option",
|
||||
"select_user": "Sélectionner un utilisateur",
|
||||
"error": "Erreur",
|
||||
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Sélectionnez une couleur d'accent pour personnaliser l'apparence de Pocket ID.",
|
||||
"accent_color": "Couleur d'accent",
|
||||
"custom_accent_color": "Couleur d'accent personnalisée",
|
||||
"custom_accent_color_description": "Entrez une couleur personnalisée en utilisant un format CSS valide (par ex. hex, rgb, hsl).",
|
||||
"color_value": "Valeur de la couleur",
|
||||
"apply": "Appliquer",
|
||||
"signup_token": "Signup Token",
|
||||
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
|
||||
"usage_limit": "Usage Limit",
|
||||
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
|
||||
"expires": "Expires",
|
||||
"signup": "Sign Up",
|
||||
"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",
|
||||
"signup_to_appname": "Sign Up to {appName}",
|
||||
"create_your_account_to_get_started": "Create your account to get started.",
|
||||
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
|
||||
"setup_your_passkey": "Set up your passkey",
|
||||
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
|
||||
"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.",
|
||||
"user_signups_are_disabled": "User signups are currently disabled",
|
||||
"create_signup_token": "Create Signup Token",
|
||||
"view_active_signup_tokens": "View Active Signup Tokens",
|
||||
"manage_signup_tokens": "Manage Signup Tokens",
|
||||
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
|
||||
"signup_token_deleted_successfully": "Signup token deleted successfully.",
|
||||
"expired": "Expired",
|
||||
"used_up": "Used Up",
|
||||
"active": "Active",
|
||||
"usage": "Usage",
|
||||
"created": "Created",
|
||||
"token": "Token",
|
||||
"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",
|
||||
"signup_open_description": "Anyone can create a new account without restrictions.",
|
||||
"of": "of",
|
||||
"skip_passkey_setup": "Skip Passkey Setup",
|
||||
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
|
||||
}
|
||||
|
||||
@@ -65,11 +65,9 @@
|
||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Vuoi disconnetterti da Pocket ID con l'account <b>{username}</b>?",
|
||||
"sign_in_to_appname": "Accedi a {appName}",
|
||||
"please_try_to_sign_in_again": "Per favore, prova ad accedere di nuovo.",
|
||||
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Autenticati con la tua passkey per accedere al pannello di amministrazione.",
|
||||
"authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
|
||||
"authenticate": "Autentica",
|
||||
"appname_setup": "Configurazione di {appName}",
|
||||
"please_try_again": "Per favore, riprova.",
|
||||
"you_are_about_to_sign_in_to_the_initial_admin_account": "Stai per accedere all'account amministratore iniziale. Chiunque abbia questo link può accedere all'account finché non viene aggiunta una passkey. Configura una passkey il prima possibile per prevenire accessi non autorizzati.",
|
||||
"continue": "Continua",
|
||||
"alternative_sign_in": "Accesso Alternativo",
|
||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Se non hai accesso alla tua passkey, puoi accedere utilizzando uno dei seguenti metodi.",
|
||||
@@ -320,6 +318,7 @@
|
||||
"all_users": "Tutti gli utenti",
|
||||
"all_events": "Tutti gli eventi",
|
||||
"all_clients": "Tutti i client",
|
||||
"all_locations": "Tutte le posizioni",
|
||||
"global_audit_log": "Registro attività globale",
|
||||
"see_all_account_activities_from_the_last_3_months": "Visualizza tutte le attività degli utenti degli ultimi 3 mesi.",
|
||||
"token_sign_in": "Accesso con token",
|
||||
@@ -378,5 +377,46 @@
|
||||
"custom_accent_color": "Colore in Risalto Personalizzato",
|
||||
"custom_accent_color_description": "Inserisci un colore personalizzato usando formati di colore CSS validi (es: hex, rgb, hsl).",
|
||||
"color_value": "Valore Colore",
|
||||
"apply": "Applica"
|
||||
"apply": "Applica",
|
||||
"signup_token": "Signup Token",
|
||||
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
|
||||
"usage_limit": "Usage Limit",
|
||||
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
|
||||
"expires": "Expires",
|
||||
"signup": "Sign Up",
|
||||
"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",
|
||||
"signup_to_appname": "Sign Up to {appName}",
|
||||
"create_your_account_to_get_started": "Create your account to get started.",
|
||||
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
|
||||
"setup_your_passkey": "Set up your passkey",
|
||||
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
|
||||
"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.",
|
||||
"user_signups_are_disabled": "User signups are currently disabled",
|
||||
"create_signup_token": "Create Signup Token",
|
||||
"view_active_signup_tokens": "View Active Signup Tokens",
|
||||
"manage_signup_tokens": "Manage Signup Tokens",
|
||||
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
|
||||
"signup_token_deleted_successfully": "Signup token deleted successfully.",
|
||||
"expired": "Expired",
|
||||
"used_up": "Used Up",
|
||||
"active": "Active",
|
||||
"usage": "Usage",
|
||||
"created": "Created",
|
||||
"token": "Token",
|
||||
"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",
|
||||
"signup_open_description": "Anyone can create a new account without restrictions.",
|
||||
"of": "of",
|
||||
"skip_passkey_setup": "Skip Passkey Setup",
|
||||
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
|
||||
}
|
||||
|
||||
@@ -65,11 +65,9 @@
|
||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Wilt u zich afmelden bij Pocket ID met het account <b>{username}</b> ?",
|
||||
"sign_in_to_appname": "Meld u aan bij {appName}",
|
||||
"please_try_to_sign_in_again": "Probeer opnieuw in te loggen.",
|
||||
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Verifieer uzelf met uw toegangscode om toegang te krijgen tot het beheerderspaneel.",
|
||||
"authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
|
||||
"authenticate": "Authenticeren",
|
||||
"appname_setup": "{appName} Instellen",
|
||||
"please_try_again": "Probeer het opnieuw.",
|
||||
"you_are_about_to_sign_in_to_the_initial_admin_account": "U staat op het punt om in te loggen op het oorspronkelijke beheerdersaccount. Iedereen met deze link heeft toegang tot het account totdat er een passkey is toegevoegd. Stel zo snel mogelijk een passkey in om ongeautoriseerde toegang te voorkomen.",
|
||||
"continue": "Doorgaan",
|
||||
"alternative_sign_in": "Alternatieve aanmelding",
|
||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Als u geen toegang hebt tot uw passkeys, kunt u zich op een van de volgende manieren aanmelden.",
|
||||
@@ -320,6 +318,7 @@
|
||||
"all_users": "Alle gebruikers",
|
||||
"all_events": "Alle activiteiten",
|
||||
"all_clients": "Alle clients",
|
||||
"all_locations": "All Locations",
|
||||
"global_audit_log": "Algemeen audit logboek",
|
||||
"see_all_account_activities_from_the_last_3_months": "Bekijk alle gebruikersactiviteit van de afgelopen 3 maanden.",
|
||||
"token_sign_in": "Token Sign In",
|
||||
@@ -378,5 +377,46 @@
|
||||
"custom_accent_color": "Custom Accent Color",
|
||||
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
|
||||
"color_value": "Color Value",
|
||||
"apply": "Apply"
|
||||
"apply": "Apply",
|
||||
"signup_token": "Signup Token",
|
||||
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
|
||||
"usage_limit": "Usage Limit",
|
||||
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
|
||||
"expires": "Expires",
|
||||
"signup": "Sign Up",
|
||||
"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",
|
||||
"signup_to_appname": "Sign Up to {appName}",
|
||||
"create_your_account_to_get_started": "Create your account to get started.",
|
||||
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
|
||||
"setup_your_passkey": "Set up your passkey",
|
||||
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
|
||||
"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.",
|
||||
"user_signups_are_disabled": "User signups are currently disabled",
|
||||
"create_signup_token": "Create Signup Token",
|
||||
"view_active_signup_tokens": "View Active Signup Tokens",
|
||||
"manage_signup_tokens": "Manage Signup Tokens",
|
||||
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
|
||||
"signup_token_deleted_successfully": "Signup token deleted successfully.",
|
||||
"expired": "Expired",
|
||||
"used_up": "Used Up",
|
||||
"active": "Active",
|
||||
"usage": "Usage",
|
||||
"created": "Created",
|
||||
"token": "Token",
|
||||
"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",
|
||||
"signup_open_description": "Anyone can create a new account without restrictions.",
|
||||
"of": "of",
|
||||
"skip_passkey_setup": "Skip Passkey Setup",
|
||||
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
|
||||
}
|
||||
|
||||
@@ -65,11 +65,9 @@
|
||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Czy chcesz się wylogować z Pocket ID z konta <b>{username}</b>?",
|
||||
"sign_in_to_appname": "Zaloguj się do {appName}",
|
||||
"please_try_to_sign_in_again": "Spróbuj zalogować się ponownie.",
|
||||
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Uwierzytelnij się swoim kluczem, aby uzyskać dostęp do panelu administracyjnego.",
|
||||
"authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
|
||||
"authenticate": "Uwierzytelnij",
|
||||
"appname_setup": "Konfiguracja {appName}",
|
||||
"please_try_again": "Spróbuj ponownie.",
|
||||
"you_are_about_to_sign_in_to_the_initial_admin_account": "Zaraz zalogujesz się na początkowe konto administratora. Każdy z tym linkiem ma dostęp do konta, dopóki nie zostanie dodany klucz. Dodaj klucz jak najszybciej, aby zapobiec nieautoryzowanemu dostępowi.",
|
||||
"continue": "Kontynuuj",
|
||||
"alternative_sign_in": "Alternatywne logowanie",
|
||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Jeśli nie masz dostępu do swojego klucza, możesz zalogować się, używając jednej z następujących metod.",
|
||||
@@ -320,6 +318,7 @@
|
||||
"all_users": "Wszyscy użytkownicy",
|
||||
"all_events": "Wszystkie wydarzenia",
|
||||
"all_clients": "Wszyscy klienci",
|
||||
"all_locations": "All Locations",
|
||||
"global_audit_log": "Globalny dziennik audytu",
|
||||
"see_all_account_activities_from_the_last_3_months": "Zobacz wszystkie działania użytkowników z ostatnich 3 miesięcy.",
|
||||
"token_sign_in": "Logowanie za pomocą tokena",
|
||||
@@ -378,5 +377,46 @@
|
||||
"custom_accent_color": "Custom Accent Color",
|
||||
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
|
||||
"color_value": "Color Value",
|
||||
"apply": "Apply"
|
||||
"apply": "Apply",
|
||||
"signup_token": "Signup Token",
|
||||
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
|
||||
"usage_limit": "Usage Limit",
|
||||
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
|
||||
"expires": "Expires",
|
||||
"signup": "Sign Up",
|
||||
"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",
|
||||
"signup_to_appname": "Sign Up to {appName}",
|
||||
"create_your_account_to_get_started": "Create your account to get started.",
|
||||
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
|
||||
"setup_your_passkey": "Set up your passkey",
|
||||
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
|
||||
"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.",
|
||||
"user_signups_are_disabled": "User signups are currently disabled",
|
||||
"create_signup_token": "Create Signup Token",
|
||||
"view_active_signup_tokens": "View Active Signup Tokens",
|
||||
"manage_signup_tokens": "Manage Signup Tokens",
|
||||
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
|
||||
"signup_token_deleted_successfully": "Signup token deleted successfully.",
|
||||
"expired": "Expired",
|
||||
"used_up": "Used Up",
|
||||
"active": "Active",
|
||||
"usage": "Usage",
|
||||
"created": "Created",
|
||||
"token": "Token",
|
||||
"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",
|
||||
"signup_open_description": "Anyone can create a new account without restrictions.",
|
||||
"of": "of",
|
||||
"skip_passkey_setup": "Skip Passkey Setup",
|
||||
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
|
||||
}
|
||||
|
||||
@@ -65,11 +65,9 @@
|
||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Do you want to sign out of Pocket ID with the account <b>{username}</b>?",
|
||||
"sign_in_to_appname": "Entrar em {appName}",
|
||||
"please_try_to_sign_in_again": "Please try to sign in again.",
|
||||
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Authenticate yourself with your passkey to access the admin panel.",
|
||||
"authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
|
||||
"authenticate": "Autenticar",
|
||||
"appname_setup": "{appName} Setup",
|
||||
"please_try_again": "Please try again.",
|
||||
"you_are_about_to_sign_in_to_the_initial_admin_account": "You're about to sign in to the initial admin account. Anyone with this link can access the account until a passkey is added. Please set up a passkey as soon as possible to prevent unauthorized access.",
|
||||
"continue": "Continuar",
|
||||
"alternative_sign_in": "Alternative Sign In",
|
||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.",
|
||||
@@ -320,6 +318,7 @@
|
||||
"all_users": "All Users",
|
||||
"all_events": "All Events",
|
||||
"all_clients": "All Clients",
|
||||
"all_locations": "All Locations",
|
||||
"global_audit_log": "Global Audit Log",
|
||||
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
|
||||
"token_sign_in": "Token Sign In",
|
||||
@@ -378,5 +377,46 @@
|
||||
"custom_accent_color": "Custom Accent Color",
|
||||
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
|
||||
"color_value": "Color Value",
|
||||
"apply": "Apply"
|
||||
"apply": "Apply",
|
||||
"signup_token": "Signup Token",
|
||||
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
|
||||
"usage_limit": "Usage Limit",
|
||||
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
|
||||
"expires": "Expires",
|
||||
"signup": "Sign Up",
|
||||
"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",
|
||||
"signup_to_appname": "Sign Up to {appName}",
|
||||
"create_your_account_to_get_started": "Create your account to get started.",
|
||||
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
|
||||
"setup_your_passkey": "Set up your passkey",
|
||||
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
|
||||
"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.",
|
||||
"user_signups_are_disabled": "User signups are currently disabled",
|
||||
"create_signup_token": "Create Signup Token",
|
||||
"view_active_signup_tokens": "View Active Signup Tokens",
|
||||
"manage_signup_tokens": "Manage Signup Tokens",
|
||||
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
|
||||
"signup_token_deleted_successfully": "Signup token deleted successfully.",
|
||||
"expired": "Expired",
|
||||
"used_up": "Used Up",
|
||||
"active": "Active",
|
||||
"usage": "Usage",
|
||||
"created": "Created",
|
||||
"token": "Token",
|
||||
"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",
|
||||
"signup_open_description": "Anyone can create a new account without restrictions.",
|
||||
"of": "of",
|
||||
"skip_passkey_setup": "Skip Passkey Setup",
|
||||
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
|
||||
}
|
||||
|
||||
@@ -65,11 +65,9 @@
|
||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Вы хотите выйти из Pocket ID с учетной записью <b>{username}</b>?",
|
||||
"sign_in_to_appname": "Вход в {appName}",
|
||||
"please_try_to_sign_in_again": "Пожалуйста, попробуйте войти снова.",
|
||||
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Авторизуйтесь с использованием passkey для доступа к панели администратора.",
|
||||
"authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
|
||||
"authenticate": "Авторизоваться",
|
||||
"appname_setup": "Настройка {appName}",
|
||||
"please_try_again": "Пожалуйста, повторите попытку.",
|
||||
"you_are_about_to_sign_in_to_the_initial_admin_account": "Вы собираетесь впервые войти в учетную запись администратора. Любой пользователь с этой ссылкой может получить доступ к учетной записи до тех пор, пока не будет добавлен passkey. Пожалуйста, настройте passkey как можно скорее для предотвращения несанкционированного доступа.",
|
||||
"continue": "Продолжить",
|
||||
"alternative_sign_in": "Альтернативный вход",
|
||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Если у вас нет доступа к вашему passkey, вы можете войти одним из следующих способов.",
|
||||
@@ -320,6 +318,7 @@
|
||||
"all_users": "Все пользователи",
|
||||
"all_events": "Все события",
|
||||
"all_clients": "Все клиенты",
|
||||
"all_locations": "Все местоположения",
|
||||
"global_audit_log": "Глобальный журнал аудита",
|
||||
"see_all_account_activities_from_the_last_3_months": "Смотрите всю активность пользователей за последние 3 месяца.",
|
||||
"token_sign_in": "Вход с помощью токена",
|
||||
@@ -378,5 +377,46 @@
|
||||
"custom_accent_color": "Пользовательский цвет акцента",
|
||||
"custom_accent_color_description": "Введите пользовательский цвет, используя правильные цветовые форматы CSS (например, hex, rgb, hsl).",
|
||||
"color_value": "Значение цвета",
|
||||
"apply": "Применить"
|
||||
"apply": "Применить",
|
||||
"signup_token": "Signup Token",
|
||||
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
|
||||
"usage_limit": "Usage Limit",
|
||||
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
|
||||
"expires": "Expires",
|
||||
"signup": "Sign Up",
|
||||
"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",
|
||||
"signup_to_appname": "Sign Up to {appName}",
|
||||
"create_your_account_to_get_started": "Create your account to get started.",
|
||||
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
|
||||
"setup_your_passkey": "Set up your passkey",
|
||||
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
|
||||
"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.",
|
||||
"user_signups_are_disabled": "User signups are currently disabled",
|
||||
"create_signup_token": "Create Signup Token",
|
||||
"view_active_signup_tokens": "View Active Signup Tokens",
|
||||
"manage_signup_tokens": "Manage Signup Tokens",
|
||||
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
|
||||
"signup_token_deleted_successfully": "Signup token deleted successfully.",
|
||||
"expired": "Expired",
|
||||
"used_up": "Used Up",
|
||||
"active": "Active",
|
||||
"usage": "Usage",
|
||||
"created": "Created",
|
||||
"token": "Token",
|
||||
"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",
|
||||
"signup_open_description": "Anyone can create a new account without restrictions.",
|
||||
"of": "of",
|
||||
"skip_passkey_setup": "Skip Passkey Setup",
|
||||
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
|
||||
}
|
||||
|
||||
@@ -65,11 +65,9 @@
|
||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "您确定要退出 {appName} 应用中的帐号 <b>{username}</b> 吗?",
|
||||
"sign_in_to_appname": "登录到 {appName}",
|
||||
"please_try_to_sign_in_again": "请尝试重新登录。",
|
||||
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "使用通行密钥或通过临时登录码进行登录。",
|
||||
"authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
|
||||
"authenticate": "登录",
|
||||
"appname_setup": "{appName} 设置",
|
||||
"please_try_again": "请再试一次。",
|
||||
"you_are_about_to_sign_in_to_the_initial_admin_account": "您即将登录到初始管理员账户。在此添加通行密钥之前,任何拥有此链接的人都可以访问该账户。请尽快设置通行密钥以防止未经授权的访问。",
|
||||
"continue": "继续",
|
||||
"alternative_sign_in": "替代登录方式",
|
||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "如果您无法使用通行密钥,可以通过以下方式之一登录。",
|
||||
@@ -320,6 +318,7 @@
|
||||
"all_users": "所有用户",
|
||||
"all_events": "所有事件",
|
||||
"all_clients": "所有客户端",
|
||||
"all_locations": "All Locations",
|
||||
"global_audit_log": "全局日志",
|
||||
"see_all_account_activities_from_the_last_3_months": "查看过去 3 个月的所有用户活动。",
|
||||
"token_sign_in": "Token 登录",
|
||||
@@ -378,5 +377,46 @@
|
||||
"custom_accent_color": "Custom Accent Color",
|
||||
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
|
||||
"color_value": "Color Value",
|
||||
"apply": "Apply"
|
||||
"apply": "Apply",
|
||||
"signup_token": "Signup Token",
|
||||
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
|
||||
"usage_limit": "Usage Limit",
|
||||
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
|
||||
"expires": "Expires",
|
||||
"signup": "Sign Up",
|
||||
"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",
|
||||
"signup_to_appname": "Sign Up to {appName}",
|
||||
"create_your_account_to_get_started": "Create your account to get started.",
|
||||
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
|
||||
"setup_your_passkey": "Set up your passkey",
|
||||
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
|
||||
"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.",
|
||||
"user_signups_are_disabled": "User signups are currently disabled",
|
||||
"create_signup_token": "Create Signup Token",
|
||||
"view_active_signup_tokens": "View Active Signup Tokens",
|
||||
"manage_signup_tokens": "Manage Signup Tokens",
|
||||
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
|
||||
"signup_token_deleted_successfully": "Signup token deleted successfully.",
|
||||
"expired": "Expired",
|
||||
"used_up": "Used Up",
|
||||
"active": "Active",
|
||||
"usage": "Usage",
|
||||
"created": "Created",
|
||||
"token": "Token",
|
||||
"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",
|
||||
"signup_open_description": "Anyone can create a new account without restrictions.",
|
||||
"of": "of",
|
||||
"skip_passkey_setup": "Skip Passkey Setup",
|
||||
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
|
||||
}
|
||||
|
||||
@@ -65,11 +65,9 @@
|
||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "您確定要使用帳號 <b>{username}</b> 登出 {appName} 嗎?",
|
||||
"sign_in_to_appname": "登入 {appName}",
|
||||
"please_try_to_sign_in_again": "請嘗試重新登入。",
|
||||
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "請使用您的密碼金鑰進行驗證以存取管理面板。",
|
||||
"authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
|
||||
"authenticate": "驗證",
|
||||
"appname_setup": "{appName} 設定",
|
||||
"please_try_again": "請再試一次。",
|
||||
"you_are_about_to_sign_in_to_the_initial_admin_account": "您即將登入初始管理員帳號。在新增密碼金鑰之前,任何擁有此連結的人都可以存取該帳號。為避免未經授權的存取,請儘快設定密碼金鑰。",
|
||||
"continue": "繼續",
|
||||
"alternative_sign_in": "替代登入方式",
|
||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "如果您無法使用您的密碼金鑰,可以改用下列其中一種方式登入。",
|
||||
@@ -320,6 +318,7 @@
|
||||
"all_users": "所有使用者",
|
||||
"all_events": "所有事件",
|
||||
"all_clients": "所有客戶端",
|
||||
"all_locations": "All Locations",
|
||||
"global_audit_log": "全域稽核日誌",
|
||||
"see_all_account_activities_from_the_last_3_months": "查看過去 3 個月的所有使用者活動。",
|
||||
"token_sign_in": "Token 登入",
|
||||
@@ -378,5 +377,46 @@
|
||||
"custom_accent_color": "Custom Accent Color",
|
||||
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
|
||||
"color_value": "Color Value",
|
||||
"apply": "Apply"
|
||||
"apply": "Apply",
|
||||
"signup_token": "Signup Token",
|
||||
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
|
||||
"usage_limit": "Usage Limit",
|
||||
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
|
||||
"expires": "Expires",
|
||||
"signup": "Sign Up",
|
||||
"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",
|
||||
"signup_to_appname": "Sign Up to {appName}",
|
||||
"create_your_account_to_get_started": "Create your account to get started.",
|
||||
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
|
||||
"setup_your_passkey": "Set up your passkey",
|
||||
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
|
||||
"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.",
|
||||
"user_signups_are_disabled": "User signups are currently disabled",
|
||||
"create_signup_token": "Create Signup Token",
|
||||
"view_active_signup_tokens": "View Active Signup Tokens",
|
||||
"manage_signup_tokens": "Manage Signup Tokens",
|
||||
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
|
||||
"signup_token_deleted_successfully": "Signup token deleted successfully.",
|
||||
"expired": "Expired",
|
||||
"used_up": "Used Up",
|
||||
"active": "Active",
|
||||
"usage": "Usage",
|
||||
"created": "Created",
|
||||
"token": "Token",
|
||||
"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",
|
||||
"signup_open_description": "Anyone can create a new account without restrictions.",
|
||||
"of": "of",
|
||||
"skip_passkey_setup": "Skip Passkey Setup",
|
||||
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
|
||||
}
|
||||
|
||||
11001
frontend/package-lock.json
generated
11001
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pocket-id-frontend",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -28,7 +28,7 @@
|
||||
"@inlang/plugin-m-function-matcher": "^2.0.10",
|
||||
"@inlang/plugin-message-format": "^4.0.0",
|
||||
"@internationalized/date": "^3.8.2",
|
||||
"@lucide/svelte": "^0.513.0",
|
||||
"@lucide/svelte": "^0.522.0",
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.20.7",
|
||||
@@ -36,7 +36,7 @@
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/node": "^22.10.10",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"bits-ui": "^2.5.0",
|
||||
"bits-ui": "^2.8.8",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
{/if}
|
||||
{/if}
|
||||
{#if input?.error}
|
||||
<p class="text-destructive mt-1 text-xs">{input.error}</p>
|
||||
<p class="text-destructive mt-1 text-xs text-start">{input.error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
async function logout() {
|
||||
await webauthnService.logout();
|
||||
window.location.reload();
|
||||
goto('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
import Logo from '../logo.svelte';
|
||||
import HeaderAvatar from './header-avatar.svelte';
|
||||
|
||||
const authUrls = [/^\/authorize$/, /^\/device$/, /^\/login(?:\/.*)?$/, /^\/logout$/];
|
||||
const authUrls = [
|
||||
/^\/authorize$/,
|
||||
/^\/device$/,
|
||||
/^\/login(?:\/.*)?$/,
|
||||
/^\/logout$/,
|
||||
/^\/signup(?:\/.*)?$/
|
||||
];
|
||||
|
||||
let isAuthPage = $derived(
|
||||
!page.error && authUrls.some((pattern) => pattern.test(page.url.pathname))
|
||||
|
||||
64
frontend/src/lib/components/signup/signup-form.svelte
Normal file
64
frontend/src/lib/components/signup/signup-form.svelte
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import FormInput from '$lib/components/form/form-input.svelte';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import type { UserSignUp } from '$lib/types/user.type';
|
||||
import { preventDefault } from '$lib/utils/event-util';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
import { tryCatch } from '$lib/utils/try-catch-util';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
let {
|
||||
callback,
|
||||
isLoading
|
||||
}: {
|
||||
callback: (user: UserSignUp) => Promise<boolean>;
|
||||
isLoading: boolean;
|
||||
} = $props();
|
||||
|
||||
const initialData: UserSignUp = {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
username: ''
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
firstName: z.string().min(1).max(50),
|
||||
lastName: z.string().max(50).optional(),
|
||||
username: z
|
||||
.string()
|
||||
.min(2)
|
||||
.max(30)
|
||||
.regex(/^[a-z0-9_@.-]+$/, m.username_can_only_contain()),
|
||||
email: z.email()
|
||||
});
|
||||
type FormSchema = typeof formSchema;
|
||||
|
||||
const { inputs, ...form } = createForm<FormSchema>(formSchema, initialData);
|
||||
|
||||
let userData: UserSignUp | null = $state(null);
|
||||
|
||||
async function onSubmit() {
|
||||
const data = form.validate();
|
||||
if (!data) return;
|
||||
|
||||
isLoading = true;
|
||||
const result = await tryCatch(callback(data));
|
||||
if (result.data) {
|
||||
userData = data;
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form id="sign-up-form" onsubmit={preventDefault(onSubmit)} class="w-full">
|
||||
<div class="mt-7 space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
|
||||
<FormInput label={m.last_name()} bind:input={$inputs.lastName} />
|
||||
</div>
|
||||
|
||||
<FormInput label={m.username()} bind:input={$inputs.username} />
|
||||
<FormInput label={m.email()} bind:input={$inputs.email} type="email" />
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,185 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
||||
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
|
||||
import { Badge, type BadgeVariant } from '$lib/components/ui/badge';
|
||||
import { Button, buttonVariants } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||
import type { SignupTokenDto } from '$lib/types/signup-token.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { Copy, Ellipsis, Trash2 } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
signupTokens = $bindable(),
|
||||
signupTokensRequestOptions,
|
||||
onTokenDeleted
|
||||
}: {
|
||||
open: boolean;
|
||||
signupTokens: Paginated<SignupTokenDto>;
|
||||
signupTokensRequestOptions: SearchPaginationSortRequest;
|
||||
onTokenDeleted?: () => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
function formatDate(dateStr: string | undefined) {
|
||||
if (!dateStr) return m.never();
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
async function deleteToken(token: SignupTokenDto) {
|
||||
openConfirmDialog({
|
||||
title: m.delete_signup_token(),
|
||||
message: m.are_you_sure_you_want_to_delete_this_signup_token(),
|
||||
confirm: {
|
||||
label: m.delete(),
|
||||
destructive: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await userService.deleteSignupToken(token.id);
|
||||
toast.success(m.signup_token_deleted_successfully());
|
||||
|
||||
// Refresh the tokens
|
||||
if (onTokenDeleted) {
|
||||
await onTokenDeleted();
|
||||
}
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onOpenChange(isOpen: boolean) {
|
||||
open = isOpen;
|
||||
}
|
||||
|
||||
function isTokenExpired(expiresAt: string) {
|
||||
return new Date(expiresAt) < new Date();
|
||||
}
|
||||
|
||||
function isTokenUsedUp(token: SignupTokenDto) {
|
||||
return token.usageCount >= token.usageLimit;
|
||||
}
|
||||
|
||||
function getTokenStatus(token: SignupTokenDto) {
|
||||
if (isTokenExpired(token.expiresAt)) return 'expired';
|
||||
if (isTokenUsedUp(token)) return 'used-up';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string): { variant: BadgeVariant; text: string } {
|
||||
switch (status) {
|
||||
case 'expired':
|
||||
return { variant: 'destructive', text: m.expired() };
|
||||
case 'used-up':
|
||||
return { variant: 'secondary', text: m.used_up() };
|
||||
default:
|
||||
return { variant: 'default', text: m.active() };
|
||||
}
|
||||
}
|
||||
|
||||
function copySignupLink(token: SignupTokenDto) {
|
||||
const signupLink = `${$page.url.origin}/st/${token.token}`;
|
||||
navigator.clipboard
|
||||
.writeText(signupLink)
|
||||
.then(() => {
|
||||
toast.success(m.copied());
|
||||
})
|
||||
.catch((err) => {
|
||||
axiosErrorToast(err);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root {open} {onOpenChange}>
|
||||
<Dialog.Content class="sm-min-w[500px] max-h-[90vh] min-w-[90vw] overflow-auto lg:min-w-[1000px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{m.manage_signup_tokens()}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{m.view_and_manage_active_signup_tokens()}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<AdvancedTable
|
||||
items={signupTokens}
|
||||
requestOptions={signupTokensRequestOptions}
|
||||
withoutSearch={true}
|
||||
onRefresh={async (options) => {
|
||||
const result = await userService.listSignupTokens(options);
|
||||
signupTokens = result;
|
||||
return result;
|
||||
}}
|
||||
columns={[
|
||||
{ label: m.token() },
|
||||
{ label: m.status() },
|
||||
{ label: m.usage(), sortColumn: 'usageCount' },
|
||||
{ label: m.expires(), sortColumn: 'expiresAt' },
|
||||
{ label: m.created(), sortColumn: 'createdAt' },
|
||||
{ label: m.actions(), hidden: true }
|
||||
]}
|
||||
>
|
||||
{#snippet rows({ item })}
|
||||
<Table.Cell class="font-mono text-xs">
|
||||
{item.token.substring(0, 2)}...{item.token.substring(item.token.length - 4)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{@const status = getTokenStatus(item)}
|
||||
{@const statusBadge = getStatusBadge(status)}
|
||||
<Badge class="rounded-full" variant={statusBadge.variant}>
|
||||
{statusBadge.text}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div class="flex items-center gap-1">
|
||||
{`${item.usageCount} ${m.of()} ${item.usageLimit}`}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-sm">
|
||||
<div class="flex items-center gap-1">
|
||||
{formatDate(item.expiresAt)}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-sm">
|
||||
{formatDate(item.createdAt)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger class={buttonVariants({ variant: 'ghost', size: 'icon' })}>
|
||||
<Ellipsis class="size-4" />
|
||||
<span class="sr-only">{m.toggle_menu()}</span>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => copySignupLink(item)}>
|
||||
<Copy class="mr-2 size-4" />
|
||||
{m.copy()}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
class="text-red-500 focus:!text-red-700"
|
||||
onclick={() => deleteToken(item)}
|
||||
>
|
||||
<Trash2 class="mr-2 size-4" />
|
||||
{m.delete()}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
{/snippet}
|
||||
</AdvancedTable>
|
||||
</div>
|
||||
<Dialog.Footer class="mt-3">
|
||||
<Button onclick={() => (open = false)}>
|
||||
{m.close()}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
138
frontend/src/lib/components/signup/signup-token-modal.svelte
Normal file
138
frontend/src/lib/components/signup/signup-token-modal.svelte
Normal file
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
|
||||
import Qrcode from '$lib/components/qrcode/qrcode.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import * as Select from '$lib/components/ui/select/index.js';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { mode } from 'mode-watcher';
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
onTokenCreated
|
||||
}: {
|
||||
open: boolean;
|
||||
onTokenCreated?: () => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
let signupToken: string | null = $state(null);
|
||||
let signupLink: string | null = $state(null);
|
||||
let selectedExpiration: keyof typeof availableExpirations = $state(m.one_day());
|
||||
let usageLimit: number = $state(1);
|
||||
|
||||
let availableExpirations = {
|
||||
[m.one_hour()]: 60 * 60,
|
||||
[m.twelve_hours()]: 60 * 60 * 12,
|
||||
[m.one_day()]: 60 * 60 * 24,
|
||||
[m.one_week()]: 60 * 60 * 24 * 7,
|
||||
[m.one_month()]: 60 * 60 * 24 * 30
|
||||
};
|
||||
|
||||
async function createSignupToken() {
|
||||
try {
|
||||
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
|
||||
signupToken = await userService.createSignupToken(expiration, usageLimit);
|
||||
signupLink = `${page.url.origin}/st/${signupToken}`;
|
||||
|
||||
if (onTokenCreated) {
|
||||
await onTokenCreated();
|
||||
}
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
}
|
||||
|
||||
function onOpenChange(isOpen: boolean) {
|
||||
open = isOpen;
|
||||
if (!isOpen) {
|
||||
signupToken = null;
|
||||
signupLink = null;
|
||||
selectedExpiration = m.one_day();
|
||||
usageLimit = 1;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root {open} {onOpenChange}>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{m.signup_token()}</Dialog.Title>
|
||||
<Dialog.Description
|
||||
>{m.create_a_signup_token_to_allow_new_user_registration()}</Dialog.Description
|
||||
>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if signupToken === null}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<Label for="expiration">{m.expiration()}</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={Object.keys(availableExpirations)[0]}
|
||||
onValueChange={(v) => (selectedExpiration = v! as keyof typeof availableExpirations)}
|
||||
>
|
||||
<Select.Trigger id="expiration" class="h-9 w-full">
|
||||
{selectedExpiration}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each Object.keys(availableExpirations) as key}
|
||||
<Select.Item value={key}>{key}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-0" for="usage-limit">{m.usage_limit()}</Label>
|
||||
<p class="text-muted-foreground mt-1 mb-2 text-xs">
|
||||
{m.number_of_times_token_can_be_used()}
|
||||
</p>
|
||||
<Input
|
||||
id="usage-limit"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
bind:value={usageLimit}
|
||||
class="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="mt-4">
|
||||
<Button
|
||||
onclick={() => createSignupToken()}
|
||||
disabled={!selectedExpiration || usageLimit < 1}
|
||||
>
|
||||
{m.create()}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<Qrcode
|
||||
class="mb-2"
|
||||
value={signupLink}
|
||||
size={180}
|
||||
color={mode.current === 'dark' ? '#FFFFFF' : '#000000'}
|
||||
backgroundColor={mode.current === 'dark' ? '#000000' : '#FFFFFF'}
|
||||
/>
|
||||
<CopyToClipboard value={signupLink!}>
|
||||
<p data-testId="signup-token-link" class="px-2 text-center text-sm break-all">
|
||||
{signupLink!}
|
||||
</p>
|
||||
</CopyToClipboard>
|
||||
|
||||
<div class="text-muted-foreground mt-2 text-center text-sm">
|
||||
<p>{m.usage_limit()}: {usageLimit}</p>
|
||||
<p>{m.expiration()}: {selectedExpiration}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts" module>
|
||||
import { cn } from '$lib/utils/style.js';
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
|
||||
export type DropdownButtonContentProps = DropdownMenuPrimitive.ContentProps;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
sideOffset = 4,
|
||||
portalProps,
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.ContentProps & {
|
||||
portalProps?: DropdownMenuPrimitive.PortalProps;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Portal {...portalProps}>
|
||||
<DropdownMenuPrimitive.Content
|
||||
bind:ref
|
||||
{sideOffset}
|
||||
class={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 overflow-hidden rounded-md border p-1 shadow-md outline-none',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<DropdownMenuPrimitive.Arrow />
|
||||
{@render children?.()}
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils/style.js';
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.ItemProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Item
|
||||
bind:ref
|
||||
class={cn(
|
||||
'data-highlighted:bg-accent data-highlighted:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts" module>
|
||||
import { cn, type WithElementRef } from '$lib/utils/style.js';
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||
import {
|
||||
buttonVariants,
|
||||
type ButtonVariant,
|
||||
type ButtonSize
|
||||
} from '$lib/components/ui/button/button.svelte';
|
||||
|
||||
export type DropdownButtonMainProps = WithElementRef<HTMLButtonAttributes> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let {
|
||||
class: className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
ref = $bindable(null),
|
||||
type = 'button',
|
||||
disabled,
|
||||
children,
|
||||
...restProps
|
||||
}: DropdownButtonMainProps = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
bind:this={ref}
|
||||
data-slot="dropdown-button-main"
|
||||
class={cn(buttonVariants({ variant, size }), 'rounded-r-none border-r-0', className)}
|
||||
{type}
|
||||
{disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts" module>
|
||||
import { cn } from '$lib/utils/style.js';
|
||||
|
||||
export type DropdownButtonSeparatorProps = DropdownMenuPrimitive.SeparatorProps;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.SeparatorProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Separator
|
||||
bind:ref
|
||||
class={cn('bg-muted -mx-1 my-1 h-px', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts" module>
|
||||
import { cn, type WithElementRef } from '$lib/utils/style.js';
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||
import {
|
||||
buttonVariants,
|
||||
type ButtonVariant,
|
||||
type ButtonSize
|
||||
} from '$lib/components/ui/button/button.svelte';
|
||||
|
||||
export type DropdownButtonTriggerProps = WithElementRef<HTMLButtonAttributes> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
builders?: any[];
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||
|
||||
let {
|
||||
class: className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
ref = $bindable(null),
|
||||
type = 'button',
|
||||
disabled,
|
||||
builders = [],
|
||||
children,
|
||||
...restProps
|
||||
}: DropdownButtonTriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
bind:this={ref}
|
||||
use:builders[0]
|
||||
data-slot="dropdown-button-trigger"
|
||||
class={cn(
|
||||
buttonVariants({ variant, size }),
|
||||
'border-l-background/20 rounded-l-none border-l px-2',
|
||||
className
|
||||
)}
|
||||
{type}
|
||||
{disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<ChevronDown class="size-4" />
|
||||
{/if}
|
||||
</button>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts" module>
|
||||
import { cn, type WithElementRef } from '$lib/utils/style.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
export type DropdownButtonProps = WithElementRef<HTMLAttributes<HTMLDivElement>>;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let {
|
||||
class: className,
|
||||
ref = $bindable(null),
|
||||
children,
|
||||
...restProps
|
||||
}: DropdownButtonProps = $props();
|
||||
</script>
|
||||
|
||||
<div bind:this={ref} data-slot="dropdown-button" class={cn('flex', className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
30
frontend/src/lib/components/ui/dropdown-button/index.ts
Normal file
30
frontend/src/lib/components/ui/dropdown-button/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||
import Root from './dropdown-button.svelte';
|
||||
import Main from './dropdown-button-main.svelte';
|
||||
import Trigger from './dropdown-button-trigger.svelte';
|
||||
import Content from './dropdown-button-content.svelte';
|
||||
import Item from './dropdown-button-item.svelte';
|
||||
import Separator from './dropdown-button-separator.svelte';
|
||||
|
||||
const DropdownRoot = DropdownMenuPrimitive.Root;
|
||||
const DropdownTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Main,
|
||||
Trigger,
|
||||
Content,
|
||||
Item,
|
||||
Separator,
|
||||
DropdownRoot,
|
||||
DropdownTrigger,
|
||||
//
|
||||
Root as DropdownButton,
|
||||
Main as DropdownButtonMain,
|
||||
Trigger as DropdownButtonTrigger,
|
||||
Content as DropdownButtonContent,
|
||||
Item as DropdownButtonItem,
|
||||
Separator as DropdownButtonSeparator,
|
||||
DropdownRoot as DropdownButtonRoot,
|
||||
DropdownTrigger as DropdownButtonPrimitiveTrigger
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||
import type { SignupTokenDto } from '$lib/types/signup-token.type';
|
||||
import type { UserGroup } from '$lib/types/user-group.type';
|
||||
import type { User, UserCreate } from '$lib/types/user.type';
|
||||
import type { User, UserCreate, UserSignUp } from '$lib/types/user.type';
|
||||
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
|
||||
import { get } from 'svelte/store';
|
||||
import APIService from './api-service';
|
||||
@@ -82,6 +83,14 @@ export default class UserService extends APIService {
|
||||
return res.data.token;
|
||||
}
|
||||
|
||||
async createSignupToken(expiresAt: Date, usageLimit: number) {
|
||||
const res = await this.api.post(`/signup-tokens`, {
|
||||
expiresAt,
|
||||
usageLimit
|
||||
});
|
||||
return res.data.token;
|
||||
}
|
||||
|
||||
async exchangeOneTimeAccessToken(token: string) {
|
||||
const res = await this.api.post(`/one-time-access-token/${token}`);
|
||||
return res.data as User;
|
||||
@@ -99,4 +108,25 @@ export default class UserService extends APIService {
|
||||
const res = await this.api.put(`/users/${id}/user-groups`, { userGroupIds });
|
||||
return res.data as User;
|
||||
}
|
||||
|
||||
async signup(data: UserSignUp) {
|
||||
const res = await this.api.post(`/signup`, data);
|
||||
return res.data as User;
|
||||
}
|
||||
|
||||
async signupInitialUser(data: UserSignUp) {
|
||||
const res = await this.api.post(`/signup/setup`, data);
|
||||
return res.data as User;
|
||||
}
|
||||
|
||||
async listSignupTokens(options?: SearchPaginationSortRequest) {
|
||||
const res = await this.api.get('/signup-tokens', {
|
||||
params: options
|
||||
});
|
||||
return res.data as Paginated<SignupTokenDto>;
|
||||
}
|
||||
|
||||
async deleteSignupToken(tokenId: string) {
|
||||
await this.api.delete(`/signup-tokens/${tokenId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type AppConfig = {
|
||||
appName: string;
|
||||
allowOwnAccountEdit: boolean;
|
||||
allowUserSignups: 'disabled' | 'withToken' | 'open';
|
||||
emailOneTimeAccessAsUnauthenticatedEnabled: boolean;
|
||||
emailOneTimeAccessAsAdminEnabled: boolean;
|
||||
ldapEnabled: boolean;
|
||||
|
||||
8
frontend/src/lib/types/signup-token.type.ts
Normal file
8
frontend/src/lib/types/signup-token.type.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface SignupTokenDto {
|
||||
id: string;
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
usageLimit: number;
|
||||
usageCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -17,3 +17,7 @@ export type User = {
|
||||
};
|
||||
|
||||
export type UserCreate = Omit<User, 'id' | 'customClaims' | 'ldapId' | 'userGroups'>;
|
||||
|
||||
export type UserSignUp = Omit<UserCreate, 'isAdmin' | 'disabled'> & {
|
||||
token?: string;
|
||||
};
|
||||
|
||||
@@ -7,7 +7,13 @@ export function getAuthRedirectPath(path: string, user: User | null) {
|
||||
const isAdmin = user?.isAdmin;
|
||||
|
||||
const isUnauthenticatedOnlyPath =
|
||||
path == '/login' || path.startsWith('/login/') || path == '/lc' || path.startsWith('/lc/');
|
||||
path == '/login' ||
|
||||
path.startsWith('/login/') ||
|
||||
path == '/lc' ||
|
||||
path.startsWith('/lc/') ||
|
||||
path == '/signup' ||
|
||||
path == '/signup/setup' ||
|
||||
path.startsWith('/st/');
|
||||
const isPublicPath = ['/authorize', '/device', '/health', '/healthz'].includes(path);
|
||||
const isAdminPath = path == '/settings/admin' || path.startsWith('/settings/admin/');
|
||||
|
||||
|
||||
20
frontend/src/lib/utils/try-catch-util.ts
Normal file
20
frontend/src/lib/utils/try-catch-util.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
type Success<T> = {
|
||||
data: T;
|
||||
error: null;
|
||||
};
|
||||
|
||||
type Failure<E> = {
|
||||
data: null;
|
||||
error: E;
|
||||
};
|
||||
|
||||
export type Result<T, E = Error> = Success<T> | Failure<E>;
|
||||
|
||||
export async function tryCatch<T, E = Error>(promise: Promise<T>): Promise<Result<T, E>> {
|
||||
try {
|
||||
const data = await promise;
|
||||
return { data, error: null };
|
||||
} catch (error) {
|
||||
return { data: null, error: error as E };
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import WebAuthnService from '$lib/services/webauthn-service';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import userStore from '$lib/stores/user-store';
|
||||
@@ -9,7 +10,6 @@
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import { fade } from 'svelte/transition';
|
||||
import LoginLogoErrorSuccessIndicator from './components/login-logo-error-success-indicator.svelte';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
const webauthnService = new WebAuthnService();
|
||||
|
||||
let isLoading = $state(false);
|
||||
@@ -49,10 +49,17 @@
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-muted-foreground mt-2" in:fade>
|
||||
{m.authenticate_yourself_with_your_passkey_to_access_the_admin_panel()}
|
||||
{m.authenticate_with_passkey_to_access_account()}
|
||||
</p>
|
||||
{/if}
|
||||
<Button class="mt-10" {isLoading} onclick={authenticate} autofocus={true}>
|
||||
{error ? m.try_again() : m.authenticate()}
|
||||
</Button>
|
||||
<div class="mt-10 flex justify-center gap-3">
|
||||
{#if $appConfigStore.allowUserSignups === 'open'}
|
||||
<Button variant="secondary" href="/signup">
|
||||
{m.signup()}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button {isLoading} onclick={authenticate} autofocus={true}>
|
||||
{error ? m.try_again() : m.authenticate()}
|
||||
</Button>
|
||||
</div>
|
||||
</SignInWrapper>
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store.js';
|
||||
import userStore from '$lib/stores/user-store.js';
|
||||
import { getAxiosErrorMessage } from '$lib/utils/error-util';
|
||||
import LoginLogoErrorSuccessIndicator from '../components/login-logo-error-success-indicator.svelte';
|
||||
|
||||
let isLoading = $state(false);
|
||||
let error: string | undefined = $state();
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
async function authenticate() {
|
||||
isLoading = true;
|
||||
try {
|
||||
const user = await userService.exchangeOneTimeAccessToken('setup');
|
||||
userStore.setUser(user);
|
||||
|
||||
goto('/settings');
|
||||
} catch (e) {
|
||||
error = getAxiosErrorMessage(e);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<SignInWrapper animate={!$appConfigStore.disableAnimations}>
|
||||
<div class="flex justify-center">
|
||||
<LoginLogoErrorSuccessIndicator error={!!error} />
|
||||
</div>
|
||||
<h1 class="font-playfair mt-5 text-4xl font-bold">
|
||||
{m.appname_setup({ appName: $appConfigStore.appName })}
|
||||
</h1>
|
||||
{#if error}
|
||||
<p class="text-muted-foreground mt-2">
|
||||
{error}. {m.please_try_again()}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-muted-foreground mt-2">
|
||||
{m.you_are_about_to_sign_in_to_the_initial_admin_account()}
|
||||
</p>
|
||||
<Button class="mt-5" {isLoading} onclick={authenticate}>{m.continue()}</Button>
|
||||
{/if}
|
||||
</SignInWrapper>
|
||||
@@ -7,8 +7,9 @@
|
||||
|
||||
let {
|
||||
selectedColor = $bindable(),
|
||||
previousColor
|
||||
}: { selectedColor: string; previousColor: string } = $props();
|
||||
previousColor,
|
||||
disabled = false
|
||||
}: { selectedColor: string; previousColor: string; disabled?: boolean } = $props();
|
||||
let showCustomColorDialog = $state(false);
|
||||
|
||||
const accentColors = [
|
||||
@@ -30,10 +31,6 @@
|
||||
selectedColor = accentValue;
|
||||
applyAccentColor(accentValue);
|
||||
}
|
||||
|
||||
function handleCustomColorApply(color: string) {
|
||||
handleAccentColorChange(color);
|
||||
}
|
||||
</script>
|
||||
|
||||
<RadioGroup.Root
|
||||
@@ -54,7 +51,7 @@
|
||||
{@render colorOption('Custom', 'custom', false, true)}
|
||||
</RadioGroup.Root>
|
||||
|
||||
<CustomColorDialog bind:open={showCustomColorDialog} onApply={handleCustomColorApply} />
|
||||
<CustomColorDialog bind:open={showCustomColorDialog} onApply={handleAccentColorChange} />
|
||||
|
||||
{#snippet colorOption(
|
||||
label: string,
|
||||
@@ -66,9 +63,13 @@
|
||||
<RadioGroup.Item id={color} value={color} class="sr-only" />
|
||||
<Label
|
||||
for={color}
|
||||
class="cursor-pointer {isCustomColorSelection ? 'group' : ''}"
|
||||
class={{
|
||||
'cursor-pointer': !disabled,
|
||||
'cursor-not-allowed': disabled,
|
||||
group: isCustomColorSelection
|
||||
}}
|
||||
onclick={() => {
|
||||
if (isCustomColorSelection) {
|
||||
if (isCustomColorSelection && !disabled) {
|
||||
showCustomColorDialog = true;
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import type { AllAppConfig } from '$lib/types/application-configuration';
|
||||
@@ -22,11 +23,27 @@
|
||||
|
||||
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
|
||||
};
|
||||
@@ -36,6 +53,7 @@
|
||||
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()
|
||||
});
|
||||
@@ -62,13 +80,62 @@
|
||||
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()}
|
||||
description={m.whether_the_users_should_be_able_to_edit_their_own_account_details()}
|
||||
bind:checked={$inputs.allowOwnAccountEdit.value}
|
||||
/>
|
||||
|
||||
<SwitchWithLabel
|
||||
id="emails-verified"
|
||||
label={m.emails_verified()}
|
||||
@@ -94,6 +161,7 @@
|
||||
<AccentColorPicker
|
||||
previousColor={appConfig.accentColor}
|
||||
bind:selectedColor={$inputs.accentColor.value}
|
||||
disabled={$appConfigStore.uiConfigDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script lang="ts">
|
||||
import SignupTokenListModal from '$lib/components/signup/signup-token-list-modal.svelte';
|
||||
import SignupTokenModal from '$lib/components/signup/signup-token-modal.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as DropdownButton from '$lib/components/ui/dropdown-button';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
@@ -15,8 +18,13 @@
|
||||
let { data } = $props();
|
||||
let users = $state(data.users);
|
||||
let usersRequestOptions = $state(data.usersRequestOptions);
|
||||
let signupTokens = $state(data.signupTokens);
|
||||
let signupTokensRequestOptions = $state(data.signupTokensRequestOptions);
|
||||
|
||||
let selectedCreateOptions = $state('Add User');
|
||||
let expandAddUser = $state(false);
|
||||
let signupTokenModalOpen = $state(false);
|
||||
let signupTokenListModalOpen = $state(false);
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
@@ -33,6 +41,10 @@
|
||||
users = await userService.list(usersRequestOptions);
|
||||
return success;
|
||||
}
|
||||
|
||||
async function refreshSignupTokens() {
|
||||
signupTokens = await userService.listSignupTokens(signupTokensRequestOptions);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -55,7 +67,30 @@
|
||||
>
|
||||
</div>
|
||||
{#if !expandAddUser}
|
||||
<Button onclick={() => (expandAddUser = true)}>{m.add_user()}</Button>
|
||||
{#if $appConfigStore.allowUserSignups !== 'disabled'}
|
||||
<DropdownButton.DropdownRoot>
|
||||
<DropdownButton.Root>
|
||||
<DropdownButton.Main disabled={false} onclick={() => (expandAddUser = true)}>
|
||||
{selectedCreateOptions}
|
||||
</DropdownButton.Main>
|
||||
|
||||
<DropdownButton.DropdownTrigger>
|
||||
<DropdownButton.Trigger class="border-l" />
|
||||
</DropdownButton.DropdownTrigger>
|
||||
</DropdownButton.Root>
|
||||
|
||||
<DropdownButton.Content align="end">
|
||||
<DropdownButton.Item onclick={() => (signupTokenModalOpen = true)}>
|
||||
{m.create_signup_token()}
|
||||
</DropdownButton.Item>
|
||||
<DropdownButton.Item onclick={() => (signupTokenListModalOpen = true)}>
|
||||
{m.view_active_signup_tokens()}
|
||||
</DropdownButton.Item>
|
||||
</DropdownButton.Content>
|
||||
</DropdownButton.DropdownRoot>
|
||||
{:else}
|
||||
<Button onclick={() => (expandAddUser = true)}>{m.add_user()}</Button>
|
||||
{/if}
|
||||
{:else}
|
||||
<Button class="h-8 p-3" variant="ghost" onclick={() => (expandAddUser = false)}>
|
||||
<LucideMinus class="size-5" />
|
||||
@@ -86,3 +121,11 @@
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<SignupTokenModal bind:open={signupTokenModalOpen} onTokenCreated={refreshSignupTokens} />
|
||||
<SignupTokenListModal
|
||||
bind:open={signupTokenListModalOpen}
|
||||
bind:signupTokens
|
||||
{signupTokensRequestOptions}
|
||||
onTokenDeleted={refreshSignupTokens}
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,22 @@ export const load: PageLoad = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const users = await userService.list(usersRequestOptions);
|
||||
return { users, usersRequestOptions };
|
||||
const signupTokensRequestOptions: SearchPaginationSortRequest = {
|
||||
sort: {
|
||||
column: 'createdAt',
|
||||
direction: 'desc'
|
||||
}
|
||||
};
|
||||
|
||||
const [users, signupTokens] = await Promise.all([
|
||||
userService.list(usersRequestOptions),
|
||||
userService.listSignupTokens(signupTokensRequestOptions)
|
||||
]);
|
||||
|
||||
return {
|
||||
users,
|
||||
usersRequestOptions,
|
||||
signupTokens,
|
||||
signupTokensRequestOptions
|
||||
};
|
||||
};
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
SIGN_IN: m.sign_in(),
|
||||
TOKEN_SIGN_IN: m.token_sign_in(),
|
||||
CLIENT_AUTHORIZATION: m.client_authorization(),
|
||||
NEW_CLIENT_AUTHORIZATION: m.new_client_authorization()
|
||||
NEW_CLIENT_AUTHORIZATION: m.new_client_authorization(),
|
||||
ACCOUNT_CREATED: m.account_created()
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
|
||||
5
frontend/src/routes/setup/+page.ts
Normal file
5
frontend/src/routes/setup/+page.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
// Alias for /signup/setup
|
||||
export const load: PageLoad = async () => redirect(307, '/signup/setup');
|
||||
90
frontend/src/routes/signup/+page.svelte
Normal file
90
frontend/src/routes/signup/+page.svelte
Normal file
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||
import SignupForm from '$lib/components/signup/signup-form.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import type { UserSignUp } from '$lib/types/user.type';
|
||||
import { getAxiosErrorMessage } from '$lib/utils/error-util';
|
||||
import { tryCatch } from '$lib/utils/try-catch-util';
|
||||
import { LucideChevronLeft } from '@lucide/svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import LoginLogoErrorSuccessIndicator from '../login/components/login-logo-error-success-indicator.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
const userService = new UserService();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let error: string | undefined = $state();
|
||||
|
||||
async function handleSignup(userData: UserSignUp) {
|
||||
isLoading = true;
|
||||
|
||||
const result = await tryCatch(userService.signup({ ...userData, token: data.token }));
|
||||
|
||||
if (result.error) {
|
||||
error = getAxiosErrorMessage(result.error);
|
||||
isLoading = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
userStore.setUser(result.data);
|
||||
isLoading = false;
|
||||
|
||||
goto('/signup/add-passkey');
|
||||
return true;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!$appConfigStore.allowUserSignups || $appConfigStore.allowUserSignups === 'disabled') {
|
||||
error = m.user_signups_are_disabled();
|
||||
return;
|
||||
}
|
||||
|
||||
// For token-based signups, check if we have a valid token
|
||||
if ($appConfigStore.allowUserSignups === 'withToken' && !data.token) {
|
||||
error = m.signup_requires_valid_token();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.signup()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<SignInWrapper animate={!$appConfigStore.disableAnimations}>
|
||||
<div class="flex justify-center">
|
||||
<LoginLogoErrorSuccessIndicator error={!!error} />
|
||||
</div>
|
||||
|
||||
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
|
||||
{m.signup_to_appname({ appName: $appConfigStore.appName })}
|
||||
</h1>
|
||||
|
||||
{#if !error}
|
||||
<p class="text-muted-foreground mt-2" in:fade>
|
||||
{m.create_your_account_to_get_started()}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-muted-foreground mt-2" in:fade>
|
||||
{error}.
|
||||
</p>
|
||||
{/if}
|
||||
{#if $appConfigStore.allowUserSignups === 'open' || data.token}
|
||||
<SignupForm callback={handleSignup} {isLoading} />
|
||||
<div class="mt-10 flex w-full items-center justify-between gap-2">
|
||||
<a class="text-muted-foreground mt-5 flex text-sm" href="/login"
|
||||
><LucideChevronLeft class="size-5" /> {m.back()}</a
|
||||
>
|
||||
<Button type="submit" form="sign-up-form" onclick={() => (error = undefined)}
|
||||
>{m.signup()}</Button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<Button class="mt-10" href="/login">{m.go_to_login()}</Button>
|
||||
{/if}
|
||||
</SignInWrapper>
|
||||
7
frontend/src/routes/signup/+page.ts
Normal file
7
frontend/src/routes/signup/+page.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ url }) => {
|
||||
return {
|
||||
token: url.searchParams.get('token') || undefined
|
||||
};
|
||||
};
|
||||
92
frontend/src/routes/signup/add-passkey/+page.svelte
Normal file
92
frontend/src/routes/signup/add-passkey/+page.svelte
Normal file
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { openConfirmDialog } from '$lib/components/confirm-dialog';
|
||||
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import WebAuthnService from '$lib/services/webauthn-service';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||
import { tryCatch } from '$lib/utils/try-catch-util';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
import { fade } from 'svelte/transition';
|
||||
import LoginLogoErrorSuccessIndicator from '../../login/components/login-logo-error-success-indicator.svelte';
|
||||
|
||||
const webauthnService = new WebAuthnService();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let error: string | undefined = $state();
|
||||
|
||||
async function createPasskeyAndContinue() {
|
||||
isLoading = true;
|
||||
error = undefined;
|
||||
|
||||
const optsResult = await tryCatch(webauthnService.getRegistrationOptions());
|
||||
if (optsResult.error) {
|
||||
error = getWebauthnErrorMessage(optsResult.error);
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const attRespResult = await tryCatch(startRegistration({ optionsJSON: optsResult.data }));
|
||||
if (attRespResult.error) {
|
||||
error = getWebauthnErrorMessage(attRespResult.error);
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const finishResult = await tryCatch(webauthnService.finishRegistration(attRespResult.data));
|
||||
if (finishResult.error) {
|
||||
error = getWebauthnErrorMessage(finishResult.error);
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
goto('/settings/account');
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
function skipForNow() {
|
||||
openConfirmDialog({
|
||||
title: m.skip_passkey_setup(),
|
||||
message: m.skip_passkey_setup_description(),
|
||||
confirm: {
|
||||
label: m.skip_for_now(),
|
||||
destructive: true,
|
||||
action: () => {
|
||||
goto('/settings/account');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.add_passkey()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<SignInWrapper animate={!$appConfigStore.disableAnimations}>
|
||||
<div class="w-full text-center">
|
||||
<div class="flex justify-center">
|
||||
<LoginLogoErrorSuccessIndicator error={!!error} />
|
||||
</div>
|
||||
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
|
||||
{m.setup_your_passkey()}
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-2" in:fade>
|
||||
{#if !error}
|
||||
{m.create_a_passkey_to_securely_access_your_account()}
|
||||
{:else}
|
||||
{error}. {m.please_try_again()}
|
||||
{/if}
|
||||
</p>
|
||||
<div class="mt-10 flex w-full justify-between gap-2">
|
||||
<Button variant="secondary" onclick={skipForNow} disabled={isLoading} class="flex-1">
|
||||
{m.skip_for_now()}
|
||||
</Button>
|
||||
<Button onclick={createPasskeyAndContinue} {isLoading} class="flex-1">
|
||||
{m.add_passkey()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SignInWrapper>
|
||||
70
frontend/src/routes/signup/setup/+page.svelte
Normal file
70
frontend/src/routes/signup/setup/+page.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||
import SignupForm from '$lib/components/signup/signup-form.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import type { UserSignUp } from '$lib/types/user.type';
|
||||
import { getAxiosErrorMessage } from '$lib/utils/error-util';
|
||||
import { tryCatch } from '$lib/utils/try-catch-util';
|
||||
import { fade } from 'svelte/transition';
|
||||
import LoginLogoErrorSuccessIndicator from '../../login/components/login-logo-error-success-indicator.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
const userService = new UserService();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let error: string | undefined = $state();
|
||||
|
||||
async function handleSignup(userData: UserSignUp) {
|
||||
isLoading = true;
|
||||
|
||||
const result = await tryCatch(userService.signupInitialUser(userData));
|
||||
|
||||
if (result.error) {
|
||||
error = getAxiosErrorMessage(result.error);
|
||||
isLoading = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
userStore.setUser(result.data);
|
||||
isLoading = false;
|
||||
|
||||
goto('/signup/add-passkey');
|
||||
return true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.signup()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<SignInWrapper animate={!$appConfigStore.disableAnimations}>
|
||||
<div class="flex justify-center">
|
||||
<LoginLogoErrorSuccessIndicator error={!!error} />
|
||||
</div>
|
||||
|
||||
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
|
||||
{m.signup_to_appname({ appName: $appConfigStore.appName })}
|
||||
</h1>
|
||||
|
||||
{#if !error}
|
||||
<p class="text-muted-foreground mt-2" in:fade>
|
||||
{m.initial_account_creation_description()}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-muted-foreground mt-2" in:fade>
|
||||
{error}.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<SignupForm callback={handleSignup} {isLoading} />
|
||||
<div class="mt-10 flex w-full justify-end">
|
||||
<Button type="submit" form="sign-up-form" onclick={() => (error = undefined)}
|
||||
>{m.signup()}</Button
|
||||
>
|
||||
</div>
|
||||
</SignInWrapper>
|
||||
16
frontend/src/routes/st/[token]/+page.ts
Normal file
16
frontend/src/routes/st/[token]/+page.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
// Alias for /signup?token=...
|
||||
export const load: PageLoad = async ({ url, params }) => {
|
||||
const targetPath = '/signup';
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('token', params.token);
|
||||
|
||||
if (url.searchParams.has('redirect')) {
|
||||
searchParams.set('redirect', url.searchParams.get('redirect')!);
|
||||
}
|
||||
|
||||
return redirect(307, `${targetPath}?${searchParams.toString()}`);
|
||||
};
|
||||
6
tests/.prettierignore
Normal file
6
tests/.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
|
||||
.output/
|
||||
6
tests/.prettierrc
Normal file
6
tests/.prettierrc
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -36,13 +36,13 @@ export const oidcClients = {
|
||||
secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x'
|
||||
},
|
||||
federated: {
|
||||
id: "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
|
||||
id: 'c48232ff-ff65-45ed-ae96-7afa8a9b443b',
|
||||
name: 'Federated',
|
||||
callbackUrl: 'http://federated/auth/callback',
|
||||
federatedJWT: {
|
||||
issuer: 'https://external-idp.local',
|
||||
issuer: 'https://external-idp.local',
|
||||
audience: 'api://PocketID',
|
||||
subject: 'c48232ff-ff65-45ed-ae96-7afa8a9b443b',
|
||||
subject: 'c48232ff-ff65-45ed-ae96-7afa8a9b443b'
|
||||
},
|
||||
accessCodes: ['federated']
|
||||
},
|
||||
@@ -97,3 +97,38 @@ export const refreshTokens = [
|
||||
expired: true
|
||||
}
|
||||
];
|
||||
|
||||
export const signupTokens = {
|
||||
valid: {
|
||||
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
token: 'VALID1234567890A',
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
usageLimit: 1,
|
||||
usageCount: 0,
|
||||
createdAt: new Date().toISOString()
|
||||
},
|
||||
partiallyUsed: {
|
||||
id: 'b2c3d4e5-f6g7-8901-bcde-f12345678901',
|
||||
token: 'PARTIAL567890ABC',
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
usageLimit: 5,
|
||||
usageCount: 2,
|
||||
createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString()
|
||||
},
|
||||
expired: {
|
||||
id: 'c3d4e5f6-g7h8-9012-cdef-123456789012',
|
||||
token: 'EXPIRED34567890B',
|
||||
expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
|
||||
usageLimit: 3,
|
||||
usageCount: 1,
|
||||
createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString()
|
||||
},
|
||||
fullyUsed: {
|
||||
id: 'd4e5f6g7-h8i9-0123-def0-234567890123',
|
||||
token: 'FULLYUSED567890C',
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
usageLimit: 1,
|
||||
usageCount: 1,
|
||||
createdAt: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
230
tests/package-lock.json
generated
230
tests/package-lock.json
generated
@@ -1,118 +1,116 @@
|
||||
{
|
||||
"name": "tests",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@types/node": "^22.15.21",
|
||||
"dotenv": "^16.5.0",
|
||||
"jose": "^6.0.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz",
|
||||
"integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.52.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.15.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz",
|
||||
"integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.5.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
|
||||
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.0.11",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.0.11.tgz",
|
||||
"integrity": "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz",
|
||||
"integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.52.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz",
|
||||
"integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
"name": "tests",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@types/node": "^22.15.21",
|
||||
"dotenv": "^16.5.0",
|
||||
"jose": "^6.0.11",
|
||||
"prettier": "^3.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.52.0",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.52.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.15.21",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.5.0",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.0.11",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.52.0",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.52.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.52.0",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.6.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@types/node": "^22.15.21",
|
||||
"jose": "^6.0.11",
|
||||
"dotenv": "^16.5.0"
|
||||
}
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@types/node": "^22.15.21",
|
||||
"dotenv": "^16.5.0",
|
||||
"jose": "^6.0.11",
|
||||
"prettier": "^3.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import 'dotenv/config';
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
outputDir: "./.output",
|
||||
timeout: 10000,
|
||||
testDir: "./specs",
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: 1,
|
||||
reporter: process.env.CI
|
||||
? [["html", { outputFolder: ".report" }], ["github"]]
|
||||
: [["line"], ["html", { open: "never", outputFolder: ".report" }]],
|
||||
use: {
|
||||
baseURL: process.env.APP_URL ?? "http://localhost:1411",
|
||||
video: "retain-on-failure",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{ name: "setup", testMatch: /.*\.setup\.ts/ },
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"], storageState: ".auth/user.json" },
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
],
|
||||
outputDir: './.output',
|
||||
timeout: 10000,
|
||||
testDir: './specs',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: 1,
|
||||
reporter: process.env.CI
|
||||
? [['html', { outputFolder: '.report' }], ['github']]
|
||||
: [['line'], ['html', { open: 'never', outputFolder: '.report' }]],
|
||||
use: {
|
||||
baseURL: process.env.APP_URL ?? 'http://localhost:1411',
|
||||
video: 'retain-on-failure',
|
||||
trace: 'on-first-retry'
|
||||
},
|
||||
projects: [
|
||||
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json' },
|
||||
dependencies: ['setup']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ services:
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=pocket-id
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -21,4 +21,4 @@ services:
|
||||
service: pocket-id
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
condition: service_healthy
|
||||
|
||||
@@ -11,11 +11,11 @@ services:
|
||||
pocket-id:
|
||||
image: pocket-id:test
|
||||
ports:
|
||||
- "1411:1411"
|
||||
- '1411:1411'
|
||||
environment:
|
||||
- APP_ENV=test
|
||||
- APP_ENV=test
|
||||
build:
|
||||
args:
|
||||
- BUILD_TAGS=e2etest
|
||||
context: ../..
|
||||
dockerfile: Dockerfile
|
||||
dockerfile: Dockerfile
|
||||
|
||||
@@ -1,135 +1,116 @@
|
||||
import test, { expect } from "@playwright/test";
|
||||
import { users } from "../data";
|
||||
import authUtil from "../utils/auth.util";
|
||||
import { cleanupBackend } from "../utils/cleanup.util";
|
||||
import passkeyUtil from "../utils/passkey.util";
|
||||
import test, { expect } from '@playwright/test';
|
||||
import { users } from '../data';
|
||||
import authUtil from '../utils/auth.util';
|
||||
import { cleanupBackend } from '../utils/cleanup.util';
|
||||
import passkeyUtil from '../utils/passkey.util';
|
||||
|
||||
test.beforeEach(cleanupBackend);
|
||||
test.beforeEach(() => cleanupBackend());
|
||||
|
||||
test("Update account details", async ({ page }) => {
|
||||
await page.goto("/settings/account");
|
||||
test('Update account details', async ({ page }) => {
|
||||
await page.goto('/settings/account');
|
||||
|
||||
await page.getByLabel("First name").fill("Timothy");
|
||||
await page.getByLabel("Last name").fill("Apple");
|
||||
await page.getByLabel("Email").fill("timothy.apple@test.com");
|
||||
await page.getByLabel("Username").fill("timothy");
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
await page.getByLabel('First name').fill('Timothy');
|
||||
await page.getByLabel('Last name').fill('Apple');
|
||||
await page.getByLabel('Email').fill('timothy.apple@test.com');
|
||||
await page.getByLabel('Username').fill('timothy');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
"Account details updated successfully"
|
||||
);
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
'Account details updated successfully'
|
||||
);
|
||||
});
|
||||
|
||||
test("Update account details fails with already taken email", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/settings/account");
|
||||
test('Update account details fails with already taken email', async ({ page }) => {
|
||||
await page.goto('/settings/account');
|
||||
|
||||
await page.getByLabel("Email").fill(users.craig.email);
|
||||
await page.getByLabel('Email').fill(users.craig.email);
|
||||
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="error"]')).toHaveText(
|
||||
"Email is already in use"
|
||||
);
|
||||
await expect(page.locator('[data-type="error"]')).toHaveText('Email is already in use');
|
||||
});
|
||||
|
||||
test("Update account details fails with already taken username", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/settings/account");
|
||||
test('Update account details fails with already taken username', async ({ page }) => {
|
||||
await page.goto('/settings/account');
|
||||
|
||||
await page.getByLabel("Username").fill(users.craig.username);
|
||||
await page.getByLabel('Username').fill(users.craig.username);
|
||||
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="error"]')).toHaveText(
|
||||
"Username is already in use"
|
||||
);
|
||||
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
|
||||
});
|
||||
|
||||
test("Change Locale", async ({ page }) => {
|
||||
await page.goto("/settings/account");
|
||||
test('Change Locale', async ({ page }) => {
|
||||
await page.goto('/settings/account');
|
||||
|
||||
await page.getByLabel("Select Locale").click();
|
||||
await page.getByRole("option", { name: "Nederlands" }).click();
|
||||
await page.getByLabel('Select Locale').click();
|
||||
await page.getByRole('option', { name: 'Nederlands' }).click();
|
||||
|
||||
// Check if th language heading now says 'Taal' instead of 'Language'
|
||||
await expect(page.getByText("Taal", { exact: true })).toBeVisible();
|
||||
// Check if th language heading now says 'Taal' instead of 'Language'
|
||||
await expect(page.getByText('Taal', { exact: true })).toBeVisible();
|
||||
|
||||
// Check if the validation messages are translated because they are provided by Zod
|
||||
await page.getByRole("textbox", { name: "Voornaam" }).fill("");
|
||||
await page.getByRole("button", { name: "Opslaan" }).click();
|
||||
await expect(page.getByText("Te kort: verwacht dat string")).toBeVisible();
|
||||
// Check if the validation messages are translated because they are provided by Zod
|
||||
await page.getByRole('textbox', { name: 'Voornaam' }).fill('');
|
||||
await page.getByRole('button', { name: 'Opslaan' }).click();
|
||||
await expect(page.getByText('Te kort: verwacht dat string')).toBeVisible();
|
||||
|
||||
// Clear all cookies and sign in again to check if the language is still set to Dutch
|
||||
await page.context().clearCookies();
|
||||
await authUtil.authenticate(page);
|
||||
// Clear all cookies and sign in again to check if the language is still set to Dutch
|
||||
await page.context().clearCookies();
|
||||
await authUtil.authenticate(page);
|
||||
|
||||
await expect(page.getByText("Taal", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Taal', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole("textbox", { name: "Voornaam" }).fill("");
|
||||
await page.getByRole("button", { name: "Opslaan" }).click();
|
||||
await expect(page.getByText("Te kort: verwacht dat string")).toBeVisible();
|
||||
await page.getByRole('textbox', { name: 'Voornaam' }).fill('');
|
||||
await page.getByRole('button', { name: 'Opslaan' }).click();
|
||||
await expect(page.getByText('Te kort: verwacht dat string')).toBeVisible();
|
||||
});
|
||||
|
||||
test("Add passkey to an account", async ({ page }) => {
|
||||
await page.goto("/settings/account");
|
||||
test('Add passkey to an account', async ({ page }) => {
|
||||
await page.goto('/settings/account');
|
||||
|
||||
await (await passkeyUtil.init(page)).addPasskey("timNew");
|
||||
await (await passkeyUtil.init(page)).addPasskey('timNew');
|
||||
|
||||
await page.getByRole("button", { name: "Add Passkey" }).click();
|
||||
await page.getByRole('button', { name: 'Add Passkey' }).click();
|
||||
|
||||
await page.getByLabel("Name", { exact: true }).fill("Test Passkey");
|
||||
await page
|
||||
.getByLabel("Name Passkey")
|
||||
.getByRole("button", { name: "Save" })
|
||||
.click();
|
||||
await page.getByLabel('Name', { exact: true }).fill('Test Passkey');
|
||||
await page.getByLabel('Name Passkey').getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.getByText("Test Passkey")).toBeVisible();
|
||||
await expect(page.getByText('Test Passkey')).toBeVisible();
|
||||
});
|
||||
|
||||
test("Rename passkey", async ({ page }) => {
|
||||
await page.goto("/settings/account");
|
||||
test('Rename passkey', async ({ page }) => {
|
||||
await page.goto('/settings/account');
|
||||
|
||||
await page.getByLabel("Rename").first().click();
|
||||
await page.getByLabel('Rename').first().click();
|
||||
|
||||
await page.getByLabel("Name", { exact: true }).fill("Renamed Passkey");
|
||||
await page
|
||||
.getByLabel("Name Passkey")
|
||||
.getByRole("button", { name: "Save" })
|
||||
.click();
|
||||
await page.getByLabel('Name', { exact: true }).fill('Renamed Passkey');
|
||||
await page.getByLabel('Name Passkey').getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.getByText("Renamed Passkey")).toBeVisible();
|
||||
await expect(page.getByText('Renamed Passkey')).toBeVisible();
|
||||
});
|
||||
|
||||
test("Delete passkey from account", async ({ page }) => {
|
||||
await page.goto("/settings/account");
|
||||
test('Delete passkey from account', async ({ page }) => {
|
||||
await page.goto('/settings/account');
|
||||
|
||||
await page.getByLabel("Delete").first().click();
|
||||
await page.getByText("Delete", { exact: true }).click();
|
||||
await page.getByLabel('Delete').first().click();
|
||||
await page.getByText('Delete', { exact: true }).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
"Passkey deleted successfully"
|
||||
);
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('Passkey deleted successfully');
|
||||
});
|
||||
|
||||
test("Generate own one time access token as non admin", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.clearCookies();
|
||||
await page.goto("/login");
|
||||
await (await passkeyUtil.init(page)).addPasskey("craig");
|
||||
test('Generate own one time access token as non admin', async ({ page, context }) => {
|
||||
await context.clearCookies();
|
||||
await page.goto('/login');
|
||||
await (await passkeyUtil.init(page)).addPasskey('craig');
|
||||
|
||||
await page.getByRole("button", { name: "Authenticate" }).click();
|
||||
await page.waitForURL("/settings/account");
|
||||
await page.getByRole('button', { name: 'Authenticate' }).click();
|
||||
await page.waitForURL('/settings/account');
|
||||
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
const link = await page.getByTestId("login-code-link").textContent();
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
const link = await page.getByTestId('login-code-link').textContent();
|
||||
|
||||
await context.clearCookies();
|
||||
await context.clearCookies();
|
||||
|
||||
await page.goto(link!);
|
||||
await page.waitForURL("/settings/account");
|
||||
await page.goto(link!);
|
||||
await page.waitForURL('/settings/account');
|
||||
});
|
||||
|
||||
@@ -1,79 +1,70 @@
|
||||
// frontend/tests/api-key.spec.ts
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { apiKeys } from "../data";
|
||||
import { cleanupBackend } from "../utils/cleanup.util";
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { apiKeys } from '../data';
|
||||
import { cleanupBackend } from '../utils/cleanup.util';
|
||||
|
||||
test.describe("API Key Management", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await cleanupBackend();
|
||||
await page.goto("/settings/admin/api-keys");
|
||||
});
|
||||
test.describe('API Key Management', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await cleanupBackend();
|
||||
await page.goto('/settings/admin/api-keys');
|
||||
});
|
||||
|
||||
test("Create new API key", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Add API Key" }).click();
|
||||
test('Create new API key', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Add API Key' }).click();
|
||||
|
||||
// Fill out the API key form
|
||||
const name = "New Test API Key";
|
||||
await page.getByLabel("Name").fill(name);
|
||||
await page.getByLabel("Description").fill("Created by automated test");
|
||||
// Fill out the API key form
|
||||
const name = 'New Test API Key';
|
||||
await page.getByLabel('Name').fill(name);
|
||||
await page.getByLabel('Description').fill('Created by automated test');
|
||||
|
||||
// Choose the date
|
||||
const currentDate = new Date();
|
||||
await page.getByRole("button", { name: "Select a date" }).click();
|
||||
await page.getByLabel("Select year").click();
|
||||
// Select the next year
|
||||
await page.getByText((currentDate.getFullYear() + 1).toString()).click();
|
||||
// Select the first day of the month
|
||||
await page
|
||||
.getByRole("button", { name: /([A-Z][a-z]+), ([A-Z][a-z]+) 1, (\d{4})/ })
|
||||
.first()
|
||||
.click();
|
||||
// Choose the date
|
||||
const currentDate = new Date();
|
||||
await page.getByRole('button', { name: 'Select a date' }).click();
|
||||
await page.getByLabel('Select year').click();
|
||||
// Select the next year
|
||||
await page.getByText((currentDate.getFullYear() + 1).toString()).click();
|
||||
// Select the first day of the month
|
||||
await page
|
||||
.getByRole('button', { name: /([A-Z][a-z]+), ([A-Z][a-z]+) 1, (\d{4})/ })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Submit the form
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
// Submit the form
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// Verify the success dialog appears
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "API Key Created" })
|
||||
).toBeVisible();
|
||||
// Verify the success dialog appears
|
||||
await expect(page.getByRole('heading', { name: 'API Key Created' })).toBeVisible();
|
||||
|
||||
// Verify the key details are shown
|
||||
await expect(page.getByRole("cell", { name })).toBeVisible();
|
||||
// Verify the key details are shown
|
||||
await expect(page.getByRole('cell', { name })).toBeVisible();
|
||||
|
||||
// Verify the token is displayed (should be 32 characters)
|
||||
const token = await page.locator(".font-mono").textContent();
|
||||
expect(token?.length).toBe(32);
|
||||
// Verify the token is displayed (should be 32 characters)
|
||||
const token = await page.locator('.font-mono').textContent();
|
||||
expect(token?.length).toBe(32);
|
||||
|
||||
// Close the dialog
|
||||
await page
|
||||
.getByRole("button", { name: "Close", exact: true })
|
||||
.nth(1)
|
||||
.click();
|
||||
// Close the dialog
|
||||
await page.getByRole('button', { name: 'Close', exact: true }).nth(1).click();
|
||||
|
||||
await page.reload();
|
||||
await page.reload();
|
||||
|
||||
// Verify the key appears in the list
|
||||
await expect(page.getByRole("cell", { name }).first()).toContainText(name);
|
||||
});
|
||||
// Verify the key appears in the list
|
||||
await expect(page.getByRole('cell', { name }).first()).toContainText(name);
|
||||
});
|
||||
|
||||
test("Revoke API key", async ({ page }) => {
|
||||
const apiKey = apiKeys[0];
|
||||
test('Revoke API key', async ({ page }) => {
|
||||
const apiKey = apiKeys[0];
|
||||
|
||||
await page
|
||||
.getByRole("row", { name: apiKey.name })
|
||||
.getByRole("button", { name: "Revoke" })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('row', { name: apiKey.name })
|
||||
.getByRole('button', { name: 'Revoke' })
|
||||
.click();
|
||||
|
||||
await page.getByText("Revoke", { exact: true }).click();
|
||||
await page.getByText('Revoke', { exact: true }).click();
|
||||
|
||||
// Verify success message
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
"API key revoked successfully"
|
||||
);
|
||||
// Verify success message
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('API key revoked successfully');
|
||||
|
||||
// Verify key is no longer in the list
|
||||
await expect(
|
||||
page.getByRole("cell", { name: apiKey.name })
|
||||
).not.toBeVisible();
|
||||
});
|
||||
// Verify key is no longer in the list
|
||||
await expect(page.getByRole('cell', { name: apiKey.name })).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,99 +1,83 @@
|
||||
import test, { expect } from "@playwright/test";
|
||||
import { cleanupBackend } from "../utils/cleanup.util";
|
||||
import test, { expect } from '@playwright/test';
|
||||
import { cleanupBackend } from '../utils/cleanup.util';
|
||||
|
||||
test.beforeEach(cleanupBackend);
|
||||
test.beforeEach(() => cleanupBackend());
|
||||
|
||||
test("Update general configuration", async ({ page }) => {
|
||||
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();
|
||||
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();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
"Application configuration updated successfully"
|
||||
);
|
||||
await expect(page.getByTestId("application-name")).toHaveText("Updated Name");
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
'Application configuration updated successfully'
|
||||
);
|
||||
await expect(page.getByTestId('application-name')).toHaveText('Updated Name');
|
||||
|
||||
await page.reload();
|
||||
await page.reload();
|
||||
|
||||
await expect(
|
||||
page.getByLabel("Application Name", { exact: true })
|
||||
).toHaveValue("Updated Name");
|
||||
await expect(page.getByLabel("Session Duration")).toHaveValue("30");
|
||||
await expect(page.getByLabel('Application Name', { exact: true })).toHaveValue('Updated Name');
|
||||
await expect(page.getByLabel('Session Duration')).toHaveValue('30');
|
||||
});
|
||||
|
||||
test("Update email configuration", async ({ page }) => {
|
||||
await page.goto("/settings/admin/application-configuration");
|
||||
test('Update email configuration', async ({ page }) => {
|
||||
await page.goto('/settings/admin/application-configuration');
|
||||
|
||||
await page.getByRole("button", { name: "Expand card" }).nth(1).click();
|
||||
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
|
||||
|
||||
await page.getByLabel("SMTP Host").fill("smtp.gmail.com");
|
||||
await page.getByLabel("SMTP Port").fill("587");
|
||||
await page.getByLabel("SMTP User").fill("test@gmail.com");
|
||||
await page.getByLabel("SMTP Password").fill("password");
|
||||
await page.getByLabel("SMTP From").fill("test@gmail.com");
|
||||
await page.getByLabel("Email Login Notification").click();
|
||||
await page.getByLabel("Email Login Code Requested by User").click();
|
||||
await page.getByLabel("Email Login Code from Admin").click();
|
||||
await page.getByLabel("API Key Expiration").click();
|
||||
await page.getByLabel('SMTP Host').fill('smtp.gmail.com');
|
||||
await page.getByLabel('SMTP Port').fill('587');
|
||||
await page.getByLabel('SMTP User').fill('test@gmail.com');
|
||||
await page.getByLabel('SMTP Password').fill('password');
|
||||
await page.getByLabel('SMTP From').fill('test@gmail.com');
|
||||
await page.getByLabel('Email Login Notification').click();
|
||||
await page.getByLabel('Email Login Code Requested by User').click();
|
||||
await page.getByLabel('Email Login Code from Admin').click();
|
||||
await page.getByLabel('API Key Expiration').click();
|
||||
|
||||
await page.getByRole("button", { name: "Save" }).nth(1).click();
|
||||
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
"Email configuration updated successfully"
|
||||
);
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
'Email configuration updated successfully'
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
await page.reload();
|
||||
|
||||
await expect(page.getByLabel("SMTP Host")).toHaveValue("smtp.gmail.com");
|
||||
await expect(page.getByLabel("SMTP Port")).toHaveValue("587");
|
||||
await expect(page.getByLabel("SMTP User")).toHaveValue("test@gmail.com");
|
||||
await expect(page.getByLabel("SMTP Password")).toHaveValue("password");
|
||||
await expect(page.getByLabel("SMTP From")).toHaveValue("test@gmail.com");
|
||||
await expect(page.getByLabel("Email Login Notification")).toBeChecked();
|
||||
await expect(
|
||||
page.getByLabel("Email Login Code Requested by User")
|
||||
).toBeChecked();
|
||||
await expect(page.getByLabel("Email Login Code from Admin")).toBeChecked();
|
||||
await expect(page.getByLabel("API Key Expiration")).toBeChecked();
|
||||
await expect(page.getByLabel('SMTP Host')).toHaveValue('smtp.gmail.com');
|
||||
await expect(page.getByLabel('SMTP Port')).toHaveValue('587');
|
||||
await expect(page.getByLabel('SMTP User')).toHaveValue('test@gmail.com');
|
||||
await expect(page.getByLabel('SMTP Password')).toHaveValue('password');
|
||||
await expect(page.getByLabel('SMTP From')).toHaveValue('test@gmail.com');
|
||||
await expect(page.getByLabel('Email Login Notification')).toBeChecked();
|
||||
await expect(page.getByLabel('Email Login Code Requested by User')).toBeChecked();
|
||||
await expect(page.getByLabel('Email Login Code from Admin')).toBeChecked();
|
||||
await expect(page.getByLabel('API Key Expiration')).toBeChecked();
|
||||
});
|
||||
|
||||
test("Update application images", async ({ page }) => {
|
||||
await page.goto("/settings/admin/application-configuration");
|
||||
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(3).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.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 expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
"Images updated successfully"
|
||||
);
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('Images updated successfully');
|
||||
|
||||
await page.request
|
||||
.get("/api/application-configuration/favicon")
|
||||
.then((res) => expect.soft(res.status()).toBe(200));
|
||||
await page.request
|
||||
.get("/api/application-configuration/logo?light=true")
|
||||
.then((res) => expect.soft(res.status()).toBe(200));
|
||||
await page.request
|
||||
.get("/api/application-configuration/logo?light=false")
|
||||
.then((res) => expect.soft(res.status()).toBe(200));
|
||||
await page.request
|
||||
.get("/api/application-configuration/background-image")
|
||||
.then((res) => expect.soft(res.status()).toBe(200));
|
||||
await page.request
|
||||
.get('/api/application-configuration/favicon')
|
||||
.then((res) => expect.soft(res.status()).toBe(200));
|
||||
await page.request
|
||||
.get('/api/application-configuration/logo?light=true')
|
||||
.then((res) => expect.soft(res.status()).toBe(200));
|
||||
await page.request
|
||||
.get('/api/application-configuration/logo?light=false')
|
||||
.then((res) => expect.soft(res.status()).toBe(200));
|
||||
await page.request
|
||||
.get('/api/application-configuration/background-image')
|
||||
.then((res) => expect.soft(res.status()).toBe(200));
|
||||
});
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import test, { expect } from '@playwright/test';
|
||||
import { cleanupBackend } from '../utils/cleanup.util';
|
||||
|
||||
test.beforeEach(cleanupBackend);
|
||||
test.beforeEach(() => cleanupBackend());
|
||||
|
||||
test.describe('LDAP Integration', () => {
|
||||
test.skip(process.env.SKIP_LDAP_TESTS === "true", 'Skipping LDAP tests due to SKIP_LDAP_TESTS environment variable');
|
||||
|
||||
test.skip(
|
||||
process.env.SKIP_LDAP_TESTS === 'true',
|
||||
'Skipping LDAP tests due to SKIP_LDAP_TESTS environment variable'
|
||||
);
|
||||
|
||||
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 expect(page.getByRole('button', { name: 'Disable' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Disable', exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel('LDAP URL')).toHaveValue(/ldap:\/\/.*/);
|
||||
await expect(page.getByLabel('LDAP Base DN')).not.toBeEmpty();
|
||||
|
||||
|
||||
@@ -1,100 +1,80 @@
|
||||
import test, { expect } from "@playwright/test";
|
||||
import { oidcClients } from "../data";
|
||||
import { cleanupBackend } from "../utils/cleanup.util";
|
||||
import test, { expect } from '@playwright/test';
|
||||
import { oidcClients } from '../data';
|
||||
import { cleanupBackend } from '../utils/cleanup.util';
|
||||
|
||||
test.beforeEach(cleanupBackend);
|
||||
test.beforeEach(() => cleanupBackend());
|
||||
|
||||
test("Create OIDC client", async ({ page }) => {
|
||||
await page.goto("/settings/admin/oidc-clients");
|
||||
const oidcClient = oidcClients.pingvinShare;
|
||||
test('Create OIDC client', async ({ page }) => {
|
||||
await page.goto('/settings/admin/oidc-clients');
|
||||
const oidcClient = oidcClients.pingvinShare;
|
||||
|
||||
await page.getByRole("button", { name: "Add OIDC Client" }).click();
|
||||
await page.getByLabel("Name").fill(oidcClient.name);
|
||||
await page.getByRole('button', { name: 'Add OIDC Client' }).click();
|
||||
await page.getByLabel('Name').fill(oidcClient.name);
|
||||
|
||||
await page.getByRole("button", { name: "Add" }).nth(1).click();
|
||||
await page.getByTestId("callback-url-1").fill(oidcClient.callbackUrl);
|
||||
await page.getByRole("button", { name: "Add another" }).click();
|
||||
await page.getByTestId("callback-url-2").fill(oidcClient.secondCallbackUrl!);
|
||||
await page.getByRole('button', { name: 'Add' }).nth(1).click();
|
||||
await page.getByTestId('callback-url-1').fill(oidcClient.callbackUrl);
|
||||
await page.getByRole('button', { name: 'Add another' }).click();
|
||||
await page.getByTestId('callback-url-2').fill(oidcClient.secondCallbackUrl!);
|
||||
|
||||
await page.getByLabel("logo").setInputFiles("assets/pingvin-share-logo.png");
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
await page.getByLabel('logo').setInputFiles('assets/pingvin-share-logo.png');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
const clientId = await page.getByTestId("client-id").textContent();
|
||||
const clientId = await page.getByTestId('client-id').textContent();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
"OIDC client created successfully"
|
||||
);
|
||||
expect(clientId?.length).toBe(36);
|
||||
expect((await page.getByTestId("client-secret").textContent())?.length).toBe(
|
||||
32
|
||||
);
|
||||
await expect(page.getByLabel("Name")).toHaveValue(oidcClient.name);
|
||||
await expect(page.getByTestId("callback-url-1")).toHaveValue(
|
||||
oidcClient.callbackUrl
|
||||
);
|
||||
await expect(page.getByTestId("callback-url-2")).toHaveValue(
|
||||
oidcClient.secondCallbackUrl!
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("img", { name: `${oidcClient.name} logo` })
|
||||
).toBeVisible();
|
||||
await page.request
|
||||
.get(`/api/oidc/clients/${clientId}/logo`)
|
||||
.then((res) => expect.soft(res.status()).toBe(200));
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
'OIDC client created successfully'
|
||||
);
|
||||
expect(clientId?.length).toBe(36);
|
||||
expect((await page.getByTestId('client-secret').textContent())?.length).toBe(32);
|
||||
await expect(page.getByLabel('Name')).toHaveValue(oidcClient.name);
|
||||
await expect(page.getByTestId('callback-url-1')).toHaveValue(oidcClient.callbackUrl);
|
||||
await expect(page.getByTestId('callback-url-2')).toHaveValue(oidcClient.secondCallbackUrl!);
|
||||
await expect(page.getByRole('img', { name: `${oidcClient.name} logo` })).toBeVisible();
|
||||
await page.request
|
||||
.get(`/api/oidc/clients/${clientId}/logo`)
|
||||
.then((res) => expect.soft(res.status()).toBe(200));
|
||||
});
|
||||
|
||||
test("Edit OIDC client", async ({ page }) => {
|
||||
const oidcClient = oidcClients.nextcloud;
|
||||
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
|
||||
test('Edit OIDC client', async ({ page }) => {
|
||||
const oidcClient = oidcClients.nextcloud;
|
||||
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
|
||||
|
||||
await page.getByLabel("Name").fill("Nextcloud updated");
|
||||
await page
|
||||
.getByTestId("callback-url-1")
|
||||
.first()
|
||||
.fill("http://nextcloud-updated/auth/callback");
|
||||
await page.getByLabel("logo").setInputFiles("assets/nextcloud-logo.png");
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
await page.getByLabel('Name').fill('Nextcloud updated');
|
||||
await page.getByTestId('callback-url-1').first().fill('http://nextcloud-updated/auth/callback');
|
||||
await page.getByLabel('logo').setInputFiles('assets/nextcloud-logo.png');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
"OIDC client updated successfully"
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("img", { name: "Nextcloud updated logo" })
|
||||
).toBeVisible();
|
||||
await page.request
|
||||
.get(`/api/oidc/clients/${oidcClient.id}/logo`)
|
||||
.then((res) => expect.soft(res.status()).toBe(200));
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
'OIDC client updated successfully'
|
||||
);
|
||||
await expect(page.getByRole('img', { name: 'Nextcloud updated logo' })).toBeVisible();
|
||||
await page.request
|
||||
.get(`/api/oidc/clients/${oidcClient.id}/logo`)
|
||||
.then((res) => expect.soft(res.status()).toBe(200));
|
||||
});
|
||||
|
||||
test("Create new OIDC client secret", async ({ page }) => {
|
||||
const oidcClient = oidcClients.nextcloud;
|
||||
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
|
||||
test('Create new OIDC client secret', async ({ page }) => {
|
||||
const oidcClient = oidcClients.nextcloud;
|
||||
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
|
||||
|
||||
await page.getByLabel("Create new client secret").click();
|
||||
await page.getByRole("button", { name: "Generate" }).click();
|
||||
await page.getByLabel('Create new client secret').click();
|
||||
await page.getByRole('button', { name: 'Generate' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
"New client secret created successfully"
|
||||
);
|
||||
expect((await page.getByTestId("client-secret").textContent())?.length).toBe(
|
||||
32
|
||||
);
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
'New client secret created successfully'
|
||||
);
|
||||
expect((await page.getByTestId('client-secret').textContent())?.length).toBe(32);
|
||||
});
|
||||
|
||||
test("Delete OIDC client", async ({ page }) => {
|
||||
const oidcClient = oidcClients.nextcloud;
|
||||
await page.goto("/settings/admin/oidc-clients");
|
||||
test('Delete OIDC client', async ({ page }) => {
|
||||
const oidcClient = oidcClients.nextcloud;
|
||||
await page.goto('/settings/admin/oidc-clients');
|
||||
|
||||
await page
|
||||
.getByRole("row", { name: oidcClient.name })
|
||||
.getByLabel("Delete")
|
||||
.click();
|
||||
await page.getByText("Delete", { exact: true }).click();
|
||||
await page.getByRole('row', { name: oidcClient.name }).getByLabel('Delete').click();
|
||||
await page.getByText('Delete', { exact: true }).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
"OIDC client deleted successfully"
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("row", { name: oidcClient.name })
|
||||
).not.toBeVisible();
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
'OIDC client deleted successfully'
|
||||
);
|
||||
await expect(page.getByRole('row', { name: oidcClient.name })).not.toBeVisible();
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,48 +1,48 @@
|
||||
import test, { expect } from "@playwright/test";
|
||||
import { oneTimeAccessTokens } from "../data";
|
||||
import { cleanupBackend } from "../utils/cleanup.util";
|
||||
import test, { expect } from '@playwright/test';
|
||||
import { oneTimeAccessTokens } from '../data';
|
||||
import { cleanupBackend } from '../utils/cleanup.util';
|
||||
|
||||
test.beforeEach(cleanupBackend);
|
||||
test.beforeEach(() => cleanupBackend());
|
||||
|
||||
// Disable authentication for these tests
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test("Sign in with login code", async ({ page }) => {
|
||||
const token = oneTimeAccessTokens.filter((t) => !t.expired)[0];
|
||||
await page.goto(`/lc/${token.token}`);
|
||||
test('Sign in with login code', async ({ page }) => {
|
||||
const token = oneTimeAccessTokens.filter((t) => !t.expired)[0];
|
||||
await page.goto(`/lc/${token.token}`);
|
||||
|
||||
await page.waitForURL("/settings/account");
|
||||
await page.waitForURL('/settings/account');
|
||||
});
|
||||
|
||||
test("Sign in with login code entered manually", async ({ page }) => {
|
||||
const token = oneTimeAccessTokens.filter((t) => !t.expired)[0];
|
||||
await page.goto("/lc");
|
||||
test('Sign in with login code entered manually', async ({ page }) => {
|
||||
const token = oneTimeAccessTokens.filter((t) => !t.expired)[0];
|
||||
await page.goto('/lc');
|
||||
|
||||
await page.getByPlaceholder("Code").first().fill(token.token);
|
||||
await page.getByPlaceholder('Code').first().fill(token.token);
|
||||
|
||||
await page.getByText("Submit").first().click();
|
||||
await page.getByText('Submit').first().click();
|
||||
|
||||
await page.waitForURL("/settings/account");
|
||||
await page.waitForURL('/settings/account');
|
||||
});
|
||||
|
||||
test("Sign in with expired login code fails", async ({ page }) => {
|
||||
const token = oneTimeAccessTokens.filter((t) => t.expired)[0];
|
||||
await page.goto(`/lc/${token.token}`);
|
||||
test('Sign in with expired login code fails', async ({ page }) => {
|
||||
const token = oneTimeAccessTokens.filter((t) => t.expired)[0];
|
||||
await page.goto(`/lc/${token.token}`);
|
||||
|
||||
await expect(page.getByRole("paragraph")).toHaveText(
|
||||
"Token is invalid or expired. Please try again."
|
||||
);
|
||||
await expect(page.getByRole('paragraph')).toHaveText(
|
||||
'Token is invalid or expired. Please try again.'
|
||||
);
|
||||
});
|
||||
|
||||
test("Sign in with login code entered manually fails", async ({ page }) => {
|
||||
const token = oneTimeAccessTokens.filter((t) => t.expired)[0];
|
||||
await page.goto("/lc");
|
||||
test('Sign in with login code entered manually fails', async ({ page }) => {
|
||||
const token = oneTimeAccessTokens.filter((t) => t.expired)[0];
|
||||
await page.goto('/lc');
|
||||
|
||||
await page.getByPlaceholder("Code").first().fill(token.token);
|
||||
await page.getByPlaceholder('Code').first().fill(token.token);
|
||||
|
||||
await page.getByText("Submit").first().click();
|
||||
await page.getByText('Submit').first().click();
|
||||
|
||||
await expect(page.getByRole("paragraph")).toHaveText(
|
||||
"Token is invalid or expired. Please try again."
|
||||
);
|
||||
await expect(page.getByRole('paragraph')).toHaveText(
|
||||
'Token is invalid or expired. Please try again.'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,152 +1,115 @@
|
||||
import test, { expect } from "@playwright/test";
|
||||
import { userGroups, users } from "../data";
|
||||
import { cleanupBackend } from "../utils/cleanup.util";
|
||||
import test, { expect } from '@playwright/test';
|
||||
import { userGroups, users } from '../data';
|
||||
import { cleanupBackend } from '../utils/cleanup.util';
|
||||
|
||||
test.beforeEach(cleanupBackend);
|
||||
test.beforeEach(() => cleanupBackend());
|
||||
|
||||
test("Create user group", async ({ page }) => {
|
||||
await page.goto("/settings/admin/user-groups");
|
||||
const group = userGroups.humanResources;
|
||||
test('Create user group', async ({ page }) => {
|
||||
await page.goto('/settings/admin/user-groups');
|
||||
const group = userGroups.humanResources;
|
||||
|
||||
await page.getByRole("button", { name: "Add Group" }).click();
|
||||
await page.getByLabel("Friendly Name").fill(group.friendlyName);
|
||||
await page.getByRole('button', { name: 'Add Group' }).click();
|
||||
await page.getByLabel('Friendly Name').fill(group.friendlyName);
|
||||
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
"User group created successfully"
|
||||
);
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('User group created successfully');
|
||||
|
||||
await page.waitForURL("/settings/admin/user-groups/*");
|
||||
await page.waitForURL('/settings/admin/user-groups/*');
|
||||
|
||||
await expect(page.getByLabel("Friendly Name")).toHaveValue(
|
||||
group.friendlyName
|
||||
);
|
||||
await expect(page.getByLabel("Name", { exact: true })).toHaveValue(
|
||||
group.name
|
||||
);
|
||||
await expect(page.getByLabel('Friendly Name')).toHaveValue(group.friendlyName);
|
||||
await expect(page.getByLabel('Name', { exact: true })).toHaveValue(group.name);
|
||||
});
|
||||
|
||||
test("Edit user group", async ({ page }) => {
|
||||
await page.goto("/settings/admin/user-groups");
|
||||
const group = userGroups.developers;
|
||||
test('Edit user group', async ({ page }) => {
|
||||
await page.goto('/settings/admin/user-groups');
|
||||
const group = userGroups.developers;
|
||||
|
||||
await page
|
||||
.getByRole("row", { name: group.name })
|
||||
.getByRole("button")
|
||||
.click();
|
||||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||||
await page.getByRole('row', { name: group.name }).getByRole('button').click();
|
||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||
|
||||
await page.getByLabel("Friendly Name").fill("Developers updated");
|
||||
await page.getByLabel('Friendly Name').fill('Developers updated');
|
||||
|
||||
await expect(page.getByLabel("Name", { exact: true })).toHaveValue(
|
||||
group.name
|
||||
);
|
||||
await expect(page.getByLabel('Name', { exact: true })).toHaveValue(group.name);
|
||||
|
||||
await page.getByLabel("Name", { exact: true }).fill("developers_updated");
|
||||
await page.getByLabel('Name', { exact: true }).fill('developers_updated');
|
||||
|
||||
await page.getByRole("button", { name: "Save" }).nth(0).click();
|
||||
await page.getByRole('button', { name: 'Save' }).nth(0).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
"User group updated successfully"
|
||||
);
|
||||
await expect(page.getByLabel("Friendly Name")).toHaveValue(
|
||||
"Developers updated"
|
||||
);
|
||||
await expect(page.getByLabel("Name", { exact: true })).toHaveValue(
|
||||
"developers_updated"
|
||||
);
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('User group updated successfully');
|
||||
await expect(page.getByLabel('Friendly Name')).toHaveValue('Developers updated');
|
||||
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('developers_updated');
|
||||
});
|
||||
|
||||
test("Update user group users", async ({ page }) => {
|
||||
const group = userGroups.designers;
|
||||
await page.goto(`/settings/admin/user-groups/${group.id}`);
|
||||
test('Update user group users', async ({ page }) => {
|
||||
const group = userGroups.designers;
|
||||
await page.goto(`/settings/admin/user-groups/${group.id}`);
|
||||
|
||||
await page
|
||||
.getByRole("row", { name: users.tim.email })
|
||||
.getByRole("checkbox")
|
||||
.click();
|
||||
await page
|
||||
.getByRole("row", { name: users.craig.email })
|
||||
.getByRole("checkbox")
|
||||
.click();
|
||||
await page.getByRole('row', { name: users.tim.email }).getByRole('checkbox').click();
|
||||
await page.getByRole('row', { name: users.craig.email }).getByRole('checkbox').click();
|
||||
|
||||
await page.getByRole("button", { name: "Save" }).nth(1).click();
|
||||
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
"Users updated successfully"
|
||||
);
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('Users updated successfully');
|
||||
|
||||
await page.reload();
|
||||
await page.reload();
|
||||
|
||||
await expect(
|
||||
page.getByRole("row", { name: users.tim.email }).getByRole("checkbox")
|
||||
).toHaveAttribute("data-state", "unchecked");
|
||||
await expect(
|
||||
page.getByRole("row", { name: users.craig.email }).getByRole("checkbox")
|
||||
).toHaveAttribute("data-state", "checked");
|
||||
await expect(
|
||||
page.getByRole('row', { name: users.tim.email }).getByRole('checkbox')
|
||||
).toHaveAttribute('data-state', 'unchecked');
|
||||
await expect(
|
||||
page.getByRole('row', { name: users.craig.email }).getByRole('checkbox')
|
||||
).toHaveAttribute('data-state', 'checked');
|
||||
});
|
||||
|
||||
test("Delete user group", async ({ page }) => {
|
||||
const group = userGroups.developers;
|
||||
await page.goto("/settings/admin/user-groups");
|
||||
test('Delete user group', async ({ page }) => {
|
||||
const group = userGroups.developers;
|
||||
await page.goto('/settings/admin/user-groups');
|
||||
|
||||
await page.getByRole("row", { name: group.name }).getByRole("button").click();
|
||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||
await page.getByRole("button", { name: "Delete" }).click();
|
||||
await page.getByRole('row', { name: group.name }).getByRole('button').click();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
"User group deleted successfully"
|
||||
);
|
||||
await expect(page.getByRole("row", { name: group.name })).not.toBeVisible();
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('User group deleted successfully');
|
||||
await expect(page.getByRole('row', { name: group.name })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("Update user group custom claims", async ({ page }) => {
|
||||
await page.goto(`/settings/admin/user-groups/${userGroups.designers.id}`);
|
||||
test('Update user group custom claims', async ({ page }) => {
|
||||
await page.goto(`/settings/admin/user-groups/${userGroups.designers.id}`);
|
||||
|
||||
await page.getByRole("button", { name: "Expand card" }).click();
|
||||
await page.getByRole('button', { name: 'Expand card' }).click();
|
||||
|
||||
// Add two custom claims
|
||||
await page.getByRole("button", { name: "Add custom claim" }).click();
|
||||
// Add two custom claims
|
||||
await page.getByRole('button', { name: 'Add custom claim' }).click();
|
||||
|
||||
await page.getByPlaceholder("Key").fill("customClaim1");
|
||||
await page.getByPlaceholder("Value").fill("customClaim1_value");
|
||||
await page.getByPlaceholder('Key').fill('customClaim1');
|
||||
await page.getByPlaceholder('Value').fill('customClaim1_value');
|
||||
|
||||
await page.getByRole("button", { name: "Add another" }).click();
|
||||
await page.getByPlaceholder("Key").nth(1).fill("customClaim2");
|
||||
await page.getByPlaceholder("Value").nth(1).fill("customClaim2_value");
|
||||
await page.getByRole('button', { name: 'Add another' }).click();
|
||||
await page.getByPlaceholder('Key').nth(1).fill('customClaim2');
|
||||
await page.getByPlaceholder('Value').nth(1).fill('customClaim2_value');
|
||||
|
||||
await page.getByRole("button", { name: "Save" }).nth(2).click();
|
||||
await page.getByRole('button', { name: 'Save' }).nth(2).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
"Custom claims updated successfully"
|
||||
);
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
'Custom claims updated successfully'
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
await page.reload();
|
||||
|
||||
// Check if custom claims are saved
|
||||
await expect(page.getByPlaceholder("Key").first()).toHaveValue(
|
||||
"customClaim1"
|
||||
);
|
||||
await expect(page.getByPlaceholder("Value").first()).toHaveValue(
|
||||
"customClaim1_value"
|
||||
);
|
||||
await expect(page.getByPlaceholder("Key").nth(1)).toHaveValue("customClaim2");
|
||||
await expect(page.getByPlaceholder("Value").nth(1)).toHaveValue(
|
||||
"customClaim2_value"
|
||||
);
|
||||
// Check if custom claims are saved
|
||||
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim1');
|
||||
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim1_value');
|
||||
await expect(page.getByPlaceholder('Key').nth(1)).toHaveValue('customClaim2');
|
||||
await expect(page.getByPlaceholder('Value').nth(1)).toHaveValue('customClaim2_value');
|
||||
|
||||
// Remove one custom claim
|
||||
await page.getByLabel("Remove custom claim").first().click();
|
||||
await page.getByRole("button", { name: "Save" }).nth(2).click();
|
||||
// Remove one custom claim
|
||||
await page.getByLabel('Remove custom claim').first().click();
|
||||
await page.getByRole('button', { name: 'Save' }).nth(2).click();
|
||||
|
||||
await page.reload();
|
||||
await page.reload();
|
||||
|
||||
// Check if custom claim is removed
|
||||
await expect(page.getByPlaceholder("Key").first()).toHaveValue(
|
||||
"customClaim2"
|
||||
);
|
||||
await expect(page.getByPlaceholder("Value").first()).toHaveValue(
|
||||
"customClaim2_value"
|
||||
);
|
||||
// Check if custom claim is removed
|
||||
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim2');
|
||||
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim2_value');
|
||||
});
|
||||
|
||||
@@ -1,253 +1,217 @@
|
||||
import test, { expect } from "@playwright/test";
|
||||
import { userGroups, users } from "../data";
|
||||
import { cleanupBackend } from "../utils/cleanup.util";
|
||||
import test, { expect } from '@playwright/test';
|
||||
import { userGroups, users } from '../data';
|
||||
import { cleanupBackend } from '../utils/cleanup.util';
|
||||
|
||||
test.beforeEach(cleanupBackend);
|
||||
test.beforeEach(() => cleanupBackend());
|
||||
|
||||
test("Create user", async ({ page }) => {
|
||||
const user = users.steve;
|
||||
test('Create user', async ({ page }) => {
|
||||
const user = users.steve;
|
||||
|
||||
await page.goto("/settings/admin/users");
|
||||
await page.goto('/settings/admin/users');
|
||||
|
||||
await page.getByRole("button", { name: "Add User" }).click();
|
||||
await page.getByLabel("First name").fill(user.firstname);
|
||||
await page.getByLabel("Last name").fill(user.lastname);
|
||||
await page.getByLabel("Email").fill(user.email);
|
||||
await page.getByLabel("Username").fill(user.username);
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
await page.getByRole('button', { name: 'Add User' }).click();
|
||||
await page.getByLabel('First name').fill(user.firstname);
|
||||
await page.getByLabel('Last name').fill(user.lastname);
|
||||
await page.getByLabel('Email').fill(user.email);
|
||||
await page.getByLabel('Username').fill(user.username);
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("row", { name: `${user.firstname} ${user.lastname}` })
|
||||
).toBeVisible();
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
"User created successfully"
|
||||
);
|
||||
await expect(page.getByRole('row', { name: `${user.firstname} ${user.lastname}` })).toBeVisible();
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('User created successfully');
|
||||
});
|
||||
|
||||
test("Create user fails with already taken email", async ({ page }) => {
|
||||
const user = users.steve;
|
||||
test('Create user fails with already taken email', async ({ page }) => {
|
||||
const user = users.steve;
|
||||
|
||||
await page.goto("/settings/admin/users");
|
||||
await page.goto('/settings/admin/users');
|
||||
|
||||
await page.getByRole("button", { name: "Add User" }).click();
|
||||
await page.getByLabel("First name").fill(user.firstname);
|
||||
await page.getByLabel("Last name").fill(user.lastname);
|
||||
await page.getByLabel("Email").fill(users.tim.email);
|
||||
await page.getByLabel("Username").fill(user.username);
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
await page.getByRole('button', { name: 'Add User' }).click();
|
||||
await page.getByLabel('First name').fill(user.firstname);
|
||||
await page.getByLabel('Last name').fill(user.lastname);
|
||||
await page.getByLabel('Email').fill(users.tim.email);
|
||||
await page.getByLabel('Username').fill(user.username);
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="error"]')).toHaveText(
|
||||
"Email is already in use"
|
||||
);
|
||||
await expect(page.locator('[data-type="error"]')).toHaveText('Email is already in use');
|
||||
});
|
||||
|
||||
test("Create user fails with already taken username", async ({ page }) => {
|
||||
const user = users.steve;
|
||||
test('Create user fails with already taken username', async ({ page }) => {
|
||||
const user = users.steve;
|
||||
|
||||
await page.goto("/settings/admin/users");
|
||||
await page.goto('/settings/admin/users');
|
||||
|
||||
await page.getByRole("button", { name: "Add User" }).click();
|
||||
await page.getByLabel("First name").fill(user.firstname);
|
||||
await page.getByLabel("Last name").fill(user.lastname);
|
||||
await page.getByLabel("Email").fill(user.email);
|
||||
await page.getByLabel("Username").fill(users.tim.username);
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
await page.getByRole('button', { name: 'Add User' }).click();
|
||||
await page.getByLabel('First name').fill(user.firstname);
|
||||
await page.getByLabel('Last name').fill(user.lastname);
|
||||
await page.getByLabel('Email').fill(user.email);
|
||||
await page.getByLabel('Username').fill(users.tim.username);
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="error"]')).toHaveText(
|
||||
"Username is already in use"
|
||||
);
|
||||
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
|
||||
});
|
||||
|
||||
test("Create one time access token", async ({ page, context }) => {
|
||||
await page.goto("/settings/admin/users");
|
||||
test('Create one time access token', async ({ page, context }) => {
|
||||
await page.goto('/settings/admin/users');
|
||||
|
||||
await page
|
||||
.getByRole("row", {
|
||||
name: `${users.craig.firstname} ${users.craig.lastname}`,
|
||||
})
|
||||
.getByRole("button")
|
||||
.click();
|
||||
await page
|
||||
.getByRole('row', {
|
||||
name: `${users.craig.firstname} ${users.craig.lastname}`
|
||||
})
|
||||
.getByRole('button')
|
||||
.click();
|
||||
|
||||
await page.getByRole("menuitem", { name: "Login Code" }).click();
|
||||
await page.getByRole('menuitem', { name: 'Login Code' }).click();
|
||||
|
||||
await page.getByLabel("Expiration").click();
|
||||
await page.getByRole("option", { name: "12 hours" }).click();
|
||||
await page.getByRole("button", { name: "Show Code" }).click();
|
||||
await page.getByLabel('Expiration').click();
|
||||
await page.getByRole('option', { name: '12 hours' }).click();
|
||||
await page.getByRole('button', { name: 'Show Code' }).click();
|
||||
|
||||
const link = await page.getByTestId("login-code-link").textContent();
|
||||
await context.clearCookies();
|
||||
const link = await page.getByTestId('login-code-link').textContent();
|
||||
await context.clearCookies();
|
||||
|
||||
await page.goto(link!);
|
||||
await page.waitForURL("/settings/account");
|
||||
await page.goto(link!);
|
||||
await page.waitForURL('/settings/account');
|
||||
});
|
||||
|
||||
test("Delete user", async ({ page }) => {
|
||||
await page.goto("/settings/admin/users");
|
||||
test('Delete user', async ({ page }) => {
|
||||
await page.goto('/settings/admin/users');
|
||||
|
||||
await page
|
||||
.getByRole("row", {
|
||||
name: `${users.craig.firstname} ${users.craig.lastname}`,
|
||||
})
|
||||
.getByRole("button")
|
||||
.click();
|
||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||
await page.getByRole("button", { name: "Delete" }).click();
|
||||
await page
|
||||
.getByRole('row', {
|
||||
name: `${users.craig.firstname} ${users.craig.lastname}`
|
||||
})
|
||||
.getByRole('button')
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
"User deleted successfully"
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("row", {
|
||||
name: `${users.craig.firstname} ${users.craig.lastname}`,
|
||||
})
|
||||
).not.toBeVisible();
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('User deleted successfully');
|
||||
await expect(
|
||||
page.getByRole('row', {
|
||||
name: `${users.craig.firstname} ${users.craig.lastname}`
|
||||
})
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("Update user", async ({ page }) => {
|
||||
const user = users.craig;
|
||||
test('Update user', async ({ page }) => {
|
||||
const user = users.craig;
|
||||
|
||||
await page.goto("/settings/admin/users");
|
||||
await page.goto('/settings/admin/users');
|
||||
|
||||
await page
|
||||
.getByRole("row", { name: `${user.firstname} ${user.lastname}` })
|
||||
.getByRole("button")
|
||||
.click();
|
||||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||||
await page
|
||||
.getByRole('row', { name: `${user.firstname} ${user.lastname}` })
|
||||
.getByRole('button')
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||
|
||||
await page.getByLabel("First name").fill("Crack");
|
||||
await page.getByLabel("Last name").fill("Apple");
|
||||
await page.getByLabel("Email").fill("crack.apple@test.com");
|
||||
await page.getByLabel("Username").fill("crack");
|
||||
await page.getByRole("button", { name: "Save" }).first().click();
|
||||
await page.getByLabel('First name').fill('Crack');
|
||||
await page.getByLabel('Last name').fill('Apple');
|
||||
await page.getByLabel('Email').fill('crack.apple@test.com');
|
||||
await page.getByLabel('Username').fill('crack');
|
||||
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
"User updated successfully"
|
||||
);
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('User updated successfully');
|
||||
});
|
||||
|
||||
test("Update user fails with already taken email", async ({ page }) => {
|
||||
const user = users.craig;
|
||||
test('Update user fails with already taken email', async ({ page }) => {
|
||||
const user = users.craig;
|
||||
|
||||
await page.goto("/settings/admin/users");
|
||||
await page.goto('/settings/admin/users');
|
||||
|
||||
await page
|
||||
.getByRole("row", { name: `${user.firstname} ${user.lastname}` })
|
||||
.getByRole("button")
|
||||
.click();
|
||||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||||
await page
|
||||
.getByRole('row', { name: `${user.firstname} ${user.lastname}` })
|
||||
.getByRole('button')
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||
|
||||
await page.getByLabel("Email").fill(users.tim.email);
|
||||
await page.getByRole("button", { name: "Save" }).first().click();
|
||||
await page.getByLabel('Email').fill(users.tim.email);
|
||||
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||
|
||||
await expect(page.locator('[data-type="error"]')).toHaveText(
|
||||
"Email is already in use"
|
||||
);
|
||||
await expect(page.locator('[data-type="error"]')).toHaveText('Email is already in use');
|
||||
});
|
||||
|
||||
test("Update user fails with already taken username", async ({ page }) => {
|
||||
const user = users.craig;
|
||||
test('Update user fails with already taken username', async ({ page }) => {
|
||||
const user = users.craig;
|
||||
|
||||
await page.goto("/settings/admin/users");
|
||||
await page.goto('/settings/admin/users');
|
||||
|
||||
await page
|
||||
.getByRole("row", { name: `${user.firstname} ${user.lastname}` })
|
||||
.getByRole("button")
|
||||
.click();
|
||||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||||
await page
|
||||
.getByRole('row', { name: `${user.firstname} ${user.lastname}` })
|
||||
.getByRole('button')
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||
|
||||
await page.getByLabel("Username").fill(users.tim.username);
|
||||
await page.getByRole("button", { name: "Save" }).first().click();
|
||||
await page.getByLabel('Username').fill(users.tim.username);
|
||||
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||
|
||||
await expect(page.locator('[data-type="error"]')).toHaveText(
|
||||
"Username is already in use"
|
||||
);
|
||||
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
|
||||
});
|
||||
|
||||
test("Update user custom claims", async ({ page }) => {
|
||||
await page.goto(`/settings/admin/users/${users.craig.id}`);
|
||||
test('Update user custom claims', async ({ page }) => {
|
||||
await page.goto(`/settings/admin/users/${users.craig.id}`);
|
||||
|
||||
await page.getByRole("button", { name: "Expand card" }).nth(1).click();
|
||||
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
|
||||
|
||||
// Add two custom claims
|
||||
await page.getByRole("button", { name: "Add custom claim" }).click();
|
||||
// Add two custom claims
|
||||
await page.getByRole('button', { name: 'Add custom claim' }).click();
|
||||
|
||||
await page.getByPlaceholder("Key").fill("customClaim1");
|
||||
await page.getByPlaceholder("Value").fill("customClaim1_value");
|
||||
await page.getByPlaceholder('Key').fill('customClaim1');
|
||||
await page.getByPlaceholder('Value').fill('customClaim1_value');
|
||||
|
||||
await page.getByRole("button", { name: "Add another" }).click();
|
||||
await page.getByPlaceholder("Key").nth(1).fill("customClaim2");
|
||||
await page.getByPlaceholder("Value").nth(1).fill("customClaim2_value");
|
||||
await page.getByRole('button', { name: 'Add another' }).click();
|
||||
await page.getByPlaceholder('Key').nth(1).fill('customClaim2');
|
||||
await page.getByPlaceholder('Value').nth(1).fill('customClaim2_value');
|
||||
|
||||
await page.getByRole("button", { name: "Save" }).nth(1).click();
|
||||
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
"Custom claims updated successfully"
|
||||
);
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
'Custom claims updated successfully'
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
await page.reload();
|
||||
|
||||
// Check if custom claims are saved
|
||||
await expect(page.getByPlaceholder("Key").first()).toHaveValue(
|
||||
"customClaim1"
|
||||
);
|
||||
await expect(page.getByPlaceholder("Value").first()).toHaveValue(
|
||||
"customClaim1_value"
|
||||
);
|
||||
await expect(page.getByPlaceholder("Key").nth(1)).toHaveValue("customClaim2");
|
||||
await expect(page.getByPlaceholder("Value").nth(1)).toHaveValue(
|
||||
"customClaim2_value"
|
||||
);
|
||||
// Check if custom claims are saved
|
||||
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim1');
|
||||
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim1_value');
|
||||
await expect(page.getByPlaceholder('Key').nth(1)).toHaveValue('customClaim2');
|
||||
await expect(page.getByPlaceholder('Value').nth(1)).toHaveValue('customClaim2_value');
|
||||
|
||||
// Remove one custom claim
|
||||
await page.getByLabel("Remove custom claim").first().click();
|
||||
await page.getByRole("button", { name: "Save" }).nth(1).click();
|
||||
// Remove one custom claim
|
||||
await page.getByLabel('Remove custom claim').first().click();
|
||||
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
"Custom claims updated successfully"
|
||||
);
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
'Custom claims updated successfully'
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
await page.reload();
|
||||
|
||||
// Check if custom claim is removed
|
||||
await expect(page.getByPlaceholder("Key").first()).toHaveValue(
|
||||
"customClaim2"
|
||||
);
|
||||
await expect(page.getByPlaceholder("Value").first()).toHaveValue(
|
||||
"customClaim2_value"
|
||||
);
|
||||
// Check if custom claim is removed
|
||||
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim2');
|
||||
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim2_value');
|
||||
});
|
||||
|
||||
test("Update user group assignments", async ({ page }) => {
|
||||
const user = users.craig;
|
||||
await page.goto(`/settings/admin/users/${user.id}`);
|
||||
test('Update user group assignments', async ({ page }) => {
|
||||
const user = users.craig;
|
||||
await page.goto(`/settings/admin/users/${user.id}`);
|
||||
|
||||
page.getByRole("button", { name: "Expand card" }).first().click();
|
||||
page.getByRole('button', { name: 'Expand card' }).first().click();
|
||||
|
||||
await page
|
||||
.getByRole("row", { name: userGroups.developers.name })
|
||||
.getByRole("checkbox")
|
||||
.click();
|
||||
await page
|
||||
.getByRole("row", { name: userGroups.designers.name })
|
||||
.getByRole("checkbox")
|
||||
.click();
|
||||
await page.getByRole('row', { name: userGroups.developers.name }).getByRole('checkbox').click();
|
||||
await page.getByRole('row', { name: userGroups.designers.name }).getByRole('checkbox').click();
|
||||
|
||||
await page.getByRole("button", { name: "Save" }).nth(1).click();
|
||||
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
"User groups updated successfully"
|
||||
);
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
'User groups updated successfully'
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
await page.reload();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByRole("row", { name: userGroups.designers.name })
|
||||
.getByRole("checkbox")
|
||||
).toHaveAttribute("data-state", "checked");
|
||||
await expect(
|
||||
page
|
||||
.getByRole("row", { name: userGroups.developers.name })
|
||||
.getByRole("checkbox")
|
||||
).toHaveAttribute("data-state", "unchecked");
|
||||
await expect(
|
||||
page.getByRole('row', { name: userGroups.designers.name }).getByRole('checkbox')
|
||||
).toHaveAttribute('data-state', 'checked');
|
||||
await expect(
|
||||
page.getByRole('row', { name: userGroups.developers.name }).getByRole('checkbox')
|
||||
).toHaveAttribute('data-state', 'unchecked');
|
||||
});
|
||||
|
||||
215
tests/specs/user-signup.spec.ts
Normal file
215
tests/specs/user-signup.spec.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import test, { expect } from '@playwright/test';
|
||||
import { signupTokens, users } from 'data';
|
||||
import { cleanupBackend } from '../utils/cleanup.util';
|
||||
import passkeyUtil from '../utils/passkey.util';
|
||||
|
||||
test.beforeEach(() => cleanupBackend());
|
||||
|
||||
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.getByLabel('Enable user signups').click();
|
||||
await page.getByRole('option', { name: mode }).click();
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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 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();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"lib": ["ES2022"]
|
||||
}
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"lib": ["ES2022"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import playwrightConfig from "../playwright.config";
|
||||
import playwrightConfig from '../playwright.config';
|
||||
|
||||
export async function cleanupBackend() {
|
||||
const url = new URL("/api/test/reset", playwrightConfig.use!.baseURL);
|
||||
export async function cleanupBackend(skipSeed = false) {
|
||||
const url = new URL('/api/test/reset', playwrightConfig.use!.baseURL);
|
||||
|
||||
if (process.env.SKIP_LDAP_TESTS === "true") {
|
||||
url.searchParams.append("skip-ldap", "true");
|
||||
}
|
||||
if (process.env.SKIP_LDAP_TESTS === 'true' || skipSeed) {
|
||||
url.searchParams.append('skip-ldap', 'true');
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
});
|
||||
if (skipSeed) {
|
||||
url.searchParams.append('skip-seed', 'true');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to reset backend: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to reset backend: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +1,56 @@
|
||||
import * as jose from "jose";
|
||||
import playwrightConfig from "../playwright.config";
|
||||
import * as jose from 'jose';
|
||||
import playwrightConfig from '../playwright.config';
|
||||
|
||||
const PRIVATE_KEY_STRING = `{"alg":"RS256","d":"mvMDWSdPPvcum0c0iEHE2gbqtV2NKMmLwrl9E6K7g8lTV95SePLnW_bwyMPV7EGp7PQk3l17I5XRhFjze7GqTnFIOgKzMianPs7jv2ELtBMGK0xOPATgu1iGb70xZ6vcvuEfRyY3dJ0zr4jpUdVuXwKmx9rK4IdZn2dFCKfvSuspqIpz11RhF1ALrqDLkxGVv7ZwNh0_VhJZU9hcjG5l6xc7rQEKpPRkZp0IdjkGS8Z0FskoVaiRIWAbZuiVFB9WCW8k1czC4HQTPLpII01bUQx2ludbm0UlXRgVU9ptUUbU7GAImQqTOW8LfPGklEvcgzlIlR_oqw4P9yBxLi-yMQ","dp":"pvNCSnnhbo8Igw9psPR-DicxFnkXlu_ix4gpy6efTrxA-z1VDFDioJ814vKQNioYDzpyAP1gfMPhRkvG_q0hRZsJah3Sb9dfA-WkhSWY7lURQP4yIBTMU0PF_rEATuS7lRciYk1SOx5fqXZd3m_LP0vpBC4Ujlq6NAq6CIjCnms","dq":"TtUVGCCkPNgfOLmkYXu7dxxUCV5kB01-xAEK2OY0n0pG8vfDophH4_D_ZC7nvJ8J9uDhs_3JStexq1lIvaWtG99RNTChIEDzpdn6GH9yaVcb_eB4uJjrNm64FhF8PGCCwxA-xMCZMaARKwhMB2_IOMkxUbWboL3gnhJ2rDO_QO0","e":"AQAB","kid":"8uHDw3M6rf8","kty":"RSA","n":"yaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC-585UXacoJ0chUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl_4EDDTO8HwawTjwkPoQlRzeByhlvGPVvwgB3Fn93B8QJ_cZhXKxJvjjrC_8Pk76heC_ntEMru71Ix77BoC3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeOZl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJw","p":"_Yylc9e07CKdqNRD2EosMC2mrhrEa9j5oY_l00Qyy4-jmCA59Q9viyqvveRo0U7cRvFA5BWgWN6GGLh1DG3X-QBqVr0dnk3uzbobb55RYUXyPLuBZI2q6w2oasbiDwPdY7KpkVv_H-bpITQlyDvO8hhucA6rUV7F6KTQVz8M3Ms","q":"y5p3hch-7jJ21TkAhp_Vk1fLCAuD4tbErwQs2of9ja8sB4iJOs5Wn6HD3P7Mc8Plye7qaLHvzc8I5g0tPKWvC0DPd_FLPXiWwMVAzee3NUX_oGeJNOQp11y1w_KqdO9qZqHSEPZ3NcFL_SZMFgggxhM1uzRiPzsVN0lnD_6prZU","qi":"2Grt6uXHm61ji3xSdkBWNtUnj19vS1-7rFJp5SoYztVQVThf_W52BAiXKBdYZDRVoItC_VS2NvAOjeJjhYO_xQ_q3hK7MdtuXfEPpLnyXKkmWo3lrJ26wbeF6l05LexCkI7ShsOuSt-dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI","use":"sig"}`;
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
id: string;
|
||||
email: string;
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
};
|
||||
|
||||
const privateKey = JSON.parse(PRIVATE_KEY_STRING);
|
||||
const privateKeyImported = await jose.importJWK(privateKey, "RS256");
|
||||
const privateKeyImported = await jose.importJWK(privateKey, 'RS256');
|
||||
|
||||
export async function generateIdToken(
|
||||
user: User,
|
||||
clientId: string,
|
||||
expired = false
|
||||
) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiration = expired ? now + 1 : now + 1000000000; // Either expired or valid for a long time
|
||||
export async function generateIdToken(user: User, clientId: string, expired = false) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiration = expired ? now + 1 : now + 1000000000; // Either expired or valid for a long time
|
||||
|
||||
const payload = {
|
||||
aud: clientId,
|
||||
email: user.email,
|
||||
email_verified: true,
|
||||
exp: expiration,
|
||||
family_name: user.lastname,
|
||||
given_name: user.firstname,
|
||||
iat: now,
|
||||
iss: playwrightConfig.use!.baseURL,
|
||||
name: `${user.firstname} ${user.lastname}`,
|
||||
nonce: "oW1A1O78GQ15D73OsHEx7WQKj7ZqvHLZu_37mdXIqAQ",
|
||||
sub: user.id,
|
||||
type: "id-token",
|
||||
};
|
||||
const payload = {
|
||||
aud: clientId,
|
||||
email: user.email,
|
||||
email_verified: true,
|
||||
exp: expiration,
|
||||
family_name: user.lastname,
|
||||
given_name: user.firstname,
|
||||
iat: now,
|
||||
iss: playwrightConfig.use!.baseURL,
|
||||
name: `${user.firstname} ${user.lastname}`,
|
||||
nonce: 'oW1A1O78GQ15D73OsHEx7WQKj7ZqvHLZu_37mdXIqAQ',
|
||||
sub: user.id,
|
||||
type: 'id-token'
|
||||
};
|
||||
|
||||
return await new jose.SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "RS256", kid: privateKey.kid, typ: "JWT" })
|
||||
.sign(privateKeyImported);
|
||||
return await new jose.SignJWT(payload)
|
||||
.setProtectedHeader({ alg: 'RS256', kid: privateKey.kid, typ: 'JWT' })
|
||||
.sign(privateKeyImported);
|
||||
}
|
||||
|
||||
export async function generateOauthAccessToken(
|
||||
user: User,
|
||||
clientId: string,
|
||||
expired = false
|
||||
) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiration = expired ? now - 1000 : now + 1000000000; // Either expired or valid for a long time
|
||||
export async function generateOauthAccessToken(user: User, clientId: string, expired = false) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiration = expired ? now - 1000 : now + 1000000000; // Either expired or valid for a long time
|
||||
|
||||
const payload = {
|
||||
aud: [clientId],
|
||||
exp: expiration,
|
||||
iat: now,
|
||||
iss: playwrightConfig.use!.baseURL,
|
||||
sub: user.id,
|
||||
type: "oauth-access-token",
|
||||
};
|
||||
const payload = {
|
||||
aud: [clientId],
|
||||
exp: expiration,
|
||||
iat: now,
|
||||
iss: playwrightConfig.use!.baseURL,
|
||||
sub: user.id,
|
||||
type: 'oauth-access-token'
|
||||
};
|
||||
|
||||
return await new jose.SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "RS256", kid: privateKey.kid, typ: "JWT" })
|
||||
.sign(privateKeyImported);
|
||||
return await new jose.SignJWT(payload)
|
||||
.setProtectedHeader({ alg: 'RS256', kid: privateKey.kid, typ: 'JWT' })
|
||||
.sign(privateKeyImported);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
export async function getUserCode(page: Page, clientId: string, clientSecret: string): Promise<string> {
|
||||
export async function getUserCode(
|
||||
page: Page,
|
||||
clientId: string,
|
||||
clientSecret: string
|
||||
): Promise<string> {
|
||||
return page.request
|
||||
.post('/api/oidc/device/authorize', {
|
||||
headers: {
|
||||
@@ -16,25 +20,31 @@ export async function getUserCode(page: Page, clientId: string, clientSecret: st
|
||||
.then((r) => r.user_code);
|
||||
}
|
||||
|
||||
export async function exchangeCode(page: Page, params: Record<string,string>): Promise<{access_token?: string, token_type?: string, expires_in?: number, error?: string}> {
|
||||
export async function exchangeCode(
|
||||
page: Page,
|
||||
params: Record<string, string>
|
||||
): Promise<{ access_token?: string; token_type?: string; expires_in?: number; error?: string }> {
|
||||
return page.request
|
||||
.post('/api/oidc/token', {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
form: params,
|
||||
form: params
|
||||
})
|
||||
.then((r) => r.json());
|
||||
}
|
||||
|
||||
export async function getClientAssertion(page: Page, data: {issuer: string, audience: string, subject: string}): Promise<string> {
|
||||
export async function getClientAssertion(
|
||||
page: Page,
|
||||
data: { issuer: string; audience: string; subject: string }
|
||||
): Promise<string> {
|
||||
return page.request
|
||||
.post('/api/externalidp/sign', {
|
||||
data: {
|
||||
iss: data.issuer,
|
||||
aud: data.audience,
|
||||
sub: data.subject,
|
||||
},
|
||||
sub: data.subject
|
||||
}
|
||||
})
|
||||
.then((r) => r.text());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user