mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-20 03:39:52 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c64bebf6a | ||
|
|
2a11c3e609 | ||
|
|
a0ced2443c | ||
|
|
746aa71d67 | ||
|
|
9ca3d33c88 | ||
|
|
4df4bcb645 | ||
|
|
875c5b94a6 | ||
|
|
0e2cdc393e | ||
|
|
1e7442f5df | ||
|
|
e955118a6f | ||
|
|
811e8772b6 | ||
|
|
0a94f0fd64 | ||
|
|
03f9be0d12 | ||
|
|
2f25861d15 | ||
|
|
2af70d9b4d | ||
|
|
5828fa5779 | ||
|
|
1a032a812e | ||
|
|
8c68b08c12 | ||
|
|
646f849441 | ||
|
|
20bbd4a06f | ||
|
|
2d7e2ec8df | ||
|
|
72009ced67 | ||
|
|
4881130ead | ||
|
|
d6a7b503ff |
41
CHANGELOG.md
41
CHANGELOG.md
@@ -1,3 +1,44 @@
|
||||
## 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
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- invalid cookie name for email login code device token ([d6a7b50](https://github.com/pocket-id/pocket-id/commit/d6a7b503ff4571b1291a55a569add3374f5e2d5b) by @stonith404)
|
||||
|
||||
### Features
|
||||
|
||||
- add issuer url to oidc client details list ([#1197](https://github.com/pocket-id/pocket-id/pull/1197) by @kmendell)
|
||||
- process nonce within device authorization flow ([#1185](https://github.com/pocket-id/pocket-id/pull/1185) by @justincmoy)
|
||||
|
||||
### Other
|
||||
|
||||
- run SCIM jobs in context of gocron instead of custom implementation ([4881130](https://github.com/pocket-id/pocket-id/commit/4881130eadcef0642f8a87650b7c36fda453b51b) by @stonith404)
|
||||
|
||||
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.0.2...v2.1.0
|
||||
|
||||
## v2.0.2
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -48,8 +48,13 @@ func Bootstrap(ctx context.Context) error {
|
||||
return fmt.Errorf("failed to initialize application images: %w", err)
|
||||
}
|
||||
|
||||
scheduler, err := job.NewScheduler()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create job scheduler: %w", err)
|
||||
}
|
||||
|
||||
// Create all services
|
||||
svc, err := initServices(ctx, db, httpClient, imageExtensions, fileStorage)
|
||||
svc, err := initServices(ctx, db, httpClient, imageExtensions, fileStorage, scheduler)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize services: %w", err)
|
||||
}
|
||||
@@ -74,11 +79,7 @@ func Bootstrap(ctx context.Context) error {
|
||||
}
|
||||
shutdownFns = append(shutdownFns, shutdownFn)
|
||||
|
||||
// Init the job scheduler
|
||||
scheduler, err := job.NewScheduler()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create job scheduler: %w", err)
|
||||
}
|
||||
// Register scheduled jobs
|
||||
err = registerScheduledJobs(ctx, db, svc, httpClient, scheduler)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register scheduled jobs: %w", err)
|
||||
|
||||
@@ -76,7 +76,7 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService)
|
||||
controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService)
|
||||
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.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
|
||||
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.NewVersionController(apiGroup, svc.versionService)
|
||||
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
|
||||
controller.NewUserSignupController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userSignUpService, svc.appConfigService)
|
||||
|
||||
// Add test controller in non-production environments
|
||||
if !common.EnvConfig.AppEnv.IsProduction() {
|
||||
|
||||
@@ -35,6 +35,10 @@ func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, http
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register analytics job in scheduler: %w", err)
|
||||
}
|
||||
err = scheduler.RegisterScimJobs(ctx, svc.scimService)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register SCIM scheduler job: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
@@ -20,7 +21,6 @@ type services struct {
|
||||
jwtService *service.JwtService
|
||||
webauthnService *service.WebAuthnService
|
||||
scimService *service.ScimService
|
||||
scimSchedulerService *service.ScimSchedulerService
|
||||
userService *service.UserService
|
||||
customClaimService *service.CustomClaimService
|
||||
oidcService *service.OidcService
|
||||
@@ -30,10 +30,12 @@ type services struct {
|
||||
versionService *service.VersionService
|
||||
fileStorage storage.FileStorage
|
||||
appLockService *service.AppLockService
|
||||
userSignUpService *service.UserSignUpService
|
||||
oneTimeAccessService *service.OneTimeAccessService
|
||||
}
|
||||
|
||||
// Initializes all services
|
||||
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, imageExtensions map[string]string, fileStorage storage.FileStorage) (svc *services, err error) {
|
||||
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, imageExtensions map[string]string, fileStorage storage.FileStorage, scheduler *job.Scheduler) (svc *services, err error) {
|
||||
svc = &services{}
|
||||
|
||||
svc.appConfigService, err = service.NewAppConfigService(ctx, db)
|
||||
@@ -52,7 +54,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
|
||||
|
||||
svc.geoLiteService = service.NewGeoLiteService(httpClient)
|
||||
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 {
|
||||
return nil, fmt.Errorf("failed to create JWT service: %w", err)
|
||||
}
|
||||
@@ -63,21 +65,25 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
|
||||
return nil, fmt.Errorf("failed to create WebAuthn service: %w", err)
|
||||
}
|
||||
|
||||
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService, svc.webauthnService, httpClient, fileStorage)
|
||||
svc.scimService = service.NewScimService(db, scheduler, httpClient)
|
||||
|
||||
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService, svc.webauthnService, svc.scimService, httpClient, fileStorage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
|
||||
}
|
||||
|
||||
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService)
|
||||
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, fileStorage)
|
||||
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.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage)
|
||||
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
|
||||
svc.scimService = service.NewScimService(db, httpClient)
|
||||
svc.scimSchedulerService, err = service.NewScimSchedulerService(ctx, svc.scimService)
|
||||
|
||||
svc.apiKeyService, err = service.NewApiKeyService(ctx, db, svc.emailService)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create SCIM scheduler service: %w", err)
|
||||
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)
|
||||
|
||||
return svc, nil
|
||||
|
||||
187
backend/internal/cmds/encryption_key_rotate.go
Normal file
187
backend/internal/cmds/encryption_key_rotate.go
Normal 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
|
||||
}
|
||||
89
backend/internal/cmds/encryption_key_rotate_test.go
Normal file
89
backend/internal/cmds/encryption_key_rotate_test.go
Normal 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))
|
||||
}
|
||||
@@ -102,7 +102,7 @@ func keyRotate(ctx context.Context, flags keyRotateFlags, db *gorm.DB, envConfig
|
||||
}
|
||||
|
||||
// Save the key
|
||||
err = keyProvider.SaveKey(key)
|
||||
err = keyProvider.SaveKey(ctx, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store new key: %w", err)
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ func testKeyRotateWithDatabaseStorage(t *testing.T, flags keyRotateFlags, wantEr
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify key was created
|
||||
key, err := keyProvider.LoadKey()
|
||||
key, err := keyProvider.LoadKey(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, key)
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ type EnvConfigSchema struct {
|
||||
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
|
||||
InternalAppURL string `env:"INTERNAL_APP_URL"`
|
||||
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"`
|
||||
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")
|
||||
}
|
||||
|
||||
if config.StaticApiKey != "" && len(config.StaticApiKey) < 16 {
|
||||
return errors.New("STATIC_API_KEY must be at least 16 characters long")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
@@ -266,6 +266,13 @@ func (e *APIKeyNotFoundError) Error() string {
|
||||
}
|
||||
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{}
|
||||
|
||||
func (e *APIKeyExpirationDateError) Error() string {
|
||||
@@ -405,3 +412,13 @@ func (e *ImageNotFoundError) Error() string {
|
||||
func (e *ImageNotFoundError) HttpStatusCode() int {
|
||||
return http.StatusNotFound
|
||||
}
|
||||
|
||||
type InvalidEmailVerificationTokenError struct{}
|
||||
|
||||
func (e *InvalidEmailVerificationTokenError) Error() string {
|
||||
return "Invalid email verification token"
|
||||
}
|
||||
|
||||
func (e *InvalidEmailVerificationTokenError) HttpStatusCode() int {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.Auth
|
||||
{
|
||||
apiKeyGroup.GET("", uc.listApiKeysHandler)
|
||||
apiKeyGroup.POST("", uc.createApiKeyHandler)
|
||||
apiKeyGroup.POST("/:id/renew", uc.renewApiKeyHandler)
|
||||
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
|
||||
// @Summary Revoke API key
|
||||
// @Description Revoke (delete) an existing API key by ID
|
||||
|
||||
@@ -14,19 +14,17 @@ import (
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultOneTimeAccessTokenDuration = 15 * time.Minute
|
||||
defaultSignupTokenDuration = time.Hour
|
||||
)
|
||||
const defaultOneTimeAccessTokenDuration = 15 * time.Minute
|
||||
|
||||
// NewUserController creates a new controller for user management endpoints
|
||||
// @Summary User management controller
|
||||
// @Description Initializes all user-related API endpoints
|
||||
// @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{
|
||||
userService: userService,
|
||||
appConfigService: appConfigService,
|
||||
userService: userService,
|
||||
oneTimeAccessService: oneTimeAccessService,
|
||||
appConfigService: appConfigService,
|
||||
}
|
||||
|
||||
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/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
|
||||
|
||||
group.POST("/signup-tokens", authMiddleware.Add(), uc.createSignupTokenHandler)
|
||||
group.GET("/signup-tokens", authMiddleware.Add(), uc.listSignupTokensHandler)
|
||||
group.DELETE("/signup-tokens/:id", authMiddleware.Add(), uc.deleteSignupTokenHandler)
|
||||
group.POST("/signup", rateLimitMiddleware.Add(rate.Every(1*time.Minute), 10), uc.signupHandler)
|
||||
group.POST("/signup/setup", uc.signUpInitialAdmin)
|
||||
|
||||
group.POST("/users/me/send-email-verification", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), authMiddleware.WithAdminNotRequired().Add(), uc.sendEmailVerificationHandler)
|
||||
group.POST("/users/me/verify-email", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), authMiddleware.WithAdminNotRequired().Add(), uc.verifyEmailHandler)
|
||||
}
|
||||
|
||||
type UserController struct {
|
||||
userService *service.UserService
|
||||
appConfigService *service.AppConfigService
|
||||
userService *service.UserService
|
||||
oneTimeAccessService *service.OneTimeAccessService
|
||||
appConfigService *service.AppConfigService
|
||||
}
|
||||
|
||||
// getUserGroupsHandler godoc
|
||||
@@ -342,7 +337,7 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bo
|
||||
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 {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -391,7 +386,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(
|
||||
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 {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -424,7 +419,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
|
||||
if ttl <= 0 {
|
||||
ttl = defaultOneTimeAccessTokenDuration
|
||||
}
|
||||
err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl)
|
||||
err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -442,41 +437,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
|
||||
// @Router /api/one-time-access-token/{token} [post]
|
||||
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
deviceToken, _ := c.Cookie(cookie.DeviceTokenCookieName)
|
||||
user, token, err := uc.userService.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)
|
||||
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
|
||||
@@ -524,130 +485,6 @@ func (uc *UserController) updateUserGroups(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
}
|
||||
|
||||
// createSignupTokenHandler godoc
|
||||
// @Summary Create signup token
|
||||
// @Description Create a new signup token that allows user registration
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param token body dto.SignupTokenCreateDto true "Signup token information"
|
||||
// @Success 201 {object} dto.SignupTokenDto
|
||||
// @Router /api/signup-tokens [post]
|
||||
func (uc *UserController) createSignupTokenHandler(c *gin.Context) {
|
||||
var input dto.SignupTokenCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||
var input dto.UserCreateDto
|
||||
@@ -714,3 +551,44 @@ func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
198
backend/internal/controller/user_signup_controller.go
Normal file
198
backend/internal/controller/user_signup_controller.go
Normal 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)
|
||||
}
|
||||
@@ -10,6 +10,10 @@ type ApiKeyCreateDto struct {
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
|
||||
}
|
||||
|
||||
type ApiKeyRenewDto struct {
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
|
||||
}
|
||||
|
||||
type ApiKeyDto struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -14,6 +14,7 @@ type AppConfigVariableDto struct {
|
||||
type AppConfigUpdateDto struct {
|
||||
AppName string `json:"appName" binding:"required,min=1,max=30" unorm:"nfc"`
|
||||
SessionDuration string `json:"sessionDuration" binding:"required"`
|
||||
HomePageURL string `json:"homePageUrl" binding:"required"`
|
||||
EmailsVerified string `json:"emailsVerified" binding:"required"`
|
||||
DisableAnimations string `json:"disableAnimations" binding:"required"`
|
||||
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
|
||||
@@ -53,4 +54,5 @@ type AppConfigUpdateDto struct {
|
||||
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"`
|
||||
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
|
||||
EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" binding:"required"`
|
||||
EmailVerificationEnabled string `json:"emailVerificationEnabled" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@ type OidcDeviceAuthorizationRequestDto struct {
|
||||
ClientSecret string `form:"client_secret"`
|
||||
ClientAssertion string `form:"client_assertion"`
|
||||
ClientAssertionType string `form:"client_assertion_type"`
|
||||
Nonce string `form:"nonce"`
|
||||
}
|
||||
|
||||
type OidcDeviceAuthorizationResponseDto struct {
|
||||
|
||||
17
backend/internal/dto/one_time_access_dto.go
Normal file
17
backend/internal/dto/one_time_access_dto.go
Normal 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"`
|
||||
}
|
||||
9
backend/internal/dto/signup_dto.go
Normal file
9
backend/internal/dto/signup_dto.go
Normal 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"`
|
||||
}
|
||||
@@ -4,35 +4,36 @@ import (
|
||||
"errors"
|
||||
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
type UserDto struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email *string `json:"email" `
|
||||
FirstName string `json:"firstName"`
|
||||
LastName *string `json:"lastName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Locale *string `json:"locale"`
|
||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||
UserGroups []UserGroupMinimalDto `json:"userGroups"`
|
||||
LdapID *string `json:"ldapId"`
|
||||
Disabled bool `json:"disabled"`
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email *string `json:"email"`
|
||||
EmailVerified bool `json:"emailVerified"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName *string `json:"lastName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Locale *string `json:"locale"`
|
||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||
UserGroups []UserGroupMinimalDto `json:"userGroups"`
|
||||
LdapID *string `json:"ldapId"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
type UserCreateDto 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"`
|
||||
DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Locale *string `json:"locale"`
|
||||
Disabled bool `json:"disabled"`
|
||||
UserGroupIds []string `json:"userGroupIds"`
|
||||
LdapID string `json:"-"`
|
||||
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
|
||||
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
||||
EmailVerified bool `json:"emailVerified"`
|
||||
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||
DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Locale *string `json:"locale"`
|
||||
Disabled bool `json:"disabled"`
|
||||
UserGroupIds []string `json:"userGroupIds"`
|
||||
LdapID string `json:"-"`
|
||||
}
|
||||
|
||||
func (u UserCreateDto) Validate() error {
|
||||
@@ -46,28 +47,10 @@ func (u UserCreateDto) Validate() error {
|
||||
return e.Struct(u)
|
||||
}
|
||||
|
||||
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"`
|
||||
type EmailVerificationDto struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
|
||||
type UserUpdateUserGroupDto struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service
|
||||
appConfig: appConfig,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
return s.registerJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, true)
|
||||
return s.RegisterJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, true)
|
||||
}
|
||||
|
||||
type AnalyticsJob struct {
|
||||
|
||||
@@ -22,7 +22,7 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *
|
||||
}
|
||||
|
||||
// Send every day at midnight
|
||||
return s.registerJob(ctx, "ExpiredApiKeyEmailJob", gocron.CronJob("0 0 * * *", false), jobs.checkAndNotifyExpiringApiKeys, false)
|
||||
return s.RegisterJob(ctx, "ExpiredApiKeyEmailJob", gocron.CronJob("0 0 * * *", false), jobs.checkAndNotifyExpiringApiKeys, false)
|
||||
}
|
||||
|
||||
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {
|
||||
|
||||
@@ -21,13 +21,14 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
|
||||
// Run every 24 hours (but with some jitter so they don't run at the exact same time), and now
|
||||
def := gocron.DurationRandomJob(24*time.Hour-2*time.Minute, 24*time.Hour+2*time.Minute)
|
||||
return errors.Join(
|
||||
s.registerJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
|
||||
s.registerJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
|
||||
s.registerJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
|
||||
s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
|
||||
s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
|
||||
s.registerJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true),
|
||||
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
|
||||
s.RegisterJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
|
||||
s.RegisterJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
|
||||
s.RegisterJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
|
||||
s.RegisterJob(ctx, "ClearEmailVerificationTokens", def, jobs.clearEmailVerificationTokens, true),
|
||||
s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
|
||||
s.RegisterJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
|
||||
s.RegisterJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true),
|
||||
s.RegisterJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -135,3 +136,16 @@ func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -19,11 +19,11 @@ import (
|
||||
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB, fileStorage storage.FileStorage) error {
|
||||
jobs := &FileCleanupJobs{db: db, fileStorage: fileStorage}
|
||||
|
||||
err := s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, false)
|
||||
err := s.RegisterJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, false)
|
||||
|
||||
// Only necessary for file system storage
|
||||
if fileStorage.Type() == storage.TypeFileSystem {
|
||||
err = errors.Join(err, s.registerJob(ctx, "ClearOrphanedTempFiles", gocron.DurationJob(12*time.Hour), jobs.clearOrphanedTempFiles, true))
|
||||
err = errors.Join(err, s.RegisterJob(ctx, "ClearOrphanedTempFiles", gocron.DurationJob(12*time.Hour), jobs.clearOrphanedTempFiles, true))
|
||||
}
|
||||
|
||||
return err
|
||||
|
||||
@@ -23,7 +23,7 @@ func (s *Scheduler) RegisterGeoLiteUpdateJobs(ctx context.Context, geoLiteServic
|
||||
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
|
||||
|
||||
// Run every 24 hours (and right away)
|
||||
return s.registerJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, true)
|
||||
return s.RegisterJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, true)
|
||||
}
|
||||
|
||||
func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error {
|
||||
|
||||
@@ -18,7 +18,7 @@ func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.L
|
||||
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
|
||||
|
||||
// Register the job to run every hour
|
||||
return s.registerJob(ctx, "SyncLdap", gocron.DurationJob(time.Hour), jobs.syncLdap, true)
|
||||
return s.RegisterJob(ctx, "SyncLdap", gocron.DurationJob(time.Hour), jobs.syncLdap, true)
|
||||
}
|
||||
|
||||
func (j *LdapJobs) syncLdap(ctx context.Context) error {
|
||||
|
||||
@@ -2,6 +2,7 @@ package job
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
@@ -24,6 +25,26 @@ func NewScheduler() (*Scheduler, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) RemoveJob(name string) error {
|
||||
jobs := s.scheduler.Jobs()
|
||||
|
||||
var errs []error
|
||||
for _, job := range jobs {
|
||||
if job.Name() == name {
|
||||
err := s.scheduler.RemoveJob(job.ID())
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to unqueue job %q with ID %q: %w", name, job.ID().String(), err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run the scheduler.
|
||||
// This function blocks until the context is canceled.
|
||||
func (s *Scheduler) Run(ctx context.Context) error {
|
||||
@@ -43,9 +64,10 @@ func (s *Scheduler) Run(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) registerJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool) error {
|
||||
func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool, extraOptions ...gocron.JobOption) error {
|
||||
jobOptions := []gocron.JobOption{
|
||||
gocron.WithContext(ctx),
|
||||
gocron.WithName(name),
|
||||
gocron.WithEventListeners(
|
||||
gocron.BeforeJobRuns(func(jobID uuid.UUID, jobName string) {
|
||||
slog.Info("Starting job",
|
||||
@@ -73,6 +95,8 @@ func (s *Scheduler) registerJob(ctx context.Context, name string, def gocron.Job
|
||||
jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately()))
|
||||
}
|
||||
|
||||
jobOptions = append(jobOptions, extraOptions...)
|
||||
|
||||
_, err := s.scheduler.NewJob(def, gocron.NewTask(job), jobOptions...)
|
||||
|
||||
if err != nil {
|
||||
|
||||
25
backend/internal/job/scim_job.go
Normal file
25
backend/internal/job/scim_job.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
type ScimJobs struct {
|
||||
scimService *service.ScimService
|
||||
}
|
||||
|
||||
func (s *Scheduler) RegisterScimJobs(ctx context.Context, scimService *service.ScimService) error {
|
||||
jobs := &ScimJobs{scimService: scimService}
|
||||
|
||||
// Register the job to run every hour
|
||||
return s.RegisterJob(ctx, "SyncScim", gocron.DurationJob(time.Hour), jobs.SyncScim, true)
|
||||
}
|
||||
|
||||
func (j *ScimJobs) SyncScim(ctx context.Context) error {
|
||||
return j.scimService.SyncAll(ctx)
|
||||
}
|
||||
@@ -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) {
|
||||
apiKey := c.GetHeader("X-API-KEY")
|
||||
apiKey := c.GetHeader("X-API-Key")
|
||||
|
||||
user, err := m.apiKeyService.ValidateApiKey(c.Request.Context(), apiKey)
|
||||
if err != nil {
|
||||
|
||||
@@ -17,6 +17,12 @@ func NewRateLimitMiddleware() *RateLimitMiddleware {
|
||||
}
|
||||
|
||||
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
|
||||
var clients = make(map[string]*client)
|
||||
var mu sync.Mutex
|
||||
|
||||
@@ -36,6 +36,7 @@ type AppConfig struct {
|
||||
// General
|
||||
AppName AppConfigVariable `key:"appName,public"` // Public
|
||||
SessionDuration AppConfigVariable `key:"sessionDuration"`
|
||||
HomePageURL AppConfigVariable `key:"homePageUrl,public"` // Public
|
||||
EmailsVerified AppConfigVariable `key:"emailsVerified"`
|
||||
AccentColor AppConfigVariable `key:"accentColor,public"` // Public
|
||||
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
|
||||
@@ -58,6 +59,7 @@ type AppConfig struct {
|
||||
EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigVariable `key:"emailOneTimeAccessAsUnauthenticatedEnabled,public"` // Public
|
||||
EmailOneTimeAccessAsAdminEnabled AppConfigVariable `key:"emailOneTimeAccessAsAdminEnabled,public"` // Public
|
||||
EmailApiKeyExpirationEnabled AppConfigVariable `key:"emailApiKeyExpirationEnabled"`
|
||||
EmailVerificationEnabled AppConfigVariable `key:"emailVerificationEnabled,public"` // Public
|
||||
// LDAP
|
||||
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
|
||||
LdapUrl AppConfigVariable `key:"ldapUrl"`
|
||||
|
||||
13
backend/internal/model/email_verification_token.go
Normal file
13
backend/internal/model/email_verification_token.go
Normal 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
|
||||
}
|
||||
@@ -144,6 +144,7 @@ type OidcDeviceCode struct {
|
||||
DeviceCode string
|
||||
UserCode string
|
||||
Scope string
|
||||
Nonce string
|
||||
ExpiresAt datatype.DateTime
|
||||
IsAuthorized bool
|
||||
|
||||
|
||||
13
backend/internal/model/one_time_access_token.go
Normal file
13
backend/internal/model/one_time_access_token.go
Normal 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
|
||||
}
|
||||
@@ -40,14 +40,9 @@ func (e *EncryptedString) Scan(value any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
encBytes, err := base64.StdEncoding.DecodeString(raw)
|
||||
decBytes, err := DecryptEncryptedStringWithKey(encStringKey, raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode encrypted string: %w", err)
|
||||
}
|
||||
|
||||
decBytes, err := cryptoutils.Decrypt(encStringKey, encBytes, []byte(encryptedStringAAD))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt encrypted string: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
*e = EncryptedString(decBytes)
|
||||
@@ -59,19 +54,20 @@ func (e EncryptedString) Value() (driver.Value, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
encBytes, err := cryptoutils.Encrypt(encStringKey, []byte(e), []byte(encryptedStringAAD))
|
||||
encValue, err := EncryptEncryptedStringWithKey(encStringKey, []byte(e))
|
||||
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 {
|
||||
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"
|
||||
r := hkdf.New(sha256.New, master, nil, []byte(info))
|
||||
|
||||
@@ -82,8 +78,33 @@ func deriveEncryptedStringKey(master []byte) ([]byte, error) {
|
||||
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() {
|
||||
key, err := deriveEncryptedStringKey(common.EnvConfig.EncryptionKey)
|
||||
key, err := DeriveEncryptedStringKey(common.EnvConfig.EncryptionKey)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to derive encrypted string key: %v", err))
|
||||
}
|
||||
|
||||
@@ -14,16 +14,17 @@ import (
|
||||
type User struct {
|
||||
Base
|
||||
|
||||
Username string `sortable:"true"`
|
||||
Email *string `sortable:"true"`
|
||||
FirstName string `sortable:"true"`
|
||||
LastName string `sortable:"true"`
|
||||
DisplayName string `sortable:"true"`
|
||||
IsAdmin bool `sortable:"true" filterable:"true"`
|
||||
Locale *string
|
||||
LdapID *string
|
||||
Disabled bool `sortable:"true" filterable:"true"`
|
||||
UpdatedAt *datatype.DateTime
|
||||
Username string `sortable:"true"`
|
||||
Email *string `sortable:"true"`
|
||||
EmailVerified bool `sortable:"true" filterable:"true"`
|
||||
FirstName string `sortable:"true"`
|
||||
LastName string `sortable:"true"`
|
||||
DisplayName string `sortable:"true"`
|
||||
IsAdmin bool `sortable:"true" filterable:"true"`
|
||||
Locale *string
|
||||
LdapID *string
|
||||
Disabled bool `sortable:"true" filterable:"true"`
|
||||
UpdatedAt *datatype.DateTime
|
||||
|
||||
CustomClaims []CustomClaim
|
||||
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
||||
@@ -93,13 +94,3 @@ func (u User) LastModified() time.Time {
|
||||
}
|
||||
return u.CreatedAt.ToTime()
|
||||
}
|
||||
|
||||
type OneTimeAccessToken struct {
|
||||
Base
|
||||
Token string
|
||||
DeviceToken *string
|
||||
ExpiresAt datatype.DateTime
|
||||
|
||||
UserID string
|
||||
User User
|
||||
}
|
||||
|
||||
@@ -16,13 +16,25 @@ import (
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const staticApiKeyUserID = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
type ApiKeyService struct {
|
||||
db *gorm.DB
|
||||
emailService *EmailService
|
||||
}
|
||||
|
||||
func NewApiKeyService(db *gorm.DB, emailService *EmailService) *ApiKeyService {
|
||||
return &ApiKeyService{db: db, emailService: emailService}
|
||||
func NewApiKeyService(ctx context.Context, db *gorm.DB, emailService *EmailService) (*ApiKeyService, error) {
|
||||
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) {
|
||||
@@ -72,6 +84,56 @@ func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input d
|
||||
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 {
|
||||
var apiKey model.ApiKey
|
||||
err := s.db.
|
||||
@@ -94,6 +156,10 @@ func (s *ApiKeyService) ValidateApiKey(ctx context.Context, apiKey string) (mode
|
||||
return model.User{}, &common.NoAPIKeyProvidedError{}
|
||||
}
|
||||
|
||||
if common.EnvConfig.StaticApiKey != "" && apiKey == common.EnvConfig.StaticApiKey {
|
||||
return s.initStaticApiKeyUser(ctx)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
hashedKey := utils.CreateSha256Hash(apiKey)
|
||||
|
||||
@@ -167,3 +233,47 @@ func (s *ApiKeyService) SendApiKeyExpiringSoonEmail(ctx context.Context, apiKey
|
||||
Update("expiration_email_sent", true).
|
||||
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
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
|
||||
// General
|
||||
AppName: model.AppConfigVariable{Value: "Pocket ID"},
|
||||
SessionDuration: model.AppConfigVariable{Value: "60"},
|
||||
HomePageURL: model.AppConfigVariable{Value: "/settings/account"},
|
||||
EmailsVerified: model.AppConfigVariable{Value: "false"},
|
||||
DisableAnimations: model.AppConfigVariable{Value: "false"},
|
||||
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
|
||||
@@ -83,6 +84,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
|
||||
EmailOneTimeAccessAsUnauthenticatedEnabled: model.AppConfigVariable{Value: "false"},
|
||||
EmailOneTimeAccessAsAdminEnabled: model.AppConfigVariable{Value: "false"},
|
||||
EmailApiKeyExpirationEnabled: model.AppConfigVariable{Value: "false"},
|
||||
EmailVerificationEnabled: model.AppConfigVariable{Value: "false"},
|
||||
// LDAP
|
||||
LdapEnabled: model.AppConfigVariable{Value: "false"},
|
||||
LdapUrl: model.AppConfigVariable{},
|
||||
|
||||
@@ -80,23 +80,25 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
Base: model.Base{
|
||||
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
||||
},
|
||||
Username: "tim",
|
||||
Email: utils.Ptr("tim.cook@test.com"),
|
||||
FirstName: "Tim",
|
||||
LastName: "Cook",
|
||||
DisplayName: "Tim Cook",
|
||||
IsAdmin: true,
|
||||
Username: "tim",
|
||||
Email: utils.Ptr("tim.cook@test.com"),
|
||||
EmailVerified: true,
|
||||
FirstName: "Tim",
|
||||
LastName: "Cook",
|
||||
DisplayName: "Tim Cook",
|
||||
IsAdmin: true,
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
|
||||
},
|
||||
Username: "craig",
|
||||
Email: utils.Ptr("craig.federighi@test.com"),
|
||||
FirstName: "Craig",
|
||||
LastName: "Federighi",
|
||||
DisplayName: "Craig Federighi",
|
||||
IsAdmin: false,
|
||||
Username: "craig",
|
||||
Email: utils.Ptr("craig.federighi@test.com"),
|
||||
EmailVerified: false,
|
||||
FirstName: "Craig",
|
||||
LastName: "Federighi",
|
||||
DisplayName: "Craig Federighi",
|
||||
IsAdmin: false,
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
@@ -354,17 +356,30 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
apiKey := model.ApiKey{
|
||||
Base: model.Base{
|
||||
ID: "5f1fa856-c164-4295-961e-175a0d22d725",
|
||||
apiKeys := []model.ApiKey{
|
||||
{
|
||||
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 {
|
||||
return err
|
||||
for _, apiKey := range apiKeys {
|
||||
if err := tx.Create(&apiKey).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
{
|
||||
Key: jwkutils.PrivateKeyDBKey,
|
||||
@@ -526,7 +566,7 @@ func (s *TestService) ResetAppConfig(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// Reload the JWK
|
||||
if err := s.jwtService.LoadOrGenerateKey(); err != nil {
|
||||
if err := s.jwtService.LoadOrGenerateKey(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
IPAddress string
|
||||
Country string
|
||||
@@ -70,5 +77,10 @@ type ApiKeyExpiringSoonTemplateData struct {
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type EmailVerificationTemplateData struct {
|
||||
UserFullName string
|
||||
VerificationLink string
|
||||
}
|
||||
|
||||
// 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}
|
||||
|
||||
@@ -56,10 +56,10 @@ type JwtService struct {
|
||||
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{}
|
||||
|
||||
err := service.init(db, appConfigService, &common.EnvConfig)
|
||||
err := service.init(ctx, db, appConfigService, &common.EnvConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -67,16 +67,16 @@ func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) (*JwtService
|
||||
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.envConfig = envConfig
|
||||
s.db = db
|
||||
|
||||
// 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
|
||||
keyProvider, err := jwkutils.GetKeyProvider(s.db, s.envConfig, s.appConfigService.GetDbConfig().InstanceID.Value)
|
||||
if err != nil {
|
||||
@@ -84,7 +84,7 @@ func (s *JwtService) LoadOrGenerateKey() error {
|
||||
}
|
||||
|
||||
// Try loading a key
|
||||
key, err := keyProvider.LoadKey()
|
||||
key, err := keyProvider.LoadKey(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load key: %w", err)
|
||||
}
|
||||
@@ -105,7 +105,7 @@ func (s *JwtService) LoadOrGenerateKey() error {
|
||||
}
|
||||
|
||||
// Save the newly-generated key
|
||||
err = keyProvider.SaveKey(s.privateKey)
|
||||
err = keyProvider.SaveKey(ctx, s.privateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func initJwtService(t *testing.T, db *gorm.DB, appConfig *AppConfigService, envC
|
||||
t.Helper()
|
||||
|
||||
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")
|
||||
|
||||
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)
|
||||
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")
|
||||
|
||||
kid, ok := key.KeyID()
|
||||
@@ -93,7 +93,7 @@ func TestJwtService_Init(t *testing.T) {
|
||||
// Verify the key has been persisted in the database
|
||||
keyProvider, err := jwkutils.GetKeyProvider(db, mockEnvConfig, mockConfig.GetDbConfig().InstanceID.Value)
|
||||
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.NotNil(t, key, "Key should be present in the database")
|
||||
|
||||
|
||||
@@ -378,13 +378,14 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
||||
}
|
||||
|
||||
newUser := dto.UserCreateDto{
|
||||
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
|
||||
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
|
||||
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
|
||||
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
|
||||
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
|
||||
IsAdmin: isAdmin,
|
||||
LdapID: ldapId,
|
||||
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
|
||||
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
|
||||
EmailVerified: true,
|
||||
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
|
||||
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
|
||||
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
|
||||
IsAdmin: isAdmin,
|
||||
LdapID: ldapId,
|
||||
}
|
||||
|
||||
if newUser.DisplayName == "" {
|
||||
|
||||
@@ -56,6 +56,7 @@ type OidcService struct {
|
||||
auditLogService *AuditLogService
|
||||
customClaimService *CustomClaimService
|
||||
webAuthnService *WebAuthnService
|
||||
scimService *ScimService
|
||||
|
||||
httpClient *http.Client
|
||||
jwkCache *jwk.Cache
|
||||
@@ -70,6 +71,7 @@ func NewOidcService(
|
||||
auditLogService *AuditLogService,
|
||||
customClaimService *CustomClaimService,
|
||||
webAuthnService *WebAuthnService,
|
||||
scimService *ScimService,
|
||||
httpClient *http.Client,
|
||||
fileStorage storage.FileStorage,
|
||||
) (s *OidcService, err error) {
|
||||
@@ -80,6 +82,7 @@ func NewOidcService(
|
||||
auditLogService: auditLogService,
|
||||
customClaimService: customClaimService,
|
||||
webAuthnService: webAuthnService,
|
||||
scimService: scimService,
|
||||
httpClient: httpClient,
|
||||
fileStorage: fileStorage,
|
||||
}
|
||||
@@ -311,7 +314,7 @@ func (s *OidcService) createTokenFromDeviceCode(ctx context.Context, input dto.O
|
||||
}
|
||||
|
||||
// Explicitly use the input clientID for the audience claim to ensure consistency
|
||||
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, "")
|
||||
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, deviceAuth.Nonce)
|
||||
if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
@@ -1088,6 +1091,7 @@ func (s *OidcService) UpdateAllowedUserGroups(ctx context.Context, id string, in
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
return client, nil
|
||||
}
|
||||
|
||||
@@ -1278,6 +1282,7 @@ func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.O
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(DeviceCodeDuration)),
|
||||
IsAuthorized: false,
|
||||
ClientID: client.ID,
|
||||
Nonce: input.Nonce,
|
||||
}
|
||||
|
||||
if err := s.db.Create(deviceAuth).Error; err != nil {
|
||||
@@ -1895,7 +1900,7 @@ func (s *OidcService) getUserClaims(ctx context.Context, user *model.User, scope
|
||||
claims["sub"] = user.ID
|
||||
if slices.Contains(scopes, "email") {
|
||||
claims["email"] = user.Email
|
||||
claims["email_verified"] = s.appConfigService.GetDbConfig().EmailsVerified.IsTrue()
|
||||
claims["email_verified"] = user.EmailVerified
|
||||
}
|
||||
|
||||
if slices.Contains(scopes, "groups") {
|
||||
|
||||
@@ -160,7 +160,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
||||
mockConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
|
||||
})
|
||||
mockJwtService, err := NewJwtService(db, mockConfig)
|
||||
mockJwtService, err := NewJwtService(t.Context(), db, mockConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a mock HTTP client with custom transport to return the JWKS
|
||||
|
||||
229
backend/internal/service/one_time_access_service.go
Normal file
229
backend/internal/service/one_time_access_service.go
Normal 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
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ScimSchedulerService schedules and triggers periodic synchronization
|
||||
// of SCIM service providers. Each provider is tracked independently,
|
||||
// and sync operations are run at or after their scheduled time.
|
||||
type ScimSchedulerService struct {
|
||||
scimService *ScimService
|
||||
providerSyncTime map[string]time.Time
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewScimSchedulerService(ctx context.Context, scimService *ScimService) (*ScimSchedulerService, error) {
|
||||
s := &ScimSchedulerService{
|
||||
scimService: scimService,
|
||||
providerSyncTime: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
err := s.start(ctx)
|
||||
return s, err
|
||||
}
|
||||
|
||||
// ScheduleSync forces the given provider to be synced soon by
|
||||
// moving its next scheduled time to 5 minutes from now.
|
||||
func (s *ScimSchedulerService) ScheduleSync(providerID string) {
|
||||
s.setSyncTime(providerID, 5*time.Minute)
|
||||
}
|
||||
|
||||
// start initializes the scheduler and begins the synchronization loop.
|
||||
// Syncs happen every hour by default, but ScheduleSync can be called to schedule a sync sooner.
|
||||
func (s *ScimSchedulerService) start(ctx context.Context) error {
|
||||
if err := s.refreshProviders(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
const (
|
||||
syncCheckInterval = 5 * time.Second
|
||||
providerRefreshDelay = time.Minute
|
||||
)
|
||||
|
||||
ticker := time.NewTicker(syncCheckInterval)
|
||||
defer ticker.Stop()
|
||||
lastProviderRefresh := time.Now()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
// Runs every 5 seconds to check if any provider is due for sync
|
||||
case <-ticker.C:
|
||||
now := time.Now()
|
||||
if now.Sub(lastProviderRefresh) >= providerRefreshDelay {
|
||||
err := s.refreshProviders(ctx)
|
||||
if err != nil {
|
||||
slog.Error("Error refreshing SCIM service providers",
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else {
|
||||
lastProviderRefresh = now
|
||||
}
|
||||
}
|
||||
|
||||
var due []string
|
||||
s.mu.RLock()
|
||||
for providerID, syncTime := range s.providerSyncTime {
|
||||
if !syncTime.After(now) {
|
||||
due = append(due, providerID)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
s.syncProviders(ctx, due)
|
||||
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ScimSchedulerService) refreshProviders(ctx context.Context) error {
|
||||
providers, err := s.scimService.ListServiceProviders(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
inAHour := time.Now().Add(time.Hour)
|
||||
|
||||
s.mu.Lock()
|
||||
for _, provider := range providers {
|
||||
if _, exists := s.providerSyncTime[provider.ID]; !exists {
|
||||
s.providerSyncTime[provider.ID] = inAHour
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ScimSchedulerService) syncProviders(ctx context.Context, providerIDs []string) {
|
||||
for _, providerID := range providerIDs {
|
||||
err := s.scimService.SyncServiceProvider(ctx, providerID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// Remove the provider from the schedule if it no longer exists
|
||||
s.mu.Lock()
|
||||
delete(s.providerSyncTime, providerID)
|
||||
s.mu.Unlock()
|
||||
} else {
|
||||
slog.Error("Error syncing SCIM client",
|
||||
slog.String("provider_id", providerID),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// A successful sync schedules the next sync in an hour
|
||||
s.setSyncTime(providerID, time.Hour)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScimSchedulerService) setSyncTime(providerID string, t time.Duration) {
|
||||
s.mu.Lock()
|
||||
s.providerSyncTime[providerID] = time.Now().Add(t)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"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"
|
||||
@@ -32,6 +33,11 @@ const scimErrorBodyLimit = 4096
|
||||
|
||||
type scimSyncAction int
|
||||
|
||||
type Scheduler interface {
|
||||
RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool, extraOptions ...gocron.JobOption) error
|
||||
RemoveJob(name string) error
|
||||
}
|
||||
|
||||
const (
|
||||
scimActionNone scimSyncAction = iota
|
||||
scimActionCreated
|
||||
@@ -48,15 +54,16 @@ type scimSyncStats struct {
|
||||
// ScimService handles SCIM provisioning to external service providers.
|
||||
type ScimService struct {
|
||||
db *gorm.DB
|
||||
scheduler Scheduler
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewScimService(db *gorm.DB, httpClient *http.Client) *ScimService {
|
||||
func NewScimService(db *gorm.DB, scheduler Scheduler, httpClient *http.Client) *ScimService {
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
|
||||
return &ScimService{db: db, httpClient: httpClient}
|
||||
return &ScimService{db: db, scheduler: scheduler, httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (s *ScimService) GetServiceProvider(
|
||||
@@ -132,6 +139,41 @@ func (s *ScimService) DeleteServiceProvider(ctx context.Context, serviceProvider
|
||||
Error
|
||||
}
|
||||
|
||||
//nolint:contextcheck
|
||||
func (s *ScimService) ScheduleSync() {
|
||||
jobName := "ScheduledScimSync"
|
||||
start := time.Now().Add(5 * time.Minute)
|
||||
|
||||
_ = s.scheduler.RemoveJob(jobName)
|
||||
|
||||
err := s.scheduler.RegisterJob(
|
||||
context.Background(), jobName,
|
||||
gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(start)), s.SyncAll, false)
|
||||
|
||||
if err != nil {
|
||||
slog.Error("Failed to schedule SCIM sync", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScimService) SyncAll(ctx context.Context) error {
|
||||
providers, err := s.ListServiceProviders(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for _, provider := range providers {
|
||||
if ctx.Err() != nil {
|
||||
errs = append(errs, ctx.Err())
|
||||
break
|
||||
}
|
||||
if err := s.SyncServiceProvider(ctx, provider.ID); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to sync SCIM provider %s: %w", provider.ID, err))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (s *ScimService) SyncServiceProvider(ctx context.Context, serviceProviderID string) error {
|
||||
start := time.Now()
|
||||
provider, err := s.GetServiceProvider(ctx, serviceProviderID)
|
||||
|
||||
@@ -16,11 +16,12 @@ import (
|
||||
|
||||
type UserGroupService struct {
|
||||
db *gorm.DB
|
||||
scimService *ScimService
|
||||
appConfigService *AppConfigService
|
||||
}
|
||||
|
||||
func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService) *UserGroupService {
|
||||
return &UserGroupService{db: db, appConfigService: appConfigService}
|
||||
func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService, scimService *ScimService) *UserGroupService {
|
||||
return &UserGroupService{db: db, appConfigService: appConfigService, scimService: scimService}
|
||||
}
|
||||
|
||||
func (s *UserGroupService) List(ctx context.Context, name string, listRequestOptions utils.ListRequestOptions) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
|
||||
@@ -90,7 +91,13 @@ func (s *UserGroupService) Delete(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserGroupService) Create(ctx context.Context, input dto.UserGroupCreateDto) (group model.UserGroup, err error) {
|
||||
@@ -118,6 +125,8 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro
|
||||
}
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
return group, nil
|
||||
}
|
||||
|
||||
@@ -165,6 +174,8 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
|
||||
} else if err != nil {
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
return group, nil
|
||||
}
|
||||
|
||||
@@ -227,6 +238,7 @@ func (s *UserGroupService) updateUsersInternal(ctx context.Context, id string, u
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
return group, nil
|
||||
}
|
||||
|
||||
@@ -303,5 +315,6 @@ func (s *UserGroupService) UpdateAllowedOidcClient(ctx context.Context, id strin
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
return group, nil
|
||||
}
|
||||
|
||||
@@ -9,13 +9,11 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/clause"
|
||||
|
||||
@@ -25,7 +23,6 @@ import (
|
||||
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/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||
profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image"
|
||||
)
|
||||
|
||||
@@ -37,10 +34,11 @@ type UserService struct {
|
||||
appConfigService *AppConfigService
|
||||
customClaimService *CustomClaimService
|
||||
appImagesService *AppImagesService
|
||||
scimService *ScimService
|
||||
fileStorage storage.FileStorage
|
||||
}
|
||||
|
||||
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService, appImagesService *AppImagesService, fileStorage storage.FileStorage) *UserService {
|
||||
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService {
|
||||
return &UserService{
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
@@ -49,6 +47,7 @@ func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditL
|
||||
appConfigService: appConfigService,
|
||||
customClaimService: customClaimService,
|
||||
appImagesService: appImagesService,
|
||||
scimService: scimService,
|
||||
fileStorage: fileStorage,
|
||||
}
|
||||
}
|
||||
@@ -226,6 +225,7 @@ func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userI
|
||||
return fmt.Errorf("failed to delete user: %w", err)
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -266,15 +266,16 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
||||
}
|
||||
|
||||
user := model.User{
|
||||
FirstName: input.FirstName,
|
||||
LastName: input.LastName,
|
||||
DisplayName: input.DisplayName,
|
||||
Email: input.Email,
|
||||
Username: input.Username,
|
||||
IsAdmin: input.IsAdmin,
|
||||
Locale: input.Locale,
|
||||
Disabled: input.Disabled,
|
||||
UserGroups: userGroups,
|
||||
FirstName: input.FirstName,
|
||||
LastName: input.LastName,
|
||||
DisplayName: input.DisplayName,
|
||||
Email: input.Email,
|
||||
EmailVerified: input.EmailVerified,
|
||||
Username: input.Username,
|
||||
IsAdmin: input.IsAdmin,
|
||||
Locale: input.Locale,
|
||||
Disabled: input.Disabled,
|
||||
UserGroups: userGroups,
|
||||
}
|
||||
if input.LdapID != "" {
|
||||
user.LdapID = &input.LdapID
|
||||
@@ -309,6 +310,7 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
||||
}
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -415,13 +417,20 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
|
||||
user.FirstName = updatedUser.FirstName
|
||||
user.LastName = updatedUser.LastName
|
||||
user.DisplayName = updatedUser.DisplayName
|
||||
user.Email = updatedUser.Email
|
||||
user.Username = updatedUser.Username
|
||||
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
|
||||
if !updateOwnUser {
|
||||
user.IsAdmin = updatedUser.IsAdmin
|
||||
user.EmailVerified = updatedUser.EmailVerified
|
||||
user.Disabled = updatedUser.Disabled
|
||||
}
|
||||
}
|
||||
@@ -447,167 +456,10 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
|
||||
return user, err
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
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) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
@@ -663,50 +515,10 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
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 {
|
||||
var result struct {
|
||||
Found bool
|
||||
@@ -753,180 +565,87 @@ func (s *UserService) ResetProfilePicture(ctx context.Context, userID string) er
|
||||
}
|
||||
|
||||
func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, userID string) error {
|
||||
return tx.
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Model(&model.User{}).
|
||||
Where("id = ?", userID).
|
||||
Update("disabled", true).
|
||||
Error
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (model.SignupToken, error) {
|
||||
signupToken, err := NewSignupToken(ttl, usageLimit)
|
||||
func (s *UserService) SendEmailVerification(ctx context.Context, userID string) error {
|
||||
user, err := s.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return model.SignupToken{}, err
|
||||
return 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
|
||||
if user.Email == nil {
|
||||
return &common.UserEmailNotSetError{}
|
||||
}
|
||||
|
||||
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()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
defer 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 config.AllowUserSignups.Value != "open" && !tokenProvided {
|
||||
return model.User{}, "", &common.OpenSignupDisabledError{}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &common.InvalidEmailVerificationTokenError{}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
user, err := s.createUserInternal(ctx, userToCreate, false, tx)
|
||||
user, err := s.getUserInternal(ctx, emailVerificationToken.UserID, tx)
|
||||
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 {
|
||||
return model.User{}, "", err
|
||||
return 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
|
||||
err = tx.WithContext(ctx).Delete(&emailVerificationToken).Error
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
return err
|
||||
}
|
||||
|
||||
return user, accessToken, nil
|
||||
}
|
||||
|
||||
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
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
216
backend/internal/service/user_signup_service.go
Normal file
216
backend/internal/service/user_signup_service.go
Normal 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
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
var AccessTokenCookieName = "__Host-access_token"
|
||||
var SessionIdCookieName = "__Host-session"
|
||||
var DeviceTokenCookieName = "__Host-device_token" //nolint:gosec
|
||||
var DeviceTokenCookieName = "__Secure-device_token" //nolint:gosec
|
||||
|
||||
func init() {
|
||||
if strings.HasPrefix(common.EnvConfig.AppURL, "http://") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
slog.Info("Fetching migrations from GitHub to handle possible downgrades")
|
||||
return migrateDatabaseFromGitHub(sqlDb, requiredVersion)
|
||||
return migrateDatabaseFromGitHub(sqlDb, requiredVersion, currentVersion)
|
||||
}
|
||||
|
||||
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.
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package jwk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
@@ -17,8 +18,8 @@ type KeyProviderOpts struct {
|
||||
|
||||
type KeyProvider interface {
|
||||
Init(opts KeyProviderOpts) error
|
||||
LoadKey() (jwk.Key, error)
|
||||
SaveKey(key jwk.Key) error
|
||||
LoadKey(ctx context.Context) (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) {
|
||||
|
||||
@@ -33,12 +33,12 @@ func (f *KeyProviderDatabase) Init(opts KeyProviderOpts) error {
|
||||
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{
|
||||
Key: PrivateKeyDBKey,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
err = f.db.WithContext(ctx).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -74,7 +74,7 @@ func (f *KeyProviderDatabase) LoadKey() (key jwk.Key, err error) {
|
||||
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
|
||||
data, err := EncodeJWKBytes(key)
|
||||
if err != nil {
|
||||
@@ -94,7 +94,7 @@ func (f *KeyProviderDatabase) SaveKey(key jwk.Key) error {
|
||||
Value: &encB64,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
err = f.db.
|
||||
WithContext(ctx).
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Load key when none exists
|
||||
loadedKey, err := provider.LoadKey()
|
||||
loadedKey, err := provider.LoadKey(t.Context())
|
||||
require.NoError(t, err)
|
||||
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)
|
||||
|
||||
// Save a key
|
||||
err = provider.SaveKey(key)
|
||||
err = provider.SaveKey(t.Context(), key)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Load the key
|
||||
loadedKey, err := provider.LoadKey()
|
||||
loadedKey, err := provider.LoadKey(t.Context())
|
||||
require.NoError(t, err)
|
||||
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)
|
||||
|
||||
// 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.ErrorContains(t, err, "not a valid base64-encoded value")
|
||||
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
|
||||
@@ -140,7 +140,7 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// 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.ErrorContains(t, err, "failed to decrypt")
|
||||
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
|
||||
@@ -158,7 +158,7 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = originalProvider.SaveKey(key)
|
||||
err = originalProvider.SaveKey(t.Context(), key)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Now try to load with a different KEK
|
||||
@@ -171,7 +171,7 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// 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.ErrorContains(t, err, "failed to decrypt")
|
||||
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
|
||||
@@ -206,7 +206,7 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// 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.ErrorContains(t, err, "failed to parse")
|
||||
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
|
||||
@@ -233,7 +233,7 @@ func TestKeyProviderDatabase_SaveKey(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Save the key
|
||||
err = provider.SaveKey(key)
|
||||
err = provider.SaveKey(t.Context(), key)
|
||||
require.NoError(t, err, "Expected no error when saving key")
|
||||
|
||||
// Verify record exists in database
|
||||
|
||||
@@ -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}}
|
||||
@@ -6,7 +6,6 @@ API KEY EXPIRING SOON
|
||||
Warning
|
||||
|
||||
Hello {{.Data.Name}},
|
||||
This is a reminder that your API key {{.Data.APIKeyName}} will expire on
|
||||
{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.
|
||||
This is a reminder that your API key {{.Data.APIKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.
|
||||
|
||||
Please generate a new API key if you need continued access.{{end}}
|
||||
@@ -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>   </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>   ​</i><![endif]--></span></a></div></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -5,15 +5,13 @@ NEW SIGN-IN DETECTED
|
||||
|
||||
Warning
|
||||
|
||||
Your {{.AppName}} account was recently accessed from a new IP address or
|
||||
browser. If you recognize this activity, no further action is required.
|
||||
Your {{.AppName}} account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.
|
||||
|
||||
DETAILS
|
||||
|
||||
Approximate Location
|
||||
|
||||
{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if
|
||||
.Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}
|
||||
{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}
|
||||
|
||||
IP Address
|
||||
|
||||
|
||||
@@ -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>   </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>   ​</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>   </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>   ​</i><![endif]--></span></a></div></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}
|
||||
@@ -4,8 +4,7 @@
|
||||
YOUR 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
|
||||
{{.Data.Code}}.
|
||||
Or visit {{.Data.LoginLink}} and enter the code {{.Data.Code}}.
|
||||
|
||||
This code expires in {{.Data.ExpirationString}}.
|
||||
|
||||
|
||||
@@ -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}}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE oidc_device_codes DROP COLUMN nonce;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE oidc_device_codes ADD COLUMN nonce VARCHAR(255);
|
||||
@@ -0,0 +1 @@
|
||||
-- No-op on Postgres
|
||||
@@ -0,0 +1 @@
|
||||
-- No-op on Postgres
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE email_verification_tokens;
|
||||
ALTER TABLE users DROP COLUMN email_verified;
|
||||
@@ -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');
|
||||
@@ -0,0 +1,5 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
ALTER TABLE oidc_device_codes DROP COLUMN nonce;
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,5 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
ALTER TABLE oidc_device_codes ADD COLUMN nonce TEXT;
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1 @@
|
||||
-- No-op
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
54
email-templates/emails/email-verification.tsx
Normal file
54
email-templates/emails/email-verification.tsx
Normal 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",
|
||||
},
|
||||
};
|
||||
@@ -46,7 +46,11 @@
|
||||
"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",
|
||||
"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.",
|
||||
"sign_in_to": "Přihlásit se k {name}",
|
||||
"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.",
|
||||
"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.",
|
||||
"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_disabled_successfully": "LDAP úspěšně zakázán",
|
||||
"ldap_sync_finished": "LDAP synchronizace dokončena",
|
||||
@@ -498,5 +500,26 @@
|
||||
"scim_sync_successful": "Synchronizace SCIM byla úspěšně dokončena.",
|
||||
"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í?",
|
||||
"scopes": "Rozsah"
|
||||
"scopes": "Rozsah",
|
||||
"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é."
|
||||
}
|
||||
|
||||
@@ -46,7 +46,11 @@
|
||||
"authenticator_does_not_support_resident_keys": "Godkenderen understøtter ikke gemte nøgler",
|
||||
"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_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.",
|
||||
"sign_in_to": "Log ind på {name}",
|
||||
"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.",
|
||||
"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.",
|
||||
"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_disabled_successfully": "LDAP blev deaktiveret",
|
||||
"ldap_sync_finished": "LDAP-synkronisering fuldført",
|
||||
@@ -498,5 +500,26 @@
|
||||
"scim_sync_successful": "SCIM-synkroniseringen er gennemført med succes.",
|
||||
"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?",
|
||||
"scopes": "Omfang"
|
||||
"scopes": "Omfang",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -46,7 +46,11 @@
|
||||
"authenticator_does_not_support_resident_keys": "Der Authentifikator unterstützt keine residenten Schlüssel",
|
||||
"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_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.",
|
||||
"sign_in_to": "Bei {name} anmelden",
|
||||
"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.",
|
||||
"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.",
|
||||
"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_disabled_successfully": "LDAP erfolgreich deaktiviert",
|
||||
"ldap_sync_finished": "LDAP-Synchronisation beendet",
|
||||
@@ -498,5 +500,26 @@
|
||||
"scim_sync_successful": "Die SCIM-Synchronisierung ist erfolgreich abgeschlossen worden.",
|
||||
"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?",
|
||||
"scopes": "Kopfsuchgeräte"
|
||||
"scopes": "Kopfsuchgeräte",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -46,7 +46,11 @@
|
||||
"authenticator_does_not_support_resident_keys": "The authenticator does not support resident keys",
|
||||
"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_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.",
|
||||
"sign_in_to": "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": "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_disabled_successfully": "LDAP disabled successfully",
|
||||
"ldap_sync_finished": "LDAP sync finished",
|
||||
@@ -498,5 +500,26 @@
|
||||
"scim_sync_successful": "The SCIM sync has been completed successfully.",
|
||||
"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?",
|
||||
"scopes": "Scopes"
|
||||
"scopes": "Scopes",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -46,7 +46,11 @@
|
||||
"authenticator_does_not_support_resident_keys": "El autenticador no soporta claves residentes",
|
||||
"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_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.",
|
||||
"sign_in_to": "Iniciar sesión en {name}",
|
||||
"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.",
|
||||
"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.",
|
||||
"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_disabled_successfully": "LDAP desactivado correctamente",
|
||||
"ldap_sync_finished": "Sincronización LDAP finalizada",
|
||||
@@ -498,5 +500,26 @@
|
||||
"scim_sync_successful": "La sincronización SCIM se ha completado correctamente.",
|
||||
"save_and_sync": "Guardar y sincronizar",
|
||||
"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",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -46,7 +46,11 @@
|
||||
"authenticator_does_not_support_resident_keys": "Todentaja ei tue laiteavaimia",
|
||||
"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_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.",
|
||||
"sign_in_to": "Kirjaudu palveluun {name}",
|
||||
"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.",
|
||||
"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.",
|
||||
"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_disabled_successfully": "LDAP poistettu käytöstä onnistuneesti",
|
||||
"ldap_sync_finished": "LDAP-synkronointi valmis",
|
||||
@@ -498,5 +500,26 @@
|
||||
"scim_sync_successful": "SCIM-synkronointi on suoritettu onnistuneesti.",
|
||||
"save_and_sync": "Tallenna ja synkronoi",
|
||||
"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",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -46,7 +46,11 @@
|
||||
"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",
|
||||
"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.",
|
||||
"sign_in_to": "Connexion à {name}",
|
||||
"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.",
|
||||
"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.",
|
||||
"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_disabled_successfully": "LDAP désactivé avec succès",
|
||||
"ldap_sync_finished": "Synchronisation LDAP terminée",
|
||||
@@ -498,5 +500,26 @@
|
||||
"scim_sync_successful": "La synchronisation SCIM s'est bien passée.",
|
||||
"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 ?",
|
||||
"scopes": "Portées"
|
||||
"scopes": "Portées",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -46,7 +46,11 @@
|
||||
"authenticator_does_not_support_resident_keys": "L'autenticatore non supporta le chiavi residenti",
|
||||
"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_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.",
|
||||
"sign_in_to": "Accedi a {name}",
|
||||
"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.",
|
||||
"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.",
|
||||
"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_disabled_successfully": "LDAP disabilitato con successo",
|
||||
"ldap_sync_finished": "Sincronizzazione LDAP completata",
|
||||
@@ -498,5 +500,26 @@
|
||||
"scim_sync_successful": "La sincronizzazione SCIM è andata a buon fine.",
|
||||
"save_and_sync": "Salva e sincronizza",
|
||||
"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",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -46,7 +46,11 @@
|
||||
"authenticator_does_not_support_resident_keys": "認証ツールは resident key をサポートしていません",
|
||||
"passkey_was_previously_registered": "このパスキーは既に登録されています",
|
||||
"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": "重大なエラーが発生しました。管理者にお問い合わせください。",
|
||||
"sign_in_to": "{name} にサインイン",
|
||||
"client_not_found": "クライアントが見つかりません",
|
||||
@@ -192,8 +196,6 @@
|
||||
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "ユーザーが再度ログインする必要があるまでのセッションの継続時間。(分単位)",
|
||||
"enable_self_account_editing": "自身のアカウント編集を有効にする",
|
||||
"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_disabled_successfully": "LDAPは正常に無効化されました",
|
||||
"ldap_sync_finished": "LDAP同期が完了しました",
|
||||
@@ -498,5 +500,26 @@
|
||||
"scim_sync_successful": "SCIM同期が正常に完了しました。",
|
||||
"save_and_sync": "保存と同期",
|
||||
"scim_save_changes_description": "SCIM同期を開始する前に変更を保存する必要があります。今すぐ保存しますか?",
|
||||
"scopes": "スコープ"
|
||||
"scopes": "スコープ",
|
||||
"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": "有効化すると、ユーザーが登録時またはメールアドレスを変更した際に、デフォルトでメールアドレスが確認済みとしてマークされます。"
|
||||
}
|
||||
|
||||
@@ -46,7 +46,11 @@
|
||||
"authenticator_does_not_support_resident_keys": "인증기가 레지던트 키를 지원하지 않습니다",
|
||||
"passkey_was_previously_registered": "이 패스키는 이미 등록되었습니다",
|
||||
"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": "치명적인 오류가 발생했습니다. 관리자에게 연락해주세요.",
|
||||
"sign_in_to": "{name}에 로그인",
|
||||
"client_not_found": "클라이언트를 찾을 수 없습니다",
|
||||
@@ -192,8 +196,6 @@
|
||||
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "사용자가 다시 로그인하기 전 세션의 시간(분)입니다.",
|
||||
"enable_self_account_editing": "셀프 계정 편집 활성화",
|
||||
"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_disabled_successfully": "LDAP가 성공적으로 비활성화되었습니다",
|
||||
"ldap_sync_finished": "LDAP 동기화 완료",
|
||||
@@ -498,5 +500,26 @@
|
||||
"scim_sync_successful": "SCIM 동기화가 성공적으로 완료되었습니다.",
|
||||
"save_and_sync": "저장 및 동기화",
|
||||
"scim_save_changes_description": "SCIM 동기화를 시작하기 전에 변경 사항을 저장해야 합니다. 지금 저장하시겠습니까?",
|
||||
"scopes": "범위"
|
||||
"scopes": "범위",
|
||||
"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": "이 기능이 활성화되면, 사용자의 이메일 주소는 가입 시 또는 이메일 주소 변경 시 기본적으로 확인된 상태로 표시됩니다."
|
||||
}
|
||||
|
||||
@@ -46,7 +46,11 @@
|
||||
"authenticator_does_not_support_resident_keys": "De authenticator ondersteunt geen vaste sleutels",
|
||||
"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_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.",
|
||||
"sign_in_to": "Meld je aan bij {name}",
|
||||
"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.",
|
||||
"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.",
|
||||
"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_disabled_successfully": "LDAP succesvol uitgeschakeld",
|
||||
"ldap_sync_finished": "LDAP-synchronisatie voltooid",
|
||||
@@ -498,5 +500,26 @@
|
||||
"scim_sync_successful": "De SCIM-synchronisatie is goed gelukt.",
|
||||
"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?",
|
||||
"scopes": "Scopes"
|
||||
"scopes": "Scopes",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -46,7 +46,11 @@
|
||||
"authenticator_does_not_support_resident_keys": "Autoryzator nie obsługuje kluczy rezydentnych",
|
||||
"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_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.",
|
||||
"sign_in_to": "Zaloguj się do {name}",
|
||||
"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ć.",
|
||||
"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.",
|
||||
"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_disabled_successfully": "Sukces! LDAP został wyłączony.",
|
||||
"ldap_sync_finished": "Synchronizacja LDAP zakończona",
|
||||
@@ -498,5 +500,26 @@
|
||||
"scim_sync_successful": "Synchronizacja SCIM została pomyślnie zakończona.",
|
||||
"save_and_sync": "Zapisz i zsynchronizuj",
|
||||
"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",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -46,7 +46,11 @@
|
||||
"authenticator_does_not_support_resident_keys": "O autenticador não aceita chaves residentes",
|
||||
"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_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.",
|
||||
"sign_in_to": "Entrar em {name}",
|
||||
"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.",
|
||||
"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.",
|
||||
"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_disabled_successfully": "LDAP desativado com sucesso",
|
||||
"ldap_sync_finished": "Sincronização LDAP concluída",
|
||||
@@ -498,5 +500,26 @@
|
||||
"scim_sync_successful": "A sincronização SCIM foi concluída com sucesso.",
|
||||
"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?",
|
||||
"scopes": "Âmbitos"
|
||||
"scopes": "Âmbitos",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -46,7 +46,11 @@
|
||||
"authenticator_does_not_support_resident_keys": "Аутентификатор не поддерживает резидентные ключи",
|
||||
"passkey_was_previously_registered": "Этот пасскей был ранее зарегистрирован",
|
||||
"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": "Произошла критическая ошибка. Обратитесь к администратору.",
|
||||
"sign_in_to": "Войти в {name}",
|
||||
"client_not_found": "Клиент не найден",
|
||||
@@ -192,8 +196,6 @@
|
||||
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Продолжительность сессии в минутах, прежде чем пользователь должен войти снова.",
|
||||
"enable_self_account_editing": "Включить редактирование собственной учетной записи",
|
||||
"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_disabled_successfully": "LDAP успешно отключен",
|
||||
"ldap_sync_finished": "Синхронизация с LDAP завершена",
|
||||
@@ -498,5 +500,26 @@
|
||||
"scim_sync_successful": "Синхронизация SCIM прошла без проблем.",
|
||||
"save_and_sync": "Сохранить и синхронизировать",
|
||||
"scim_save_changes_description": "Перед тем, как начать синхронизацию SCIM, нужно сохранить изменения. Хочешь сохранить сейчас?",
|
||||
"scopes": "Области применения"
|
||||
"scopes": "Области применения",
|
||||
"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": "Если эта функция включена, адреса электронной почты пользователей будут по умолчанию отмечаться как подтвержденные при регистрации или при изменении адреса электронной почты."
|
||||
}
|
||||
|
||||
@@ -46,7 +46,11 @@
|
||||
"authenticator_does_not_support_resident_keys": "Autentiseraren stöder inte lagrade nycklar",
|
||||
"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_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.",
|
||||
"sign_in_to": "Logga in på {name}",
|
||||
"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.",
|
||||
"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.",
|
||||
"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_disabled_successfully": "LDAP har inaktiverats",
|
||||
"ldap_sync_finished": "LDAP-synkronisering slutförd",
|
||||
@@ -498,5 +500,26 @@
|
||||
"scim_sync_successful": "SCIM-synkroniseringen har slutförts.",
|
||||
"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?",
|
||||
"scopes": "Omfattning"
|
||||
"scopes": "Omfattning",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -46,7 +46,11 @@
|
||||
"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",
|
||||
"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.",
|
||||
"sign_in_to": "{name} hesabına giriş yap",
|
||||
"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.",
|
||||
"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.",
|
||||
"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_disabled_successfully": "LDAP başarıyla devre dışı bırakıldı",
|
||||
"ldap_sync_finished": "LDAP senkronizasyonu tamamlandı",
|
||||
@@ -498,5 +500,26 @@
|
||||
"scim_sync_successful": "SCIM senkronizasyonu başarıyla tamamlandı.",
|
||||
"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?",
|
||||
"scopes": "Kapsamlar"
|
||||
"scopes": "Kapsamlar",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -46,7 +46,11 @@
|
||||
"authenticator_does_not_support_resident_keys": "Автентифікатор не підтримує локальні ключі",
|
||||
"passkey_was_previously_registered": "Цей ключ доступу був раніше зареєстрований",
|
||||
"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": "Виникла критична помилка. Будь ласка, зверніться до адміністратора.",
|
||||
"sign_in_to": "Увійти в {name}",
|
||||
"client_not_found": "Клієнта не знайдено",
|
||||
@@ -192,8 +196,6 @@
|
||||
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Тривалість сесії у хвилинах до повторного входу користувача.",
|
||||
"enable_self_account_editing": "Увімкнути редагування власного облікового запису",
|
||||
"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_disabled_successfully": "LDAP успішно вимкнено",
|
||||
"ldap_sync_finished": "Синхронізація LDAP завершена",
|
||||
@@ -498,5 +500,26 @@
|
||||
"scim_sync_successful": "Синхронізація SCIM успішно завершена.",
|
||||
"save_and_sync": "Зберегти та синхронізувати",
|
||||
"scim_save_changes_description": "Перед початком синхронізації SCIM необхідно зберегти зміни. Чи хочете ви зберегти зараз?",
|
||||
"scopes": "Області застосування"
|
||||
"scopes": "Області застосування",
|
||||
"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": "Якщо ця опція увімкнена, адреси електронної пошти користувачів будуть позначатися як підтверджені за замовчуванням під час реєстрації або при зміні адреси електронної пошти."
|
||||
}
|
||||
|
||||
@@ -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ữ",
|
||||
"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_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.",
|
||||
"sign_in_to": "Đăng nhập {name}",
|
||||
"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.",
|
||||
"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?",
|
||||
"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_disabled_successfully": "Tắt LDAP thành công",
|
||||
"ldap_sync_finished": "Quá trình đồng bộ hóa LDAP đã hoàn tất",
|
||||
@@ -498,5 +500,26 @@
|
||||
"scim_sync_successful": "Quá trình đồng bộ hóa SCIM đã hoàn tất thành công.",
|
||||
"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?",
|
||||
"scopes": "Phạm vi"
|
||||
"scopes": "Phạm vi",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"profile_picture": "头像",
|
||||
"profile_picture_is_managed_by_ldap_server": "头像由 LDAP 服务器管理,无法在此处更改。",
|
||||
"click_profile_picture_to_upload_custom": "点击头像来从文件中上传您的自定义头像。",
|
||||
"image_should_be_in_format": "图片应为 PNG、JPEG 或 WEBP 格式。",
|
||||
"image_should_be_in_format": "图片格式支持 PNG、JPEG 或 WEBP。",
|
||||
"items_per_page": "每页条数",
|
||||
"no_items_found": "这里暂时空空如也",
|
||||
"select_items": "选择项目……",
|
||||
@@ -46,7 +46,11 @@
|
||||
"authenticator_does_not_support_resident_keys": "认证器不支持常驻密钥",
|
||||
"passkey_was_previously_registered": "此通行密钥曾被注册",
|
||||
"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": "发生严重错误。请联系您的管理员。",
|
||||
"sign_in_to": "登录到 {name}",
|
||||
"client_not_found": "客户端未找到",
|
||||
@@ -95,7 +99,7 @@
|
||||
"settings": "设置",
|
||||
"update_pocket_id": "更新 Pocket ID",
|
||||
"powered_by": "",
|
||||
"see_your_recent_account_activities": "查看您账户在配置的保留期内的活动记录。",
|
||||
"see_your_recent_account_activities": "查看您账户的活动日志。日志保存期已在环境变量中配置。",
|
||||
"time": "时间",
|
||||
"event": "事件",
|
||||
"approximate_location": "大致位置",
|
||||
@@ -155,7 +159,7 @@
|
||||
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "您确定要撤销 API 密钥 \"{apiKeyName}\" 吗?这将中断使用此密钥的任何集成。",
|
||||
"last_used": "上次使用时间",
|
||||
"actions": "操作",
|
||||
"images_updated_successfully": "图片更新成功。更新过程可能需要几分钟时间。",
|
||||
"images_updated_successfully": "图片更新成功。可能需要几分钟生效。",
|
||||
"general": "常规",
|
||||
"configure_smtp_to_send_emails": "启用电子邮件通知,当检测到来自新设备或新位置的登录时提醒用户。",
|
||||
"ldap": "LDAP",
|
||||
@@ -192,8 +196,6 @@
|
||||
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "用户需再次登录之前的会话时长(以分钟为单位)。",
|
||||
"enable_self_account_editing": "启用用户自行编辑账户功能",
|
||||
"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_disabled_successfully": "LDAP 已成功禁用",
|
||||
"ldap_sync_finished": "LDAP 同步完成",
|
||||
@@ -306,16 +308,16 @@
|
||||
"client_secret": "客户端密钥",
|
||||
"show_more_details": "显示更多详情",
|
||||
"allowed_user_groups": "允许的用户组",
|
||||
"allowed_user_groups_description": "选择允许其成员登录此客户端的用户组。",
|
||||
"allowed_user_groups_description": "选择允许登录此客户端的用户组。",
|
||||
"allowed_user_groups_status_unrestricted_description": "未应用任何用户组限制。任何用户均可登录此客户端。",
|
||||
"unrestrict": "解除限制",
|
||||
"unrestrict": "取消限制",
|
||||
"restrict": "限制",
|
||||
"user_groups_restriction_updated_successfully": "用户组限制已成功更新",
|
||||
"allowed_user_groups_updated_successfully": "已成功更新允许的用户组",
|
||||
"favicon": "网站图标",
|
||||
"light_mode_logo": "浅色模式 Logo",
|
||||
"dark_mode_logo": "深色模式 Logo",
|
||||
"email_logo": "电子邮件徽标",
|
||||
"email_logo": "电子邮件图标",
|
||||
"background_image": "背景图片",
|
||||
"language": "语言",
|
||||
"reset_profile_picture_question": "重置头像?",
|
||||
@@ -332,7 +334,7 @@
|
||||
"all_clients": "所有客户端",
|
||||
"all_locations": "所有地方",
|
||||
"global_audit_log": "全局日志",
|
||||
"see_all_recent_account_activities": "查看所有用户在设定保留期内的账户活动。",
|
||||
"see_all_recent_account_activities": "查看所有用户的活动日志。日志保存期已在环境变量中配置。",
|
||||
"token_sign_in": "Token 登录",
|
||||
"client_authorization": "客户端授权",
|
||||
"new_client_authorization": "首次客户端授权",
|
||||
@@ -460,7 +462,7 @@
|
||||
"display_name": "显示名称",
|
||||
"configure_application_images": "配置应用图标",
|
||||
"ui_config_disabled_info_title": "用户界面配置已禁用",
|
||||
"ui_config_disabled_info_description": "由于应用配置设置已通过环境变量设定,用户界面配置已禁用。某些设置可能无法编辑。",
|
||||
"ui_config_disabled_info_description": "用户界面配置已被环境变量禁用。某些设置可能无法编辑。",
|
||||
"logo_from_url_description": "粘贴直接图片URL(svg、png、webp格式)。<link href=\"https://selfh.st/icons\">可在Selfh.st图标库</link>或<link href=\"https://dashboardicons.com\">仪表盘图标库</link>中查找图标。",
|
||||
"invalid_url": "无效网址",
|
||||
"require_user_email": "需要电子邮件地址",
|
||||
@@ -475,15 +477,15 @@
|
||||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
"system": "系统",
|
||||
"signup_token_user_groups_description": "自动将这些组分配给使用此令牌注册的用户。",
|
||||
"signup_token_user_groups_description": "使用此令牌注册时的自动分配给用户的用户组。",
|
||||
"allowed_oidc_clients": "允许的 OIDC 客户端",
|
||||
"allowed_oidc_clients_description": "选择允许此用户组成员登录的 OIDC 客户端。",
|
||||
"unrestrict_oidc_client": "解除限制 {clientName}",
|
||||
"confirm_unrestrict_oidc_client_description": "您确定要解除对该 OIDC 客户端的限制吗? <b>{clientName}</b>?此操作将移除此客户端的所有组分配,任何用户均可登录。",
|
||||
"allowed_oidc_clients_description": "此用户组成员有权访问的OIDC 客户端。",
|
||||
"unrestrict_oidc_client": "取消限制 {clientName}",
|
||||
"confirm_unrestrict_oidc_client_description": "您确定要取消对该 OIDC 客户端的限制吗? <b>{clientName}</b>?此操作将移除此客户端的所有组分配,任何用户均可登录。",
|
||||
"allowed_oidc_clients_updated_successfully": "允许的 OIDC 客户端已成功更新",
|
||||
"yes": "是的",
|
||||
"no": "不",
|
||||
"restricted": "限制",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"restricted": "受限",
|
||||
"scim_provisioning": "SCIM 配置",
|
||||
"scim_provisioning_description": "SCIM 配置功能可让您自动为 OIDC 客户端创建和删除用户及组。更多详情请参<link href='https://pocket-id.org/docs/configuration/scim'>阅文档</link>。",
|
||||
"scim_endpoint": "SCIM 端点",
|
||||
@@ -498,5 +500,26 @@
|
||||
"scim_sync_successful": "SCIM 同步已成功完成。",
|
||||
"save_and_sync": "保存并同步",
|
||||
"scim_save_changes_description": "在开始 SCIM 同步之前,您必须先保存更改。是否现在保存?",
|
||||
"scopes": "Scopes"
|
||||
"scopes": "Scopes",
|
||||
"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": "启用后,用户的电子邮件地址将在注册时或更改电子邮件地址时默认标记为已验证。"
|
||||
}
|
||||
|
||||
@@ -46,7 +46,11 @@
|
||||
"authenticator_does_not_support_resident_keys": "此驗證器不支援常駐金鑰",
|
||||
"passkey_was_previously_registered": "這個密碼金鑰先前已註冊",
|
||||
"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": "發生嚴重錯誤,請聯絡您的管理員。",
|
||||
"sign_in_to": "登入 {name}",
|
||||
"client_not_found": "找不到客戶端",
|
||||
@@ -192,8 +196,6 @@
|
||||
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "使用者需重新登入前的階段時長(以分鐘為單位)。",
|
||||
"enable_self_account_editing": "允許使用者自行編輯帳號資訊",
|
||||
"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_disabled_successfully": "LDAP 已成功停用",
|
||||
"ldap_sync_finished": "LDAP 同步完成",
|
||||
@@ -498,5 +500,26 @@
|
||||
"scim_sync_successful": "SCIM 同步已成功完成。",
|
||||
"save_and_sync": "儲存與同步",
|
||||
"scim_save_changes_description": "您必須在開始 SCIM 同步前儲存變更。現在要儲存嗎?",
|
||||
"scopes": "範圍"
|
||||
"scopes": "範圍",
|
||||
"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": "啟用此功能後,用戶的電子郵件地址將在註冊時或變更電子郵件地址時,預設標記為已驗證狀態。"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pocket-id-frontend",
|
||||
"version": "2.0.2",
|
||||
"version": "2.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user