Compare commits

..

18 Commits

Author SHA1 Message Date
Elias Schneider
2c64bebf6a release: 2.2.0 2026-01-11 15:46:36 +01:00
Elias Schneider
2a11c3e609 fix: use user specific email verified claim instead of global one 2026-01-11 15:46:14 +01:00
Elias Schneider
a0ced2443c chore(translations): update translations via Crowdin (#1230) 2026-01-11 15:43:21 +01:00
Elias Schneider
746aa71d67 feat: add static api key env variable (#1229) 2026-01-11 15:36:27 +01:00
Elias Schneider
9ca3d33c88 feat: add environment variable to disable built-in rate limiting 2026-01-11 14:26:30 +01:00
Elias Schneider
4df4bcb645 fix: db version downgrades don't downgrade db schema 2026-01-11 14:14:44 +01:00
Elias Schneider
875c5b94a6 chore(translations): update translations via Crowdin (#1226) 2026-01-11 13:01:12 +01:00
Elias Schneider
0e2cdc393e fix: allow exchanging logic code if already authenticated 2026-01-11 12:59:31 +01:00
Elias Schneider
1e7442f5df feat: add support for email verification (#1223) 2026-01-11 12:31:26 +01:00
Elias Schneider
e955118a6f chore(translations): update translations via Crowdin (#1213) 2026-01-10 23:19:26 +01:00
Elias Schneider
811e8772b6 feat: add option to renew API key (#1214) 2026-01-09 12:08:58 +01:00
Elias Schneider
0a94f0fd64 feat: make home page URL configurable (#1215) 2026-01-07 22:01:51 +01:00
Elias Schneider
03f9be0d12 fix: login codes sent by an admin incorrectly requires a device token 2026-01-07 16:13:18 +01:00
Elias Schneider
2f25861d15 feat: improve passkey error messages 2026-01-07 11:30:37 +01:00
Elias Schneider
2af70d9b4d feat: add CLI command for encryption key rotation (#1209) 2026-01-07 09:34:23 +01:00
Elias Schneider
5828fa5779 fix: user can't update account if email is empty 2026-01-06 17:35:47 +01:00
Elias Schneider
1a032a812e fix: data import from sqlite to postgres fails because of wrong datatype 2026-01-06 16:08:49 +01:00
Elias Schneider
8c68b08c12 fix: allow changing "require email address" if no SMTP credentials present 2026-01-06 14:28:08 +01:00
123 changed files with 2916 additions and 898 deletions

View File

@@ -1 +1 @@
2.1.0 2.2.0

View File

@@ -1,3 +1,27 @@
## v2.2.0
### Bug Fixes
- allow changing "require email address" if no SMTP credentials present ([8c68b08](https://github.com/pocket-id/pocket-id/commit/8c68b08c12ba371deda61662e3d048d63d07c56f) by @stonith404)
- data import from sqlite to postgres fails because of wrong datatype ([1a032a8](https://github.com/pocket-id/pocket-id/commit/1a032a812ef78b250a898d14bec73a8ef7a7859a) by @stonith404)
- user can't update account if email is empty ([5828fa5](https://github.com/pocket-id/pocket-id/commit/5828fa57791314594625d52475733dce23cc2fcc) by @stonith404)
- login codes sent by an admin incorrectly requires a device token ([03f9be0](https://github.com/pocket-id/pocket-id/commit/03f9be0d125732e02a8e2c5390d9e6d0c74ce957) by @stonith404)
- allow exchanging logic code if already authenticated ([0e2cdc3](https://github.com/pocket-id/pocket-id/commit/0e2cdc393e34276bb3b8ea318cdc7261de3f2dec) by @stonith404)
- db version downgrades don't downgrade db schema ([4df4bcb](https://github.com/pocket-id/pocket-id/commit/4df4bcb6451b4bf88093e04f3222c8737f2c7be3) by @stonith404)
- use user specific email verified claim instead of global one ([2a11c3e](https://github.com/pocket-id/pocket-id/commit/2a11c3e60942d45c2e5b422d99945bce65a622a2) by @stonith404)
### Features
- add CLI command for encryption key rotation ([#1209](https://github.com/pocket-id/pocket-id/pull/1209) by @stonith404)
- improve passkey error messages ([2f25861](https://github.com/pocket-id/pocket-id/commit/2f25861d15aefa868042e70d3e21b7b38a6ae679) by @stonith404)
- make home page URL configurable ([#1215](https://github.com/pocket-id/pocket-id/pull/1215) by @stonith404)
- add option to renew API key ([#1214](https://github.com/pocket-id/pocket-id/pull/1214) by @stonith404)
- add support for email verification ([#1223](https://github.com/pocket-id/pocket-id/pull/1223) by @stonith404)
- add environment variable to disable built-in rate limiting ([9ca3d33](https://github.com/pocket-id/pocket-id/commit/9ca3d33c8897cf49a871783058205bb180529cd2) by @stonith404)
- add static api key env variable ([#1229](https://github.com/pocket-id/pocket-id/pull/1229) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.1.0...v2.2.0
## v2.1.0 ## v2.1.0
### Bug Fixes ### Bug Fixes

View File

@@ -76,7 +76,7 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService) controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService)
controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService) controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService)
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService) controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService)
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.appConfigService) controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.oneTimeAccessService, svc.appConfigService)
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService) controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService) controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware) controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
@@ -84,6 +84,7 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService) controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
controller.NewVersionController(apiGroup, svc.versionService) controller.NewVersionController(apiGroup, svc.versionService)
controller.NewScimController(apiGroup, authMiddleware, svc.scimService) controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
controller.NewUserSignupController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userSignUpService, svc.appConfigService)
// Add test controller in non-production environments // Add test controller in non-production environments
if !common.EnvConfig.AppEnv.IsProduction() { if !common.EnvConfig.AppEnv.IsProduction() {

View File

@@ -13,23 +13,25 @@ import (
) )
type services struct { type services struct {
appConfigService *service.AppConfigService appConfigService *service.AppConfigService
appImagesService *service.AppImagesService appImagesService *service.AppImagesService
emailService *service.EmailService emailService *service.EmailService
geoLiteService *service.GeoLiteService geoLiteService *service.GeoLiteService
auditLogService *service.AuditLogService auditLogService *service.AuditLogService
jwtService *service.JwtService jwtService *service.JwtService
webauthnService *service.WebAuthnService webauthnService *service.WebAuthnService
scimService *service.ScimService scimService *service.ScimService
userService *service.UserService userService *service.UserService
customClaimService *service.CustomClaimService customClaimService *service.CustomClaimService
oidcService *service.OidcService oidcService *service.OidcService
userGroupService *service.UserGroupService userGroupService *service.UserGroupService
ldapService *service.LdapService ldapService *service.LdapService
apiKeyService *service.ApiKeyService apiKeyService *service.ApiKeyService
versionService *service.VersionService versionService *service.VersionService
fileStorage storage.FileStorage fileStorage storage.FileStorage
appLockService *service.AppLockService appLockService *service.AppLockService
userSignUpService *service.UserSignUpService
oneTimeAccessService *service.OneTimeAccessService
} }
// Initializes all services // Initializes all services
@@ -52,7 +54,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
svc.geoLiteService = service.NewGeoLiteService(httpClient) svc.geoLiteService = service.NewGeoLiteService(httpClient)
svc.auditLogService = service.NewAuditLogService(db, svc.appConfigService, svc.emailService, svc.geoLiteService) svc.auditLogService = service.NewAuditLogService(db, svc.appConfigService, svc.emailService, svc.geoLiteService)
svc.jwtService, err = service.NewJwtService(db, svc.appConfigService) svc.jwtService, err = service.NewJwtService(ctx, db, svc.appConfigService)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create JWT service: %w", err) return nil, fmt.Errorf("failed to create JWT service: %w", err)
} }
@@ -73,7 +75,14 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService, svc.scimService) svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService, svc.scimService)
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, svc.scimService, fileStorage) svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, svc.scimService, fileStorage)
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage) svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage)
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
svc.apiKeyService, err = service.NewApiKeyService(ctx, db, svc.emailService)
if err != nil {
return nil, fmt.Errorf("failed to create API key service: %w", err)
}
svc.userSignUpService = service.NewUserSignupService(db, svc.jwtService, svc.auditLogService, svc.appConfigService, svc.userService)
svc.oneTimeAccessService = service.NewOneTimeAccessService(db, svc.userService, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)
svc.versionService = service.NewVersionService(httpClient) svc.versionService = service.NewVersionService(httpClient)

View File

@@ -0,0 +1,187 @@
package cmds
import (
"context"
"errors"
"fmt"
"os"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/spf13/cobra"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/common"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
)
type encryptionKeyRotateFlags struct {
NewKey string
Yes bool
}
func init() {
var flags encryptionKeyRotateFlags
encryptionKeyRotateCmd := &cobra.Command{
Use: "encryption-key-rotate",
Short: "Re-encrypts data using a new encryption key",
RunE: func(cmd *cobra.Command, args []string) error {
db, err := bootstrap.NewDatabase()
if err != nil {
return err
}
return encryptionKeyRotate(cmd.Context(), flags, db, &common.EnvConfig)
},
}
encryptionKeyRotateCmd.Flags().StringVar(&flags.NewKey, "new-key", "", "New encryption key to re-encrypt data with")
encryptionKeyRotateCmd.Flags().BoolVarP(&flags.Yes, "yes", "y", false, "Do not prompt for confirmation")
rootCmd.AddCommand(encryptionKeyRotateCmd)
}
func encryptionKeyRotate(ctx context.Context, flags encryptionKeyRotateFlags, db *gorm.DB, envConfig *common.EnvConfigSchema) error {
oldKey := envConfig.EncryptionKey
newKey := []byte(flags.NewKey)
if len(newKey) == 0 {
return errors.New("new encryption key is required (--new-key)")
}
if len(newKey) < 16 {
return errors.New("new encryption key must be at least 16 bytes long")
}
if !flags.Yes {
fmt.Println("WARNING: Rotating the encryption key will re-encrypt secrets in the database. Pocket-ID must be restarted with the new ENCRYPTION_KEY after rotation is complete.")
ok, err := utils.PromptForConfirmation("Continue")
if err != nil {
return err
}
if !ok {
fmt.Println("Aborted")
os.Exit(1)
}
}
appConfigService, err := service.NewAppConfigService(ctx, db)
if err != nil {
return fmt.Errorf("failed to create app config service: %w", err)
}
instanceID := appConfigService.GetDbConfig().InstanceID.Value
// Derive the encryption keys used for the JWK encryption
oldKek, err := jwkutils.LoadKeyEncryptionKey(&common.EnvConfigSchema{EncryptionKey: oldKey}, instanceID)
if err != nil {
return fmt.Errorf("failed to derive old key encryption key: %w", err)
}
newKek, err := jwkutils.LoadKeyEncryptionKey(&common.EnvConfigSchema{EncryptionKey: newKey}, instanceID)
if err != nil {
return fmt.Errorf("failed to derive new key encryption key: %w", err)
}
// Derive the encryption keys used for EncryptedString fields
oldEncKey, err := datatype.DeriveEncryptedStringKey(oldKey)
if err != nil {
return fmt.Errorf("failed to derive old encrypted string key: %w", err)
}
newEncKey, err := datatype.DeriveEncryptedStringKey(newKey)
if err != nil {
return fmt.Errorf("failed to derive new encrypted string key: %w", err)
}
err = db.Transaction(func(tx *gorm.DB) error {
err = rotateSigningKeyEncryption(ctx, tx, oldKek, newKek)
if err != nil {
return err
}
err = rotateScimTokens(tx, oldEncKey, newEncKey)
if err != nil {
return err
}
return nil
})
if err != nil {
return err
}
fmt.Println("Encryption key rotation completed successfully.")
fmt.Println("Restart pocket-id with the new ENCRYPTION_KEY to use the rotated data.")
return nil
}
func rotateSigningKeyEncryption(ctx context.Context, db *gorm.DB, oldKek []byte, newKek []byte) error {
oldProvider := &jwkutils.KeyProviderDatabase{}
err := oldProvider.Init(jwkutils.KeyProviderOpts{
DB: db,
Kek: oldKek,
})
if err != nil {
return fmt.Errorf("failed to init key provider with old encryption key: %w", err)
}
key, err := oldProvider.LoadKey(ctx)
if err != nil {
return fmt.Errorf("failed to load signing key using old encryption key: %w", err)
}
if key == nil {
return nil
}
newProvider := &jwkutils.KeyProviderDatabase{}
err = newProvider.Init(jwkutils.KeyProviderOpts{
DB: db,
Kek: newKek,
})
if err != nil {
return fmt.Errorf("failed to init key provider with new encryption key: %w", err)
}
if err := newProvider.SaveKey(ctx, key); err != nil {
return fmt.Errorf("failed to store signing key with new encryption key: %w", err)
}
return nil
}
type scimTokenRow struct {
ID string
Token string
}
func rotateScimTokens(db *gorm.DB, oldEncKey []byte, newEncKey []byte) error {
var rows []scimTokenRow
err := db.Model(&model.ScimServiceProvider{}).Select("id, token").Scan(&rows).Error
if err != nil {
return fmt.Errorf("failed to list SCIM service providers: %w", err)
}
for _, row := range rows {
if row.Token == "" {
continue
}
decBytes, err := datatype.DecryptEncryptedStringWithKey(oldEncKey, row.Token)
if err != nil {
return fmt.Errorf("failed to decrypt SCIM token for provider %s: %w", row.ID, err)
}
encValue, err := datatype.EncryptEncryptedStringWithKey(newEncKey, decBytes)
if err != nil {
return fmt.Errorf("failed to encrypt SCIM token for provider %s: %w", row.ID, err)
}
err = db.Model(&model.ScimServiceProvider{}).Where("id = ?", row.ID).Update("token", encValue).Error
if err != nil {
return fmt.Errorf("failed to update SCIM token for provider %s: %w", row.ID, err)
}
}
return nil
}

View File

@@ -0,0 +1,89 @@
package cmds
import (
"testing"
"time"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/service"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
testingutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func TestEncryptionKeyRotate(t *testing.T) {
oldKey := []byte("old-encryption-key-123456")
newKey := []byte("new-encryption-key-654321")
envConfig := &common.EnvConfigSchema{
EncryptionKey: oldKey,
}
db := testingutils.NewDatabaseForTest(t)
appConfigService, err := service.NewAppConfigService(t.Context(), db)
require.NoError(t, err)
instanceID := appConfigService.GetDbConfig().InstanceID.Value
oldKek, err := jwkutils.LoadKeyEncryptionKey(envConfig, instanceID)
require.NoError(t, err)
oldProvider := &jwkutils.KeyProviderDatabase{}
require.NoError(t, oldProvider.Init(jwkutils.KeyProviderOpts{
DB: db,
Kek: oldKek,
}))
signingKey, err := jwkutils.GenerateKey("RS256", "")
require.NoError(t, err)
require.NoError(t, oldProvider.SaveKey(t.Context(), signingKey))
oldEncKey, err := datatype.DeriveEncryptedStringKey(oldKey)
require.NoError(t, err)
encToken, err := datatype.EncryptEncryptedStringWithKey(oldEncKey, []byte("scim-token-123"))
require.NoError(t, err)
err = db.Exec(
`INSERT INTO scim_service_providers (id, created_at, endpoint, token, oidc_client_id) VALUES (?, ?, ?, ?, ?)`,
"scim-1",
time.Now(),
"https://example.com/scim",
encToken,
"client-1",
).Error
require.NoError(t, err)
flags := encryptionKeyRotateFlags{
NewKey: string(newKey),
Yes: true,
}
require.NoError(t, encryptionKeyRotate(t.Context(), flags, db, envConfig))
newKek, err := jwkutils.LoadKeyEncryptionKey(&common.EnvConfigSchema{EncryptionKey: newKey}, instanceID)
require.NoError(t, err)
newProvider := &jwkutils.KeyProviderDatabase{}
require.NoError(t, newProvider.Init(jwkutils.KeyProviderOpts{
DB: db,
Kek: newKek,
}))
rotatedKey, err := newProvider.LoadKey(t.Context())
require.NoError(t, err)
require.NotNil(t, rotatedKey)
var storedToken string
err = db.Model(&model.ScimServiceProvider{}).Where("id = ?", "scim-1").Pluck("token", &storedToken).Error
require.NoError(t, err)
newEncKey, err := datatype.DeriveEncryptedStringKey(newKey)
require.NoError(t, err)
decBytes, err := datatype.DecryptEncryptedStringWithKey(newEncKey, storedToken)
require.NoError(t, err)
assert.Equal(t, "scim-token-123", string(decBytes))
}

View File

@@ -102,7 +102,7 @@ func keyRotate(ctx context.Context, flags keyRotateFlags, db *gorm.DB, envConfig
} }
// Save the key // Save the key
err = keyProvider.SaveKey(key) err = keyProvider.SaveKey(ctx, key)
if err != nil { if err != nil {
return fmt.Errorf("failed to store new key: %w", err) return fmt.Errorf("failed to store new key: %w", err)
} }

View File

@@ -104,7 +104,7 @@ func testKeyRotateWithDatabaseStorage(t *testing.T, flags keyRotateFlags, wantEr
require.NoError(t, err) require.NoError(t, err)
// Verify key was created // Verify key was created
key, err := keyProvider.LoadKey() key, err := keyProvider.LoadKey(t.Context())
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, key) require.NotNil(t, key)

View File

@@ -49,6 +49,8 @@ type EnvConfigSchema struct {
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"` AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
InternalAppURL string `env:"INTERNAL_APP_URL"` InternalAppURL string `env:"INTERNAL_APP_URL"`
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"` UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
DisableRateLimiting bool `env:"DISABLE_RATE_LIMITING"`
StaticApiKey string `env:"STATIC_API_KEY" options:"file"`
FileBackend string `env:"FILE_BACKEND" options:"toLower"` FileBackend string `env:"FILE_BACKEND" options:"toLower"`
UploadPath string `env:"UPLOAD_PATH"` UploadPath string `env:"UPLOAD_PATH"`
@@ -199,6 +201,10 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
return errors.New("AUDIT_LOG_RETENTION_DAYS must be greater than 0") return errors.New("AUDIT_LOG_RETENTION_DAYS must be greater than 0")
} }
if config.StaticApiKey != "" && len(config.StaticApiKey) < 16 {
return errors.New("STATIC_API_KEY must be at least 16 characters long")
}
return nil return nil
} }

View File

@@ -266,6 +266,13 @@ func (e *APIKeyNotFoundError) Error() string {
} }
func (e *APIKeyNotFoundError) HttpStatusCode() int { return http.StatusUnauthorized } func (e *APIKeyNotFoundError) HttpStatusCode() int { return http.StatusUnauthorized }
type APIKeyNotExpiredError struct{}
func (e *APIKeyNotExpiredError) Error() string {
return "API Key is not expired yet"
}
func (e *APIKeyNotExpiredError) HttpStatusCode() int { return http.StatusBadRequest }
type APIKeyExpirationDateError struct{} type APIKeyExpirationDateError struct{}
func (e *APIKeyExpirationDateError) Error() string { func (e *APIKeyExpirationDateError) Error() string {
@@ -405,3 +412,13 @@ func (e *ImageNotFoundError) Error() string {
func (e *ImageNotFoundError) HttpStatusCode() int { func (e *ImageNotFoundError) HttpStatusCode() int {
return http.StatusNotFound return http.StatusNotFound
} }
type InvalidEmailVerificationTokenError struct{}
func (e *InvalidEmailVerificationTokenError) Error() string {
return "Invalid email verification token"
}
func (e *InvalidEmailVerificationTokenError) HttpStatusCode() int {
return http.StatusBadRequest
}

View File

@@ -30,6 +30,7 @@ func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.Auth
{ {
apiKeyGroup.GET("", uc.listApiKeysHandler) apiKeyGroup.GET("", uc.listApiKeysHandler)
apiKeyGroup.POST("", uc.createApiKeyHandler) apiKeyGroup.POST("", uc.createApiKeyHandler)
apiKeyGroup.POST("/:id/renew", uc.renewApiKeyHandler)
apiKeyGroup.DELETE("/:id", uc.revokeApiKeyHandler) apiKeyGroup.DELETE("/:id", uc.revokeApiKeyHandler)
} }
} }
@@ -101,6 +102,41 @@ func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) {
}) })
} }
// renewApiKeyHandler godoc
// @Summary Renew API key
// @Description Renew an existing API key by ID
// @Tags API Keys
// @Param id path string true "API Key ID"
// @Success 200 {object} dto.ApiKeyResponseDto "Renewed API key with new token"
// @Router /api/api-keys/{id}/renew [post]
func (c *ApiKeyController) renewApiKeyHandler(ctx *gin.Context) {
userID := ctx.GetString("userID")
apiKeyID := ctx.Param("id")
var input dto.ApiKeyRenewDto
if err := dto.ShouldBindWithNormalizedJSON(ctx, &input); err != nil {
_ = ctx.Error(err)
return
}
apiKey, token, err := c.apiKeyService.RenewApiKey(ctx.Request.Context(), userID, apiKeyID, input.ExpiresAt.ToTime())
if err != nil {
_ = ctx.Error(err)
return
}
var apiKeyDto dto.ApiKeyDto
if err := dto.MapStruct(apiKey, &apiKeyDto); err != nil {
_ = ctx.Error(err)
return
}
ctx.JSON(http.StatusOK, dto.ApiKeyResponseDto{
ApiKey: apiKeyDto,
Token: token,
})
}
// revokeApiKeyHandler godoc // revokeApiKeyHandler godoc
// @Summary Revoke API key // @Summary Revoke API key
// @Description Revoke (delete) an existing API key by ID // @Description Revoke (delete) an existing API key by ID

View File

@@ -14,19 +14,17 @@ import (
"golang.org/x/time/rate" "golang.org/x/time/rate"
) )
const ( const defaultOneTimeAccessTokenDuration = 15 * time.Minute
defaultOneTimeAccessTokenDuration = 15 * time.Minute
defaultSignupTokenDuration = time.Hour
)
// NewUserController creates a new controller for user management endpoints // NewUserController creates a new controller for user management endpoints
// @Summary User management controller // @Summary User management controller
// @Description Initializes all user-related API endpoints // @Description Initializes all user-related API endpoints
// @Tags Users // @Tags Users
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) { func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, appConfigService *service.AppConfigService) {
uc := UserController{ uc := UserController{
userService: userService, userService: userService,
appConfigService: appConfigService, oneTimeAccessService: oneTimeAccessService,
appConfigService: appConfigService,
} }
group.GET("/users", authMiddleware.Add(), uc.listUsersHandler) group.GET("/users", authMiddleware.Add(), uc.listUsersHandler)
@@ -54,17 +52,14 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler) group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler) group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
group.POST("/signup-tokens", authMiddleware.Add(), uc.createSignupTokenHandler) group.POST("/users/me/send-email-verification", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), authMiddleware.WithAdminNotRequired().Add(), uc.sendEmailVerificationHandler)
group.GET("/signup-tokens", authMiddleware.Add(), uc.listSignupTokensHandler) group.POST("/users/me/verify-email", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), authMiddleware.WithAdminNotRequired().Add(), uc.verifyEmailHandler)
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 { type UserController struct {
userService *service.UserService userService *service.UserService
appConfigService *service.AppConfigService oneTimeAccessService *service.OneTimeAccessService
appConfigService *service.AppConfigService
} }
// getUserGroupsHandler godoc // getUserGroupsHandler godoc
@@ -342,7 +337,7 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bo
ttl = defaultOneTimeAccessTokenDuration ttl = defaultOneTimeAccessTokenDuration
} }
} }
token, err := uc.userService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, ttl) token, err := uc.oneTimeAccessService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, ttl)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -391,7 +386,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(
return return
} }
deviceToken, err := uc.userService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath) deviceToken, err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -424,7 +419,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
if ttl <= 0 { if ttl <= 0 {
ttl = defaultOneTimeAccessTokenDuration ttl = defaultOneTimeAccessTokenDuration
} }
err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl) err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -442,41 +437,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
// @Router /api/one-time-access-token/{token} [post] // @Router /api/one-time-access-token/{token} [post]
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) { func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
deviceToken, _ := c.Cookie(cookie.DeviceTokenCookieName) deviceToken, _ := c.Cookie(cookie.DeviceTokenCookieName)
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Request.Context(), c.Param("token"), deviceToken, c.ClientIP(), c.Request.UserAgent()) user, token, err := uc.oneTimeAccessService.ExchangeOneTimeAccessToken(c.Request.Context(), c.Param("token"), deviceToken, c.ClientIP(), c.Request.UserAgent())
if err != nil {
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
}
maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
}
// 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/signup/setup [post]
func (uc *UserController) signUpInitialAdmin(c *gin.Context) {
var input dto.SignUpDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
user, token, err := uc.userService.SignUpInitialAdmin(c.Request.Context(), input)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -524,130 +485,6 @@ func (uc *UserController) updateUserGroups(c *gin.Context) {
c.JSON(http.StatusOK, userDto) 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
}
ttl := input.TTL.Duration
if ttl <= 0 {
ttl = defaultSignupTokenDuration
}
signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs)
if err != nil {
_ = c.Error(err)
return
}
var tokenDto dto.SignupTokenDto
err = dto.MapStruct(signupToken, &tokenDto)
if 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) {
listRequestOptions := utils.ParseListRequestOptions(c)
tokens, pagination, err := uc.userService.ListSignupTokens(c.Request.Context(), listRequestOptions)
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 := dto.ShouldBindWithNormalizedJSON(c, &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 // updateUser is an internal helper method, not exposed as an API endpoint
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) { func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
var input dto.UserCreateDto var input dto.UserCreateDto
@@ -714,3 +551,44 @@ func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context)
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }
// sendEmailVerificationHandler godoc
// @Summary Send email verification
// @Description Send an email verification to the currently authenticated user
// @Tags Users
// @Produce json
// @Success 204 "No Content"
// @Router /api/users/me/send-email-verification [post]
func (uc *UserController) sendEmailVerificationHandler(c *gin.Context) {
userID := c.GetString("userID")
if err := uc.userService.SendEmailVerification(c.Request.Context(), userID); err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// verifyEmailHandler godoc
// @Summary Verify email
// @Description Verify the currently authenticated user's email using a verification token
// @Tags Users
// @Param body body dto.EmailVerificationDto true "Email verification token"
// @Success 204 "No Content"
// @Router /api/users/me/verify-email [post]
func (uc *UserController) verifyEmailHandler(c *gin.Context) {
var input dto.EmailVerificationDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
userID := c.GetString("userID")
if err := uc.userService.VerifyEmail(c.Request.Context(), userID, input.Token); err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}

View File

@@ -0,0 +1,198 @@
package controller
import (
"net/http"
"time"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"golang.org/x/time/rate"
)
const defaultSignupTokenDuration = time.Hour
// NewUserSignupController creates a new controller for user signup and signup token management
// @Summary User signup and signup token management controller
// @Description Initializes all user signup-related API endpoints
// @Tags Users
func NewUserSignupController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userSignUpService *service.UserSignUpService, appConfigService *service.AppConfigService) {
usc := UserSignupController{
userSignUpService: userSignUpService,
appConfigService: appConfigService,
}
group.POST("/signup-tokens", authMiddleware.Add(), usc.createSignupTokenHandler)
group.GET("/signup-tokens", authMiddleware.Add(), usc.listSignupTokensHandler)
group.DELETE("/signup-tokens/:id", authMiddleware.Add(), usc.deleteSignupTokenHandler)
group.POST("/signup", rateLimitMiddleware.Add(rate.Every(1*time.Minute), 10), usc.signupHandler)
group.POST("/signup/setup", usc.signUpInitialAdmin)
}
type UserSignupController struct {
userSignUpService *service.UserSignUpService
appConfigService *service.AppConfigService
}
// 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/signup/setup [post]
func (usc *UserSignupController) signUpInitialAdmin(c *gin.Context) {
var input dto.SignUpDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
user, token, err := usc.userSignUpService.SignUpInitialAdmin(c.Request.Context(), input)
if err != nil {
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
}
maxAge := int(usc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
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 (usc *UserSignupController) createSignupTokenHandler(c *gin.Context) {
var input dto.SignupTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
ttl := input.TTL.Duration
if ttl <= 0 {
ttl = defaultSignupTokenDuration
}
signupToken, err := usc.userSignUpService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs)
if err != nil {
_ = c.Error(err)
return
}
var tokenDto dto.SignupTokenDto
err = dto.MapStruct(signupToken, &tokenDto)
if 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 (usc *UserSignupController) listSignupTokensHandler(c *gin.Context) {
listRequestOptions := utils.ParseListRequestOptions(c)
tokens, pagination, err := usc.userSignUpService.ListSignupTokens(c.Request.Context(), listRequestOptions)
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 (usc *UserSignupController) deleteSignupTokenHandler(c *gin.Context) {
tokenID := c.Param("id")
err := usc.userSignUpService.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 (usc *UserSignupController) signupHandler(c *gin.Context) {
var input dto.SignUpDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
ipAddress := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
user, accessToken, err := usc.userSignUpService.SignUp(c.Request.Context(), input, ipAddress, userAgent)
if err != nil {
_ = c.Error(err)
return
}
maxAge := int(usc.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)
}

View File

@@ -10,6 +10,10 @@ type ApiKeyCreateDto struct {
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"` ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
} }
type ApiKeyRenewDto struct {
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
}
type ApiKeyDto struct { type ApiKeyDto struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`

View File

@@ -14,6 +14,7 @@ type AppConfigVariableDto struct {
type AppConfigUpdateDto struct { type AppConfigUpdateDto struct {
AppName string `json:"appName" binding:"required,min=1,max=30" unorm:"nfc"` AppName string `json:"appName" binding:"required,min=1,max=30" unorm:"nfc"`
SessionDuration string `json:"sessionDuration" binding:"required"` SessionDuration string `json:"sessionDuration" binding:"required"`
HomePageURL string `json:"homePageUrl" binding:"required"`
EmailsVerified string `json:"emailsVerified" binding:"required"` EmailsVerified string `json:"emailsVerified" binding:"required"`
DisableAnimations string `json:"disableAnimations" binding:"required"` DisableAnimations string `json:"disableAnimations" binding:"required"`
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"` AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
@@ -53,4 +54,5 @@ type AppConfigUpdateDto struct {
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"` EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"`
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"` EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" binding:"required"` EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" binding:"required"`
EmailVerificationEnabled string `json:"emailVerificationEnabled" binding:"required"`
} }

View File

@@ -0,0 +1,17 @@
package dto
import "github.com/pocket-id/pocket-id/backend/internal/utils"
type OneTimeAccessTokenCreateDto struct {
UserID string `json:"userId"`
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
}
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
Email string `json:"email" binding:"required,email" unorm:"nfc"`
RedirectPath string `json:"redirectPath"`
}
type OneTimeAccessEmailAsAdminDto struct {
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
}

View File

@@ -0,0 +1,9 @@
package dto
type SignUpDto struct {
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
Token string `json:"token"`
}

View File

@@ -4,35 +4,36 @@ import (
"errors" "errors"
"github.com/gin-gonic/gin/binding" "github.com/gin-gonic/gin/binding"
"github.com/pocket-id/pocket-id/backend/internal/utils"
) )
type UserDto struct { type UserDto struct {
ID string `json:"id"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Email *string `json:"email" ` Email *string `json:"email"`
FirstName string `json:"firstName"` EmailVerified bool `json:"emailVerified"`
LastName *string `json:"lastName"` FirstName string `json:"firstName"`
DisplayName string `json:"displayName"` LastName *string `json:"lastName"`
IsAdmin bool `json:"isAdmin"` DisplayName string `json:"displayName"`
Locale *string `json:"locale"` IsAdmin bool `json:"isAdmin"`
CustomClaims []CustomClaimDto `json:"customClaims"` Locale *string `json:"locale"`
UserGroups []UserGroupMinimalDto `json:"userGroups"` CustomClaims []CustomClaimDto `json:"customClaims"`
LdapID *string `json:"ldapId"` UserGroups []UserGroupMinimalDto `json:"userGroups"`
Disabled bool `json:"disabled"` LdapID *string `json:"ldapId"`
Disabled bool `json:"disabled"`
} }
type UserCreateDto struct { type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"` Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"` Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"` EmailVerified bool `json:"emailVerified"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"` LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
IsAdmin bool `json:"isAdmin"` DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
Locale *string `json:"locale"` IsAdmin bool `json:"isAdmin"`
Disabled bool `json:"disabled"` Locale *string `json:"locale"`
UserGroupIds []string `json:"userGroupIds"` Disabled bool `json:"disabled"`
LdapID string `json:"-"` UserGroupIds []string `json:"userGroupIds"`
LdapID string `json:"-"`
} }
func (u UserCreateDto) Validate() error { func (u UserCreateDto) Validate() error {
@@ -46,28 +47,10 @@ func (u UserCreateDto) Validate() error {
return e.Struct(u) return e.Struct(u)
} }
type OneTimeAccessTokenCreateDto struct { type EmailVerificationDto struct {
UserID string `json:"userId"` Token string `json:"token" binding:"required"`
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
}
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
Email string `json:"email" binding:"required,email" unorm:"nfc"`
RedirectPath string `json:"redirectPath"`
}
type OneTimeAccessEmailAsAdminDto struct {
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
} }
type UserUpdateUserGroupDto struct { type UserUpdateUserGroupDto struct {
UserGroupIds []string `json:"userGroupIds" binding:"required"` UserGroupIds []string `json:"userGroupIds" binding:"required"`
} }
type SignUpDto struct {
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
Token string `json:"token"`
}

View File

@@ -24,6 +24,7 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
s.RegisterJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true), s.RegisterJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
s.RegisterJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true), s.RegisterJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
s.RegisterJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true), s.RegisterJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
s.RegisterJob(ctx, "ClearEmailVerificationTokens", def, jobs.clearEmailVerificationTokens, true),
s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true), s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
s.RegisterJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true), s.RegisterJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
s.RegisterJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true), s.RegisterJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true),
@@ -135,3 +136,16 @@ func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
return nil return nil
} }
// ClearEmailVerificationTokens deletes email verification tokens that have expired
func (j *DbCleanupJobs) clearEmailVerificationTokens(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.EmailVerificationToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired email verification tokens: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired email verification tokens", slog.Int64("count", st.RowsAffected))
return nil
}

View File

@@ -34,7 +34,7 @@ func (m *ApiKeyAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
} }
func (m *ApiKeyAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userID string, isAdmin bool, err error) { func (m *ApiKeyAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userID string, isAdmin bool, err error) {
apiKey := c.GetHeader("X-API-KEY") apiKey := c.GetHeader("X-API-Key")
user, err := m.apiKeyService.ValidateApiKey(c.Request.Context(), apiKey) user, err := m.apiKeyService.ValidateApiKey(c.Request.Context(), apiKey)
if err != nil { if err != nil {

View File

@@ -17,6 +17,12 @@ func NewRateLimitMiddleware() *RateLimitMiddleware {
} }
func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc { func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
if common.EnvConfig.DisableRateLimiting {
return func(c *gin.Context) {
c.Next()
}
}
// Map to store the rate limiters per IP // Map to store the rate limiters per IP
var clients = make(map[string]*client) var clients = make(map[string]*client)
var mu sync.Mutex var mu sync.Mutex

View File

@@ -36,6 +36,7 @@ type AppConfig struct {
// General // General
AppName AppConfigVariable `key:"appName,public"` // Public AppName AppConfigVariable `key:"appName,public"` // Public
SessionDuration AppConfigVariable `key:"sessionDuration"` SessionDuration AppConfigVariable `key:"sessionDuration"`
HomePageURL AppConfigVariable `key:"homePageUrl,public"` // Public
EmailsVerified AppConfigVariable `key:"emailsVerified"` EmailsVerified AppConfigVariable `key:"emailsVerified"`
AccentColor AppConfigVariable `key:"accentColor,public"` // Public AccentColor AppConfigVariable `key:"accentColor,public"` // Public
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
@@ -58,6 +59,7 @@ type AppConfig struct {
EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigVariable `key:"emailOneTimeAccessAsUnauthenticatedEnabled,public"` // Public EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigVariable `key:"emailOneTimeAccessAsUnauthenticatedEnabled,public"` // Public
EmailOneTimeAccessAsAdminEnabled AppConfigVariable `key:"emailOneTimeAccessAsAdminEnabled,public"` // Public EmailOneTimeAccessAsAdminEnabled AppConfigVariable `key:"emailOneTimeAccessAsAdminEnabled,public"` // Public
EmailApiKeyExpirationEnabled AppConfigVariable `key:"emailApiKeyExpirationEnabled"` EmailApiKeyExpirationEnabled AppConfigVariable `key:"emailApiKeyExpirationEnabled"`
EmailVerificationEnabled AppConfigVariable `key:"emailVerificationEnabled,public"` // Public
// LDAP // LDAP
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
LdapUrl AppConfigVariable `key:"ldapUrl"` LdapUrl AppConfigVariable `key:"ldapUrl"`

View File

@@ -0,0 +1,13 @@
package model
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
type EmailVerificationToken struct {
Base
Token string
ExpiresAt datatype.DateTime
UserID string
User User
}

View File

@@ -0,0 +1,13 @@
package model
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
type OneTimeAccessToken struct {
Base
Token string
DeviceToken *string
ExpiresAt datatype.DateTime
UserID string
User User
}

View File

@@ -40,14 +40,9 @@ func (e *EncryptedString) Scan(value any) error {
return nil return nil
} }
encBytes, err := base64.StdEncoding.DecodeString(raw) decBytes, err := DecryptEncryptedStringWithKey(encStringKey, raw)
if err != nil { if err != nil {
return fmt.Errorf("failed to decode encrypted string: %w", err) return err
}
decBytes, err := cryptoutils.Decrypt(encStringKey, encBytes, []byte(encryptedStringAAD))
if err != nil {
return fmt.Errorf("failed to decrypt encrypted string: %w", err)
} }
*e = EncryptedString(decBytes) *e = EncryptedString(decBytes)
@@ -59,19 +54,20 @@ func (e EncryptedString) Value() (driver.Value, error) {
return "", nil return "", nil
} }
encBytes, err := cryptoutils.Encrypt(encStringKey, []byte(e), []byte(encryptedStringAAD)) encValue, err := EncryptEncryptedStringWithKey(encStringKey, []byte(e))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to encrypt string: %w", err) return nil, err
} }
return base64.StdEncoding.EncodeToString(encBytes), nil return encValue, nil
} }
func (e EncryptedString) String() string { func (e EncryptedString) String() string {
return string(e) return string(e)
} }
func deriveEncryptedStringKey(master []byte) ([]byte, error) { // DeriveEncryptedStringKey derives a key for encrypting EncryptedString values from the master key.
func DeriveEncryptedStringKey(master []byte) ([]byte, error) {
const info = "pocketid/encrypted_string" const info = "pocketid/encrypted_string"
r := hkdf.New(sha256.New, master, nil, []byte(info)) r := hkdf.New(sha256.New, master, nil, []byte(info))
@@ -82,8 +78,33 @@ func deriveEncryptedStringKey(master []byte) ([]byte, error) {
return key, nil return key, nil
} }
// DecryptEncryptedStringWithKey decrypts an EncryptedString value using the derived key.
func DecryptEncryptedStringWithKey(key []byte, encoded string) ([]byte, error) {
encBytes, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("failed to decode encrypted string: %w", err)
}
decBytes, err := cryptoutils.Decrypt(key, encBytes, []byte(encryptedStringAAD))
if err != nil {
return nil, fmt.Errorf("failed to decrypt encrypted string: %w", err)
}
return decBytes, nil
}
// EncryptEncryptedStringWithKey encrypts an EncryptedString value using the derived key.
func EncryptEncryptedStringWithKey(key []byte, plaintext []byte) (string, error) {
encBytes, err := cryptoutils.Encrypt(key, plaintext, []byte(encryptedStringAAD))
if err != nil {
return "", fmt.Errorf("failed to encrypt string: %w", err)
}
return base64.StdEncoding.EncodeToString(encBytes), nil
}
func init() { func init() {
key, err := deriveEncryptedStringKey(common.EnvConfig.EncryptionKey) key, err := DeriveEncryptedStringKey(common.EnvConfig.EncryptionKey)
if err != nil { if err != nil {
panic(fmt.Sprintf("failed to derive encrypted string key: %v", err)) panic(fmt.Sprintf("failed to derive encrypted string key: %v", err))
} }

View File

@@ -14,16 +14,17 @@ import (
type User struct { type User struct {
Base Base
Username string `sortable:"true"` Username string `sortable:"true"`
Email *string `sortable:"true"` Email *string `sortable:"true"`
FirstName string `sortable:"true"` EmailVerified bool `sortable:"true" filterable:"true"`
LastName string `sortable:"true"` FirstName string `sortable:"true"`
DisplayName string `sortable:"true"` LastName string `sortable:"true"`
IsAdmin bool `sortable:"true" filterable:"true"` DisplayName string `sortable:"true"`
Locale *string IsAdmin bool `sortable:"true" filterable:"true"`
LdapID *string Locale *string
Disabled bool `sortable:"true" filterable:"true"` LdapID *string
UpdatedAt *datatype.DateTime Disabled bool `sortable:"true" filterable:"true"`
UpdatedAt *datatype.DateTime
CustomClaims []CustomClaim CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"` UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
@@ -93,13 +94,3 @@ func (u User) LastModified() time.Time {
} }
return u.CreatedAt.ToTime() return u.CreatedAt.ToTime()
} }
type OneTimeAccessToken struct {
Base
Token string
DeviceToken *string
ExpiresAt datatype.DateTime
UserID string
User User
}

View File

@@ -16,13 +16,25 @@ import (
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
const staticApiKeyUserID = "00000000-0000-0000-0000-000000000000"
type ApiKeyService struct { type ApiKeyService struct {
db *gorm.DB db *gorm.DB
emailService *EmailService emailService *EmailService
} }
func NewApiKeyService(db *gorm.DB, emailService *EmailService) *ApiKeyService { func NewApiKeyService(ctx context.Context, db *gorm.DB, emailService *EmailService) (*ApiKeyService, error) {
return &ApiKeyService{db: db, emailService: emailService} s := &ApiKeyService{db: db, emailService: emailService}
if common.EnvConfig.StaticApiKey == "" {
err := s.deleteStaticApiKeyUser(ctx)
if err != nil {
return nil, err
}
}
return s, nil
} }
func (s *ApiKeyService) ListApiKeys(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.ApiKey, utils.PaginationResponse, error) { func (s *ApiKeyService) ListApiKeys(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.ApiKey, utils.PaginationResponse, error) {
@@ -72,6 +84,56 @@ func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input d
return apiKey, token, nil return apiKey, token, nil
} }
func (s *ApiKeyService) RenewApiKey(ctx context.Context, userID, apiKeyID string, expiration time.Time) (model.ApiKey, string, error) {
// Check if expiration is in the future
if !expiration.After(time.Now()) {
return model.ApiKey{}, "", &common.APIKeyExpirationDateError{}
}
tx := s.db.Begin()
defer tx.Rollback()
var apiKey model.ApiKey
err := tx.
WithContext(ctx).
Model(&model.ApiKey{}).
Where("id = ? AND user_id = ?", apiKeyID, userID).
First(&apiKey).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.ApiKey{}, "", &common.APIKeyNotFoundError{}
}
return model.ApiKey{}, "", err
}
// Only allow renewal if the key has already expired
if apiKey.ExpiresAt.ToTime().After(time.Now()) {
return model.ApiKey{}, "", &common.APIKeyNotExpiredError{}
}
// Generate a secure random API key
token, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return model.ApiKey{}, "", err
}
apiKey.Key = utils.CreateSha256Hash(token)
apiKey.ExpiresAt = datatype.DateTime(expiration)
err = tx.WithContext(ctx).Save(&apiKey).Error
if err != nil {
return model.ApiKey{}, "", err
}
if err := tx.Commit().Error; err != nil {
return model.ApiKey{}, "", err
}
return apiKey, token, nil
}
func (s *ApiKeyService) RevokeApiKey(ctx context.Context, userID, apiKeyID string) error { func (s *ApiKeyService) RevokeApiKey(ctx context.Context, userID, apiKeyID string) error {
var apiKey model.ApiKey var apiKey model.ApiKey
err := s.db. err := s.db.
@@ -94,6 +156,10 @@ func (s *ApiKeyService) ValidateApiKey(ctx context.Context, apiKey string) (mode
return model.User{}, &common.NoAPIKeyProvidedError{} return model.User{}, &common.NoAPIKeyProvidedError{}
} }
if common.EnvConfig.StaticApiKey != "" && apiKey == common.EnvConfig.StaticApiKey {
return s.initStaticApiKeyUser(ctx)
}
now := time.Now() now := time.Now()
hashedKey := utils.CreateSha256Hash(apiKey) hashedKey := utils.CreateSha256Hash(apiKey)
@@ -167,3 +233,47 @@ func (s *ApiKeyService) SendApiKeyExpiringSoonEmail(ctx context.Context, apiKey
Update("expiration_email_sent", true). Update("expiration_email_sent", true).
Error Error
} }
func (s *ApiKeyService) initStaticApiKeyUser(ctx context.Context) (user model.User, err error) {
err = s.db.
WithContext(ctx).
First(&user, "id = ?", staticApiKeyUserID).
Error
if err == nil {
return user, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, err
}
usernameSuffix, err := utils.GenerateRandomAlphanumericString(6)
if err != nil {
return model.User{}, err
}
user = model.User{
Base: model.Base{
ID: staticApiKeyUserID,
},
FirstName: "Static API User",
Username: "static-api-user-" + usernameSuffix,
DisplayName: "Static API User",
IsAdmin: true,
}
err = s.db.
WithContext(ctx).
Create(&user).
Error
return user, err
}
func (s *ApiKeyService) deleteStaticApiKeyUser(ctx context.Context) error {
return s.db.
WithContext(ctx).
Delete(&model.User{}, "id = ?", staticApiKeyUserID).
Error
}

View File

@@ -61,6 +61,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
// General // General
AppName: model.AppConfigVariable{Value: "Pocket ID"}, AppName: model.AppConfigVariable{Value: "Pocket ID"},
SessionDuration: model.AppConfigVariable{Value: "60"}, SessionDuration: model.AppConfigVariable{Value: "60"},
HomePageURL: model.AppConfigVariable{Value: "/settings/account"},
EmailsVerified: model.AppConfigVariable{Value: "false"}, EmailsVerified: model.AppConfigVariable{Value: "false"},
DisableAnimations: model.AppConfigVariable{Value: "false"}, DisableAnimations: model.AppConfigVariable{Value: "false"},
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"}, AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
@@ -83,6 +84,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
EmailOneTimeAccessAsUnauthenticatedEnabled: model.AppConfigVariable{Value: "false"}, EmailOneTimeAccessAsUnauthenticatedEnabled: model.AppConfigVariable{Value: "false"},
EmailOneTimeAccessAsAdminEnabled: model.AppConfigVariable{Value: "false"}, EmailOneTimeAccessAsAdminEnabled: model.AppConfigVariable{Value: "false"},
EmailApiKeyExpirationEnabled: model.AppConfigVariable{Value: "false"}, EmailApiKeyExpirationEnabled: model.AppConfigVariable{Value: "false"},
EmailVerificationEnabled: model.AppConfigVariable{Value: "false"},
// LDAP // LDAP
LdapEnabled: model.AppConfigVariable{Value: "false"}, LdapEnabled: model.AppConfigVariable{Value: "false"},
LdapUrl: model.AppConfigVariable{}, LdapUrl: model.AppConfigVariable{},

View File

@@ -80,23 +80,25 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Base: model.Base{ Base: model.Base{
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e", ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
}, },
Username: "tim", Username: "tim",
Email: utils.Ptr("tim.cook@test.com"), Email: utils.Ptr("tim.cook@test.com"),
FirstName: "Tim", EmailVerified: true,
LastName: "Cook", FirstName: "Tim",
DisplayName: "Tim Cook", LastName: "Cook",
IsAdmin: true, DisplayName: "Tim Cook",
IsAdmin: true,
}, },
{ {
Base: model.Base{ Base: model.Base{
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036", ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
}, },
Username: "craig", Username: "craig",
Email: utils.Ptr("craig.federighi@test.com"), Email: utils.Ptr("craig.federighi@test.com"),
FirstName: "Craig", EmailVerified: false,
LastName: "Federighi", FirstName: "Craig",
DisplayName: "Craig Federighi", LastName: "Federighi",
IsAdmin: false, DisplayName: "Craig Federighi",
IsAdmin: false,
}, },
{ {
Base: model.Base{ Base: model.Base{
@@ -354,17 +356,30 @@ func (s *TestService) SeedDatabase(baseURL string) error {
return err return err
} }
apiKey := model.ApiKey{ apiKeys := []model.ApiKey{
Base: model.Base{ {
ID: "5f1fa856-c164-4295-961e-175a0d22d725", Base: model.Base{
ID: "5f1fa856-c164-4295-961e-175a0d22d725",
},
Name: "Test API Key",
Key: "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20",
UserID: users[0].ID,
ExpiresAt: datatype.DateTime(time.Now().Add(30 * 24 * time.Hour)),
},
{
Base: model.Base{
ID: "98900330-7a7b-48fe-881b-2cc6ad049976",
},
Name: "Expired API Key",
Key: "141ff8ac9db640ba93630099de83d0ead8e7ac673e3a7d31b4fd7ff2252e6389",
UserID: users[0].ID,
ExpiresAt: datatype.DateTime(time.Now().Add(-20 * 24 * time.Hour)),
}, },
Name: "Test API Key",
Key: "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20",
UserID: users[0].ID,
ExpiresAt: datatype.DateTime(time.Now().Add(30 * 24 * time.Hour)),
} }
if err := tx.Create(&apiKey).Error; err != nil { for _, apiKey := range apiKeys {
return err if err := tx.Create(&apiKey).Error; err != nil {
return err
}
} }
signupTokens := []model.SignupToken{ signupTokens := []model.SignupToken{
@@ -414,6 +429,31 @@ func (s *TestService) SeedDatabase(baseURL string) error {
} }
} }
emailVerificationTokens := []model.EmailVerificationToken{
{
Base: model.Base{
ID: "ef9ca469-b178-4857-bd39-26639dca45de",
},
Token: "2FZFSoupBdHyqIL65bWTsgCgHIhxlXup",
ExpiresAt: datatype.DateTime(time.Now().Add(2 * time.Hour)),
UserID: users[1].ID,
},
{
Base: model.Base{
ID: "a3dcb4d2-7f3c-4e8a-9f4d-5b6c7d8e9f00",
},
Token: "EXPIRED1234567890ABCDE",
ExpiresAt: datatype.DateTime(time.Now().Add(-1 * time.Hour)),
UserID: users[1].ID,
},
}
for _, token := range emailVerificationTokens {
if err := tx.Create(&token).Error; err != nil {
return err
}
}
keyValues := []model.KV{ keyValues := []model.KV{
{ {
Key: jwkutils.PrivateKeyDBKey, Key: jwkutils.PrivateKeyDBKey,
@@ -526,7 +566,7 @@ func (s *TestService) ResetAppConfig(ctx context.Context) error {
} }
// Reload the JWK // Reload the JWK
if err := s.jwtService.LoadOrGenerateKey(); err != nil { if err := s.jwtService.LoadOrGenerateKey(ctx); err != nil {
return err return err
} }

View File

@@ -49,6 +49,13 @@ var ApiKeyExpiringSoonTemplate = email.Template[ApiKeyExpiringSoonTemplateData]{
}, },
} }
var EmailVerificationTemplate = email.Template[EmailVerificationTemplateData]{
Path: "email-verification",
Title: func(data *email.TemplateData[EmailVerificationTemplateData]) string {
return "Verify your " + data.AppName + " email address"
},
}
type NewLoginTemplateData struct { type NewLoginTemplateData struct {
IPAddress string IPAddress string
Country string Country string
@@ -70,5 +77,10 @@ type ApiKeyExpiringSoonTemplateData struct {
ExpiresAt time.Time ExpiresAt time.Time
} }
type EmailVerificationTemplateData struct {
UserFullName string
VerificationLink string
}
// this is list of all template paths used for preloading templates // this is list of all template paths used for preloading templates
var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path, ApiKeyExpiringSoonTemplate.Path} var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path, ApiKeyExpiringSoonTemplate.Path, EmailVerificationTemplate.Path}

View File

@@ -56,10 +56,10 @@ type JwtService struct {
jwksEncoded []byte jwksEncoded []byte
} }
func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) (*JwtService, error) { func NewJwtService(ctx context.Context, db *gorm.DB, appConfigService *AppConfigService) (*JwtService, error) {
service := &JwtService{} service := &JwtService{}
err := service.init(db, appConfigService, &common.EnvConfig) err := service.init(ctx, db, appConfigService, &common.EnvConfig)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -67,16 +67,16 @@ func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) (*JwtService
return service, nil return service, nil
} }
func (s *JwtService) init(db *gorm.DB, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) { func (s *JwtService) init(ctx context.Context, db *gorm.DB, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) {
s.appConfigService = appConfigService s.appConfigService = appConfigService
s.envConfig = envConfig s.envConfig = envConfig
s.db = db s.db = db
// Ensure keys are generated or loaded // Ensure keys are generated or loaded
return s.LoadOrGenerateKey() return s.LoadOrGenerateKey(ctx)
} }
func (s *JwtService) LoadOrGenerateKey() error { func (s *JwtService) LoadOrGenerateKey(ctx context.Context) error {
// Get the key provider // Get the key provider
keyProvider, err := jwkutils.GetKeyProvider(s.db, s.envConfig, s.appConfigService.GetDbConfig().InstanceID.Value) keyProvider, err := jwkutils.GetKeyProvider(s.db, s.envConfig, s.appConfigService.GetDbConfig().InstanceID.Value)
if err != nil { if err != nil {
@@ -84,7 +84,7 @@ func (s *JwtService) LoadOrGenerateKey() error {
} }
// Try loading a key // Try loading a key
key, err := keyProvider.LoadKey() key, err := keyProvider.LoadKey(ctx)
if err != nil { if err != nil {
return fmt.Errorf("failed to load key: %w", err) return fmt.Errorf("failed to load key: %w", err)
} }
@@ -105,7 +105,7 @@ func (s *JwtService) LoadOrGenerateKey() error {
} }
// Save the newly-generated key // Save the newly-generated key
err = keyProvider.SaveKey(s.privateKey) err = keyProvider.SaveKey(ctx, s.privateKey)
if err != nil { if err != nil {
return fmt.Errorf("failed to save private key: %w", err) return fmt.Errorf("failed to save private key: %w", err)
} }

View File

@@ -38,7 +38,7 @@ func initJwtService(t *testing.T, db *gorm.DB, appConfig *AppConfigService, envC
t.Helper() t.Helper()
service := &JwtService{} service := &JwtService{}
err := service.init(db, appConfig, envConfig) err := service.init(t.Context(), db, appConfig, envConfig)
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
return service return service
@@ -65,7 +65,7 @@ func saveKeyToDatabase(t *testing.T, db *gorm.DB, envConfig *common.EnvConfigSch
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, appConfig.GetDbConfig().InstanceID.Value) keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, appConfig.GetDbConfig().InstanceID.Value)
require.NoError(t, err, "Failed to init key provider") require.NoError(t, err, "Failed to init key provider")
err = keyProvider.SaveKey(key) err = keyProvider.SaveKey(t.Context(), key)
require.NoError(t, err, "Failed to save key") require.NoError(t, err, "Failed to save key")
kid, ok := key.KeyID() kid, ok := key.KeyID()
@@ -93,7 +93,7 @@ func TestJwtService_Init(t *testing.T) {
// Verify the key has been persisted in the database // Verify the key has been persisted in the database
keyProvider, err := jwkutils.GetKeyProvider(db, mockEnvConfig, mockConfig.GetDbConfig().InstanceID.Value) keyProvider, err := jwkutils.GetKeyProvider(db, mockEnvConfig, mockConfig.GetDbConfig().InstanceID.Value)
require.NoError(t, err, "Failed to init key provider") require.NoError(t, err, "Failed to init key provider")
key, err := keyProvider.LoadKey() key, err := keyProvider.LoadKey(t.Context())
require.NoError(t, err, "Failed to load key from provider") require.NoError(t, err, "Failed to load key from provider")
require.NotNil(t, key, "Key should be present in the database") require.NotNil(t, key, "Key should be present in the database")

View File

@@ -378,13 +378,14 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
} }
newUser := dto.UserCreateDto{ newUser := dto.UserCreateDto{
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value), Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)), Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value), EmailVerified: true,
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value), FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value), LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
IsAdmin: isAdmin, DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
LdapID: ldapId, IsAdmin: isAdmin,
LdapID: ldapId,
} }
if newUser.DisplayName == "" { if newUser.DisplayName == "" {

View File

@@ -1900,7 +1900,7 @@ func (s *OidcService) getUserClaims(ctx context.Context, user *model.User, scope
claims["sub"] = user.ID claims["sub"] = user.ID
if slices.Contains(scopes, "email") { if slices.Contains(scopes, "email") {
claims["email"] = user.Email claims["email"] = user.Email
claims["email_verified"] = s.appConfigService.GetDbConfig().EmailsVerified.IsTrue() claims["email_verified"] = user.EmailVerified
} }
if slices.Contains(scopes, "groups") { if slices.Contains(scopes, "groups") {

View File

@@ -160,7 +160,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
mockConfig := NewTestAppConfigService(&model.AppConfig{ mockConfig := NewTestAppConfigService(&model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
}) })
mockJwtService, err := NewJwtService(db, mockConfig) mockJwtService, err := NewJwtService(t.Context(), db, mockConfig)
require.NoError(t, err) require.NoError(t, err)
// Create a mock HTTP client with custom transport to return the JWKS // Create a mock HTTP client with custom transport to return the JWKS

View File

@@ -0,0 +1,229 @@
package service
import (
"context"
"errors"
"log/slog"
"net/url"
"strings"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type OneTimeAccessService struct {
db *gorm.DB
userService *UserService
appConfigService *AppConfigService
jwtService *JwtService
auditLogService *AuditLogService
emailService *EmailService
}
func NewOneTimeAccessService(db *gorm.DB, userService *UserService, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService) *OneTimeAccessService {
return &OneTimeAccessService{
db: db,
userService: userService,
appConfigService: appConfigService,
jwtService: jwtService,
auditLogService: auditLogService,
emailService: emailService,
}
}
func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, ttl time.Duration) error {
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue()
if isDisabled {
return &common.OneTimeAccessDisabledError{}
}
_, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false)
return err
}
func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) (string, error) {
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue()
if isDisabled {
return "", &common.OneTimeAccessDisabledError{}
}
var userId string
err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// Do not return error if user not found to prevent email enumeration
return "", nil
} else if err != nil {
return "", err
}
deviceToken, err := s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute, true)
if err != nil {
return "", err
} else if deviceToken == nil {
return "", errors.New("device token expected but not returned")
}
return *deviceToken, nil
}
func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration, withDeviceToken bool) (*string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
user, err := s.userService.GetUser(ctx, userID)
if err != nil {
return nil, err
}
if user.Email == nil {
return nil, &common.UserEmailNotSetError{}
}
oneTimeAccessToken, deviceToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, withDeviceToken, tx)
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
// We use a background context here as this is running in a goroutine
//nolint:contextcheck
go func() {
span := trace.SpanFromContext(ctx)
innerCtx := trace.ContextWithSpan(context.Background(), span)
link := common.EnvConfig.AppURL + "/lc"
linkWithCode := link + "/" + oneTimeAccessToken
// Add redirect path to the link
if strings.HasPrefix(redirectPath, "/") {
encodedRedirectPath := url.QueryEscape(redirectPath)
linkWithCode = linkWithCode + "?redirect=" + encodedRedirectPath
}
errInternal := SendEmail(innerCtx, s.emailService, email.Address{
Name: user.FullName(),
Email: *user.Email,
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
Code: oneTimeAccessToken,
LoginLink: link,
LoginLinkWithCode: linkWithCode,
ExpirationString: utils.DurationToString(ttl),
})
if errInternal != nil {
slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", errInternal), slog.String("address", *user.Email))
return
}
}()
return deviceToken, nil
}
func (s *OneTimeAccessService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) {
token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, s.db)
return token, err
}
func (s *OneTimeAccessService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, withDeviceToken bool, tx *gorm.DB) (token string, deviceToken *string, err error) {
oneTimeAccessToken, err := NewOneTimeAccessToken(userID, ttl, withDeviceToken)
if err != nil {
return "", nil, err
}
err = tx.WithContext(ctx).Create(oneTimeAccessToken).Error
if err != nil {
return "", nil, err
}
return oneTimeAccessToken.Token, oneTimeAccessToken.DeviceToken, nil
}
func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var oneTimeAccessToken model.OneTimeAccessToken
err := tx.
WithContext(ctx).
Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).
Preload("User").
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&oneTimeAccessToken).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
return model.User{}, "", err
}
if oneTimeAccessToken.DeviceToken != nil && deviceToken != *oneTimeAccessToken.DeviceToken {
return model.User{}, "", &common.DeviceCodeInvalid{}
}
accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User)
if err != nil {
return model.User{}, "", err
}
err = tx.
WithContext(ctx).
Delete(&oneTimeAccessToken).
Error
if err != nil {
return model.User{}, "", err
}
s.auditLogService.Create(ctx, model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}, tx)
err = tx.Commit().Error
if err != nil {
return model.User{}, "", err
}
return oneTimeAccessToken.User, accessToken, nil
}
func NewOneTimeAccessToken(userID string, ttl time.Duration, withDeviceToken bool) (*model.OneTimeAccessToken, error) {
// If expires at is less than 15 minutes, use a 6-character token instead of 16
tokenLength := 16
if ttl <= 15*time.Minute {
tokenLength = 6
}
token, err := utils.GenerateRandomUnambiguousString(tokenLength)
if err != nil {
return nil, err
}
var deviceToken *string
if withDeviceToken {
dt, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return nil, err
}
deviceToken = &dt
}
now := time.Now().Round(time.Second)
o := &model.OneTimeAccessToken{
UserID: userID,
ExpiresAt: datatype.DateTime(now.Add(ttl)),
Token: token,
DeviceToken: deviceToken,
}
return o, nil
}

View File

@@ -9,13 +9,11 @@ import (
"io" "io"
"io/fs" "io/fs"
"log/slog" "log/slog"
"net/url"
"path" "path"
"strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"go.opentelemetry.io/otel/trace" "github.com/pocket-id/pocket-id/backend/internal/utils/email"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -25,7 +23,6 @@ import (
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/storage" "github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image" profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image"
) )
@@ -269,15 +266,16 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
} }
user := model.User{ user := model.User{
FirstName: input.FirstName, FirstName: input.FirstName,
LastName: input.LastName, LastName: input.LastName,
DisplayName: input.DisplayName, DisplayName: input.DisplayName,
Email: input.Email, Email: input.Email,
Username: input.Username, EmailVerified: input.EmailVerified,
IsAdmin: input.IsAdmin, Username: input.Username,
Locale: input.Locale, IsAdmin: input.IsAdmin,
Disabled: input.Disabled, Locale: input.Locale,
UserGroups: userGroups, Disabled: input.Disabled,
UserGroups: userGroups,
} }
if input.LdapID != "" { if input.LdapID != "" {
user.LdapID = &input.LdapID user.LdapID = &input.LdapID
@@ -419,13 +417,20 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
user.FirstName = updatedUser.FirstName user.FirstName = updatedUser.FirstName
user.LastName = updatedUser.LastName user.LastName = updatedUser.LastName
user.DisplayName = updatedUser.DisplayName user.DisplayName = updatedUser.DisplayName
user.Email = updatedUser.Email
user.Username = updatedUser.Username user.Username = updatedUser.Username
user.Locale = updatedUser.Locale user.Locale = updatedUser.Locale
if (user.Email == nil && updatedUser.Email != nil) || (user.Email != nil && updatedUser.Email != nil && *user.Email != *updatedUser.Email) {
// Email has changed, reset email verification status
user.EmailVerified = s.appConfigService.GetDbConfig().EmailsVerified.IsTrue()
}
user.Email = updatedUser.Email
// Admin-only fields: Only allow updates when not updating own account // Admin-only fields: Only allow updates when not updating own account
if !updateOwnUser { if !updateOwnUser {
user.IsAdmin = updatedUser.IsAdmin user.IsAdmin = updatedUser.IsAdmin
user.EmailVerified = updatedUser.EmailVerified
user.Disabled = updatedUser.Disabled user.Disabled = updatedUser.Disabled
} }
} }
@@ -455,164 +460,6 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
return user, nil return user, nil
} }
func (s *UserService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, ttl time.Duration) error {
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue()
if isDisabled {
return &common.OneTimeAccessDisabledError{}
}
_, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, true)
return err
}
func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) (string, error) {
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue()
if isDisabled {
return "", &common.OneTimeAccessDisabledError{}
}
var userId string
err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// Do not return error if user not found to prevent email enumeration
return "", nil
} else if err != nil {
return "", err
}
deviceToken, err := s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute, true)
if err != nil {
return "", err
} else if deviceToken == nil {
return "", errors.New("device token expected but not returned")
}
return *deviceToken, nil
}
func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration, withDeviceToken bool) (*string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
user, err := s.GetUser(ctx, userID)
if err != nil {
return nil, err
}
if user.Email == nil {
return nil, &common.UserEmailNotSetError{}
}
oneTimeAccessToken, deviceToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, withDeviceToken, tx)
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
// We use a background context here as this is running in a goroutine
//nolint:contextcheck
go func() {
span := trace.SpanFromContext(ctx)
innerCtx := trace.ContextWithSpan(context.Background(), span)
link := common.EnvConfig.AppURL + "/lc"
linkWithCode := link + "/" + oneTimeAccessToken
// Add redirect path to the link
if strings.HasPrefix(redirectPath, "/") {
encodedRedirectPath := url.QueryEscape(redirectPath)
linkWithCode = linkWithCode + "?redirect=" + encodedRedirectPath
}
errInternal := SendEmail(innerCtx, s.emailService, email.Address{
Name: user.FullName(),
Email: *user.Email,
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
Code: oneTimeAccessToken,
LoginLink: link,
LoginLinkWithCode: linkWithCode,
ExpirationString: utils.DurationToString(ttl),
})
if errInternal != nil {
slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", errInternal), slog.String("address", *user.Email))
return
}
}()
return deviceToken, nil
}
func (s *UserService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) {
token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, s.db)
return token, err
}
func (s *UserService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, withDeviceToken bool, tx *gorm.DB) (token string, deviceToken *string, err error) {
oneTimeAccessToken, err := NewOneTimeAccessToken(userID, ttl, withDeviceToken)
if err != nil {
return "", nil, err
}
err = tx.WithContext(ctx).Create(oneTimeAccessToken).Error
if err != nil {
return "", nil, err
}
return oneTimeAccessToken.Token, oneTimeAccessToken.DeviceToken, nil
}
func (s *UserService) ExchangeOneTimeAccessToken(ctx context.Context, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var oneTimeAccessToken model.OneTimeAccessToken
err := tx.
WithContext(ctx).
Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).
Preload("User").
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&oneTimeAccessToken).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
return model.User{}, "", err
}
if oneTimeAccessToken.DeviceToken != nil && deviceToken != *oneTimeAccessToken.DeviceToken {
return model.User{}, "", &common.DeviceCodeInvalid{}
}
accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User)
if err != nil {
return model.User{}, "", err
}
err = tx.
WithContext(ctx).
Delete(&oneTimeAccessToken).
Error
if err != nil {
return model.User{}, "", err
}
s.auditLogService.Create(ctx, model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}, tx)
err = tx.Commit().Error
if err != nil {
return model.User{}, "", err
}
return oneTimeAccessToken.User, accessToken, nil
}
func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroupIds []string) (user model.User, err error) { func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroupIds []string) (user model.User, err error) {
tx := s.db.Begin() tx := s.db.Begin()
defer func() { defer func() {
@@ -672,47 +519,6 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
return user, nil return user, nil
} }
func (s *UserService) SignUpInitialAdmin(ctx context.Context, signUpData dto.SignUpDto) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var userCount int64
if err := tx.WithContext(ctx).Model(&model.User{}).Count(&userCount).Error; err != nil {
return model.User{}, "", err
}
if userCount != 0 {
return model.User{}, "", &common.SetupAlreadyCompletedError{}
}
userToCreate := dto.UserCreateDto{
FirstName: signUpData.FirstName,
LastName: signUpData.LastName,
DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
Username: signUpData.Username,
Email: signUpData.Email,
IsAdmin: true,
}
user, err := s.createUserInternal(ctx, userToCreate, false, tx)
if err != nil {
return model.User{}, "", err
}
token, err := s.jwtService.GenerateAccessToken(user)
if err != nil {
return model.User{}, "", err
}
err = tx.Commit().Error
if err != nil {
return model.User{}, "", err
}
return user, token, nil
}
func (s *UserService) checkDuplicatedFields(ctx context.Context, user model.User, tx *gorm.DB) error { func (s *UserService) checkDuplicatedFields(ctx context.Context, user model.User, tx *gorm.DB) error {
var result struct { var result struct {
Found bool Found bool
@@ -774,172 +580,72 @@ func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, user
return nil return nil
} }
func (s *UserService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (model.SignupToken, error) { func (s *UserService) SendEmailVerification(ctx context.Context, userID string) error {
signupToken, err := NewSignupToken(ttl, usageLimit) user, err := s.GetUser(ctx, userID)
if err != nil { if err != nil {
return model.SignupToken{}, err return err
} }
var userGroups []model.UserGroup if user.Email == nil {
err = s.db.WithContext(ctx). return &common.UserEmailNotSetError{}
Where("id IN ?", userGroupIDs).
Find(&userGroups).
Error
if err != nil {
return model.SignupToken{}, err
}
signupToken.UserGroups = userGroups
err = s.db.WithContext(ctx).Create(signupToken).Error
if err != nil {
return model.SignupToken{}, err
} }
return *signupToken, nil randomToken, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return err
}
expiration := time.Now().Add(24 * time.Hour)
emailVerificationToken := &model.EmailVerificationToken{
UserID: user.ID,
Token: randomToken,
ExpiresAt: datatype.DateTime(expiration),
}
err = s.db.WithContext(ctx).Create(emailVerificationToken).Error
if err != nil {
return err
}
return SendEmail(ctx, s.emailService, email.Address{
Name: user.FullName(),
Email: *user.Email,
}, EmailVerificationTemplate, &EmailVerificationTemplateData{
UserFullName: user.FullName(),
VerificationLink: common.EnvConfig.AppURL + "/verify-email?token=" + emailVerificationToken.Token,
})
} }
func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAddress, userAgent string) (model.User, string, error) { func (s *UserService) VerifyEmail(ctx context.Context, userID string, token string) error {
tx := s.db.Begin() tx := s.db.Begin()
defer func() { defer tx.Rollback()
tx.Rollback()
}()
tokenProvided := signupData.Token != "" var emailVerificationToken model.EmailVerificationToken
err := tx.WithContext(ctx).Where("token = ? AND user_id = ? AND expires_at > ?",
token, userID, datatype.DateTime(time.Now())).First(&emailVerificationToken).Error
config := s.appConfigService.GetDbConfig() if errors.Is(err, gorm.ErrRecordNotFound) {
if config.AllowUserSignups.Value != "open" && !tokenProvided { return &common.InvalidEmailVerificationTokenError{}
return model.User{}, "", &common.OpenSignupDisabledError{} } else if err != nil {
return err
} }
var signupToken model.SignupToken user, err := s.getUserInternal(ctx, emailVerificationToken.UserID, tx)
var userGroupIDs []string
if tokenProvided {
err := tx.
WithContext(ctx).
Preload("UserGroups").
Where("token = ?", signupData.Token).
Clauses(clause.Locking{Strength: "UPDATE"}).
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{}
}
for _, group := range signupToken.UserGroups {
userGroupIDs = append(userGroupIDs, group.ID)
}
}
userToCreate := dto.UserCreateDto{
Username: signupData.Username,
Email: signupData.Email,
FirstName: signupData.FirstName,
LastName: signupData.LastName,
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
UserGroupIds: userGroupIDs,
}
user, err := s.createUserInternal(ctx, userToCreate, false, tx)
if err != nil { if err != nil {
return model.User{}, "", err return err
} }
accessToken, err := s.jwtService.GenerateAccessToken(user) user.EmailVerified = true
user.UpdatedAt = utils.Ptr(datatype.DateTime(time.Now()))
err = tx.WithContext(ctx).Save(&user).Error
if err != nil { if err != nil {
return model.User{}, "", err return err
} }
if tokenProvided { err = tx.WithContext(ctx).Delete(&emailVerificationToken).Error
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 { if err != nil {
return model.User{}, "", err return err
} }
return user, accessToken, nil return tx.Commit().Error
}
func (s *UserService) ListSignupTokens(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.SignupToken, utils.PaginationResponse, error) {
var tokens []model.SignupToken
query := s.db.WithContext(ctx).Preload("UserGroups").Model(&model.SignupToken{})
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, 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, ttl time.Duration, withDeviceToken bool) (*model.OneTimeAccessToken, error) {
// If expires at is less than 15 minutes, use a 6-character token instead of 16
tokenLength := 16
if ttl <= 15*time.Minute {
tokenLength = 6
}
token, err := utils.GenerateRandomUnambiguousString(tokenLength)
if err != nil {
return nil, err
}
var deviceToken *string
if withDeviceToken {
dt, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return nil, err
}
deviceToken = &dt
}
now := time.Now().Round(time.Second)
o := &model.OneTimeAccessToken{
UserID: userID,
ExpiresAt: datatype.DateTime(now.Add(ttl)),
Token: token,
DeviceToken: deviceToken,
}
return o, nil
}
func NewSignupToken(ttl time.Duration, usageLimit int) (*model.SignupToken, error) {
// Generate a random token
randomString, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return nil, err
}
now := time.Now().Round(time.Second)
token := &model.SignupToken{
Token: randomString,
ExpiresAt: datatype.DateTime(now.Add(ttl)),
UsageLimit: usageLimit,
UsageCount: 0,
}
return token, nil
} }

View File

@@ -0,0 +1,216 @@
package service
import (
"context"
"errors"
"strings"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type UserSignUpService struct {
db *gorm.DB
userService *UserService
jwtService *JwtService
auditLogService *AuditLogService
appConfigService *AppConfigService
}
func NewUserSignupService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService, userService *UserService) *UserSignUpService {
return &UserSignUpService{
db: db,
jwtService: jwtService,
auditLogService: auditLogService,
appConfigService: appConfigService,
userService: userService,
}
}
func (s *UserSignUpService) 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
var userGroupIDs []string
if tokenProvided {
err := tx.
WithContext(ctx).
Preload("UserGroups").
Where("token = ?", signupData.Token).
Clauses(clause.Locking{Strength: "UPDATE"}).
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{}
}
for _, group := range signupToken.UserGroups {
userGroupIDs = append(userGroupIDs, group.ID)
}
}
userToCreate := dto.UserCreateDto{
Username: signupData.Username,
Email: signupData.Email,
FirstName: signupData.FirstName,
LastName: signupData.LastName,
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
UserGroupIds: userGroupIDs,
EmailVerified: s.appConfigService.GetDbConfig().EmailsVerified.IsTrue(),
}
user, err := s.userService.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 *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData dto.SignUpDto) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var userCount int64
if err := tx.WithContext(ctx).Model(&model.User{}).
Where("id != ?", staticApiKeyUserID).
Count(&userCount).Error; err != nil {
return model.User{}, "", err
}
if userCount != 0 {
return model.User{}, "", &common.SetupAlreadyCompletedError{}
}
userToCreate := dto.UserCreateDto{
FirstName: signUpData.FirstName,
LastName: signUpData.LastName,
DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
Username: signUpData.Username,
Email: signUpData.Email,
IsAdmin: true,
}
user, err := s.userService.createUserInternal(ctx, userToCreate, false, tx)
if err != nil {
return model.User{}, "", err
}
token, err := s.jwtService.GenerateAccessToken(user)
if err != nil {
return model.User{}, "", err
}
err = tx.Commit().Error
if err != nil {
return model.User{}, "", err
}
return user, token, nil
}
func (s *UserSignUpService) ListSignupTokens(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.SignupToken, utils.PaginationResponse, error) {
var tokens []model.SignupToken
query := s.db.WithContext(ctx).Preload("UserGroups").Model(&model.SignupToken{})
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &tokens)
return tokens, pagination, err
}
func (s *UserSignUpService) DeleteSignupToken(ctx context.Context, tokenID string) error {
return s.db.WithContext(ctx).Delete(&model.SignupToken{}, "id = ?", tokenID).Error
}
func (s *UserSignUpService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (model.SignupToken, error) {
signupToken, err := NewSignupToken(ttl, usageLimit)
if err != nil {
return model.SignupToken{}, err
}
var userGroups []model.UserGroup
err = s.db.WithContext(ctx).
Where("id IN ?", userGroupIDs).
Find(&userGroups).
Error
if err != nil {
return model.SignupToken{}, err
}
signupToken.UserGroups = userGroups
err = s.db.WithContext(ctx).Create(signupToken).Error
if err != nil {
return model.SignupToken{}, err
}
return *signupToken, nil
}
func NewSignupToken(ttl time.Duration, usageLimit int) (*model.SignupToken, error) {
// Generate a random token
randomString, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return nil, err
}
now := time.Now().Round(time.Second)
token := &model.SignupToken{
Token: randomString,
ExpiresAt: datatype.DateTime(now.Add(ttl)),
UsageLimit: usageLimit,
UsageCount: 0,
}
return token, nil
}

View File

@@ -35,7 +35,7 @@ func MigrateDatabase(sqlDb *sql.DB) error {
return fmt.Errorf("database version (%d) is newer than application version (%d), downgrades are not allowed (set ALLOW_DOWNGRADE=true to enable)", currentVersion, requiredVersion) return fmt.Errorf("database version (%d) is newer than application version (%d), downgrades are not allowed (set ALLOW_DOWNGRADE=true to enable)", currentVersion, requiredVersion)
} }
slog.Info("Fetching migrations from GitHub to handle possible downgrades") slog.Info("Fetching migrations from GitHub to handle possible downgrades")
return migrateDatabaseFromGitHub(sqlDb, requiredVersion) return migrateDatabaseFromGitHub(sqlDb, requiredVersion, currentVersion)
} }
err = m.Migrate(requiredVersion) err = m.Migrate(requiredVersion)
@@ -92,7 +92,7 @@ func newMigrationDriver(sqlDb *sql.DB, dbProvider common.DbProvider) (driver dat
} }
// migrateDatabaseFromGitHub applies database migrations fetched from GitHub to handle downgrades. // migrateDatabaseFromGitHub applies database migrations fetched from GitHub to handle downgrades.
func migrateDatabaseFromGitHub(sqlDb *sql.DB, version uint) error { func migrateDatabaseFromGitHub(sqlDb *sql.DB, requiredVersion uint, currentVersion uint) error {
srcURL := "github://pocket-id/pocket-id/backend/resources/migrations/" + string(common.EnvConfig.DbProvider) srcURL := "github://pocket-id/pocket-id/backend/resources/migrations/" + string(common.EnvConfig.DbProvider)
driver, err := newMigrationDriver(sqlDb, common.EnvConfig.DbProvider) driver, err := newMigrationDriver(sqlDb, common.EnvConfig.DbProvider)
@@ -105,9 +105,18 @@ func migrateDatabaseFromGitHub(sqlDb *sql.DB, version uint) error {
return fmt.Errorf("failed to create GitHub migration instance: %w", err) return fmt.Errorf("failed to create GitHub migration instance: %w", err)
} }
if err := m.Force(int(version)); err != nil && !errors.Is(err, migrate.ErrNoChange) { //nolint:gosec // Reset the dirty state before forcing the version
if err := m.Force(int(currentVersion)); err != nil { //nolint:gosec
return fmt.Errorf("failed to force database version: %w", err)
}
if err := m.Migrate(requiredVersion); err != nil {
if errors.Is(err, migrate.ErrNoChange) {
return nil
}
return fmt.Errorf("failed to apply GitHub migrations: %w", err) return fmt.Errorf("failed to apply GitHub migrations: %w", err)
} }
return nil return nil
} }

View File

@@ -1,6 +1,7 @@
package jwk package jwk
import ( import (
"context"
"fmt" "fmt"
"github.com/lestrrat-go/jwx/v3/jwk" "github.com/lestrrat-go/jwx/v3/jwk"
@@ -17,8 +18,8 @@ type KeyProviderOpts struct {
type KeyProvider interface { type KeyProvider interface {
Init(opts KeyProviderOpts) error Init(opts KeyProviderOpts) error
LoadKey() (jwk.Key, error) LoadKey(ctx context.Context) (jwk.Key, error)
SaveKey(key jwk.Key) error SaveKey(ctx context.Context, key jwk.Key) error
} }
func GetKeyProvider(db *gorm.DB, envConfig *common.EnvConfigSchema, instanceID string) (keyProvider KeyProvider, err error) { func GetKeyProvider(db *gorm.DB, envConfig *common.EnvConfigSchema, instanceID string) (keyProvider KeyProvider, err error) {

View File

@@ -33,12 +33,12 @@ func (f *KeyProviderDatabase) Init(opts KeyProviderOpts) error {
return nil return nil
} }
func (f *KeyProviderDatabase) LoadKey() (key jwk.Key, err error) { func (f *KeyProviderDatabase) LoadKey(ctx context.Context) (key jwk.Key, err error) {
row := model.KV{ row := model.KV{
Key: PrivateKeyDBKey, Key: PrivateKeyDBKey,
} }
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel() defer cancel()
err = f.db.WithContext(ctx).First(&row).Error err = f.db.WithContext(ctx).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -74,7 +74,7 @@ func (f *KeyProviderDatabase) LoadKey() (key jwk.Key, err error) {
return key, nil return key, nil
} }
func (f *KeyProviderDatabase) SaveKey(key jwk.Key) error { func (f *KeyProviderDatabase) SaveKey(ctx context.Context, key jwk.Key) error {
// Encode the key to JSON // Encode the key to JSON
data, err := EncodeJWKBytes(key) data, err := EncodeJWKBytes(key)
if err != nil { if err != nil {
@@ -94,7 +94,7 @@ func (f *KeyProviderDatabase) SaveKey(key jwk.Key) error {
Value: &encB64, Value: &encB64,
} }
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel() defer cancel()
err = f.db. err = f.db.
WithContext(ctx). WithContext(ctx).

View File

@@ -59,7 +59,7 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Load key when none exists // Load key when none exists
loadedKey, err := provider.LoadKey() loadedKey, err := provider.LoadKey(t.Context())
require.NoError(t, err) require.NoError(t, err)
assert.Nil(t, loadedKey, "Expected nil key when no key exists in database") assert.Nil(t, loadedKey, "Expected nil key when no key exists in database")
}) })
@@ -76,11 +76,11 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Save a key // Save a key
err = provider.SaveKey(key) err = provider.SaveKey(t.Context(), key)
require.NoError(t, err) require.NoError(t, err)
// Load the key // Load the key
loadedKey, err := provider.LoadKey() loadedKey, err := provider.LoadKey(t.Context())
require.NoError(t, err) require.NoError(t, err)
assert.NotNil(t, loadedKey, "Expected non-nil key when key exists in database") assert.NotNil(t, loadedKey, "Expected non-nil key when key exists in database")
@@ -114,7 +114,7 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Attempt to load the key // Attempt to load the key
loadedKey, err := provider.LoadKey() loadedKey, err := provider.LoadKey(t.Context())
require.Error(t, err, "Expected error when loading key with invalid base64") require.Error(t, err, "Expected error when loading key with invalid base64")
require.ErrorContains(t, err, "not a valid base64-encoded value") require.ErrorContains(t, err, "not a valid base64-encoded value")
assert.Nil(t, loadedKey, "Expected nil key when loading fails") assert.Nil(t, loadedKey, "Expected nil key when loading fails")
@@ -140,7 +140,7 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Attempt to load the key // Attempt to load the key
loadedKey, err := provider.LoadKey() loadedKey, err := provider.LoadKey(t.Context())
require.Error(t, err, "Expected error when loading key with invalid encrypted data") require.Error(t, err, "Expected error when loading key with invalid encrypted data")
require.ErrorContains(t, err, "failed to decrypt") require.ErrorContains(t, err, "failed to decrypt")
assert.Nil(t, loadedKey, "Expected nil key when loading fails") assert.Nil(t, loadedKey, "Expected nil key when loading fails")
@@ -158,7 +158,7 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
err = originalProvider.SaveKey(key) err = originalProvider.SaveKey(t.Context(), key)
require.NoError(t, err) require.NoError(t, err)
// Now try to load with a different KEK // Now try to load with a different KEK
@@ -171,7 +171,7 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Attempt to load the key with the wrong KEK // Attempt to load the key with the wrong KEK
loadedKey, err := differentProvider.LoadKey() loadedKey, err := differentProvider.LoadKey(t.Context())
require.Error(t, err, "Expected error when loading key with wrong KEK") require.Error(t, err, "Expected error when loading key with wrong KEK")
require.ErrorContains(t, err, "failed to decrypt") require.ErrorContains(t, err, "failed to decrypt")
assert.Nil(t, loadedKey, "Expected nil key when loading fails") assert.Nil(t, loadedKey, "Expected nil key when loading fails")
@@ -206,7 +206,7 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Attempt to load the key // Attempt to load the key
loadedKey, err := provider.LoadKey() loadedKey, err := provider.LoadKey(t.Context())
require.Error(t, err, "Expected error when loading invalid key data") require.Error(t, err, "Expected error when loading invalid key data")
require.ErrorContains(t, err, "failed to parse") require.ErrorContains(t, err, "failed to parse")
assert.Nil(t, loadedKey, "Expected nil key when loading fails") assert.Nil(t, loadedKey, "Expected nil key when loading fails")
@@ -233,7 +233,7 @@ func TestKeyProviderDatabase_SaveKey(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Save the key // Save the key
err = provider.SaveKey(key) err = provider.SaveKey(t.Context(), key)
require.NoError(t, err, "Expected no error when saving key") require.NoError(t, err, "Expected no error when saving key")
// Verify record exists in database // Verify record exists in database

View File

@@ -1 +1 @@
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><!--html--><!--head--><!--body--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">API Key Expiring Soon</h1></td><td align="right" data-id="__react-email-column"><p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Hello <!-- -->{{.Data.Name}}<!-- -->, <br/>This is a reminder that your API key <strong>{{.Data.APIKeyName}}</strong> <!-- -->will expire on <strong>{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}</strong>.</p><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Please generate a new API key if you need continued access.</p></div></td></tr></tbody></table><!--/$--></body></html>{{end}} {{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="background-color:#FBFBFB"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">API Key Expiring Soon</h1></td><td align="right" data-id="__react-email-column"><p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Hello <!-- -->{{.Data.Name}}<!-- -->, <br/>This is a reminder that your API key <strong>{{.Data.APIKeyName}}</strong> <!-- -->will expire on <strong>{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}</strong>.</p><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Please generate a new API key if you need continued access.</p></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}

View File

@@ -6,7 +6,6 @@ API KEY EXPIRING SOON
Warning Warning
Hello {{.Data.Name}}, Hello {{.Data.Name}},
This is a reminder that your API key {{.Data.APIKeyName}} will expire on This is a reminder that your API key {{.Data.APIKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.
{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.
Please generate a new API key if you need continued access.{{end}} Please generate a new API key if you need continued access.{{end}}

View File

@@ -0,0 +1 @@
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="background-color:#FBFBFB"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Email Verification</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Hello <!-- -->{{.Data.UserFullName}}<!-- -->, <br/>Click the button below to verify your email address for <!-- -->{{.AppName}}<!-- -->. This link will expire in 24 hours.<br/></p><div style="text-align:center"><a href="{{.Data.VerificationLink}}" style="line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;background-color:#000000;color:#ffffff;padding:12px 24px;border-radius:4px;font-size:15px;font-weight:500;cursor:pointer;margin-top:10px;padding-top:12px;padding-right:24px;padding-bottom:12px;padding-left:24px" target="_blank"><span><!--[if mso]><i style="mso-font-width:400%;mso-text-raise:18" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Verify</span><span><!--[if mso]><i style="mso-font-width:400%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span></a></div></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}

View File

@@ -0,0 +1,10 @@
{{define "root"}}{{.AppName}}
EMAIL VERIFICATION
Hello {{.Data.UserFullName}},
Click the button below to verify your email address for {{.AppName}}. This link will expire in 24 hours.
Verify {{.Data.VerificationLink}}{{end}}

View File

@@ -1 +1 @@
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><!--html--><!--head--><!--body--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">New Sign-In Detected</h1></td><td align="right" data-id="__react-email-column"><p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Your <!-- -->{{.AppName}}<!-- --> account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.</p><h4 style="font-size:1rem;font-weight:bold;margin:30px 0 10px 0">Details</h4><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Approximate Location</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">IP Address</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.IPAddress}}</p></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-top:10px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Device</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.Device}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Sign-In Time</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}</p></td></tr></tbody></table></div></td></tr></tbody></table><!--/$--></body></html>{{end}} {{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="background-color:#FBFBFB"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">New Sign-In Detected</h1></td><td align="right" data-id="__react-email-column"><p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Your <!-- -->{{.AppName}}<!-- --> account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.</p><h4 style="font-size:1rem;font-weight:bold;margin:30px 0 10px 0">Details</h4><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Approximate Location</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">IP Address</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.IPAddress}}</p></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-top:10px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Device</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.Device}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Sign-In Time</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}</p></td></tr></tbody></table></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}

View File

@@ -5,15 +5,13 @@ NEW SIGN-IN DETECTED
Warning Warning
Your {{.AppName}} account was recently accessed from a new IP address or Your {{.AppName}} account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.
browser. If you recognize this activity, no further action is required.
DETAILS DETAILS
Approximate Location Approximate Location
{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if {{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}
.Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}
IP Address IP Address

View File

@@ -1 +1 @@
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><!--html--><!--head--><!--body--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Your Login Code</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Click the button below to sign in to <!-- -->{{.AppName}}<!-- --> with a login code.<br/>Or visit<!-- --> <a href="{{.Data.LoginLink}}" style="color:#000;text-decoration-line:none;text-decoration:underline;font-family:Arial, sans-serif" target="_blank">{{.Data.LoginLink}}</a> <!-- -->and enter the code <strong>{{.Data.Code}}</strong>.<br/><br/>This code expires in <!-- -->{{.Data.ExpirationString}}<!-- -->.</p><div style="text-align:center"><a href="{{.Data.LoginLinkWithCode}}" style="line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;background-color:#000000;color:#ffffff;padding:12px 24px;border-radius:4px;font-size:15px;font-weight:500;cursor:pointer;margin-top:10px;padding-top:12px;padding-right:24px;padding-bottom:12px;padding-left:24px" target="_blank"><span><!--[if mso]><i style="mso-font-width:400%;mso-text-raise:18" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Sign In</span><span><!--[if mso]><i style="mso-font-width:400%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span></a></div></div></td></tr></tbody></table><!--/$--></body></html>{{end}} {{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="background-color:#FBFBFB"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Your Login Code</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Click the button below to sign in to <!-- -->{{.AppName}}<!-- --> with a login code.<br/>Or visit<!-- --> <a href="{{.Data.LoginLink}}" style="color:#000;text-decoration-line:none;text-decoration:underline;font-family:Arial, sans-serif" target="_blank">{{.Data.LoginLink}}</a> <!-- -->and enter the code <strong>{{.Data.Code}}</strong>.<br/><br/>This code expires in <!-- -->{{.Data.ExpirationString}}<!-- -->.</p><div style="text-align:center"><a href="{{.Data.LoginLinkWithCode}}" style="line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;background-color:#000000;color:#ffffff;padding:12px 24px;border-radius:4px;font-size:15px;font-weight:500;cursor:pointer;margin-top:10px;padding-top:12px;padding-right:24px;padding-bottom:12px;padding-left:24px" target="_blank"><span><!--[if mso]><i style="mso-font-width:400%;mso-text-raise:18" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Sign In</span><span><!--[if mso]><i style="mso-font-width:400%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span></a></div></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}

View File

@@ -4,8 +4,7 @@
YOUR LOGIN CODE YOUR LOGIN CODE
Click the button below to sign in to {{.AppName}} with a login code. Click the button below to sign in to {{.AppName}} with a login code.
Or visit {{.Data.LoginLink}} {{.Data.LoginLink}} and enter the code Or visit {{.Data.LoginLink}} and enter the code {{.Data.Code}}.
{{.Data.Code}}.
This code expires in {{.Data.ExpirationString}}. This code expires in {{.Data.ExpirationString}}.

View File

@@ -1 +1 @@
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><!--html--><!--head--><!--body--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Test Email</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Your email setup is working correctly!</p></div></td></tr></tbody></table><!--/$--></body></html>{{end}} {{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="background-color:#FBFBFB"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Test Email</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Your email setup is working correctly!</p></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}

View File

@@ -0,0 +1 @@
-- No-op on Postgres

View File

@@ -0,0 +1 @@
-- No-op on Postgres

View File

@@ -0,0 +1,2 @@
DROP TABLE email_verification_tokens;
ALTER TABLE users DROP COLUMN email_verified;

View File

@@ -0,0 +1,17 @@
CREATE TABLE email_verification_tokens
(
id UUID PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE
);
ALTER TABLE users
ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE users
SET email_verified = EXISTS (SELECT 1
FROM app_config_variables
WHERE key = 'emailsVerified'
AND value = 'true');

View File

@@ -0,0 +1,51 @@
PRAGMA foreign_keys=OFF;
BEGIN;
CREATE TABLE oidc_clients_dg_tmp
(
id TEXT PRIMARY KEY,
created_at DATETIME NOT NULL,
name TEXT,
secret TEXT,
callback_urls BLOB,
image_type TEXT,
created_by_id TEXT REFERENCES users ON DELETE SET NULL,
is_public BOOLEAN DEFAULT FALSE,
pkce_enabled BOOLEAN DEFAULT FALSE,
logout_callback_urls BLOB,
credentials BLOB,
launch_url TEXT,
requires_reauthentication BOOLEAN NOT NULL DEFAULT FALSE,
dark_image_type TEXT,
is_group_restricted BOOLEAN NOT NULL DEFAULT 0
);
INSERT INTO oidc_clients_dg_tmp (
id, created_at, name, secret, callback_urls, image_type, created_by_id,
is_public, pkce_enabled, logout_callback_urls, credentials, launch_url,
requires_reauthentication, dark_image_type, is_group_restricted
)
SELECT
id,
created_at,
name,
secret,
callback_urls,
image_type,
created_by_id,
is_public,
pkce_enabled,
logout_callback_urls,
credentials,
launch_url,
requires_reauthentication,
dark_image_type,
is_group_restricted
FROM oidc_clients;
DROP TABLE oidc_clients;
ALTER TABLE oidc_clients_dg_tmp RENAME TO oidc_clients;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,8 @@
PRAGMA foreign_keys= OFF;
BEGIN;
DROP TABLE email_verification_tokens;
ALTER TABLE users DROP COLUMN email_verified;
COMMIT;
PRAGMA foreign_keys= ON;

View File

@@ -0,0 +1,24 @@
PRAGMA foreign_keys= OFF;
BEGIN;
CREATE TABLE email_verification_tokens
(
id TEXT PRIMARY KEY,
created_at DATETIME NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
user_id TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
ALTER TABLE users
ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE users
SET email_verified =EXISTS (SELECT 1
FROM app_config_variables
WHERE key = 'emailsVerified'
AND value = 'true');
COMMIT;
PRAGMA foreign_keys= ON;

View File

@@ -0,0 +1,54 @@
import { Text } from "@react-email/components";
import { BaseTemplate } from "../components/base-template";
import { Button } from "../components/button";
import CardHeader from "../components/card-header";
import { sharedPreviewProps, sharedTemplateProps } from "../props";
interface EmailVerificationData {
userFullName: string;
verificationLink: string;
}
interface EmailVerificationProps {
logoURL: string;
appName: string;
data: EmailVerificationData;
}
export const EmailVerification = ({
logoURL,
appName,
data,
}: EmailVerificationProps) => (
<BaseTemplate logoURL={logoURL} appName={appName}>
<CardHeader title="Email Verification" />
<Text>
Hello {data.userFullName}, <br />
Click the button below to verify your email address for {appName}. This
link will expire in 24 hours.
<br />
</Text>
<Button href={data.verificationLink}>Verify</Button>
</BaseTemplate>
);
export default EmailVerification;
EmailVerification.TemplateProps = {
...sharedTemplateProps,
data: {
userFullName: "{{.Data.UserFullName}}",
verificationLink: "{{.Data.VerificationLink}}",
},
};
EmailVerification.PreviewProps = {
...sharedPreviewProps,
data: {
userFullName: "Tim Cook",
verificationLink:
"https://localhost:1411/user/verify-email?code=abcdefg12345",
},
};

View File

@@ -46,7 +46,11 @@
"authenticator_does_not_support_resident_keys": "Autentikátor nepodporuje rezidentní klíče", "authenticator_does_not_support_resident_keys": "Autentikátor nepodporuje rezidentní klíče",
"passkey_was_previously_registered": "Tento přístupový klíč byl již dříve zaregistrován", "passkey_was_previously_registered": "Tento přístupový klíč byl již dříve zaregistrován",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Autentikátor nepodporuje žádný z požadovaných algoritmů", "authenticator_does_not_support_any_of_the_requested_algorithms": "Autentikátor nepodporuje žádný z požadovaných algoritmů",
"authenticator_timed_out": "Vypršel časový limit autentifikátoru", "webauthn_error_invalid_rp_id": "Nakonfigurované ID spoléhající strany je neplatné.",
"webauthn_error_invalid_domain": "Nakonfigurovaná doména je neplatná.",
"contact_administrator_to_fix": "Kontaktujte svého správce, aby tento problém vyřešil.",
"webauthn_operation_not_allowed_or_timed_out": "Operace nebyla povolena nebo vypršela časová lhůta.",
"webauthn_not_supported_by_browser": "Tento prohlížeč nepodporuje přístupové klíče. Použijte prosím alternativní způsob přihlášení.",
"critical_error_occurred_contact_administrator": "Došlo k kritické chybě. Obraťte se na správce.", "critical_error_occurred_contact_administrator": "Došlo k kritické chybě. Obraťte se na správce.",
"sign_in_to": "Přihlásit se k {name}", "sign_in_to": "Přihlásit se k {name}",
"client_not_found": "Klient nebyl nalezen", "client_not_found": "Klient nebyl nalezen",
@@ -192,8 +196,6 @@
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Doba trvání relace v minutách, než se uživatel musí znovu přihlásit.", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Doba trvání relace v minutách, než se uživatel musí znovu přihlásit.",
"enable_self_account_editing": "Povolit úpravy vlastního účtu", "enable_self_account_editing": "Povolit úpravy vlastního účtu",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Zda by uživatelé měli mít možnost upravit vlastní údaje o účtu.", "whether_the_users_should_be_able_to_edit_their_own_account_details": "Zda by uživatelé měli mít možnost upravit vlastní údaje o účtu.",
"emails_verified": "E-mail ověřen",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Zda má být e-mail uživatele označen jako ověřený pro OIDC klienty.",
"ldap_configuration_updated_successfully": "Nastavení LDAP bylo úspěšně aktualizováno", "ldap_configuration_updated_successfully": "Nastavení LDAP bylo úspěšně aktualizováno",
"ldap_disabled_successfully": "LDAP úspěšně zakázán", "ldap_disabled_successfully": "LDAP úspěšně zakázán",
"ldap_sync_finished": "LDAP synchronizace dokončena", "ldap_sync_finished": "LDAP synchronizace dokončena",
@@ -499,5 +501,25 @@
"save_and_sync": "Uložit a synchronizovat", "save_and_sync": "Uložit a synchronizovat",
"scim_save_changes_description": "Před spuštěním synchronizace SCIM je nutné uložit změny. Chcete uložit nyní?", "scim_save_changes_description": "Před spuštěním synchronizace SCIM je nutné uložit změny. Chcete uložit nyní?",
"scopes": "Rozsah", "scopes": "Rozsah",
"issuer_url": "URL vydavatele" "issuer_url": "URL vydavatele",
"smtp_field_required_when_other_provided": "Vyžadováno, pokud je zadáno jakékoli nastavení SMTP",
"smtp_field_required_when_email_enabled": "Vyžadováno, pokud jsou povolena e-mailová oznámení",
"renew": "Obnovit",
"renew_api_key": "Obnovit klíč API",
"renew_api_key_description": "Obnovením klíče API se vygeneruje nový klíč. Nezapomeňte aktualizovat všechny integrace, které tento klíč používají.",
"api_key_renewed": "API klíč obnoven",
"app_config_home_page": "Domovská stránka",
"app_config_home_page_description": "Stránka, na kterou jsou uživatelé přesměrováni po přihlášení.",
"email_verification_warning": "Ověřte svou e-mailovou adresu",
"email_verification_warning_description": "Vaše e-mailová adresa ještě nebyla ověřena. Ověřte ji prosím co nejdříve.",
"email_verification": "Ověření e-mailu",
"email_verification_description": "Po odeslání registrace nebo změně e-mailové adresy zašlete uživatelům ověřovací e-mail.",
"email_verification_success_title": "E-mail byl úspěšně ověřen",
"email_verification_success_description": "Vaše e-mailová adresa byla úspěšně ověřena.",
"email_verification_error_title": "Ověření e-mailu se nezdařilo",
"mark_as_unverified": "Označit jako neověřené",
"mark_as_verified": "Označit jako ověřené",
"email_verification_sent": "Ověřovací e-mail byl úspěšně odeslán.",
"emails_verified_by_default": "E-maily ověřené ve výchozím nastavení",
"emails_verified_by_default_description": "Pokud je tato funkce povolena, budou e-mailové adresy uživatelů při registraci nebo při změně e-mailové adresy automaticky označeny jako ověřené."
} }

View File

@@ -46,7 +46,11 @@
"authenticator_does_not_support_resident_keys": "Godkenderen understøtter ikke gemte nøgler", "authenticator_does_not_support_resident_keys": "Godkenderen understøtter ikke gemte nøgler",
"passkey_was_previously_registered": "Denne adgangsnøgle er allerede registreret", "passkey_was_previously_registered": "Denne adgangsnøgle er allerede registreret",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Godkenderen understøtter ikke nogen af de algoritmer, der anmodes om", "authenticator_does_not_support_any_of_the_requested_algorithms": "Godkenderen understøtter ikke nogen af de algoritmer, der anmodes om",
"authenticator_timed_out": "Godkenderen overskred tidsgrænsen", "webauthn_error_invalid_rp_id": "Den konfigurerede afhængige parts ID er ugyldig.",
"webauthn_error_invalid_domain": "Det konfigurerede domæne er ugyldigt.",
"contact_administrator_to_fix": "Kontakt din administrator for at løse dette problem.",
"webauthn_operation_not_allowed_or_timed_out": "Operationen var ikke tilladt eller timet ud",
"webauthn_not_supported_by_browser": "Passkeys understøttes ikke af denne browser. Brug en alternativ login-metode.",
"critical_error_occurred_contact_administrator": "En kritisk fejl opstod. Kontakt venligst din administrator.", "critical_error_occurred_contact_administrator": "En kritisk fejl opstod. Kontakt venligst din administrator.",
"sign_in_to": "Log ind på {name}", "sign_in_to": "Log ind på {name}",
"client_not_found": "Klient ikke fundet", "client_not_found": "Klient ikke fundet",
@@ -192,8 +196,6 @@
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Varighed i minutter før brugeren skal logge ind igen.", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Varighed i minutter før brugeren skal logge ind igen.",
"enable_self_account_editing": "Aktivér redigering af egen konto", "enable_self_account_editing": "Aktivér redigering af egen konto",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Om brugere må redigere deres egne kontooplysninger.", "whether_the_users_should_be_able_to_edit_their_own_account_details": "Om brugere må redigere deres egne kontooplysninger.",
"emails_verified": "E-mailadresser verificeret",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Om brugerens e-mail skal markeres som verificeret for OIDC-klienter.",
"ldap_configuration_updated_successfully": "LDAP-konfiguration blev opdateret", "ldap_configuration_updated_successfully": "LDAP-konfiguration blev opdateret",
"ldap_disabled_successfully": "LDAP blev deaktiveret", "ldap_disabled_successfully": "LDAP blev deaktiveret",
"ldap_sync_finished": "LDAP-synkronisering fuldført", "ldap_sync_finished": "LDAP-synkronisering fuldført",
@@ -499,5 +501,25 @@
"save_and_sync": "Gem og synkroniser", "save_and_sync": "Gem og synkroniser",
"scim_save_changes_description": "Du skal gemme ændringerne, før du starter en SCIM-synkronisering. Vil du gemme nu?", "scim_save_changes_description": "Du skal gemme ændringerne, før du starter en SCIM-synkronisering. Vil du gemme nu?",
"scopes": "Omfang", "scopes": "Omfang",
"issuer_url": "Udsteders URL" "issuer_url": "Udsteders URL",
"smtp_field_required_when_other_provided": "Påkrævet, når der angives en SMTP-indstilling",
"smtp_field_required_when_email_enabled": "Påkrævet, når e-mail-underretninger er aktiveret",
"renew": "Forny",
"renew_api_key": "Forny API-nøgle",
"renew_api_key_description": "Ved at forny API-nøglen genereres en ny nøgle. Sørg for at opdatere alle integrationer, der bruger denne nøgle.",
"api_key_renewed": "API-nøgle fornyet",
"app_config_home_page": "Hjemmeside",
"app_config_home_page_description": "Den side, som brugerne omdirigeres til efter at have logget ind.",
"email_verification_warning": "Bekræft din e-mailadresse",
"email_verification_warning_description": "Din e-mailadresse er endnu ikke bekræftet. Bekræft den venligst så hurtigt som muligt.",
"email_verification": "E-mail-bekræftelse",
"email_verification_description": "Send en bekræftelses-e-mail til brugere, når de tilmelder sig eller ændrer deres e-mailadresse.",
"email_verification_success_title": "E-mail bekræftet med succes",
"email_verification_success_description": "Din e-mailadresse er blevet bekræftet.",
"email_verification_error_title": "E-mail-bekræftelse mislykkedes",
"mark_as_unverified": "Marker som ikke verificeret",
"mark_as_verified": "Marker som verificeret",
"email_verification_sent": "Bekræftelses-e-mail sendt med succes.",
"emails_verified_by_default": "E-mails verificeret som standard",
"emails_verified_by_default_description": "Når denne funktion er aktiveret, vil brugernes e-mailadresser som standard blive markeret som verificerede ved tilmelding eller når deres e-mailadresse ændres."
} }

View File

@@ -46,7 +46,11 @@
"authenticator_does_not_support_resident_keys": "Der Authentifikator unterstützt keine residenten Schlüssel", "authenticator_does_not_support_resident_keys": "Der Authentifikator unterstützt keine residenten Schlüssel",
"passkey_was_previously_registered": "Dieser Passkey wurde bereits registriert", "passkey_was_previously_registered": "Dieser Passkey wurde bereits registriert",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Der Authentifikator unterstützt keinen der angeforderten Algorithmen", "authenticator_does_not_support_any_of_the_requested_algorithms": "Der Authentifikator unterstützt keinen der angeforderten Algorithmen",
"authenticator_timed_out": "Der Authentifikator hat eine Zeitüberschreitung", "webauthn_error_invalid_rp_id": "Die eingestellte ID der vertrauenden Seite ist nicht okay.",
"webauthn_error_invalid_domain": "Die eingestellte Domain ist nicht okay.",
"contact_administrator_to_fix": "Sprich mit deinem Administrator, um das Problem zu lösen.",
"webauthn_operation_not_allowed_or_timed_out": "Der Vorgang wurde nicht erlaubt oder ist abgelaufen.",
"webauthn_not_supported_by_browser": "Passkeys werden von diesem Browser nicht unterstützt. Bitte probier eine andere Anmeldemethode aus.",
"critical_error_occurred_contact_administrator": "Ein kritischer Fehler ist aufgetreten. Bitte kontaktiere deinen Administrator.", "critical_error_occurred_contact_administrator": "Ein kritischer Fehler ist aufgetreten. Bitte kontaktiere deinen Administrator.",
"sign_in_to": "Bei {name} anmelden", "sign_in_to": "Bei {name} anmelden",
"client_not_found": "Client nicht gefunden", "client_not_found": "Client nicht gefunden",
@@ -192,8 +196,6 @@
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Die Dauer einer Sitzung in Minuten, bevor sich der Benutzer erneut anmelden muss.", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Die Dauer einer Sitzung in Minuten, bevor sich der Benutzer erneut anmelden muss.",
"enable_self_account_editing": "Selbstverwaltung des Kontos aktivieren", "enable_self_account_editing": "Selbstverwaltung des Kontos aktivieren",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Gibt an, ob die Benutzer in der Lage sein sollen, ihre eigenen Kontodetails zu ändern.", "whether_the_users_should_be_able_to_edit_their_own_account_details": "Gibt an, ob die Benutzer in der Lage sein sollen, ihre eigenen Kontodetails zu ändern.",
"emails_verified": "E-Mail-Adressen verifiziert",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Gibt an, ob die E-Mail des Benutzers für die OIDC-Clients als verifiziert markiert werden soll.",
"ldap_configuration_updated_successfully": "LDAP-Konfiguration erfolgreich aktualisiert", "ldap_configuration_updated_successfully": "LDAP-Konfiguration erfolgreich aktualisiert",
"ldap_disabled_successfully": "LDAP erfolgreich deaktiviert", "ldap_disabled_successfully": "LDAP erfolgreich deaktiviert",
"ldap_sync_finished": "LDAP-Synchronisation beendet", "ldap_sync_finished": "LDAP-Synchronisation beendet",
@@ -499,5 +501,25 @@
"save_and_sync": "Speichern und synchronisieren", "save_and_sync": "Speichern und synchronisieren",
"scim_save_changes_description": "Du musst die Änderungen speichern, bevor du eine SCIM-Synchronisierung startest. Willst du jetzt speichern?", "scim_save_changes_description": "Du musst die Änderungen speichern, bevor du eine SCIM-Synchronisierung startest. Willst du jetzt speichern?",
"scopes": "Kopfsuchgeräte", "scopes": "Kopfsuchgeräte",
"issuer_url": "Aussteller-URL" "issuer_url": "Aussteller-URL",
"smtp_field_required_when_other_provided": "Muss angegeben werden, wenn SMTP-Einstellungen gemacht werden",
"smtp_field_required_when_email_enabled": "Muss aktiviert sein, wenn du E-Mail-Benachrichtigungen nutzen willst.",
"renew": "Erneuern",
"renew_api_key": "API-Schlüssel erneuern",
"renew_api_key_description": "Wenn du den API-Schlüssel erneuerst, wird ein neuer Schlüssel erstellt. Denk dran, alle Integrationen, die diesen Schlüssel nutzen, zu aktualisieren.",
"api_key_renewed": "API-Schlüssel erneuert",
"app_config_home_page": "Startseite",
"app_config_home_page_description": "Die Seite, auf die Nutzer nach der Anmeldung weitergeleitet werden.",
"email_verification_warning": "Bestätige deine E-Mail-Adresse",
"email_verification_warning_description": "Deine E-Mail-Adresse ist noch nicht bestätigt. Bitte bestätige sie so schnell wie möglich.",
"email_verification": "E-Mail-Bestätigung",
"email_verification_description": "Schick den Nutzern eine Bestätigungs-E-Mail, wenn sie sich anmelden oder ihre E-Mail-Adresse ändern.",
"email_verification_success_title": "E-Mail erfolgreich bestätigt",
"email_verification_success_description": "Deine E-Mail-Adresse wurde erfolgreich bestätigt.",
"email_verification_error_title": "E-Mail-Verifizierung ist schiefgegangen",
"mark_as_unverified": "Als nicht überprüft markieren",
"mark_as_verified": "Als verifiziert markieren",
"email_verification_sent": "Bestätigungs-E-Mail erfolgreich verschickt.",
"emails_verified_by_default": "E-Mails sind standardmäßig verifiziert",
"emails_verified_by_default_description": "Wenn diese Option aktiviert ist, werden die E-Mail-Adressen der Nutzer bei der Anmeldung oder bei einer Änderung ihrer E-Mail-Adresse standardmäßig als verifiziert markiert."
} }

View File

@@ -46,7 +46,11 @@
"authenticator_does_not_support_resident_keys": "The authenticator does not support resident keys", "authenticator_does_not_support_resident_keys": "The authenticator does not support resident keys",
"passkey_was_previously_registered": "This passkey was previously registered", "passkey_was_previously_registered": "This passkey was previously registered",
"authenticator_does_not_support_any_of_the_requested_algorithms": "The authenticator does not support any of the requested algorithms", "authenticator_does_not_support_any_of_the_requested_algorithms": "The authenticator does not support any of the requested algorithms",
"authenticator_timed_out": "The authenticator timed out", "webauthn_error_invalid_rp_id": "The configured relying party ID is invalid.",
"webauthn_error_invalid_domain": "The configured domain is invalid.",
"contact_administrator_to_fix": "Contact your administrator to fix this issue.",
"webauthn_operation_not_allowed_or_timed_out": "The operation was not allowed or timed out",
"webauthn_not_supported_by_browser": "Passkeys are not supported by this browser. Please use an alternative sign in method.",
"critical_error_occurred_contact_administrator": "A critical error occurred. Please contact your administrator.", "critical_error_occurred_contact_administrator": "A critical error occurred. Please contact your administrator.",
"sign_in_to": "Sign in to {name}", "sign_in_to": "Sign in to {name}",
"client_not_found": "Client not found", "client_not_found": "Client not found",
@@ -192,8 +196,6 @@
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "The duration of a session in minutes before the user has to sign in again.", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "The duration of a session in minutes before the user has to sign in again.",
"enable_self_account_editing": "Enable Self-Account Editing", "enable_self_account_editing": "Enable Self-Account Editing",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Whether the users should be able to edit their own account details.", "whether_the_users_should_be_able_to_edit_their_own_account_details": "Whether the users should be able to edit their own account details.",
"emails_verified": "Emails Verified",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Whether the user's email should be marked as verified for the OIDC clients.",
"ldap_configuration_updated_successfully": "LDAP configuration updated successfully", "ldap_configuration_updated_successfully": "LDAP configuration updated successfully",
"ldap_disabled_successfully": "LDAP disabled successfully", "ldap_disabled_successfully": "LDAP disabled successfully",
"ldap_sync_finished": "LDAP sync finished", "ldap_sync_finished": "LDAP sync finished",
@@ -499,5 +501,25 @@
"save_and_sync": "Save and Sync", "save_and_sync": "Save and Sync",
"scim_save_changes_description": "You have to save the changes before starting a SCIM sync. Do you want to save now?", "scim_save_changes_description": "You have to save the changes before starting a SCIM sync. Do you want to save now?",
"scopes": "Scopes", "scopes": "Scopes",
"issuer_url": "Issuer URL" "issuer_url": "Issuer URL",
"smtp_field_required_when_other_provided": "Required when any SMTP setting is provided",
"smtp_field_required_when_email_enabled": "Required when email notifications are enabled",
"renew": "Renew",
"renew_api_key": "Renew API Key",
"renew_api_key_description": "Renewing the API key will generate a new key. Make sure to update any integrations using this key.",
"api_key_renewed": "API key renewed",
"app_config_home_page": "Home Page",
"app_config_home_page_description": "The page users are redirected to after signing in.",
"email_verification_warning": "Verify your email address",
"email_verification_warning_description": "Your email address is not verified yet. Please verify it as soon as possible.",
"email_verification": "Email Verification",
"email_verification_description": "Send a verification email to users when they sign up or change their email address.",
"email_verification_success_title": "Email Verified Successfully",
"email_verification_success_description": "Your email address has been verified successfully.",
"email_verification_error_title": "Email Verification Failed",
"mark_as_unverified": "Mark as unverified",
"mark_as_verified": "Mark as verified",
"email_verification_sent": "Verification email sent successfully.",
"emails_verified_by_default": "Emails verified by default",
"emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed."
} }

View File

@@ -46,7 +46,11 @@
"authenticator_does_not_support_resident_keys": "El autenticador no soporta claves residentes", "authenticator_does_not_support_resident_keys": "El autenticador no soporta claves residentes",
"passkey_was_previously_registered": "Esta Passkey ha sido registrado previamente", "passkey_was_previously_registered": "Esta Passkey ha sido registrado previamente",
"authenticator_does_not_support_any_of_the_requested_algorithms": "El autenticador no soporta ninguno de los algoritmos solicitados", "authenticator_does_not_support_any_of_the_requested_algorithms": "El autenticador no soporta ninguno de los algoritmos solicitados",
"authenticator_timed_out": "Se agotó el tiempo de espera del autenticador", "webauthn_error_invalid_rp_id": "El ID de la parte confiable configurado no es válido.",
"webauthn_error_invalid_domain": "El dominio configurado no es válido.",
"contact_administrator_to_fix": "Ponte en contacto con tu administrador para solucionar este problema.",
"webauthn_operation_not_allowed_or_timed_out": "La operación no fue permitida o se agotó el tiempo de espera.",
"webauthn_not_supported_by_browser": "Este navegador no admite claves de acceso. Utiliza otro método para iniciar sesión.",
"critical_error_occurred_contact_administrator": "Ha ocurrido un error crítico. Por favor, contacte a su administrador.", "critical_error_occurred_contact_administrator": "Ha ocurrido un error crítico. Por favor, contacte a su administrador.",
"sign_in_to": "Iniciar sesión en {name}", "sign_in_to": "Iniciar sesión en {name}",
"client_not_found": "Cliente no encontrado", "client_not_found": "Cliente no encontrado",
@@ -192,8 +196,6 @@
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "La duración de una sesión en minutos antes de que el usuario tenga que iniciar sesión de nuevo.", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "La duración de una sesión en minutos antes de que el usuario tenga que iniciar sesión de nuevo.",
"enable_self_account_editing": "Habilitar la edición de la cuenta personal", "enable_self_account_editing": "Habilitar la edición de la cuenta personal",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Si los usuarios deberían poder editar los detalles de su propia cuenta.", "whether_the_users_should_be_able_to_edit_their_own_account_details": "Si los usuarios deberían poder editar los detalles de su propia cuenta.",
"emails_verified": "Correos electrónicos verificados",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Si el correo electrónico del usuario debe marcarse como verificado para los clientes OIDC.",
"ldap_configuration_updated_successfully": "Configuración LDAP actualizada correctamente", "ldap_configuration_updated_successfully": "Configuración LDAP actualizada correctamente",
"ldap_disabled_successfully": "LDAP desactivado correctamente", "ldap_disabled_successfully": "LDAP desactivado correctamente",
"ldap_sync_finished": "Sincronización LDAP finalizada", "ldap_sync_finished": "Sincronización LDAP finalizada",
@@ -499,5 +501,25 @@
"save_and_sync": "Guardar y sincronizar", "save_and_sync": "Guardar y sincronizar",
"scim_save_changes_description": "Debes guardar los cambios antes de iniciar una sincronización SCIM. ¿Deseas guardar ahora?", "scim_save_changes_description": "Debes guardar los cambios antes de iniciar una sincronización SCIM. ¿Deseas guardar ahora?",
"scopes": "Ámbitos", "scopes": "Ámbitos",
"issuer_url": "URL del emisor" "issuer_url": "URL del emisor",
"smtp_field_required_when_other_provided": "Necesario cuando se proporciona cualquier configuración SMTP.",
"smtp_field_required_when_email_enabled": "Requerido cuando las notificaciones por correo electrónico están habilitadas.",
"renew": "Renovar",
"renew_api_key": "Renovar clave API",
"renew_api_key_description": "Al renovar la clave API se generará una nueva clave. Asegúrate de actualizar cualquier integración que utilice esta clave.",
"api_key_renewed": "Clave API renovada",
"app_config_home_page": "Página de inicio",
"app_config_home_page_description": "La página a la que se redirige a los usuarios después de iniciar sesión.",
"email_verification_warning": "Verifica tu dirección de correo electrónico.",
"email_verification_warning_description": "Tu dirección de correo electrónico aún no está verificada. Verifícala lo antes posible.",
"email_verification": "Verificación de correo electrónico",
"email_verification_description": "Enviar un correo electrónico de verificación a los usuarios cuando se registren o cambien su dirección de correo electrónico.",
"email_verification_success_title": "Correo electrónico verificado correctamente",
"email_verification_success_description": "Tu dirección de correo electrónico ha sido verificada correctamente.",
"email_verification_error_title": "Error en la verificación del correo electrónico",
"mark_as_unverified": "Marcar como no verificado",
"mark_as_verified": "Marcar como verificado",
"email_verification_sent": "El correo electrónico de verificación se ha enviado correctamente.",
"emails_verified_by_default": "Correos electrónicos verificados de forma predeterminada",
"emails_verified_by_default_description": "Cuando esta opción está activada, las direcciones de correo electrónico de los usuarios se marcarán como verificadas de forma predeterminada al registrarse o cuando se modifique su dirección de correo electrónico."
} }

View File

@@ -46,7 +46,11 @@
"authenticator_does_not_support_resident_keys": "Todentaja ei tue laiteavaimia", "authenticator_does_not_support_resident_keys": "Todentaja ei tue laiteavaimia",
"passkey_was_previously_registered": "Tämä pääsyavain on aiemmin rekisteröity", "passkey_was_previously_registered": "Tämä pääsyavain on aiemmin rekisteröity",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Todentaja ei tue mitään pyydetyistä algoritmeista", "authenticator_does_not_support_any_of_the_requested_algorithms": "Todentaja ei tue mitään pyydetyistä algoritmeista",
"authenticator_timed_out": "Todentaja aikakatkaistiin", "webauthn_error_invalid_rp_id": "Määritetty luottavan osapuolen tunnus on virheellinen.",
"webauthn_error_invalid_domain": "Määritetty verkkotunnus ei ole kelvollinen.",
"contact_administrator_to_fix": "Ota yhteyttä järjestelmänvalvojaan tämän ongelman korjaamiseksi.",
"webauthn_operation_not_allowed_or_timed_out": "Toimintoa ei sallittu tai sen aikakatkaisu umpeutui.",
"webauthn_not_supported_by_browser": "Tämä selain ei tue salasanan sijaan käytettäviä tunnuksia. Käytä vaihtoehtoista kirjautumistapaa.",
"critical_error_occurred_contact_administrator": "Kriittinen virhe tapahtui. Ota yhteyttä järjestelmänvalvojaan.", "critical_error_occurred_contact_administrator": "Kriittinen virhe tapahtui. Ota yhteyttä järjestelmänvalvojaan.",
"sign_in_to": "Kirjaudu palveluun {name}", "sign_in_to": "Kirjaudu palveluun {name}",
"client_not_found": "Asiakasta ei löydy", "client_not_found": "Asiakasta ei löydy",
@@ -192,8 +196,6 @@
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Istunnon kesto minuutteina ennen kuin käyttäjän on kirjauduttava uudelleen.", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Istunnon kesto minuutteina ennen kuin käyttäjän on kirjauduttava uudelleen.",
"enable_self_account_editing": "Ota käyttöön tilin itsemuokkaus", "enable_self_account_editing": "Ota käyttöön tilin itsemuokkaus",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Määrittää voiko käyttäjät itse muokata oman tilinsä tietoja.", "whether_the_users_should_be_able_to_edit_their_own_account_details": "Määrittää voiko käyttäjät itse muokata oman tilinsä tietoja.",
"emails_verified": "Sähköpostiosoitteet vahvistettu",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Merkitäänkö käyttäjän sähköpostiosoite vahvistetuksi OIDC-asiakkaille.",
"ldap_configuration_updated_successfully": "LDAP-määritykset päivitetty onnistuneesti", "ldap_configuration_updated_successfully": "LDAP-määritykset päivitetty onnistuneesti",
"ldap_disabled_successfully": "LDAP poistettu käytöstä onnistuneesti", "ldap_disabled_successfully": "LDAP poistettu käytöstä onnistuneesti",
"ldap_sync_finished": "LDAP-synkronointi valmis", "ldap_sync_finished": "LDAP-synkronointi valmis",
@@ -499,5 +501,25 @@
"save_and_sync": "Tallenna ja synkronoi", "save_and_sync": "Tallenna ja synkronoi",
"scim_save_changes_description": "Sinun on tallennettava muutokset ennen SCIM-synkronoinnin aloittamista. Haluatko tallentaa nyt?", "scim_save_changes_description": "Sinun on tallennettava muutokset ennen SCIM-synkronoinnin aloittamista. Haluatko tallentaa nyt?",
"scopes": "Käyttöalueet", "scopes": "Käyttöalueet",
"issuer_url": "Julkaisijan URL-osoite" "issuer_url": "Julkaisijan URL-osoite",
"smtp_field_required_when_other_provided": "Vaaditaan, kun SMTP-asetukset on määritetty",
"smtp_field_required_when_email_enabled": "Vaaditaan, kun sähköpostimuistutukset ovat käytössä",
"renew": "Uudista",
"renew_api_key": "Uudista API-avain",
"renew_api_key_description": "API-avaimen uusiminen luo uuden avaimen. Muista päivittää kaikki integraatiot, joissa tätä avainta käytetään.",
"api_key_renewed": "API-avain uusittu",
"app_config_home_page": "Kotisivu",
"app_config_home_page_description": "Sivu, jolle käyttäjät ohjataan kirjautumisen jälkeen.",
"email_verification_warning": "Vahvista sähköpostiosoitteesi",
"email_verification_warning_description": "Sähköpostiosoitteesi ei ole vielä vahvistettu. Vahvista se mahdollisimman pian.",
"email_verification": "Sähköpostin vahvistus",
"email_verification_description": "Lähetä vahvistussähköposti käyttäjille, kun he rekisteröityvät tai muuttavat sähköpostiosoitteensa.",
"email_verification_success_title": "Sähköposti vahvistettu onnistuneesti",
"email_verification_success_description": "Sähköpostiosoitteesi on vahvistettu onnistuneesti.",
"email_verification_error_title": "Sähköpostin vahvistus epäonnistui",
"mark_as_unverified": "Merkitse vahvistamattomaksi",
"mark_as_verified": "Merkitse vahvistetuksi",
"email_verification_sent": "Vahvistussähköposti lähetetty onnistuneesti.",
"emails_verified_by_default": "Sähköpostit vahvistettu oletuksena",
"emails_verified_by_default_description": "Kun tämä toiminto on käytössä, käyttäjien sähköpostiosoitteet merkitään oletusarvoisesti vahvistetuiksi rekisteröitymisen yhteydessä tai kun heidän sähköpostiosoitteensa muuttuu."
} }

View File

@@ -46,7 +46,11 @@
"authenticator_does_not_support_resident_keys": "L'authentificateur ne prend pas en charge les clés résidentes", "authenticator_does_not_support_resident_keys": "L'authentificateur ne prend pas en charge les clés résidentes",
"passkey_was_previously_registered": "Cette clé d'accès a déjà été enregistrée", "passkey_was_previously_registered": "Cette clé d'accès a déjà été enregistrée",
"authenticator_does_not_support_any_of_the_requested_algorithms": "L'authentificateur ne supporte aucun des algorithmes requis", "authenticator_does_not_support_any_of_the_requested_algorithms": "L'authentificateur ne supporte aucun des algorithmes requis",
"authenticator_timed_out": "L'authentification a expiré", "webauthn_error_invalid_rp_id": "L'ID de la partie de confiance configurée n'est pas valide.",
"webauthn_error_invalid_domain": "Le domaine configuré n'est pas valide.",
"contact_administrator_to_fix": "Contacte ton administrateur pour régler ce problème.",
"webauthn_operation_not_allowed_or_timed_out": "L'opération n'a pas été autorisée ou a expiré.",
"webauthn_not_supported_by_browser": "Les clés d'accès ne sont pas prises en charge par ce navigateur. Essaie une autre méthode de connexion.",
"critical_error_occurred_contact_administrator": "Une erreur critique s'est produite. Veuillez contacter votre administrateur.", "critical_error_occurred_contact_administrator": "Une erreur critique s'est produite. Veuillez contacter votre administrateur.",
"sign_in_to": "Connexion à {name}", "sign_in_to": "Connexion à {name}",
"client_not_found": "Client introuvable", "client_not_found": "Client introuvable",
@@ -192,8 +196,6 @@
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "La durée d'une session en minutes avant que l'utilisateur ne doive se reconnecter.", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "La durée d'une session en minutes avant que l'utilisateur ne doive se reconnecter.",
"enable_self_account_editing": "Activer l'édition de compte par l'utilisateur", "enable_self_account_editing": "Activer l'édition de compte par l'utilisateur",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Cela permet aux utilisateurs de modifier les détails de leur compte.", "whether_the_users_should_be_able_to_edit_their_own_account_details": "Cela permet aux utilisateurs de modifier les détails de leur compte.",
"emails_verified": "Email vérifié",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Indique si l'adresse e-mail de l'utilisateur doit être marquée comme vérifiée pour les clients OIDC.",
"ldap_configuration_updated_successfully": "Configuration LDAP mise à jour avec succès", "ldap_configuration_updated_successfully": "Configuration LDAP mise à jour avec succès",
"ldap_disabled_successfully": "LDAP désactivé avec succès", "ldap_disabled_successfully": "LDAP désactivé avec succès",
"ldap_sync_finished": "Synchronisation LDAP terminée", "ldap_sync_finished": "Synchronisation LDAP terminée",
@@ -499,5 +501,25 @@
"save_and_sync": "Enregistrer et synchroniser", "save_and_sync": "Enregistrer et synchroniser",
"scim_save_changes_description": "Tu dois enregistrer les changements avant de lancer une synchronisation SCIM. Tu veux enregistrer maintenant ?", "scim_save_changes_description": "Tu dois enregistrer les changements avant de lancer une synchronisation SCIM. Tu veux enregistrer maintenant ?",
"scopes": "Portées", "scopes": "Portées",
"issuer_url": "URL de l'émetteur" "issuer_url": "URL de l'émetteur",
"smtp_field_required_when_other_provided": "Nécessaire quand un paramètre SMTP est fourni",
"smtp_field_required_when_email_enabled": "C'est nécessaire quand les notifications par e-mail sont activées.",
"renew": "Renouveler",
"renew_api_key": "Renouveler la clé API",
"renew_api_key_description": "Quand tu renouvelles la clé API, une nouvelle clé est créée. N'oublie pas de mettre à jour toutes les intégrations qui utilisent cette clé.",
"api_key_renewed": "Clé API renouvelée",
"app_config_home_page": "Page d'accueil",
"app_config_home_page_description": "La page où les utilisateurs sont redirigés après s'être connectés.",
"email_verification_warning": "Vérifie ton adresse e-mail",
"email_verification_warning_description": "Ton adresse e-mail n'est pas encore validée. Valide-la dès que possible.",
"email_verification": "Vérification de l'adresse e-mail",
"email_verification_description": "Envoie un e-mail de vérification aux utilisateurs quand ils s'inscrivent ou changent leur adresse e-mail.",
"email_verification_success_title": "Adresse e-mail validée avec succès",
"email_verification_success_description": "Ton adresse e-mail a été validée avec succès.",
"email_verification_error_title": "Échec de la vérification de l'adresse e-mail",
"mark_as_unverified": "Marquer comme non vérifié",
"mark_as_verified": "Marquer comme vérifié",
"email_verification_sent": "L'e-mail de vérification a été envoyé sans problème.",
"emails_verified_by_default": "E-mails vérifiés par défaut",
"emails_verified_by_default_description": "Quand cette option est activée, les adresses e-mail des utilisateurs seront marquées comme vérifiées par défaut lors de leur inscription ou quand ils changent d'adresse e-mail."
} }

View File

@@ -46,7 +46,11 @@
"authenticator_does_not_support_resident_keys": "L'autenticatore non supporta le chiavi residenti", "authenticator_does_not_support_resident_keys": "L'autenticatore non supporta le chiavi residenti",
"passkey_was_previously_registered": "Questa passkey è stata registrata in precedenza", "passkey_was_previously_registered": "Questa passkey è stata registrata in precedenza",
"authenticator_does_not_support_any_of_the_requested_algorithms": "L'autenticatore non supporta nessuno degli algoritmi richiesti", "authenticator_does_not_support_any_of_the_requested_algorithms": "L'autenticatore non supporta nessuno degli algoritmi richiesti",
"authenticator_timed_out": "L'autenticatore ha superato il tempo limite", "webauthn_error_invalid_rp_id": "L'ID della parte affidabile che hai impostato non va bene.",
"webauthn_error_invalid_domain": "Il dominio che hai impostato non va bene.",
"contact_administrator_to_fix": "Chiedi al tuo amministratore di risolvere questo problema.",
"webauthn_operation_not_allowed_or_timed_out": "L'operazione non è stata autorizzata o è scaduta.",
"webauthn_not_supported_by_browser": "Questo browser non supporta le passkey. Prova a usare un altro modo per accedere.",
"critical_error_occurred_contact_administrator": "Si è verificato un errore critico. Contatta il tuo amministratore.", "critical_error_occurred_contact_administrator": "Si è verificato un errore critico. Contatta il tuo amministratore.",
"sign_in_to": "Accedi a {name}", "sign_in_to": "Accedi a {name}",
"client_not_found": "Client non trovato", "client_not_found": "Client non trovato",
@@ -192,8 +196,6 @@
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "La durata di una sessione in minuti prima che l'utente debba accedere nuovamente.", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "La durata di una sessione in minuti prima che l'utente debba accedere nuovamente.",
"enable_self_account_editing": "Abilita modifica del proprio account", "enable_self_account_editing": "Abilita modifica del proprio account",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Se gli utenti dovrebbero essere in grado di modificare i dettagli del proprio account.", "whether_the_users_should_be_able_to_edit_their_own_account_details": "Se gli utenti dovrebbero essere in grado di modificare i dettagli del proprio account.",
"emails_verified": "Email verificate",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Se l'email dell'utente deve essere contrassegnata come verificata per i client OIDC.",
"ldap_configuration_updated_successfully": "Configurazione LDAP aggiornata con successo", "ldap_configuration_updated_successfully": "Configurazione LDAP aggiornata con successo",
"ldap_disabled_successfully": "LDAP disabilitato con successo", "ldap_disabled_successfully": "LDAP disabilitato con successo",
"ldap_sync_finished": "Sincronizzazione LDAP completata", "ldap_sync_finished": "Sincronizzazione LDAP completata",
@@ -499,5 +501,25 @@
"save_and_sync": "Salva e sincronizza", "save_and_sync": "Salva e sincronizza",
"scim_save_changes_description": "Devi salvare le modifiche prima di iniziare una sincronizzazione SCIM. Vuoi salvare adesso?", "scim_save_changes_description": "Devi salvare le modifiche prima di iniziare una sincronizzazione SCIM. Vuoi salvare adesso?",
"scopes": "Scopi", "scopes": "Scopi",
"issuer_url": "URL dell'emittente" "issuer_url": "URL dell'emittente",
"smtp_field_required_when_other_provided": "Richiesto quando c'è un'impostazione SMTP",
"smtp_field_required_when_email_enabled": "Richiesto quando le notifiche via e-mail sono attivate",
"renew": "Rinnovare",
"renew_api_key": "Rinnova chiave API",
"renew_api_key_description": "Rinnovando la chiave API ne verrà generata una nuova. Assicurati di aggiornare tutte le integrazioni che usano questa chiave.",
"api_key_renewed": "Chiave API rinnovata",
"app_config_home_page": "Pagina iniziale",
"app_config_home_page_description": "La pagina a cui gli utenti vengono reindirizzati dopo aver effettuato l'accesso.",
"email_verification_warning": "Conferma il tuo indirizzo email",
"email_verification_warning_description": "Il tuo indirizzo email non è ancora stato verificato. Ti chiediamo di farlo il prima possibile.",
"email_verification": "Verifica dell'indirizzo e-mail",
"email_verification_description": "Manda un'email di verifica agli utenti quando si registrano o cambiano il loro indirizzo email.",
"email_verification_success_title": "Email verificata con successo",
"email_verification_success_description": "Il tuo indirizzo email è stato verificato senza problemi.",
"email_verification_error_title": "Verifica e-mail non riuscita",
"mark_as_unverified": "Contrassegna come non verificato",
"mark_as_verified": "Contrassegna come verificato",
"email_verification_sent": "Email di conferma inviata con successo.",
"emails_verified_by_default": "Email verificate di default",
"emails_verified_by_default_description": "Quando questa opzione è attiva, gli indirizzi email degli utenti saranno automaticamente contrassegnati come verificati al momento della registrazione o quando cambiano il loro indirizzo email."
} }

View File

@@ -46,7 +46,11 @@
"authenticator_does_not_support_resident_keys": "認証ツールは resident key をサポートしていません", "authenticator_does_not_support_resident_keys": "認証ツールは resident key をサポートしていません",
"passkey_was_previously_registered": "このパスキーは既に登録されています", "passkey_was_previously_registered": "このパスキーは既に登録されています",
"authenticator_does_not_support_any_of_the_requested_algorithms": "認証ツールは要求されたアルゴリズムのいずれをもサポートしていません", "authenticator_does_not_support_any_of_the_requested_algorithms": "認証ツールは要求されたアルゴリズムのいずれをもサポートしていません",
"authenticator_timed_out": "認証ツールがタイムアウトしました", "webauthn_error_invalid_rp_id": "設定された信頼当事者IDは無効です。",
"webauthn_error_invalid_domain": "設定されたドメインは無効です。",
"contact_administrator_to_fix": "この問題を修正するには、管理者にお問い合わせください。",
"webauthn_operation_not_allowed_or_timed_out": "操作は許可されませんでした、またはタイムアウトしました",
"webauthn_not_supported_by_browser": "このブラウザではパスキーはサポートされていません。別のサインイン方法をご利用ください。",
"critical_error_occurred_contact_administrator": "重大なエラーが発生しました。管理者にお問い合わせください。", "critical_error_occurred_contact_administrator": "重大なエラーが発生しました。管理者にお問い合わせください。",
"sign_in_to": "{name} にサインイン", "sign_in_to": "{name} にサインイン",
"client_not_found": "クライアントが見つかりません", "client_not_found": "クライアントが見つかりません",
@@ -192,8 +196,6 @@
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "ユーザーが再度ログインする必要があるまでのセッションの継続時間。(分単位)", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "ユーザーが再度ログインする必要があるまでのセッションの継続時間。(分単位)",
"enable_self_account_editing": "自身のアカウント編集を有効にする", "enable_self_account_editing": "自身のアカウント編集を有効にする",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "ユーザーが自身のアカウントの詳細を編集できるかどうか。", "whether_the_users_should_be_able_to_edit_their_own_account_details": "ユーザーが自身のアカウントの詳細を編集できるかどうか。",
"emails_verified": "メールアドレス確認済み",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "ユーザのEメールをOIDCクライアントで検証済みとしてマークするかどうか。",
"ldap_configuration_updated_successfully": "LDAP 設定が正常に更新されました", "ldap_configuration_updated_successfully": "LDAP 設定が正常に更新されました",
"ldap_disabled_successfully": "LDAPは正常に無効化されました", "ldap_disabled_successfully": "LDAPは正常に無効化されました",
"ldap_sync_finished": "LDAP同期が完了しました", "ldap_sync_finished": "LDAP同期が完了しました",
@@ -499,5 +501,25 @@
"save_and_sync": "保存と同期", "save_and_sync": "保存と同期",
"scim_save_changes_description": "SCIM同期を開始する前に変更を保存する必要があります。今すぐ保存しますか", "scim_save_changes_description": "SCIM同期を開始する前に変更を保存する必要があります。今すぐ保存しますか",
"scopes": "スコープ", "scopes": "スコープ",
"issuer_url": "発行者URL" "issuer_url": "発行者URL",
"smtp_field_required_when_other_provided": "いずれかのSMTP設定が提供された場合に必須",
"smtp_field_required_when_email_enabled": "メール通知が有効な場合に必須",
"renew": "更新",
"renew_api_key": "APIキーを更新する",
"renew_api_key_description": "APIキーを更新すると新しいキーが生成されます。このキーを使用しているすべての連携を更新してください。",
"api_key_renewed": "APIキーを更新しました",
"app_config_home_page": "ホームページ",
"app_config_home_page_description": "ユーザーがサインイン後にリダイレクトされるページ。",
"email_verification_warning": "メールアドレスを確認してください",
"email_verification_warning_description": "メールアドレスはまだ確認されていません。できるだけ早く確認してください。",
"email_verification": "メール認証",
"email_verification_description": "ユーザーが登録時またはメールアドレスを変更した際に、確認メールを送信する。",
"email_verification_success_title": "メールアドレスの確認が完了しました",
"email_verification_success_description": "メールアドレスの確認が完了しました。",
"email_verification_error_title": "メール認証に失敗しました",
"mark_as_unverified": "未確認としてマークする",
"mark_as_verified": "確認済みとしてマークする",
"email_verification_sent": "確認メールが正常に送信されました。",
"emails_verified_by_default": "メールはデフォルトで検証済み",
"emails_verified_by_default_description": "有効化すると、ユーザーが登録時またはメールアドレスを変更した際に、デフォルトでメールアドレスが確認済みとしてマークされます。"
} }

View File

@@ -46,7 +46,11 @@
"authenticator_does_not_support_resident_keys": "인증기가 레지던트 키를 지원하지 않습니다", "authenticator_does_not_support_resident_keys": "인증기가 레지던트 키를 지원하지 않습니다",
"passkey_was_previously_registered": "이 패스키는 이미 등록되었습니다", "passkey_was_previously_registered": "이 패스키는 이미 등록되었습니다",
"authenticator_does_not_support_any_of_the_requested_algorithms": "인증기가 요청된 알고리즘 중 어느 것도 지원하지 않습니다", "authenticator_does_not_support_any_of_the_requested_algorithms": "인증기가 요청된 알고리즘 중 어느 것도 지원하지 않습니다",
"authenticator_timed_out": "인증기가 시간 초과되었습니다", "webauthn_error_invalid_rp_id": "구성된 신뢰 당사자 ID가 유효하지 않습니다.",
"webauthn_error_invalid_domain": "구성된 도메인이 유효하지 않습니다.",
"contact_administrator_to_fix": "이 문제를 해결하려면 관리자에게 문의하십시오.",
"webauthn_operation_not_allowed_or_timed_out": "작업이 허용되지 않았거나 시간 초과되었습니다.",
"webauthn_not_supported_by_browser": "이 브라우저에서는 패스키를 지원하지 않습니다. 다른 로그인 방법을 사용해 주세요.",
"critical_error_occurred_contact_administrator": "치명적인 오류가 발생했습니다. 관리자에게 연락해주세요.", "critical_error_occurred_contact_administrator": "치명적인 오류가 발생했습니다. 관리자에게 연락해주세요.",
"sign_in_to": "{name}에 로그인", "sign_in_to": "{name}에 로그인",
"client_not_found": "클라이언트를 찾을 수 없습니다", "client_not_found": "클라이언트를 찾을 수 없습니다",
@@ -192,8 +196,6 @@
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "사용자가 다시 로그인하기 전 세션의 시간(분)입니다.", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "사용자가 다시 로그인하기 전 세션의 시간(분)입니다.",
"enable_self_account_editing": "셀프 계정 편집 활성화", "enable_self_account_editing": "셀프 계정 편집 활성화",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "사용자가 자신의 계정 정보를 편집할 수 있습니다.", "whether_the_users_should_be_able_to_edit_their_own_account_details": "사용자가 자신의 계정 정보를 편집할 수 있습니다.",
"emails_verified": "이메일 인증됨",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "OIDC 클라이언트에게 사용자의 이메일이 인증된 것으로 표시합니다.",
"ldap_configuration_updated_successfully": "LDAP 구성이 성공적으로 변경되었습니다", "ldap_configuration_updated_successfully": "LDAP 구성이 성공적으로 변경되었습니다",
"ldap_disabled_successfully": "LDAP가 성공적으로 비활성화되었습니다", "ldap_disabled_successfully": "LDAP가 성공적으로 비활성화되었습니다",
"ldap_sync_finished": "LDAP 동기화 완료", "ldap_sync_finished": "LDAP 동기화 완료",
@@ -499,5 +501,25 @@
"save_and_sync": "저장 및 동기화", "save_and_sync": "저장 및 동기화",
"scim_save_changes_description": "SCIM 동기화를 시작하기 전에 변경 사항을 저장해야 합니다. 지금 저장하시겠습니까?", "scim_save_changes_description": "SCIM 동기화를 시작하기 전에 변경 사항을 저장해야 합니다. 지금 저장하시겠습니까?",
"scopes": "범위", "scopes": "범위",
"issuer_url": "발행자 URL" "issuer_url": "발행자 URL",
"smtp_field_required_when_other_provided": "어떤 SMTP 설정이라도 제공될 때 필수",
"smtp_field_required_when_email_enabled": "이메일 알림이 활성화된 경우 필수",
"renew": "갱신하다",
"renew_api_key": "API 키 갱신",
"renew_api_key_description": "API 키를 갱신하면 새 키가 생성됩니다. 이 키를 사용하는 모든 통합 기능을 반드시 업데이트하십시오.",
"api_key_renewed": "API 키 갱신됨",
"app_config_home_page": "홈페이지",
"app_config_home_page_description": "사용자가 로그인 후 이동하는 페이지.",
"email_verification_warning": "이메일 주소를 확인하세요",
"email_verification_warning_description": "귀하의 이메일 주소는 아직 확인되지 않았습니다. 가능한 한 빨리 확인해 주시기 바랍니다.",
"email_verification": "이메일 인증",
"email_verification_description": "사용자가 가입하거나 이메일 주소를 변경할 때 인증 이메일을 발송합니다.",
"email_verification_success_title": "이메일 확인이 성공적으로 완료되었습니다",
"email_verification_success_description": "귀하의 이메일 주소가 성공적으로 확인되었습니다.",
"email_verification_error_title": "이메일 확인 실패",
"mark_as_unverified": "확인되지 않음으로 표시",
"mark_as_verified": "검증됨으로 표시",
"email_verification_sent": "확인 이메일이 성공적으로 발송되었습니다.",
"emails_verified_by_default": "이메일은 기본적으로 확인됨",
"emails_verified_by_default_description": "이 기능이 활성화되면, 사용자의 이메일 주소는 가입 시 또는 이메일 주소 변경 시 기본적으로 확인된 상태로 표시됩니다."
} }

View File

@@ -46,7 +46,11 @@
"authenticator_does_not_support_resident_keys": "De authenticator ondersteunt geen vaste sleutels", "authenticator_does_not_support_resident_keys": "De authenticator ondersteunt geen vaste sleutels",
"passkey_was_previously_registered": "Deze passkey is eerder geregistreerd", "passkey_was_previously_registered": "Deze passkey is eerder geregistreerd",
"authenticator_does_not_support_any_of_the_requested_algorithms": "De authenticator ondersteunt geen van de gevraagde algoritmen", "authenticator_does_not_support_any_of_the_requested_algorithms": "De authenticator ondersteunt geen van de gevraagde algoritmen",
"authenticator_timed_out": "De authenticator is verlopen", "webauthn_error_invalid_rp_id": "De ID van de vertrouwende partij die je hebt ingesteld, klopt niet.",
"webauthn_error_invalid_domain": "Het domein dat je hebt ingesteld, klopt niet.",
"contact_administrator_to_fix": "Neem contact op met je beheerder om dit probleem op te lossen.",
"webauthn_operation_not_allowed_or_timed_out": "De bewerking is niet toegestaan of de tijd is verstreken.",
"webauthn_not_supported_by_browser": "Passkeys worden niet ondersteund door deze browser. Probeer een andere manier om in te loggen.",
"critical_error_occurred_contact_administrator": "Er is een kritieke fout opgetreden. Neem contact op met de beheerder.", "critical_error_occurred_contact_administrator": "Er is een kritieke fout opgetreden. Neem contact op met de beheerder.",
"sign_in_to": "Meld je aan bij {name}", "sign_in_to": "Meld je aan bij {name}",
"client_not_found": "Client niet gevonden", "client_not_found": "Client niet gevonden",
@@ -192,8 +196,6 @@
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "De duur van een sessie in minuten voordat de gebruiker zich opnieuw moet aanmelden.", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "De duur van een sessie in minuten voordat de gebruiker zich opnieuw moet aanmelden.",
"enable_self_account_editing": "Bewerken van eigen account mogelijk maken", "enable_self_account_editing": "Bewerken van eigen account mogelijk maken",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Of gebruikers hun eigen accountgegevens moeten kunnen bewerken.", "whether_the_users_should_be_able_to_edit_their_own_account_details": "Of gebruikers hun eigen accountgegevens moeten kunnen bewerken.",
"emails_verified": "E-mails geverifieerd",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Of het e-mailadres van de gebruiker als geverifieerd moet worden gemarkeerd voor de OIDC-clients.",
"ldap_configuration_updated_successfully": "LDAP-configuratie succesvol bijgewerkt", "ldap_configuration_updated_successfully": "LDAP-configuratie succesvol bijgewerkt",
"ldap_disabled_successfully": "LDAP succesvol uitgeschakeld", "ldap_disabled_successfully": "LDAP succesvol uitgeschakeld",
"ldap_sync_finished": "LDAP-synchronisatie voltooid", "ldap_sync_finished": "LDAP-synchronisatie voltooid",
@@ -499,5 +501,25 @@
"save_and_sync": "Opslaan en synchroniseren", "save_and_sync": "Opslaan en synchroniseren",
"scim_save_changes_description": "Je moet de wijzigingen opslaan voordat je een SCIM-synchronisatie start. Wil je nu opslaan?", "scim_save_changes_description": "Je moet de wijzigingen opslaan voordat je een SCIM-synchronisatie start. Wil je nu opslaan?",
"scopes": "Scopes", "scopes": "Scopes",
"issuer_url": "URL van de uitgever" "issuer_url": "URL van de uitgever",
"smtp_field_required_when_other_provided": "Moet je invullen als er SMTP-instellingen zijn",
"smtp_field_required_when_email_enabled": "Moet je invullen als je e-mailmeldingen hebt ingeschakeld.",
"renew": "Vernieuwen",
"renew_api_key": "API-sleutel vernieuwen",
"renew_api_key_description": "Als je de API-sleutel vernieuwt, krijg je een nieuwe sleutel. Zorg ervoor dat je alle integraties die deze sleutel gebruiken, bijwerkt.",
"api_key_renewed": "API-sleutel vernieuwd",
"app_config_home_page": "Startpagina",
"app_config_home_page_description": "De pagina waar gebruikers naartoe gaan nadat ze zijn ingelogd.",
"email_verification_warning": "Check je e-mailadres",
"email_verification_warning_description": "Je e-mailadres is nog niet geverifieerd. Doe dat alsjeblieft zo snel mogelijk.",
"email_verification": "E-mailverificatie",
"email_verification_description": "Stuur een bevestigingsmail naar mensen als ze zich aanmelden of hun e-mailadres veranderen.",
"email_verification_success_title": "E-mailadres succesvol geverifieerd",
"email_verification_success_description": "Je e-mailadres is goed geverifieerd.",
"email_verification_error_title": "E-mailverificatie mislukt",
"mark_as_unverified": "Markeer als niet geverifieerd",
"mark_as_verified": "Markeer als geverifieerd",
"email_verification_sent": "Verificatiemail is goed verstuurd.",
"emails_verified_by_default": "E-mails standaard geverifieerd",
"emails_verified_by_default_description": "Als je dit aan zet, worden de e-mailadressen van gebruikers standaard gemarkeerd als geverifieerd bij het aanmelden of als hun e-mailadres verandert."
} }

View File

@@ -46,7 +46,11 @@
"authenticator_does_not_support_resident_keys": "Autoryzator nie obsługuje kluczy rezydentnych", "authenticator_does_not_support_resident_keys": "Autoryzator nie obsługuje kluczy rezydentnych",
"passkey_was_previously_registered": "Ten klucz był już wcześniej zarejestrowany", "passkey_was_previously_registered": "Ten klucz był już wcześniej zarejestrowany",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Autoryzator nie obsługuje żadnego z żądanych algorytmów", "authenticator_does_not_support_any_of_the_requested_algorithms": "Autoryzator nie obsługuje żadnego z żądanych algorytmów",
"authenticator_timed_out": "Czas autoryzatora upłynął", "webauthn_error_invalid_rp_id": "Skonfigurowany identyfikator strony ufającej jest nieprawidłowy.",
"webauthn_error_invalid_domain": "Skonfigurowana domena jest nieprawidłowa.",
"contact_administrator_to_fix": "Skontaktuj się z administratorem, aby rozwiązać ten problem.",
"webauthn_operation_not_allowed_or_timed_out": "Operacja nie została dozwolona lub przekroczono limit czasu",
"webauthn_not_supported_by_browser": "Ta przeglądarka nie obsługuje kluczy dostępu. Proszę skorzystać z alternatywnej metody logowania.",
"critical_error_occurred_contact_administrator": "Wystąpił krytyczny błąd. Skontaktuj się z administratorem.", "critical_error_occurred_contact_administrator": "Wystąpił krytyczny błąd. Skontaktuj się z administratorem.",
"sign_in_to": "Zaloguj się do {name}", "sign_in_to": "Zaloguj się do {name}",
"client_not_found": "Nie znaleziono klienta", "client_not_found": "Nie znaleziono klienta",
@@ -192,8 +196,6 @@
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Czas trwania sesji w minutach, zanim użytkownik będzie musiał ponownie się zalogować.", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Czas trwania sesji w minutach, zanim użytkownik będzie musiał ponownie się zalogować.",
"enable_self_account_editing": "Włącz edytowanie konta przez użytkownika", "enable_self_account_editing": "Włącz edytowanie konta przez użytkownika",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Czy użytkownicy powinni mieć możliwość edytowania szczegółów swojego konta.", "whether_the_users_should_be_able_to_edit_their_own_account_details": "Czy użytkownicy powinni mieć możliwość edytowania szczegółów swojego konta.",
"emails_verified": "E-maile zweryfikowane",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Czy adres e-mail użytkownika powinien być oznaczony jako zweryfikowany dla klientów OIDC.",
"ldap_configuration_updated_successfully": "Sukces! Konfiguracja LDAP została zaktualizowana.", "ldap_configuration_updated_successfully": "Sukces! Konfiguracja LDAP została zaktualizowana.",
"ldap_disabled_successfully": "Sukces! LDAP został wyłączony.", "ldap_disabled_successfully": "Sukces! LDAP został wyłączony.",
"ldap_sync_finished": "Synchronizacja LDAP zakończona", "ldap_sync_finished": "Synchronizacja LDAP zakończona",
@@ -499,5 +501,25 @@
"save_and_sync": "Zapisz i zsynchronizuj", "save_and_sync": "Zapisz i zsynchronizuj",
"scim_save_changes_description": "Przed rozpoczęciem synchronizacji SCIM należy zapisać zmiany. Czy chcesz zapisać teraz?", "scim_save_changes_description": "Przed rozpoczęciem synchronizacji SCIM należy zapisać zmiany. Czy chcesz zapisać teraz?",
"scopes": "Zakresy", "scopes": "Zakresy",
"issuer_url": "Adres URL wystawcy" "issuer_url": "Adres URL wystawcy",
"smtp_field_required_when_other_provided": "Wymagane, gdy podano dowolne ustawienie SMTP",
"smtp_field_required_when_email_enabled": "Wymagane, gdy włączone są powiadomienia e-mailowe",
"renew": "Odnowić",
"renew_api_key": "Odnów klucz API",
"renew_api_key_description": "Odnowienie klucza API spowoduje wygenerowanie nowego klucza. Pamiętaj o aktualizacji wszystkich integracji korzystających z tego klucza.",
"api_key_renewed": "Klucz API odnowiony",
"app_config_home_page": "Strona główna",
"app_config_home_page_description": "Strona, do której użytkownicy są przekierowywani po zalogowaniu.",
"email_verification_warning": "Zweryfikuj swój adres e-mail",
"email_verification_warning_description": "Twój adres e-mail nie został jeszcze zweryfikowany. Prosimy o jak najszybszą weryfikację.",
"email_verification": "Weryfikacja adresu e-mail",
"email_verification_description": "Wyślijcie użytkownikom wiadomość e-mail z linkiem weryfikacyjnym po zarejestrowaniu się lub zmianie adresu e-mail.",
"email_verification_success_title": "Adres e-mail został pomyślnie zweryfikowany",
"email_verification_success_description": "Twój adres e-mail został pomyślnie zweryfikowany.",
"email_verification_error_title": "Weryfikacja adresu e-mail nie powiodła się",
"mark_as_unverified": "Oznacz jako niezweryfikowane",
"mark_as_verified": "Oznacz jako zweryfikowane",
"email_verification_sent": "Wiadomość e-mail z linkiem weryfikacyjnym została wysłana.",
"emails_verified_by_default": "E-maile weryfikowane domyślnie",
"emails_verified_by_default_description": "Po włączeniu tej opcji adresy e-mail użytkowników będą domyślnie oznaczane jako zweryfikowane podczas rejestracji lub zmiany adresu e-mail."
} }

View File

@@ -46,7 +46,11 @@
"authenticator_does_not_support_resident_keys": "O autenticador não aceita chaves residentes", "authenticator_does_not_support_resident_keys": "O autenticador não aceita chaves residentes",
"passkey_was_previously_registered": "Esta chave de acesso já está registrada", "passkey_was_previously_registered": "Esta chave de acesso já está registrada",
"authenticator_does_not_support_any_of_the_requested_algorithms": "O autenticador não suporta nenhum dos algoritmos solicitados", "authenticator_does_not_support_any_of_the_requested_algorithms": "O autenticador não suporta nenhum dos algoritmos solicitados",
"authenticator_timed_out": "Tempo limite do autenticador atingido", "webauthn_error_invalid_rp_id": "A identificação da parte confiável configurada não está válida.",
"webauthn_error_invalid_domain": "O domínio configurado não está certo.",
"contact_administrator_to_fix": "Fala com o administrador pra resolver esse problema.",
"webauthn_operation_not_allowed_or_timed_out": "A operação não foi permitida ou expirou.",
"webauthn_not_supported_by_browser": "As chaves de acesso não são suportadas por este navegador. Por favor, use um método alternativo de login.",
"critical_error_occurred_contact_administrator": "Ocorreu um erro grave. Por favor, entre em contato com o administrador.", "critical_error_occurred_contact_administrator": "Ocorreu um erro grave. Por favor, entre em contato com o administrador.",
"sign_in_to": "Entrar em {name}", "sign_in_to": "Entrar em {name}",
"client_not_found": "Cliente não encontrado", "client_not_found": "Cliente não encontrado",
@@ -192,8 +196,6 @@
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "O tempo que dura uma sessão, em minutos, antes que o usuário precise fazer login de novo.", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "O tempo que dura uma sessão, em minutos, antes que o usuário precise fazer login de novo.",
"enable_self_account_editing": "Ativar edição da conta pessoal", "enable_self_account_editing": "Ativar edição da conta pessoal",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Se os usuários podem editar os detalhes de suas contas.", "whether_the_users_should_be_able_to_edit_their_own_account_details": "Se os usuários podem editar os detalhes de suas contas.",
"emails_verified": "E-mails verificados",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Se o e-mail do usuário deve ser marcado como verificado para os clientes OIDC.",
"ldap_configuration_updated_successfully": "Configuração LDAP atualizada com sucesso", "ldap_configuration_updated_successfully": "Configuração LDAP atualizada com sucesso",
"ldap_disabled_successfully": "LDAP desativado com sucesso", "ldap_disabled_successfully": "LDAP desativado com sucesso",
"ldap_sync_finished": "Sincronização LDAP concluída", "ldap_sync_finished": "Sincronização LDAP concluída",
@@ -499,5 +501,25 @@
"save_and_sync": "Salvar e sincronizar", "save_and_sync": "Salvar e sincronizar",
"scim_save_changes_description": "Você precisa salvar as alterações antes de iniciar uma sincronização SCIM. Quer salvar agora?", "scim_save_changes_description": "Você precisa salvar as alterações antes de iniciar uma sincronização SCIM. Quer salvar agora?",
"scopes": "Âmbitos", "scopes": "Âmbitos",
"issuer_url": "URL do emissor" "issuer_url": "URL do emissor",
"smtp_field_required_when_other_provided": "É necessário quando qualquer configuração SMTP é fornecida.",
"smtp_field_required_when_email_enabled": "É necessário quando as notificações por e-mail estão ativadas.",
"renew": "Renovar",
"renew_api_key": "Renovar chave API",
"renew_api_key_description": "Renovar a chave API vai gerar uma nova chave. Não esqueça de atualizar todas as integrações que usam essa chave.",
"api_key_renewed": "Chave API renovada",
"app_config_home_page": "Página inicial",
"app_config_home_page_description": "A página para a qual os usuários são redirecionados após fazerem login.",
"email_verification_warning": "Confirme seu endereço de e-mail",
"email_verification_warning_description": "Seu endereço de e-mail ainda não foi verificado. Por favor, verifique-o assim que possível.",
"email_verification": "Verificação de e-mail",
"email_verification_description": "Manda um e-mail de verificação pros usuários quando eles se cadastrarem ou mudarem o endereço de e-mail.",
"email_verification_success_title": "E-mail verificado com sucesso",
"email_verification_success_description": "Seu endereço de e-mail foi verificado com sucesso.",
"email_verification_error_title": "Falha na verificação do e-mail",
"mark_as_unverified": "Marcar como não verificado",
"mark_as_verified": "Marcar como verificado",
"email_verification_sent": "E-mail de verificação enviado com sucesso.",
"emails_verified_by_default": "E-mails verificados por padrão",
"emails_verified_by_default_description": "Quando ativado, os endereços de e-mail dos usuários serão marcados como verificados por padrão no momento da inscrição ou quando o endereço de e-mail for alterado."
} }

View File

@@ -46,7 +46,11 @@
"authenticator_does_not_support_resident_keys": "Аутентификатор не поддерживает резидентные ключи", "authenticator_does_not_support_resident_keys": "Аутентификатор не поддерживает резидентные ключи",
"passkey_was_previously_registered": "Этот пасскей был ранее зарегистрирован", "passkey_was_previously_registered": "Этот пасскей был ранее зарегистрирован",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Аутентификатор не поддерживает ни один из запрошенных алгоритмов", "authenticator_does_not_support_any_of_the_requested_algorithms": "Аутентификатор не поддерживает ни один из запрошенных алгоритмов",
"authenticator_timed_out": "Время ожидания аутентификатора истекло", "webauthn_error_invalid_rp_id": "Идентификатор настроенной полагающейся стороны неверный.",
"webauthn_error_invalid_domain": "Настроенный домен не работает.",
"contact_administrator_to_fix": "Обратись к своему администратору, чтобы решить эту проблему.",
"webauthn_operation_not_allowed_or_timed_out": "Операция не разрешена или истекло время ожидания",
"webauthn_not_supported_by_browser": "Этот браузер не поддерживает пароли. Попробуй войти другим способом.",
"critical_error_occurred_contact_administrator": "Произошла критическая ошибка. Обратитесь к администратору.", "critical_error_occurred_contact_administrator": "Произошла критическая ошибка. Обратитесь к администратору.",
"sign_in_to": "Войти в {name}", "sign_in_to": "Войти в {name}",
"client_not_found": "Клиент не найден", "client_not_found": "Клиент не найден",
@@ -192,8 +196,6 @@
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Продолжительность сессии в минутах, прежде чем пользователь должен войти снова.", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Продолжительность сессии в минутах, прежде чем пользователь должен войти снова.",
"enable_self_account_editing": "Включить редактирование собственной учетной записи", "enable_self_account_editing": "Включить редактирование собственной учетной записи",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Должны ли пользователи иметь возможность редактировать данные своей учетной записи.", "whether_the_users_should_be_able_to_edit_their_own_account_details": "Должны ли пользователи иметь возможность редактировать данные своей учетной записи.",
"emails_verified": "Адреса электронной почты подтверждены",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Должен ли адрес электронной почты пользователя быть отмечен как проверенный для OIDC клиентов.",
"ldap_configuration_updated_successfully": "Конфигурация LDAP успешно обновлена", "ldap_configuration_updated_successfully": "Конфигурация LDAP успешно обновлена",
"ldap_disabled_successfully": "LDAP успешно отключен", "ldap_disabled_successfully": "LDAP успешно отключен",
"ldap_sync_finished": "Синхронизация с LDAP завершена", "ldap_sync_finished": "Синхронизация с LDAP завершена",
@@ -499,5 +501,25 @@
"save_and_sync": "Сохранить и синхронизировать", "save_and_sync": "Сохранить и синхронизировать",
"scim_save_changes_description": "Перед тем, как начать синхронизацию SCIM, нужно сохранить изменения. Хочешь сохранить сейчас?", "scim_save_changes_description": "Перед тем, как начать синхронизацию SCIM, нужно сохранить изменения. Хочешь сохранить сейчас?",
"scopes": "Области применения", "scopes": "Области применения",
"issuer_url": "URL эмитента" "issuer_url": "URL эмитента",
"smtp_field_required_when_other_provided": "Нужно, если есть какие-то настройки SMTP",
"smtp_field_required_when_email_enabled": "Нужно, если включены уведомления по электронной почте",
"renew": "Обновлять",
"renew_api_key": "Обнови ключ API",
"renew_api_key_description": "При обновлении ключа API будет сгенерирован новый ключ. Не забудь обновить все интеграции, которые используют этот ключ.",
"api_key_renewed": "Ключ API обновлен",
"app_config_home_page": "Главная страница",
"app_config_home_page_description": "Страница, куда пользователи попадают после входа в систему.",
"email_verification_warning": "Проверь свой адрес электронной почты",
"email_verification_warning_description": "Твой адрес электронной почты ещё не подтверждён. Пожалуйста, подтверди его как можно скорее.",
"email_verification": "Проверка электронной почты",
"email_verification_description": "Отправляй пользователям письмо с подтверждением, когда они регистрируются или меняют свой адрес электронной почты.",
"email_verification_success_title": "Электронная почта подтверждена",
"email_verification_success_description": "Твой адрес электронной почты подтвержден.",
"email_verification_error_title": "Не получилось подтвердить почту",
"mark_as_unverified": "Пометить как непроверенное",
"mark_as_verified": "Пометить как проверенное",
"email_verification_sent": "Письмо с подтверждением отправлено.",
"emails_verified_by_default": "Электронные письма проверяются по умолчанию",
"emails_verified_by_default_description": "Если эта функция включена, адреса электронной почты пользователей будут по умолчанию отмечаться как подтвержденные при регистрации или при изменении адреса электронной почты."
} }

View File

@@ -46,7 +46,11 @@
"authenticator_does_not_support_resident_keys": "Autentiseraren stöder inte lagrade nycklar", "authenticator_does_not_support_resident_keys": "Autentiseraren stöder inte lagrade nycklar",
"passkey_was_previously_registered": "Denna passkey har redan registrerats", "passkey_was_previously_registered": "Denna passkey har redan registrerats",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Autentiseraren stöder inte någon av de begärda algoritmerna", "authenticator_does_not_support_any_of_the_requested_algorithms": "Autentiseraren stöder inte någon av de begärda algoritmerna",
"authenticator_timed_out": "Autentiseraren överskred tidsgränsen", "webauthn_error_invalid_rp_id": "Det konfigurerade ID:t för den förlitande parten är ogiltigt.",
"webauthn_error_invalid_domain": "Den konfigurerade domänen är ogiltig.",
"contact_administrator_to_fix": "Kontakta din administratör för att åtgärda detta problem.",
"webauthn_operation_not_allowed_or_timed_out": "Operationen var inte tillåten eller tidsgränsen överskreds",
"webauthn_not_supported_by_browser": "Passkeys stöds inte av denna webbläsare. Använd en alternativ inloggningsmetod.",
"critical_error_occurred_contact_administrator": "Ett kritiskt fel har inträffat. Kontakta din administratör.", "critical_error_occurred_contact_administrator": "Ett kritiskt fel har inträffat. Kontakta din administratör.",
"sign_in_to": "Logga in på {name}", "sign_in_to": "Logga in på {name}",
"client_not_found": "Klienten hittades inte", "client_not_found": "Klienten hittades inte",
@@ -192,8 +196,6 @@
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Hur länge en session varar i minuter innan användaren måste logga in igen.", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Hur länge en session varar i minuter innan användaren måste logga in igen.",
"enable_self_account_editing": "Aktivera redigering av eget konto", "enable_self_account_editing": "Aktivera redigering av eget konto",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Om användarna ska kunna redigera sina egna kontouppgifter.", "whether_the_users_should_be_able_to_edit_their_own_account_details": "Om användarna ska kunna redigera sina egna kontouppgifter.",
"emails_verified": "E-postadresser verifierade",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Om användarens e-postadress ska markeras som verifierad för OIDC-klienterna.",
"ldap_configuration_updated_successfully": "LDAP-konfigurationen har uppdaterats", "ldap_configuration_updated_successfully": "LDAP-konfigurationen har uppdaterats",
"ldap_disabled_successfully": "LDAP har inaktiverats", "ldap_disabled_successfully": "LDAP har inaktiverats",
"ldap_sync_finished": "LDAP-synkronisering slutförd", "ldap_sync_finished": "LDAP-synkronisering slutförd",
@@ -499,5 +501,25 @@
"save_and_sync": "Spara och synkronisera", "save_and_sync": "Spara och synkronisera",
"scim_save_changes_description": "Du måste spara ändringarna innan du startar en SCIM-synkronisering. Vill du spara nu?", "scim_save_changes_description": "Du måste spara ändringarna innan du startar en SCIM-synkronisering. Vill du spara nu?",
"scopes": "Omfattning", "scopes": "Omfattning",
"issuer_url": "Utfärdarens URL" "issuer_url": "Utfärdarens URL",
"smtp_field_required_when_other_provided": "Krävs när någon SMTP-inställning anges",
"smtp_field_required_when_email_enabled": "Krävs när e-postaviseringar är aktiverade",
"renew": "Förnya",
"renew_api_key": "Förnya API-nyckel",
"renew_api_key_description": "När API-nyckeln förnyas genereras en ny nyckel. Se till att uppdatera alla integrationer som använder denna nyckel.",
"api_key_renewed": "API-nyckel förnyad",
"app_config_home_page": "Hemsida",
"app_config_home_page_description": "Den sida som användarna omdirigeras till efter inloggningen.",
"email_verification_warning": "Verifiera din e-postadress",
"email_verification_warning_description": "Din e-postadress är ännu inte verifierad. Verifiera den så snart som möjligt.",
"email_verification": "E-postverifiering",
"email_verification_description": "Skicka ett verifieringsmeddelande till användarna när de registrerar sig eller ändrar sin e-postadress.",
"email_verification_success_title": "E-postadress verifierad",
"email_verification_success_description": "Din e-postadress har verifierats.",
"email_verification_error_title": "E-postverifiering misslyckades",
"mark_as_unverified": "Markera som obekräftat",
"mark_as_verified": "Markera som verifierad",
"email_verification_sent": "Verifieringsmeddelandet har skickats.",
"emails_verified_by_default": "E-postmeddelanden verifierade som standard",
"emails_verified_by_default_description": "När funktionen är aktiverad kommer användarnas e-postadresser att markeras som verifierade som standard vid registrering eller när deras e-postadress ändras."
} }

View File

@@ -46,7 +46,11 @@
"authenticator_does_not_support_resident_keys": "Kimlik doğrulayıcı yerleşik anahtarları desteklemiyor", "authenticator_does_not_support_resident_keys": "Kimlik doğrulayıcı yerleşik anahtarları desteklemiyor",
"passkey_was_previously_registered": "Bu geçiş anahtarı daha önce kaydedilmiştir", "passkey_was_previously_registered": "Bu geçiş anahtarı daha önce kaydedilmiştir",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Kimlik doğrulayıcı, talep edilen algoritmalardan hiçbirini desteklemiyor", "authenticator_does_not_support_any_of_the_requested_algorithms": "Kimlik doğrulayıcı, talep edilen algoritmalardan hiçbirini desteklemiyor",
"authenticator_timed_out": "Kimlik doğrulayıcı zaman aşımına uğradı", "webauthn_error_invalid_rp_id": "Yapılandırılan güvenen taraf kimliği geçersiz.",
"webauthn_error_invalid_domain": "Yapılandırılan etki alanı geçersiz.",
"contact_administrator_to_fix": "Bu sorunu gidermek için yöneticinize başvurun.",
"webauthn_operation_not_allowed_or_timed_out": "İşlem izin verilmedi veya zaman aşımına uğradı",
"webauthn_not_supported_by_browser": "Bu tarayıcıda geçiş anahtarları desteklenmemektedir. Lütfen alternatif bir oturum açma yöntemi kullanın.",
"critical_error_occurred_contact_administrator": "Kritik bir hata oluştu. Lütfen sistem yöneticinizle iletişime geçin.", "critical_error_occurred_contact_administrator": "Kritik bir hata oluştu. Lütfen sistem yöneticinizle iletişime geçin.",
"sign_in_to": "{name} hesabına giriş yap", "sign_in_to": "{name} hesabına giriş yap",
"client_not_found": "İstemci bulunamadı", "client_not_found": "İstemci bulunamadı",
@@ -192,8 +196,6 @@
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Kullanıcının tekrar oturum açması gereken süre, dakika cinsinden.", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Kullanıcının tekrar oturum açması gereken süre, dakika cinsinden.",
"enable_self_account_editing": "Kullanıcının kendi hesabını düzenlemesini etkinleştir", "enable_self_account_editing": "Kullanıcının kendi hesabını düzenlemesini etkinleştir",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Kullanıcıların kendi hesap bilgilerini düzenlemesine izin verilsin mi.", "whether_the_users_should_be_able_to_edit_their_own_account_details": "Kullanıcıların kendi hesap bilgilerini düzenlemesine izin verilsin mi.",
"emails_verified": "E-postalar doğrulandı",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Kullanıcının e-postasının OIDC istemcileri için doğrulanmış olarak işaretlenip işaretlenmeyeceği.",
"ldap_configuration_updated_successfully": "LDAP yapılandırması başarıyla güncellendi", "ldap_configuration_updated_successfully": "LDAP yapılandırması başarıyla güncellendi",
"ldap_disabled_successfully": "LDAP başarıyla devre dışı bırakıldı", "ldap_disabled_successfully": "LDAP başarıyla devre dışı bırakıldı",
"ldap_sync_finished": "LDAP senkronizasyonu tamamlandı", "ldap_sync_finished": "LDAP senkronizasyonu tamamlandı",
@@ -499,5 +501,25 @@
"save_and_sync": "Kaydet ve Senkronize Et", "save_and_sync": "Kaydet ve Senkronize Et",
"scim_save_changes_description": "SCIM senkronizasyonunu başlatmadan önce değişiklikleri kaydetmeniz gerekir. Şimdi kaydetmek ister misiniz?", "scim_save_changes_description": "SCIM senkronizasyonunu başlatmadan önce değişiklikleri kaydetmeniz gerekir. Şimdi kaydetmek ister misiniz?",
"scopes": "Kapsamlar", "scopes": "Kapsamlar",
"issuer_url": "İhraççı URL" "issuer_url": "İhraççı URL",
"smtp_field_required_when_other_provided": "Herhangi bir SMTP ayarı sağlandığında gereklidir",
"smtp_field_required_when_email_enabled": "E-posta bildirimleri etkinleştirildiğinde gereklidir",
"renew": "Yenile",
"renew_api_key": "API Anahtarını Yenile",
"renew_api_key_description": "API anahtarını yenilemek yeni bir anahtar oluşturacaktır. Bu anahtarı kullanarak tüm entegrasyonları güncellediğinizden emin olun.",
"api_key_renewed": "API anahtarı yenilendi",
"app_config_home_page": "Ana Sayfa",
"app_config_home_page_description": "Kullanıcıların oturum açtıktan sonra yönlendirildikleri sayfa.",
"email_verification_warning": "E-posta adresinizi doğrulayın",
"email_verification_warning_description": "E-posta adresiniz henüz doğrulanmadı. Lütfen en kısa sürede doğrulayın.",
"email_verification": "E-posta Doğrulama",
"email_verification_description": "Kullanıcılar kaydolduğunda veya e-posta adreslerini değiştirdiğinde onlara doğrulama e-postası gönderin.",
"email_verification_success_title": "E-posta Doğrulaması Başarılı Oldu",
"email_verification_success_description": "E-posta adresiniz başarıyla doğrulandı.",
"email_verification_error_title": "E-posta Doğrulama Başarısız",
"mark_as_unverified": "Doğrulanmamış olarak işaretle",
"mark_as_verified": "Doğrulanmış olarak işaretle",
"email_verification_sent": "Doğrulama e-postası başarıyla gönderildi.",
"emails_verified_by_default": "Varsayılan olarak doğrulanmış e-postalar",
"emails_verified_by_default_description": "Etkinleştirildiğinde, kullanıcıların e-posta adresleri kayıt sırasında veya e-posta adresleri değiştirildiğinde varsayılan olarak doğrulanmış olarak işaretlenecektir."
} }

View File

@@ -46,7 +46,11 @@
"authenticator_does_not_support_resident_keys": "Автентифікатор не підтримує локальні ключі", "authenticator_does_not_support_resident_keys": "Автентифікатор не підтримує локальні ключі",
"passkey_was_previously_registered": "Цей ключ доступу був раніше зареєстрований", "passkey_was_previously_registered": "Цей ключ доступу був раніше зареєстрований",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Автентифікатор не підтримує жоден із запитаних алгоритмів", "authenticator_does_not_support_any_of_the_requested_algorithms": "Автентифікатор не підтримує жоден із запитаних алгоритмів",
"authenticator_timed_out": "Час очікування автентифікатора вичерпано", "webauthn_error_invalid_rp_id": "Налаштований ідентифікатор сторони, що покладається, є недійсним.",
"webauthn_error_invalid_domain": "Налаштований домен є недійсним.",
"contact_administrator_to_fix": "Зверніться до адміністратора, щоб вирішити цю проблему.",
"webauthn_operation_not_allowed_or_timed_out": "Операція не була дозволена або закінчився час очікування",
"webauthn_not_supported_by_browser": "Цей браузер не підтримує паролі. Будь ласка, скористайтеся альтернативним методом входу.",
"critical_error_occurred_contact_administrator": "Виникла критична помилка. Будь ласка, зверніться до адміністратора.", "critical_error_occurred_contact_administrator": "Виникла критична помилка. Будь ласка, зверніться до адміністратора.",
"sign_in_to": "Увійти в {name}", "sign_in_to": "Увійти в {name}",
"client_not_found": "Клієнта не знайдено", "client_not_found": "Клієнта не знайдено",
@@ -192,8 +196,6 @@
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Тривалість сесії у хвилинах до повторного входу користувача.", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Тривалість сесії у хвилинах до повторного входу користувача.",
"enable_self_account_editing": "Увімкнути редагування власного облікового запису", "enable_self_account_editing": "Увімкнути редагування власного облікового запису",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Чи повинні користувачі мати можливість редагувати власні дані облікового запису.", "whether_the_users_should_be_able_to_edit_their_own_account_details": "Чи повинні користувачі мати можливість редагувати власні дані облікового запису.",
"emails_verified": "Підтверджена електронна пошта",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Чи слід позначати електронну пошту користувача як підтверджену для OIDC клієнтів.",
"ldap_configuration_updated_successfully": "Налаштування LDAP успішно оновлено", "ldap_configuration_updated_successfully": "Налаштування LDAP успішно оновлено",
"ldap_disabled_successfully": "LDAP успішно вимкнено", "ldap_disabled_successfully": "LDAP успішно вимкнено",
"ldap_sync_finished": "Синхронізація LDAP завершена", "ldap_sync_finished": "Синхронізація LDAP завершена",
@@ -499,5 +501,25 @@
"save_and_sync": "Зберегти та синхронізувати", "save_and_sync": "Зберегти та синхронізувати",
"scim_save_changes_description": "Перед початком синхронізації SCIM необхідно зберегти зміни. Чи хочете ви зберегти зараз?", "scim_save_changes_description": "Перед початком синхронізації SCIM необхідно зберегти зміни. Чи хочете ви зберегти зараз?",
"scopes": "Області застосування", "scopes": "Області застосування",
"issuer_url": "URL емітента" "issuer_url": "URL емітента",
"smtp_field_required_when_other_provided": "Необхідно, якщо вказано будь-яке налаштування SMTP",
"smtp_field_required_when_email_enabled": "Необхідно, якщо увімкнено сповіщення електронною поштою",
"renew": "Оновити",
"renew_api_key": "Оновити ключ API",
"renew_api_key_description": "Оновлення API-ключа призведе до створення нового ключа. Обов'язково оновіть усі інтеграції, що використовують цей ключ.",
"api_key_renewed": "Ключ API оновлено",
"app_config_home_page": "Головна сторінка",
"app_config_home_page_description": "Сторінка, на яку перенаправляють користувачів після входу в систему.",
"email_verification_warning": "Підтвердьте свою адресу електронної пошти",
"email_verification_warning_description": "Ваша електронна адреса ще не підтверджена. Будь ласка, підтвердьте її якомога швидше.",
"email_verification": "Перевірка електронної адреси",
"email_verification_description": "Надсилайте користувачам підтверджувальний лист електронною поштою, коли вони реєструються або змінюють свою адресу електронної пошти.",
"email_verification_success_title": "Електронна адреса успішно підтверджена",
"email_verification_success_description": "Ваша електронна адреса була успішно підтверджена.",
"email_verification_error_title": "Перевірка електронної адреси не вдалася",
"mark_as_unverified": "Позначити як неперевірене",
"mark_as_verified": "Позначити як перевірене",
"email_verification_sent": "Електронний лист для підтвердження надіслано успішно.",
"emails_verified_by_default": "Електронні листи перевіряються за замовчуванням",
"emails_verified_by_default_description": "Якщо ця опція увімкнена, адреси електронної пошти користувачів будуть позначатися як підтверджені за замовчуванням під час реєстрації або при зміні адреси електронної пошти."
} }

View File

@@ -46,7 +46,11 @@
"authenticator_does_not_support_resident_keys": "Thiết bị xác thực không hỗ trợ khóa lưu trữ", "authenticator_does_not_support_resident_keys": "Thiết bị xác thực không hỗ trợ khóa lưu trữ",
"passkey_was_previously_registered": "Passkey này đã được đăng ký trước đó", "passkey_was_previously_registered": "Passkey này đã được đăng ký trước đó",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Thiết bị xác thực không hỗ trợ bất kỳ thuật toán nào trong số các thuật toán được yêu cầu", "authenticator_does_not_support_any_of_the_requested_algorithms": "Thiết bị xác thực không hỗ trợ bất kỳ thuật toán nào trong số các thuật toán được yêu cầu",
"authenticator_timed_out": "Thời gian chờ của trình xác thực đã hết hạn", "webauthn_error_invalid_rp_id": "ID của bên tin cậy đã cấu hình là không hợp lệ.",
"webauthn_error_invalid_domain": "Domain đã cấu hình không hợp lệ.",
"contact_administrator_to_fix": "Liên hệ với quản trị viên của bạn để khắc phục sự cố này.",
"webauthn_operation_not_allowed_or_timed_out": "Hoạt động này không được phép hoặc đã hết thời gian chờ.",
"webauthn_not_supported_by_browser": "Chìa khóa truy cập không được hỗ trợ bởi trình duyệt này. Vui lòng sử dụng phương thức đăng nhập thay thế.",
"critical_error_occurred_contact_administrator": "Đã xảy ra lỗi nghiêm trọng. Vui lòng liên hệ với quản trị viên.", "critical_error_occurred_contact_administrator": "Đã xảy ra lỗi nghiêm trọng. Vui lòng liên hệ với quản trị viên.",
"sign_in_to": "Đăng nhập {name}", "sign_in_to": "Đăng nhập {name}",
"client_not_found": "Không tìm thấy client.", "client_not_found": "Không tìm thấy client.",
@@ -192,8 +196,6 @@
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Thời gian của một phiên (tính bằng phút) trước khi người dùng phải đăng nhập lại.", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Thời gian của một phiên (tính bằng phút) trước khi người dùng phải đăng nhập lại.",
"enable_self_account_editing": "Cho Phép Chỉnh Sửa Tài Khoản Cá Nhân", "enable_self_account_editing": "Cho Phép Chỉnh Sửa Tài Khoản Cá Nhân",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Người dùng có nên được phép chỉnh sửa thông tin tài khoản của mình không?", "whether_the_users_should_be_able_to_edit_their_own_account_details": "Người dùng có nên được phép chỉnh sửa thông tin tài khoản của mình không?",
"emails_verified": "Xác Minh Email",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Có nên đánh dấu email của người dùng là đã xác minh cho các OIDC clients hay không.",
"ldap_configuration_updated_successfully": "Cấu hình LDAP đã được cập nhật thành công", "ldap_configuration_updated_successfully": "Cấu hình LDAP đã được cập nhật thành công",
"ldap_disabled_successfully": "Tắt LDAP thành công", "ldap_disabled_successfully": "Tắt LDAP thành công",
"ldap_sync_finished": "Quá trình đồng bộ hóa LDAP đã hoàn tất", "ldap_sync_finished": "Quá trình đồng bộ hóa LDAP đã hoàn tất",
@@ -499,5 +501,25 @@
"save_and_sync": "Lưu và Đồng bộ hóa", "save_and_sync": "Lưu và Đồng bộ hóa",
"scim_save_changes_description": "Bạn phải lưu các thay đổi trước khi bắt đầu đồng bộ hóa SCIM. Bạn có muốn lưu ngay bây giờ không?", "scim_save_changes_description": "Bạn phải lưu các thay đổi trước khi bắt đầu đồng bộ hóa SCIM. Bạn có muốn lưu ngay bây giờ không?",
"scopes": "Phạm vi", "scopes": "Phạm vi",
"issuer_url": "Địa chỉ URL của tổ chức phát hành" "issuer_url": "Địa chỉ URL của tổ chức phát hành",
"smtp_field_required_when_other_provided": "Yêu cầu khi cung cấp bất kỳ cài đặt SMTP nào.",
"smtp_field_required_when_email_enabled": "Yêu cầu khi bật thông báo qua email",
"renew": "Cập nhật",
"renew_api_key": "Cập nhật khóa API",
"renew_api_key_description": "Việc gia hạn khóa API sẽ tạo ra một khóa mới. Hãy đảm bảo cập nhật các tích hợp sử dụng khóa này.",
"api_key_renewed": "Khóa API đã được gia hạn",
"app_config_home_page": "Trang chủ",
"app_config_home_page_description": "Trang mà người dùng được chuyển hướng đến sau khi đăng nhập.",
"email_verification_warning": "Xác minh địa chỉ email của bạn",
"email_verification_warning_description": "Địa chỉ email của bạn chưa được xác minh. Vui lòng xác minh ngay lập tức.",
"email_verification": "Xác minh email",
"email_verification_description": "Gửi email xác minh cho người dùng khi họ đăng ký hoặc thay đổi địa chỉ email.",
"email_verification_success_title": "Email đã được xác minh thành công.",
"email_verification_success_description": "Địa chỉ email của bạn đã được xác minh thành công.",
"email_verification_error_title": "Xác minh email không thành công",
"mark_as_unverified": "Đánh dấu là chưa xác minh",
"mark_as_verified": "Đánh dấu là đã xác minh",
"email_verification_sent": "Email xác minh đã được gửi thành công.",
"emails_verified_by_default": "Email được xác minh theo mặc định",
"emails_verified_by_default_description": "Khi tính năng này được kích hoạt, địa chỉ email của người dùng sẽ được đánh dấu là đã xác minh theo mặc định khi đăng ký hoặc khi địa chỉ email của họ được thay đổi."
} }

View File

@@ -46,7 +46,11 @@
"authenticator_does_not_support_resident_keys": "认证器不支持常驻密钥", "authenticator_does_not_support_resident_keys": "认证器不支持常驻密钥",
"passkey_was_previously_registered": "此通行密钥曾被注册", "passkey_was_previously_registered": "此通行密钥曾被注册",
"authenticator_does_not_support_any_of_the_requested_algorithms": "认证器不支持任何请求的算法", "authenticator_does_not_support_any_of_the_requested_algorithms": "认证器不支持任何请求的算法",
"authenticator_timed_out": "认证器超时", "webauthn_error_invalid_rp_id": "配置的依赖方ID无效。",
"webauthn_error_invalid_domain": "配置的域名无效。",
"contact_administrator_to_fix": "请联系您的管理员以解决此问题。",
"webauthn_operation_not_allowed_or_timed_out": "该操作未被允许或超时",
"webauthn_not_supported_by_browser": "此浏览器不支持密钥登录。请使用其他登录方式。",
"critical_error_occurred_contact_administrator": "发生严重错误。请联系您的管理员。", "critical_error_occurred_contact_administrator": "发生严重错误。请联系您的管理员。",
"sign_in_to": "登录到 {name}", "sign_in_to": "登录到 {name}",
"client_not_found": "客户端未找到", "client_not_found": "客户端未找到",
@@ -192,8 +196,6 @@
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "用户需再次登录之前的会话时长(以分钟为单位)。", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "用户需再次登录之前的会话时长(以分钟为单位)。",
"enable_self_account_editing": "启用用户自行编辑账户功能", "enable_self_account_editing": "启用用户自行编辑账户功能",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "用户是否能够编辑自己的账户详细信息。", "whether_the_users_should_be_able_to_edit_their_own_account_details": "用户是否能够编辑自己的账户详细信息。",
"emails_verified": "已验证的邮箱地址",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "用户的电子邮件是否应标记为已验证,适用于 OIDC 客户端。",
"ldap_configuration_updated_successfully": "LDAP 配置更新成功", "ldap_configuration_updated_successfully": "LDAP 配置更新成功",
"ldap_disabled_successfully": "LDAP 已成功禁用", "ldap_disabled_successfully": "LDAP 已成功禁用",
"ldap_sync_finished": "LDAP 同步完成", "ldap_sync_finished": "LDAP 同步完成",
@@ -499,5 +501,25 @@
"save_and_sync": "保存并同步", "save_and_sync": "保存并同步",
"scim_save_changes_description": "在开始 SCIM 同步之前,您必须先保存更改。是否现在保存?", "scim_save_changes_description": "在开始 SCIM 同步之前,您必须先保存更改。是否现在保存?",
"scopes": "Scopes", "scopes": "Scopes",
"issuer_url": "发行者网址" "issuer_url": "发行者网址",
"smtp_field_required_when_other_provided": "当提供任何SMTP设置时需要",
"smtp_field_required_when_email_enabled": "启用电子邮件通知时需要",
"renew": "更新",
"renew_api_key": "更新 API 密钥",
"renew_api_key_description": "更新API密钥将生成新密钥。请确保更新所有使用此密钥的集成。",
"api_key_renewed": "API密钥已更新",
"app_config_home_page": "主页",
"app_config_home_page_description": "用户登录后被重定向到的页面。",
"email_verification_warning": "请验证您的电子邮件地址",
"email_verification_warning_description": "您的电子邮箱尚未完成验证。请尽快完成验证。",
"email_verification": "电子邮件验证",
"email_verification_description": "在用户注册或更改电子邮件地址时向其发送验证邮件。",
"email_verification_success_title": "电子邮件验证成功",
"email_verification_success_description": "您的电子邮件地址已成功验证。",
"email_verification_error_title": "电子邮件验证失败",
"mark_as_unverified": "标记为未验证",
"mark_as_verified": "标记为已验证",
"email_verification_sent": "验证邮件已成功发送。",
"emails_verified_by_default": "电子邮件默认已验证",
"emails_verified_by_default_description": "启用后,用户的电子邮件地址将在注册时或更改电子邮件地址时默认标记为已验证。"
} }

View File

@@ -46,7 +46,11 @@
"authenticator_does_not_support_resident_keys": "此驗證器不支援常駐金鑰", "authenticator_does_not_support_resident_keys": "此驗證器不支援常駐金鑰",
"passkey_was_previously_registered": "這個密碼金鑰先前已註冊", "passkey_was_previously_registered": "這個密碼金鑰先前已註冊",
"authenticator_does_not_support_any_of_the_requested_algorithms": "驗證器不支援任何一種所要求的演算法", "authenticator_does_not_support_any_of_the_requested_algorithms": "驗證器不支援任何一種所要求的演算法",
"authenticator_timed_out": "驗證器逾時", "webauthn_error_invalid_rp_id": "已設定的信賴方識別碼無效。",
"webauthn_error_invalid_domain": "設定的網域無效。",
"contact_administrator_to_fix": "請聯絡您的管理員以解決此問題。",
"webauthn_operation_not_allowed_or_timed_out": "此操作未獲許可或已超時",
"webauthn_not_supported_by_browser": "此瀏覽器不支援通行密鑰。請使用其他登入方式。",
"critical_error_occurred_contact_administrator": "發生嚴重錯誤,請聯絡您的管理員。", "critical_error_occurred_contact_administrator": "發生嚴重錯誤,請聯絡您的管理員。",
"sign_in_to": "登入 {name}", "sign_in_to": "登入 {name}",
"client_not_found": "找不到客戶端", "client_not_found": "找不到客戶端",
@@ -192,8 +196,6 @@
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "使用者需重新登入前的階段時長(以分鐘為單位)。", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "使用者需重新登入前的階段時長(以分鐘為單位)。",
"enable_self_account_editing": "允許使用者自行編輯帳號資訊", "enable_self_account_editing": "允許使用者自行編輯帳號資訊",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "是否允許使用者編輯自己的帳號資料。", "whether_the_users_should_be_able_to_edit_their_own_account_details": "是否允許使用者編輯自己的帳號資料。",
"emails_verified": "已驗證的電子郵件",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "是否應將使用者的電子郵件標記為已驗證,以供 OIDC 客戶端使用。",
"ldap_configuration_updated_successfully": "LDAP 設定更新成功", "ldap_configuration_updated_successfully": "LDAP 設定更新成功",
"ldap_disabled_successfully": "LDAP 已成功停用", "ldap_disabled_successfully": "LDAP 已成功停用",
"ldap_sync_finished": "LDAP 同步完成", "ldap_sync_finished": "LDAP 同步完成",
@@ -499,5 +501,25 @@
"save_and_sync": "儲存與同步", "save_and_sync": "儲存與同步",
"scim_save_changes_description": "您必須在開始 SCIM 同步前儲存變更。現在要儲存嗎?", "scim_save_changes_description": "您必須在開始 SCIM 同步前儲存變更。現在要儲存嗎?",
"scopes": "範圍", "scopes": "範圍",
"issuer_url": "發行者網址" "issuer_url": "發行者網址",
"smtp_field_required_when_other_provided": "當提供任何 SMTP 設定時即為必要",
"smtp_field_required_when_email_enabled": "當電子郵件通知功能啟用時,此項目為必填項目",
"renew": "更新",
"renew_api_key": "重新生成 API 金鑰",
"renew_api_key_description": "重新生成 API 金鑰將產生新的金鑰。請務必更新所有使用此金鑰的整合服務。",
"api_key_renewed": "API 金鑰已更新",
"app_config_home_page": "首頁",
"app_config_home_page_description": "用戶登入後被重定向至的頁面。",
"email_verification_warning": "請驗證您的電子郵件地址",
"email_verification_warning_description": "您的電子郵件地址尚未完成驗證。請盡快完成驗證程序。",
"email_verification": "電子郵件驗證",
"email_verification_description": "當用戶註冊或變更電子郵件地址時,向其發送驗證郵件。",
"email_verification_success_title": "電子郵件驗證成功",
"email_verification_success_description": "您的電子郵件地址已成功驗證。",
"email_verification_error_title": "電子郵件驗證失敗",
"mark_as_unverified": "標記為未驗證",
"mark_as_verified": "標記為已驗證",
"email_verification_sent": "驗證電子郵件已成功寄出。",
"emails_verified_by_default": "電子郵件預設為已驗證",
"emails_verified_by_default_description": "啟用此功能後,用戶的電子郵件地址將在註冊時或變更電子郵件地址時,預設標記為已驗證狀態。"
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "2.1.0", "version": "2.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -0,0 +1,79 @@
<script lang="ts">
import { page } from '$app/state';
import * as Alert from '$lib/components/ui/alert';
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 { axiosErrorToast } from '$lib/utils/error-util';
import { LucideAlertTriangle, LucideCheckCircle2, LucideCircleX } from '@lucide/svelte';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { get } from 'svelte/store';
const userService = new UserService();
let emailVerificationState = $state(page.url.searchParams.get('emailVerificationState'));
async function sendEmailVerification() {
await userService
.sendEmailVerification()
.then(() => {
toast.success(m.email_verification_sent());
})
.catch(axiosErrorToast);
}
function onDismiss() {
const url = new URL(page.url);
url.searchParams.delete('emailVerificationState');
history.replaceState(null, '', url.toString());
emailVerificationState = null;
}
onMount(() => {
const user = get(userStore);
if (emailVerificationState === 'success' && user) {
user.emailVerified = true;
userStore.setUser(user);
}
});
</script>
{#if emailVerificationState}
{#if emailVerificationState === 'success'}
<Alert.Root variant="success" {onDismiss}>
<LucideCheckCircle2 class="size-4" />
<Alert.Title class="font-semibold">{m.email_verification_success_title()}</Alert.Title>
<Alert.Description class="text-sm">
{m.email_verification_success_description()}
</Alert.Description>
</Alert.Root>
{:else}
<Alert.Root variant="destructive" {onDismiss}>
<LucideCircleX class="size-4" />
<Alert.Title class="font-semibold">{m.email_verification_error_title()}</Alert.Title>
<Alert.Description class="text-sm">
{emailVerificationState}
</Alert.Description>
</Alert.Root>
{/if}
{:else if $userStore && $appConfigStore.emailVerificationEnabled && !$userStore.emailVerified}
<Alert.Root variant="warning" class="flex gap-3">
<LucideAlertTriangle class="size-4" />
<div class="md:flex md:w-full md:place-content-between">
<div>
<Alert.Title class="font-semibold">{m.email_verification_warning()}</Alert.Title>
<Alert.Description class="text-sm">
{m.email_verification_warning_description()}
</Alert.Description>
</div>
<div>
<Button class="mt-2 md:mt-0" usePromiseLoading onclick={sendEmailVerification}>
{m.send_email()}
</Button>
</div>
</div>
</Alert.Root>
{/if}

View File

@@ -31,19 +31,6 @@
return new CalendarDate(d.getFullYear(), d.getMonth() + 1, d.getDate()); return new CalendarDate(d.getFullYear(), d.getMonth() + 1, d.getDate());
} }
$effect(() => {
if (calendarDisplayDate) {
const newExternalDate = calendarDisplayDate.toDate(getLocalTimeZone());
if (!value || value.getTime() !== newExternalDate.getTime()) {
value = newExternalDate;
}
} else {
if (value !== undefined) {
value = undefined;
}
}
});
$effect(() => { $effect(() => {
if (value) { if (value) {
const newInternalCalendarDate = dateToCalendarDate(value); const newInternalCalendarDate = dateToCalendarDate(value);
@@ -59,6 +46,17 @@
function handleCalendarInteraction(newDateValue?: DateValue) { function handleCalendarInteraction(newDateValue?: DateValue) {
open = false; open = false;
calendarDisplayDate = newDateValue as CalendarDate | undefined;
if (calendarDisplayDate) {
const newExternalDate = calendarDisplayDate.toDate(getLocalTimeZone());
if (!value || value.getTime() !== newExternalDate.getTime()) {
value = newExternalDate;
}
} else {
if (value !== undefined) {
value = undefined;
}
}
} }
const df = new DateFormatter(getLocale(), { const df = new DateFormatter(getLocale(), {
@@ -89,8 +87,7 @@
<Popover.Content class="w-auto p-0" align="start"> <Popover.Content class="w-auto p-0" align="start">
<Calendar <Calendar
type="single" type="single"
bind:value={calendarDisplayDate} bind:value={() => calendarDisplayDate, (newValue) => handleCalendarInteraction(newValue)}
onValueChange={handleCalendarInteraction}
initialFocus initialFocus
/> />
</Popover.Content> </Popover.Content>

View File

@@ -31,6 +31,7 @@
children, children,
onInput, onInput,
labelFor, labelFor,
inputClass,
...restProps ...restProps
}: HTMLAttributes<HTMLDivElement> & }: HTMLAttributes<HTMLDivElement> &
(WithChildren | WithoutChildren) & { (WithChildren | WithoutChildren) & {
@@ -39,6 +40,7 @@
docsLink?: string; docsLink?: string;
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
inputClass?: string;
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date'; type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date';
onInput?: (e: FormInputEvent) => void; onInput?: (e: FormInputEvent) => void;
} = $props(); } = $props();
@@ -73,6 +75,7 @@
{:else} {:else}
<Input <Input
aria-invalid={!!input.error} aria-invalid={!!input.error}
class={inputClass}
{id} {id}
{placeholder} {placeholder}
{type} {type}

View File

@@ -28,7 +28,7 @@
<div class="flex h-16 items-center"> <div class="flex h-16 items-center">
{#if !isAuthPage} {#if !isAuthPage}
<a <a
href="/settings/account" href="/"
class="flex items-center gap-3 transition-opacity hover:opacity-80" class="flex items-center gap-3 transition-opacity hover:opacity-80"
> >
<Logo class="size-8" /> <Logo class="size-8" />

View File

@@ -6,9 +6,11 @@
variants: { variants: {
variant: { variant: {
default: 'bg-card text-card-foreground', default: 'bg-card text-card-foreground',
success:
'bg-green-100 text-green-900 dark:bg-green-900 dark:text-green-100 [&>svg]:text-green-900 dark:[&>svg]:text-green-100',
info: 'bg-blue-100 text-blue-900 dark:bg-blue-900 dark:text-blue-100 [&>svg]:text-blue-900 dark:[&>svg]:text-blue-100', info: 'bg-blue-100 text-blue-900 dark:bg-blue-900 dark:text-blue-100 [&>svg]:text-blue-900 dark:[&>svg]:text-blue-100',
destructive: destructive:
'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current', 'bg-red-100 text-red-900 dark:bg-red-900 dark:text-red-100 [&>svg]:text-red-900 dark:[&>svg]:text-red-100',
warning: warning:
'bg-warning text-warning-foreground border-warning/40 [&>svg]:text-warning-foreground' 'bg-warning text-warning-foreground border-warning/40 [&>svg]:text-warning-foreground'
} }
@@ -32,10 +34,12 @@
class: className, class: className,
variant = 'default', variant = 'default',
children, children,
onDismiss,
dismissibleId = undefined, dismissibleId = undefined,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant; variant?: AlertVariant;
onDismiss?: () => void;
dismissibleId?: string; dismissibleId?: string;
} = $props(); } = $props();
@@ -49,6 +53,7 @@
}); });
function dismiss() { function dismiss() {
onDismiss?.();
if (dismissibleId) { if (dismissibleId) {
const dismissedAlerts = JSON.parse(localStorage.getItem('dismissed-alerts') || '[]'); const dismissedAlerts = JSON.parse(localStorage.getItem('dismissed-alerts') || '[]');
localStorage.setItem('dismissed-alerts', JSON.stringify([...dismissedAlerts, dismissibleId])); localStorage.setItem('dismissed-alerts', JSON.stringify([...dismissedAlerts, dismissibleId]));
@@ -66,7 +71,7 @@
role="alert" role="alert"
> >
{@render children?.()} {@render children?.()}
{#if dismissibleId} {#if dismissibleId || onDismiss}
<button onclick={dismiss} class="absolute right-0 top-0 m-3 text-black dark:text-white" <button onclick={dismiss} class="absolute right-0 top-0 m-3 text-black dark:text-white"
><LucideX class="size-4" /></button ><LucideX class="size-4" /></button
> >

View File

@@ -0,0 +1,13 @@
import Root from "./toggle.svelte";
export {
toggleVariants,
type ToggleSize,
type ToggleVariant,
type ToggleVariants,
} from "./toggle.svelte";
export {
Root,
//
Root as Toggle,
};

View File

@@ -0,0 +1,52 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const toggleVariants = tv({
base: "hover:bg-muted hover:text-muted-foreground data-[state=on]:bg-accent data-[state=on]:text-accent-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: "bg-transparent",
outline:
"border-input hover:bg-accent hover:text-accent-foreground border bg-transparent shadow-xs",
},
size: {
default: "h-9 min-w-9 px-2",
sm: "h-8 min-w-8 px-1.5",
lg: "h-10 min-w-10 px-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ToggleVariant = VariantProps<typeof toggleVariants>["variant"];
export type ToggleSize = VariantProps<typeof toggleVariants>["size"];
export type ToggleVariants = VariantProps<typeof toggleVariants>;
</script>
<script lang="ts">
import { Toggle as TogglePrimitive } from "bits-ui";
import { cn } from "$lib/utils/style.js";
let {
ref = $bindable(null),
pressed = $bindable(false),
class: className,
size = "default",
variant = "default",
...restProps
}: TogglePrimitive.RootProps & {
variant?: ToggleVariant;
size?: ToggleSize;
} = $props();
</script>
<TogglePrimitive.Root
bind:ref
bind:pressed
data-slot="toggle"
class={cn(toggleVariants({ variant, size }), className)}
{...restProps}
/>

View File

@@ -13,6 +13,13 @@ export default class ApiKeyService extends APIService {
return res.data as ApiKeyResponse; return res.data as ApiKeyResponse;
}; };
renew = async (id: string, expiresAt: Date): Promise<ApiKeyResponse> => {
const res = await this.api.post(`/api-keys/${id}/renew`, {
expiresAt
});
return res.data as ApiKeyResponse;
};
revoke = async (id: string): Promise<void> => { revoke = async (id: string): Promise<void> => {
await this.api.delete(`/api-keys/${id}`); await this.api.delete(`/api-keys/${id}`);
}; };

View File

@@ -1,5 +1,5 @@
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import type { AllAppConfig, AppConfigRawResponse } from '$lib/types/application-configuration'; import type { AllAppConfig, AppConfigRawResponse } from '$lib/types/application-configuration.type';
import { import {
cachedApplicationLogo, cachedApplicationLogo,
cachedBackgroundImage, cachedBackgroundImage,

View File

@@ -2,7 +2,7 @@ import userStore from '$lib/stores/user-store';
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type'; import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
import type { SignupToken } from '$lib/types/signup-token.type'; import type { SignupToken } from '$lib/types/signup-token.type';
import type { UserGroup } from '$lib/types/user-group.type'; import type { UserGroup } from '$lib/types/user-group.type';
import type { User, UserCreate, UserSignUp } from '$lib/types/user.type'; import type { AccountUpdate, User, UserCreate, UserSignUp } from '$lib/types/user.type';
import { cachedProfilePicture } from '$lib/utils/cached-image-util'; import { cachedProfilePicture } from '$lib/utils/cached-image-util';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import APIService from './api-service'; import APIService from './api-service';
@@ -38,7 +38,7 @@ export default class UserService extends APIService {
return res.data as User; return res.data as User;
}; };
updateCurrent = async (user: UserCreate) => { updateCurrent = async (user: AccountUpdate) => {
const res = await this.api.put('/users/me', user); const res = await this.api.put('/users/me', user);
return res.data as User; return res.data as User;
}; };
@@ -121,4 +121,14 @@ export default class UserService extends APIService {
deleteSignupToken = async (tokenId: string) => { deleteSignupToken = async (tokenId: string) => {
await this.api.delete(`/signup-tokens/${tokenId}`); await this.api.delete(`/signup-tokens/${tokenId}`);
}; };
sendEmailVerification = async () => {
const res = await this.api.post('/users/me/send-email-verification');
return res.data as User;
};
verifyEmail = async (token: string) => {
const res = await this.api.post('/users/me/verify-email', { token });
return res.data as User;
};
} }

View File

@@ -1,5 +1,5 @@
import AppConfigService from '$lib/services/app-config-service'; import AppConfigService from '$lib/services/app-config-service';
import type { AppConfig } from '$lib/types/application-configuration'; import type { AppConfig } from '$lib/types/application-configuration.type';
import { applyAccentColor } from '$lib/utils/accent-color-util'; import { applyAccentColor } from '$lib/utils/accent-color-util';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';

View File

@@ -2,10 +2,12 @@ import type { CustomClaim } from './custom-claim.type';
export type AppConfig = { export type AppConfig = {
appName: string; appName: string;
homePageUrl: string;
allowOwnAccountEdit: boolean; allowOwnAccountEdit: boolean;
allowUserSignups: 'disabled' | 'withToken' | 'open'; allowUserSignups: 'disabled' | 'withToken' | 'open';
emailOneTimeAccessAsUnauthenticatedEnabled: boolean; emailOneTimeAccessAsUnauthenticatedEnabled: boolean;
emailOneTimeAccessAsAdminEnabled: boolean; emailOneTimeAccessAsAdminEnabled: boolean;
emailVerificationEnabled: boolean;
ldapEnabled: boolean; ldapEnabled: boolean;
disableAnimations: boolean; disableAnimations: boolean;
uiConfigDisabled: boolean; uiConfigDisabled: boolean;
@@ -21,7 +23,7 @@ export type AllAppConfig = AppConfig & {
signupDefaultCustomClaims: CustomClaim[]; signupDefaultCustomClaims: CustomClaim[];
// Email // Email
smtpHost: string; smtpHost: string;
smtpPort: number; smtpPort: string;
smtpFrom: string; smtpFrom: string;
smtpUser: string; smtpUser: string;
smtpPassword: string; smtpPassword: string;

View File

@@ -6,6 +6,7 @@ export type User = {
id: string; id: string;
username: string; username: string;
email: string | undefined; email: string | undefined;
emailVerified: boolean;
firstName: string; firstName: string;
lastName?: string; lastName?: string;
displayName: string; displayName: string;
@@ -19,6 +20,11 @@ export type User = {
export type UserCreate = Omit<User, 'id' | 'customClaims' | 'ldapId' | 'userGroups'>; export type UserCreate = Omit<User, 'id' | 'customClaims' | 'ldapId' | 'userGroups'>;
export type UserSignUp = Omit<UserCreate, 'isAdmin' | 'disabled' | 'displayName'> & { export type AccountUpdate = Omit<UserCreate, 'isAdmin' | 'disabled' | 'emailVerified'>
export type UserSignUp = Omit<
UserCreate,
'isAdmin' | 'disabled' | 'displayName' | 'emailVerified'
> & {
token?: string; token?: string;
}; };

View File

@@ -33,14 +33,17 @@ export function getWebauthnErrorMessage(e: unknown) {
ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED: m.passkey_was_previously_registered(), ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED: m.passkey_was_previously_registered(),
ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG: ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG:
m.authenticator_does_not_support_any_of_the_requested_algorithms(), m.authenticator_does_not_support_any_of_the_requested_algorithms(),
ERROR_USER_DISABLED_MSG: m.user_disabled() ERROR_INVALID_DOMAIN: `${m.webauthn_error_invalid_domain()} ${m.contact_administrator_to_fix()}`,
ERROR_INVALID_RP_ID: `${m.webauthn_error_invalid_rp_id()} ${m.contact_administrator_to_fix()}`,
NotSupportedError: m.webauthn_not_supported_by_browser(),
NotAllowedError: m.webauthn_operation_not_allowed_or_timed_out()
}; };
let message = m.an_unknown_error_occurred(); let message: string = m.an_unknown_error_occurred();
if (e instanceof WebAuthnError && e.code in errors) { if (e instanceof WebAuthnError && e.code in errors) {
message = errors[e.code as keyof typeof errors]; message = errors[e.code as keyof typeof errors];
} else if (e instanceof WebAuthnError && e?.message.includes('timed out')) { } else if (e instanceof WebAuthnError && e.cause instanceof Error && e.cause.name in errors) {
message = m.authenticator_timed_out(); message = errors[e.cause.name as keyof typeof errors];
} else if (e instanceof AxiosError && e.response?.data.error) { } else if (e instanceof AxiosError && e.response?.data.error) {
message = e.response?.data.error; message = e.response?.data.error;
} else { } else {

View File

@@ -2,24 +2,29 @@ import type { User } from '$lib/types/user.type';
// Returns the path to redirect to based on the current path and user authentication status // Returns the path to redirect to based on the current path and user authentication status
// If no redirect is needed, it returns null // If no redirect is needed, it returns null
export function getAuthRedirectPath(path: string, user: User | null) { export function getAuthRedirectPath(url: URL, user: User | null) {
const path = url.pathname;
const isSignedIn = !!user; const isSignedIn = !!user;
const isAdmin = user?.isAdmin; const isAdmin = user?.isAdmin;
const isUnauthenticatedOnlyPath = const isUnauthenticatedOnlyPath =
path == '/login' || path == '/login' ||
path.startsWith('/login/') || (path.startsWith('/login/') && path != '/login/alternative/code') ||
path == '/lc' || path == '/lc' ||
path.startsWith('/lc/') ||
path == '/signup' || path == '/signup' ||
path == '/signup/setup' || path == '/signup/setup' ||
path == '/setup' || path == '/setup' ||
path.startsWith('/st/'); path.startsWith('/st/');
const isPublicPath = ['/authorize', '/device', '/health', '/healthz'].includes(path);
const isPublicPath =
path.startsWith('/lc/') ||
['/authorize', '/login/alternative/code', '/device', '/health', '/healthz'].includes(path);
const isAdminPath = path == '/settings/admin' || path.startsWith('/settings/admin/'); const isAdminPath = path == '/settings/admin' || path.startsWith('/settings/admin/');
if (!isUnauthenticatedOnlyPath && !isPublicPath && !isSignedIn) { if (!isUnauthenticatedOnlyPath && !isPublicPath && !isSignedIn) {
return '/login'; const redirect = url.pathname + url.search;
return `/login?redirect=${encodeURIComponent(redirect)}`;
} }
if (isUnauthenticatedOnlyPath && isSignedIn) { if (isUnauthenticatedOnlyPath && isSignedIn) {
@@ -29,4 +34,6 @@ export function getAuthRedirectPath(path: string, user: User | null) {
if (isAdminPath && !isAdmin) { if (isAdminPath && !isAdmin) {
return '/settings'; return '/settings';
} }
return null;
} }

View File

@@ -24,7 +24,7 @@ export const load: LayoutLoad = async ({ url }) => {
const [user, appConfig] = await Promise.all([userPromise, appConfigPromise]); const [user, appConfig] = await Promise.all([userPromise, appConfigPromise]);
const redirectPath = getAuthRedirectPath(url.pathname, user); const redirectPath = getAuthRedirectPath(url, user);
if (redirectPath) { if (redirectPath) {
redirect(302, redirectPath); redirect(302, redirectPath);
} }

View File

@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import EmailVerificationStateBox from '$lib/components/email-verification-state-box.svelte';
import FadeWrapper from '$lib/components/fade-wrapper.svelte'; import FadeWrapper from '$lib/components/fade-wrapper.svelte';
import Sidebar from '$lib/components/sidebar.svelte';
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import Sidebar from '$lib/components/sidebar.svelte';
import { LucideSettings } from '@lucide/svelte'; import { LucideSettings } from '@lucide/svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { fade, fly } from 'svelte/transition'; import { fade, fly } from 'svelte/transition';
@@ -71,6 +72,7 @@
<div class="flex w-full flex-col gap-4 overflow-hidden"> <div class="flex w-full flex-col gap-4 overflow-hidden">
<FadeWrapper> <FadeWrapper>
<EmailVerificationStateBox />
{@render children()} {@render children()}
</FadeWrapper> </FadeWrapper>
</div> </div>

View File

@@ -1,5 +1,5 @@
import VersionService from '$lib/services/version-service'; import VersionService from '$lib/services/version-service';
import type { AppVersionInformation } from '$lib/types/application-configuration'; import type { AppVersionInformation } from '$lib/types/application-configuration.type';
import type { LayoutLoad } from './$types'; import type { LayoutLoad } from './$types';
export const load: LayoutLoad = async () => { export const load: LayoutLoad = async () => {

View File

@@ -1,6 +1,8 @@
import appConfig from '$lib/stores/application-configuration-store';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { get } from 'svelte/store';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load: PageLoad = async () => { export const load: PageLoad = async () => {
throw redirect(307, '/settings/account'); throw redirect(307, get(appConfig)?.homePageUrl ?? '/');
}; };

Some files were not shown because too many files have changed in this diff Show More