mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-03-29 10:46:38 +00:00
Compare commits
7 Commits
copilot/su
...
v2.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b59e35cb59 | ||
|
|
a675d075d1 | ||
|
|
2f56d16f98 | ||
|
|
f4eb8db509 | ||
|
|
e7bd66d1a7 | ||
|
|
1d06817065 | ||
|
|
34890235ba |
32
CHANGELOG.md
32
CHANGELOG.md
@@ -1,3 +1,35 @@
|
|||||||
|
## v2.4.0
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- improve wildcard matching by using `go-urlpattern` ([#1332](https://github.com/pocket-id/pocket-id/pull/1332) by @stonith404)
|
||||||
|
- federated client credentials not working if sub ≠ client_id ([#1342](https://github.com/pocket-id/pocket-id/pull/1342) by @ItalyPaleAle)
|
||||||
|
- handle IPv6 addresses in callback URLs ([#1355](https://github.com/pocket-id/pocket-id/pull/1355) by @ItalyPaleAle)
|
||||||
|
- wildcard callback URLs blocked by browser-native URL validation ([#1359](https://github.com/pocket-id/pocket-id/pull/1359) by @Copilot)
|
||||||
|
- one-time-access-token route should get user ID from URL only ([#1358](https://github.com/pocket-id/pocket-id/pull/1358) by @ItalyPaleAle)
|
||||||
|
- various fixes in background jobs ([#1362](https://github.com/pocket-id/pocket-id/pull/1362) by @ItalyPaleAle)
|
||||||
|
- use URL keyboard type for callback URL inputs ([a675d07](https://github.com/pocket-id/pocket-id/commit/a675d075d1ab9b7ff8160f1cfc35bc0ea1f1980a) by @stonith404)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- allow first name and display name to be optional ([#1288](https://github.com/pocket-id/pocket-id/pull/1288) by @taoso)
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- bump svelte from 5.53.2 to 5.53.5 in the npm_and_yarn group across 1 directory ([#1348](https://github.com/pocket-id/pocket-id/pull/1348) by @dependabot[bot])
|
||||||
|
- bump @sveltejs/kit from 2.53.0 to 2.53.3 in the npm_and_yarn group across 1 directory ([#1349](https://github.com/pocket-id/pocket-id/pull/1349) by @dependabot[bot])
|
||||||
|
- update AAGUIDs ([#1354](https://github.com/pocket-id/pocket-id/pull/1354) by @github-actions[bot])
|
||||||
|
- add Português files ([01141b8](https://github.com/pocket-id/pocket-id/commit/01141b8c0f2e96a40fd876d3206e49a694fd12c4) by @kmendell)
|
||||||
|
- add Latvian files ([e0fc4cc](https://github.com/pocket-id/pocket-id/commit/e0fc4cc01bd51e5a97e46aad78a493a668049220) by @kmendell)
|
||||||
|
- fix wrong seed data ([e7bd66d](https://github.com/pocket-id/pocket-id/commit/e7bd66d1a77c89dde542b4385ba01dc0d432e434) by @stonith404)
|
||||||
|
- fix wrong seed data in `database.json` ([f4eb8db](https://github.com/pocket-id/pocket-id/commit/f4eb8db50993edacd90e919b39a5c6d9dd4924c7) by @stonith404)
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
|
||||||
|
- frontend performance optimizations ([#1344](https://github.com/pocket-id/pocket-id/pull/1344) by @ItalyPaleAle)
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.3.0...v2.4.0
|
||||||
|
|
||||||
## v2.3.0
|
## v2.3.0
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service
|
|||||||
appConfig: appConfig,
|
appConfig: appConfig,
|
||||||
httpClient: httpClient,
|
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, service.RegisterJobOpts{RunImmediately: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnalyticsJob struct {
|
type AnalyticsJob struct {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send every day at midnight
|
// 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, service.RegisterJobOpts{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {
|
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {
|
||||||
@@ -42,7 +42,11 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err
|
|||||||
}
|
}
|
||||||
err = j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key)
|
err = j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Failed to send expiring API key notification email", slog.String("key", key.ID), slog.Any("error", err))
|
slog.ErrorContext(ctx, "Failed to send expiring API key notification email",
|
||||||
|
slog.String("key", key.ID),
|
||||||
|
slog.String("user", key.User.ID),
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -7,28 +7,37 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-co-op/gocron/v2"
|
backoff "github.com/cenkalti/backoff/v5"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) error {
|
func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) error {
|
||||||
jobs := &DbCleanupJobs{db: db}
|
jobs := &DbCleanupJobs{db: db}
|
||||||
|
|
||||||
// Run every 24 hours (but with some jitter so they don't run at the exact same time), and now
|
newBackOff := func() *backoff.ExponentialBackOff {
|
||||||
def := gocron.DurationRandomJob(24*time.Hour-2*time.Minute, 24*time.Hour+2*time.Minute)
|
bo := backoff.NewExponentialBackOff()
|
||||||
|
bo.Multiplier = 4
|
||||||
|
bo.RandomizationFactor = 0.1
|
||||||
|
bo.InitialInterval = time.Second
|
||||||
|
bo.MaxInterval = 45 * time.Second
|
||||||
|
return bo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use exponential backoff for each DB cleanup job so transient query failures are retried automatically rather than causing an immediate job failure
|
||||||
return errors.Join(
|
return errors.Join(
|
||||||
s.RegisterJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
|
s.RegisterJob(ctx, "ClearWebauthnSessions", jobDefWithJitter(24*time.Hour), jobs.clearWebauthnSessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||||
s.RegisterJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
|
s.RegisterJob(ctx, "ClearOneTimeAccessTokens", jobDefWithJitter(24*time.Hour), jobs.clearOneTimeAccessTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||||
s.RegisterJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
|
s.RegisterJob(ctx, "ClearSignupTokens", jobDefWithJitter(24*time.Hour), jobs.clearSignupTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||||
s.RegisterJob(ctx, "ClearEmailVerificationTokens", def, jobs.clearEmailVerificationTokens, true),
|
s.RegisterJob(ctx, "ClearEmailVerificationTokens", jobDefWithJitter(24*time.Hour), jobs.clearEmailVerificationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||||
s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
|
s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", jobDefWithJitter(24*time.Hour), jobs.clearOidcAuthorizationCodes, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||||
s.RegisterJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
|
s.RegisterJob(ctx, "ClearOidcRefreshTokens", jobDefWithJitter(24*time.Hour), jobs.clearOidcRefreshTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||||
s.RegisterJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true),
|
s.RegisterJob(ctx, "ClearReauthenticationTokens", jobDefWithJitter(24*time.Hour), jobs.clearReauthenticationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||||
s.RegisterJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
|
s.RegisterJob(ctx, "ClearAuditLogs", jobDefWithJitter(24*time.Hour), jobs.clearAuditLogs, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,20 +13,26 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB, fileStorage storage.FileStorage) error {
|
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB, fileStorage storage.FileStorage) error {
|
||||||
jobs := &FileCleanupJobs{db: db, fileStorage: fileStorage}
|
jobs := &FileCleanupJobs{db: db, fileStorage: fileStorage}
|
||||||
|
|
||||||
err := s.RegisterJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, false)
|
var errs []error
|
||||||
|
errs = append(errs,
|
||||||
|
s.RegisterJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, service.RegisterJobOpts{}),
|
||||||
|
)
|
||||||
|
|
||||||
// Only necessary for file system storage
|
// Only necessary for file system storage
|
||||||
if fileStorage.Type() == storage.TypeFileSystem {
|
if fileStorage.Type() == storage.TypeFileSystem {
|
||||||
err = errors.Join(err, s.RegisterJob(ctx, "ClearOrphanedTempFiles", gocron.DurationJob(12*time.Hour), jobs.clearOrphanedTempFiles, true))
|
errs = append(errs,
|
||||||
|
s.RegisterJob(ctx, "ClearOrphanedTempFiles", gocron.DurationJob(12*time.Hour), jobs.clearOrphanedTempFiles, service.RegisterJobOpts{RunImmediately: true}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileCleanupJobs struct {
|
type FileCleanupJobs struct {
|
||||||
@@ -68,7 +74,8 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context)
|
|||||||
// If these initials aren't used by any user, delete the file
|
// If these initials aren't used by any user, delete the file
|
||||||
if _, ok := initialsInUse[initials]; !ok {
|
if _, ok := initialsInUse[initials]; !ok {
|
||||||
filePath := path.Join(defaultPicturesDir, filename)
|
filePath := path.Join(defaultPicturesDir, filename)
|
||||||
if err := j.fileStorage.Delete(ctx, filePath); err != nil {
|
err = j.fileStorage.Delete(ctx, filePath)
|
||||||
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Failed to delete unused default profile picture", slog.String("path", filePath), slog.Any("error", err))
|
slog.ErrorContext(ctx, "Failed to delete unused default profile picture", slog.String("path", filePath), slog.Any("error", err))
|
||||||
} else {
|
} else {
|
||||||
filesDeleted++
|
filesDeleted++
|
||||||
@@ -95,8 +102,9 @@ func (j *FileCleanupJobs) clearOrphanedTempFiles(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := j.fileStorage.Delete(ctx, p.Path); err != nil {
|
rErr := j.fileStorage.Delete(ctx, p.Path)
|
||||||
slog.ErrorContext(ctx, "Failed to delete temp file", slog.String("path", p.Path), slog.Any("error", err))
|
if rErr != nil {
|
||||||
|
slog.ErrorContext(ctx, "Failed to delete temp file", slog.String("path", p.Path), slog.Any("error", rErr))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
deleted++
|
deleted++
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func (s *Scheduler) RegisterGeoLiteUpdateJobs(ctx context.Context, geoLiteServic
|
|||||||
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
|
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
|
||||||
|
|
||||||
// Run every 24 hours (and right away)
|
// 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, service.RegisterJobOpts{RunImmediately: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error {
|
func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error {
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-co-op/gocron/v2"
|
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,8 +15,8 @@ type LdapJobs struct {
|
|||||||
func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.LdapService, appConfigService *service.AppConfigService) error {
|
func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.LdapService, appConfigService *service.AppConfigService) error {
|
||||||
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
|
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
|
||||||
|
|
||||||
// Register the job to run every hour
|
// Register the job to run every hour (with some jitter)
|
||||||
return s.RegisterJob(ctx, "SyncLdap", gocron.DurationJob(time.Hour), jobs.syncLdap, true)
|
return s.RegisterJob(ctx, "SyncLdap", jobDefWithJitter(time.Hour), jobs.syncLdap, service.RegisterJobOpts{RunImmediately: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *LdapJobs) syncLdap(ctx context.Context) error {
|
func (j *LdapJobs) syncLdap(ctx context.Context) error {
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
backoff "github.com/cenkalti/backoff/v5"
|
||||||
"github.com/go-co-op/gocron/v2"
|
"github.com/go-co-op/gocron/v2"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Scheduler struct {
|
type Scheduler struct {
|
||||||
@@ -33,16 +37,12 @@ func (s *Scheduler) RemoveJob(name string) error {
|
|||||||
if job.Name() == name {
|
if job.Name() == name {
|
||||||
err := s.scheduler.RemoveJob(job.ID())
|
err := s.scheduler.RemoveJob(job.ID())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errs = append(errs, fmt.Errorf("failed to unqueue job %q with ID %q: %w", name, job.ID().String(), err))
|
errs = append(errs, fmt.Errorf("failed to dequeue job %q with ID %q: %w", name, job.ID().String(), err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(errs) > 0 {
|
|
||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the scheduler.
|
// Run the scheduler.
|
||||||
@@ -64,7 +64,29 @@ func (s *Scheduler) Run(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool, extraOptions ...gocron.JobOption) error {
|
func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, jobFn func(ctx context.Context) error, opts service.RegisterJobOpts) error {
|
||||||
|
// If a BackOff strategy is provided, wrap the job with retry logic
|
||||||
|
if opts.BackOff != nil {
|
||||||
|
origJob := jobFn
|
||||||
|
jobFn = func(ctx context.Context) error {
|
||||||
|
_, err := backoff.Retry(
|
||||||
|
ctx,
|
||||||
|
func() (struct{}, error) {
|
||||||
|
return struct{}{}, origJob(ctx)
|
||||||
|
},
|
||||||
|
backoff.WithBackOff(opts.BackOff),
|
||||||
|
backoff.WithNotify(func(err error, d time.Duration) {
|
||||||
|
slog.WarnContext(ctx, "Job failed, retrying",
|
||||||
|
slog.String("name", name),
|
||||||
|
slog.Any("error", err),
|
||||||
|
slog.Duration("retryIn", d),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
jobOptions := []gocron.JobOption{
|
jobOptions := []gocron.JobOption{
|
||||||
gocron.WithContext(ctx),
|
gocron.WithContext(ctx),
|
||||||
gocron.WithName(name),
|
gocron.WithName(name),
|
||||||
@@ -91,13 +113,13 @@ func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.Job
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
if runImmediately {
|
if opts.RunImmediately {
|
||||||
jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately()))
|
jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately()))
|
||||||
}
|
}
|
||||||
|
|
||||||
jobOptions = append(jobOptions, extraOptions...)
|
jobOptions = append(jobOptions, opts.ExtraOptions...)
|
||||||
|
|
||||||
_, err := s.scheduler.NewJob(def, gocron.NewTask(job), jobOptions...)
|
_, err := s.scheduler.NewJob(def, gocron.NewTask(jobFn), jobOptions...)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to register job %q: %w", name, err)
|
return fmt.Errorf("failed to register job %q: %w", name, err)
|
||||||
@@ -105,3 +127,9 @@ func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.Job
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func jobDefWithJitter(interval time.Duration) gocron.JobDefinition {
|
||||||
|
const jitter = 5 * time.Minute
|
||||||
|
|
||||||
|
return gocron.DurationRandomJob(interval-jitter, interval+jitter)
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ type ScimJobs struct {
|
|||||||
func (s *Scheduler) RegisterScimJobs(ctx context.Context, scimService *service.ScimService) error {
|
func (s *Scheduler) RegisterScimJobs(ctx context.Context, scimService *service.ScimService) error {
|
||||||
jobs := &ScimJobs{scimService: scimService}
|
jobs := &ScimJobs{scimService: scimService}
|
||||||
|
|
||||||
// Register the job to run every hour
|
// Register the job to run every hour (with some jitter)
|
||||||
return s.RegisterJob(ctx, "SyncScim", gocron.DurationJob(time.Hour), jobs.SyncScim, true)
|
return s.RegisterJob(ctx, "SyncScim", gocron.DurationJob(time.Hour), jobs.SyncScim, service.RegisterJobOpts{RunImmediately: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *ScimJobs) SyncScim(ctx context.Context) error {
|
func (j *ScimJobs) SyncScim(ctx context.Context) error {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
@@ -205,36 +206,33 @@ func (s *ApiKeyService) ListExpiringApiKeys(ctx context.Context, daysAhead int)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ApiKeyService) SendApiKeyExpiringSoonEmail(ctx context.Context, apiKey model.ApiKey) error {
|
func (s *ApiKeyService) SendApiKeyExpiringSoonEmail(ctx context.Context, apiKey model.ApiKey) error {
|
||||||
user := apiKey.User
|
if apiKey.User.Email == nil {
|
||||||
|
|
||||||
if user.ID == "" {
|
|
||||||
if err := s.db.WithContext(ctx).First(&user, "id = ?", apiKey.UserID).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if user.Email == nil {
|
|
||||||
return &common.UserEmailNotSetError{}
|
return &common.UserEmailNotSetError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
err := SendEmail(ctx, s.emailService, email.Address{
|
err := SendEmail(ctx, s.emailService, email.Address{
|
||||||
Name: user.FullName(),
|
Name: apiKey.User.FullName(),
|
||||||
Email: *user.Email,
|
Email: *apiKey.User.Email,
|
||||||
}, ApiKeyExpiringSoonTemplate, &ApiKeyExpiringSoonTemplateData{
|
}, ApiKeyExpiringSoonTemplate, &ApiKeyExpiringSoonTemplateData{
|
||||||
ApiKeyName: apiKey.Name,
|
ApiKeyName: apiKey.Name,
|
||||||
ExpiresAt: apiKey.ExpiresAt.ToTime(),
|
ExpiresAt: apiKey.ExpiresAt.ToTime(),
|
||||||
Name: user.FirstName,
|
Name: apiKey.User.FirstName,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("error sending notification email: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark the API key as having had an expiration email sent
|
// Mark the API key as having had an expiration email sent
|
||||||
return s.db.WithContext(ctx).
|
err = s.db.WithContext(ctx).
|
||||||
Model(&model.ApiKey{}).
|
Model(&model.ApiKey{}).
|
||||||
Where("id = ?", apiKey.ID).
|
Where("id = ?", apiKey.ID).
|
||||||
Update("expiration_email_sent", true).
|
Update("expiration_email_sent", true).
|
||||||
Error
|
Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error recording expiration sent email in database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ApiKeyService) initStaticApiKeyUser(ctx context.Context) (user model.User, err error) {
|
func (s *ApiKeyService) initStaticApiKeyUser(ctx context.Context) (user model.User, err error) {
|
||||||
|
|||||||
@@ -73,7 +73,10 @@ func (lv *lockValue) Unmarshal(raw string) error {
|
|||||||
// Acquire obtains the lock. When force is true, the lock is stolen from any existing owner.
|
// Acquire obtains the lock. When force is true, the lock is stolen from any existing owner.
|
||||||
// If the lock is forcefully acquired, it blocks until the previous lock has expired.
|
// If the lock is forcefully acquired, it blocks until the previous lock has expired.
|
||||||
func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil time.Time, err error) {
|
func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil time.Time, err error) {
|
||||||
tx := s.db.Begin()
|
tx := s.db.WithContext(ctx).Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("begin lock transaction: %w", tx.Error)
|
||||||
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
}()
|
}()
|
||||||
@@ -174,7 +177,8 @@ func (s *AppLockService) RunRenewal(ctx context.Context) error {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil
|
return nil
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if err := s.renew(ctx); err != nil {
|
err := s.renew(ctx)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("renew lock: %w", err)
|
return fmt.Errorf("renew lock: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,33 +187,43 @@ func (s *AppLockService) RunRenewal(ctx context.Context) error {
|
|||||||
|
|
||||||
// Release releases the lock if it is held by this process.
|
// Release releases the lock if it is held by this process.
|
||||||
func (s *AppLockService) Release(ctx context.Context) error {
|
func (s *AppLockService) Release(ctx context.Context) error {
|
||||||
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
db, err := s.db.DB()
|
||||||
defer cancel()
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get DB connection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var query string
|
var query string
|
||||||
switch s.db.Name() {
|
switch s.db.Name() {
|
||||||
case "sqlite":
|
case "sqlite":
|
||||||
query = `
|
query = `
|
||||||
DELETE FROM kv
|
DELETE FROM kv
|
||||||
WHERE key = ?
|
WHERE key = ?
|
||||||
AND json_extract(value, '$.lock_id') = ?
|
AND json_extract(value, '$.lock_id') = ?
|
||||||
`
|
`
|
||||||
case "postgres":
|
case "postgres":
|
||||||
query = `
|
query = `
|
||||||
DELETE FROM kv
|
DELETE FROM kv
|
||||||
WHERE key = $1
|
WHERE key = $1
|
||||||
AND value::json->>'lock_id' = $2
|
AND value::json->>'lock_id' = $2
|
||||||
`
|
`
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported database dialect: %s", s.db.Name())
|
return fmt.Errorf("unsupported database dialect: %s", s.db.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
res := s.db.WithContext(opCtx).Exec(query, lockKey, s.lockID)
|
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
if res.Error != nil {
|
defer cancel()
|
||||||
return fmt.Errorf("release lock failed: %w", res.Error)
|
|
||||||
|
res, err := db.ExecContext(opCtx, query, lockKey, s.lockID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("release lock failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if res.RowsAffected == 0 {
|
count, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to count affected rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
slog.Warn("Application lock not held by this process, cannot release",
|
slog.Warn("Application lock not held by this process, cannot release",
|
||||||
slog.Int64("process_id", s.processID),
|
slog.Int64("process_id", s.processID),
|
||||||
slog.String("host_id", s.hostID),
|
slog.String("host_id", s.hostID),
|
||||||
@@ -225,6 +239,11 @@ func (s *AppLockService) Release(ctx context.Context) error {
|
|||||||
|
|
||||||
// renew tries to renew the lock, retrying up to renewRetries times (sleeping 1s between attempts).
|
// renew tries to renew the lock, retrying up to renewRetries times (sleeping 1s between attempts).
|
||||||
func (s *AppLockService) renew(ctx context.Context) error {
|
func (s *AppLockService) renew(ctx context.Context) error {
|
||||||
|
db, err := s.db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get DB connection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 1; attempt <= renewRetries; attempt++ {
|
for attempt := 1; attempt <= renewRetries; attempt++ {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -246,42 +265,56 @@ func (s *AppLockService) renew(ctx context.Context) error {
|
|||||||
switch s.db.Name() {
|
switch s.db.Name() {
|
||||||
case "sqlite":
|
case "sqlite":
|
||||||
query = `
|
query = `
|
||||||
UPDATE kv
|
UPDATE kv
|
||||||
SET value = ?
|
SET value = ?
|
||||||
WHERE key = ?
|
WHERE key = ?
|
||||||
AND json_extract(value, '$.lock_id') = ?
|
AND json_extract(value, '$.lock_id') = ?
|
||||||
AND json_extract(value, '$.expires_at') > ?
|
AND json_extract(value, '$.expires_at') > ?
|
||||||
`
|
`
|
||||||
case "postgres":
|
case "postgres":
|
||||||
query = `
|
query = `
|
||||||
UPDATE kv
|
UPDATE kv
|
||||||
SET value = $1
|
SET value = $1
|
||||||
WHERE key = $2
|
WHERE key = $2
|
||||||
AND value::json->>'lock_id' = $3
|
AND value::json->>'lock_id' = $3
|
||||||
AND ((value::json->>'expires_at')::bigint > $4)
|
AND ((value::json->>'expires_at')::bigint > $4)
|
||||||
`
|
`
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported database dialect: %s", s.db.Name())
|
return fmt.Errorf("unsupported database dialect: %s", s.db.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
res := s.db.WithContext(opCtx).Exec(query, raw, lockKey, s.lockID, nowUnix)
|
res, err := db.ExecContext(opCtx, query, raw, lockKey, s.lockID, nowUnix)
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
switch {
|
// Query succeeded, but may have updated 0 rows
|
||||||
case res.Error != nil:
|
if err == nil {
|
||||||
lastErr = fmt.Errorf("lock renewal failed: %w", res.Error)
|
count, err := res.RowsAffected()
|
||||||
case res.RowsAffected == 0:
|
if err != nil {
|
||||||
// Must be after checking res.Error
|
return fmt.Errorf("failed to count affected rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no rows were updated, we lost the lock
|
||||||
|
if count == 0 {
|
||||||
return ErrLockLost
|
return ErrLockLost
|
||||||
default:
|
}
|
||||||
|
|
||||||
|
// All good
|
||||||
slog.Debug("Renewed application lock",
|
slog.Debug("Renewed application lock",
|
||||||
slog.Int64("process_id", s.processID),
|
slog.Int64("process_id", s.processID),
|
||||||
slog.String("host_id", s.hostID),
|
slog.String("host_id", s.hostID),
|
||||||
|
slog.Duration("duration", time.Since(now)),
|
||||||
)
|
)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we're here, we have an error that can be retried
|
||||||
|
slog.Debug("Application lock renewal attempt failed",
|
||||||
|
slog.Any("error", err),
|
||||||
|
slog.Duration("duration", time.Since(now)),
|
||||||
|
)
|
||||||
|
lastErr = fmt.Errorf("lock renewal failed: %w", err)
|
||||||
|
|
||||||
// Wait before next attempt or cancel if context is done
|
// Wait before next attempt or cancel if context is done
|
||||||
if attempt < renewRetries {
|
if attempt < renewRetries {
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -49,6 +49,23 @@ func readLockValue(t *testing.T, db *gorm.DB) lockValue {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lockDatabaseForWrite(t *testing.T, db *gorm.DB) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tx := db.Begin()
|
||||||
|
require.NoError(t, tx.Error)
|
||||||
|
|
||||||
|
// Keep a write transaction open to block other queries.
|
||||||
|
err := tx.Exec(
|
||||||
|
`INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING`,
|
||||||
|
lockKey,
|
||||||
|
`{"expires_at":0}`,
|
||||||
|
).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return tx
|
||||||
|
}
|
||||||
|
|
||||||
func TestAppLockServiceAcquire(t *testing.T) {
|
func TestAppLockServiceAcquire(t *testing.T) {
|
||||||
t.Run("creates new lock when none exists", func(t *testing.T) {
|
t.Run("creates new lock when none exists", func(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
@@ -99,6 +116,66 @@ func TestAppLockServiceAcquire(t *testing.T) {
|
|||||||
require.Equal(t, service.hostID, stored.HostID)
|
require.Equal(t, service.hostID, stored.HostID)
|
||||||
require.Greater(t, stored.ExpiresAt, time.Now().Unix())
|
require.Greater(t, stored.ExpiresAt, time.Now().Unix())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("force acquisition returns wait duration when stealing active lock", func(t *testing.T) {
|
||||||
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
service := newTestAppLockService(t, db)
|
||||||
|
|
||||||
|
existing := lockValue{
|
||||||
|
ProcessID: 99,
|
||||||
|
HostID: "other-host",
|
||||||
|
LockID: "other-lock-id",
|
||||||
|
ExpiresAt: time.Now().Add(ttl).Unix(),
|
||||||
|
}
|
||||||
|
insertLock(t, db, existing)
|
||||||
|
|
||||||
|
waitUntil, err := service.Acquire(context.Background(), true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.WithinDuration(t, time.Unix(existing.ExpiresAt, 0), waitUntil, time.Second)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("force acquisition does not wait when lock id is unchanged", func(t *testing.T) {
|
||||||
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
service := newTestAppLockService(t, db)
|
||||||
|
|
||||||
|
insertLock(t, db, lockValue{
|
||||||
|
ProcessID: 99,
|
||||||
|
HostID: "other-host",
|
||||||
|
LockID: service.lockID,
|
||||||
|
ExpiresAt: time.Now().Add(ttl).Unix(),
|
||||||
|
})
|
||||||
|
|
||||||
|
waitUntil, err := service.Acquire(context.Background(), true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, waitUntil.IsZero())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns error when existing lock value is invalid JSON", func(t *testing.T) {
|
||||||
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
service := newTestAppLockService(t, db)
|
||||||
|
|
||||||
|
raw := "this-is-not-json"
|
||||||
|
err := db.Create(&model.KV{Key: lockKey, Value: &raw}).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = service.Acquire(context.Background(), false)
|
||||||
|
require.ErrorContains(t, err, "decode existing lock value")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) {
|
||||||
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
service := newTestAppLockService(t, db)
|
||||||
|
|
||||||
|
tx := lockDatabaseForWrite(t, db)
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := service.Acquire(ctx, false)
|
||||||
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||||
|
require.ErrorContains(t, err, "begin lock transaction")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAppLockServiceRelease(t *testing.T) {
|
func TestAppLockServiceRelease(t *testing.T) {
|
||||||
@@ -134,6 +211,24 @@ func TestAppLockServiceRelease(t *testing.T) {
|
|||||||
stored := readLockValue(t, db)
|
stored := readLockValue(t, db)
|
||||||
require.Equal(t, existing, stored)
|
require.Equal(t, existing, stored)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) {
|
||||||
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
service := newTestAppLockService(t, db)
|
||||||
|
|
||||||
|
_, err := service.Acquire(context.Background(), false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tx := lockDatabaseForWrite(t, db)
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err = service.Release(ctx)
|
||||||
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||||
|
require.ErrorContains(t, err, "release lock failed")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAppLockServiceRenew(t *testing.T) {
|
func TestAppLockServiceRenew(t *testing.T) {
|
||||||
@@ -186,4 +281,21 @@ func TestAppLockServiceRenew(t *testing.T) {
|
|||||||
err = service.renew(context.Background())
|
err = service.renew(context.Background())
|
||||||
require.ErrorIs(t, err, ErrLockLost)
|
require.ErrorIs(t, err, ErrLockLost)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) {
|
||||||
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
service := newTestAppLockService(t, db)
|
||||||
|
|
||||||
|
_, err := service.Acquire(context.Background(), false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tx := lockDatabaseForWrite(t, db)
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err = service.renew(ctx)
|
||||||
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
|||||||
Nonce: "nonce",
|
Nonce: "nonce",
|
||||||
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||||
UserID: users[1].ID,
|
UserID: users[1].ID,
|
||||||
ClientID: oidcClients[2].ID,
|
ClientID: oidcClients[3].ID,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, authCode := range authCodes {
|
for _, authCode := range authCodes {
|
||||||
|
|||||||
@@ -150,7 +150,8 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Addr
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send the email
|
// Send the email
|
||||||
if err := srv.sendEmailContent(client, toEmail, c); err != nil {
|
err = srv.sendEmailContent(client, toEmail, c)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("send email content: %w", err)
|
return fmt.Errorf("send email content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -404,7 +404,7 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if authorizationCodeMetaData.ClientID != input.ClientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
|
if authorizationCodeMetaData.ClientID != input.ClientID || authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
|
||||||
return CreatedTokens{}, &common.OidcInvalidAuthorizationCodeError{}
|
return CreatedTokens{}, &common.OidcInvalidAuthorizationCodeError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
25
backend/internal/service/scheduler.go
Normal file
25
backend/internal/service/scheduler.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
backoff "github.com/cenkalti/backoff/v5"
|
||||||
|
"github.com/go-co-op/gocron/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterJobOpts holds optional configuration for registering a scheduled job.
|
||||||
|
type RegisterJobOpts struct {
|
||||||
|
// RunImmediately runs the job immediately after registration.
|
||||||
|
RunImmediately bool
|
||||||
|
// ExtraOptions are additional gocron job options.
|
||||||
|
ExtraOptions []gocron.JobOption
|
||||||
|
// BackOff is an optional backoff strategy. If non-nil, the job will be wrapped
|
||||||
|
// with automatic retry logic using the provided backoff on transient failures.
|
||||||
|
BackOff backoff.BackOff
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scheduler is an interface for registering and managing background jobs.
|
||||||
|
type Scheduler interface {
|
||||||
|
RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, opts RegisterJobOpts) error
|
||||||
|
RemoveJob(name string) error
|
||||||
|
}
|
||||||
@@ -34,11 +34,6 @@ const scimErrorBodyLimit = 4096
|
|||||||
|
|
||||||
type scimSyncAction int
|
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 (
|
const (
|
||||||
scimActionNone scimSyncAction = iota
|
scimActionNone scimSyncAction = iota
|
||||||
scimActionCreated
|
scimActionCreated
|
||||||
@@ -149,7 +144,7 @@ func (s *ScimService) ScheduleSync() {
|
|||||||
|
|
||||||
err := s.scheduler.RegisterJob(
|
err := s.scheduler.RegisterJob(
|
||||||
context.Background(), jobName,
|
context.Background(), jobName,
|
||||||
gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(start)), s.SyncAll, false)
|
gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(start)), s.SyncAll, RegisterJobOpts{})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to schedule SCIM sync", slog.Any("error", err))
|
slog.Error("Failed to schedule SCIM sync", slog.Any("error", err))
|
||||||
@@ -168,7 +163,8 @@ func (s *ScimService) SyncAll(ctx context.Context) error {
|
|||||||
errs = append(errs, ctx.Err())
|
errs = append(errs, ctx.Err())
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if err := s.SyncServiceProvider(ctx, provider.ID); err != nil {
|
err = s.SyncServiceProvider(ctx, provider.ID)
|
||||||
|
if err != nil {
|
||||||
errs = append(errs, fmt.Errorf("failed to sync SCIM provider %s: %w", provider.ID, err))
|
errs = append(errs, fmt.Errorf("failed to sync SCIM provider %s: %w", provider.ID, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,26 +206,20 @@ func (s *ScimService) SyncServiceProvider(ctx context.Context, serviceProviderID
|
|||||||
}
|
}
|
||||||
|
|
||||||
var errs []error
|
var errs []error
|
||||||
var userStats scimSyncStats
|
|
||||||
var groupStats scimSyncStats
|
|
||||||
|
|
||||||
// Sync users first, so that groups can reference them
|
// Sync users first, so that groups can reference them
|
||||||
if stats, err := s.syncUsers(ctx, provider, users, &userResources); err != nil {
|
userStats, err := s.syncUsers(ctx, provider, users, &userResources)
|
||||||
errs = append(errs, err)
|
if err != nil {
|
||||||
userStats = stats
|
errs = append(errs, err)
|
||||||
} else {
|
}
|
||||||
userStats = stats
|
|
||||||
}
|
groupStats, err := s.syncGroups(ctx, provider, groups, groupResources.Resources, userResources.Resources)
|
||||||
|
|
||||||
stats, err := s.syncGroups(ctx, provider, groups, groupResources.Resources, userResources.Resources)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errs = append(errs, err)
|
errs = append(errs, err)
|
||||||
groupStats = stats
|
|
||||||
} else {
|
|
||||||
groupStats = stats
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(errs) > 0 {
|
if len(errs) > 0 {
|
||||||
|
err = errors.Join(errs...)
|
||||||
slog.WarnContext(ctx, "SCIM sync completed with errors",
|
slog.WarnContext(ctx, "SCIM sync completed with errors",
|
||||||
slog.String("provider_id", provider.ID),
|
slog.String("provider_id", provider.ID),
|
||||||
slog.Int("error_count", len(errs)),
|
slog.Int("error_count", len(errs)),
|
||||||
@@ -240,12 +230,14 @@ func (s *ScimService) SyncServiceProvider(ctx context.Context, serviceProviderID
|
|||||||
slog.Int("groups_updated", groupStats.Updated),
|
slog.Int("groups_updated", groupStats.Updated),
|
||||||
slog.Int("groups_deleted", groupStats.Deleted),
|
slog.Int("groups_deleted", groupStats.Deleted),
|
||||||
slog.Duration("duration", time.Since(start)),
|
slog.Duration("duration", time.Since(start)),
|
||||||
|
slog.Any("error", err),
|
||||||
)
|
)
|
||||||
return errors.Join(errs...)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
provider.LastSyncedAt = new(datatype.DateTime(time.Now()))
|
provider.LastSyncedAt = new(datatype.DateTime(time.Now()))
|
||||||
if err := s.db.WithContext(ctx).Save(&provider).Error; err != nil {
|
err = s.db.WithContext(ctx).Save(&provider).Error
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +265,7 @@ func (s *ScimService) syncUsers(
|
|||||||
|
|
||||||
// Update or create users
|
// Update or create users
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
existing := getResourceByExternalID[dto.ScimUser](u.ID, resourceList.Resources)
|
existing := getResourceByExternalID(u.ID, resourceList.Resources)
|
||||||
|
|
||||||
action, created, err := s.syncUser(ctx, provider, u, existing)
|
action, created, err := s.syncUser(ctx, provider, u, existing)
|
||||||
if created != nil && existing == nil {
|
if created != nil && existing == nil {
|
||||||
@@ -434,7 +426,7 @@ func (s *ScimService) syncGroup(
|
|||||||
// Prepare group members
|
// Prepare group members
|
||||||
members := make([]dto.ScimGroupMember, len(group.Users))
|
members := make([]dto.ScimGroupMember, len(group.Users))
|
||||||
for i, user := range group.Users {
|
for i, user := range group.Users {
|
||||||
userResource := getResourceByExternalID[dto.ScimUser](user.ID, userResources)
|
userResource := getResourceByExternalID(user.ID, userResources)
|
||||||
if userResource == nil {
|
if userResource == nil {
|
||||||
// Groups depend on user IDs already being provisioned
|
// Groups depend on user IDs already being provisioned
|
||||||
return scimActionNone, fmt.Errorf("cannot sync group %s: user %s is not provisioned in SCIM provider", group.ID, user.ID)
|
return scimActionNone, fmt.Errorf("cannot sync group %s: user %s is not provisioned in SCIM provider", group.ID, user.ID)
|
||||||
|
|||||||
@@ -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="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}}
|
{{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,6 +6,6 @@ API KEY EXPIRING SOON
|
|||||||
Warning
|
Warning
|
||||||
|
|
||||||
Hello {{.Data.Name}},
|
Hello {{.Data.Name}},
|
||||||
This is a reminder that your API key {{.Data.APIKeyName}} will expire on {{.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}}
|
Please generate a new API key if you need continued access.{{end}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
-- No-op
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE INDEX IF NOT EXISTS idx_webauthn_sessions_expires_at ON webauthn_sessions (expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_one_time_access_tokens_expires_at ON one_time_access_tokens (expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oidc_authorization_codes_expires_at ON oidc_authorization_codes (expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_expires_at ON oidc_refresh_tokens (expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reauthentication_tokens_expires_at ON reauthentication_tokens (expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_expires_at ON email_verification_tokens (expires_at);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
-- No-op
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
PRAGMA foreign_keys= OFF;
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webauthn_sessions_expires_at ON webauthn_sessions (expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_one_time_access_tokens_expires_at ON one_time_access_tokens (expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oidc_authorization_codes_expires_at ON oidc_authorization_codes (expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_expires_at ON oidc_refresh_tokens (expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reauthentication_tokens_expires_at ON reauthentication_tokens (expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_expires_at ON email_verification_tokens (expires_at);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
@@ -40,7 +40,7 @@ ApiKeyExpiringEmail.TemplateProps = {
|
|||||||
...sharedTemplateProps,
|
...sharedTemplateProps,
|
||||||
data: {
|
data: {
|
||||||
name: "{{.Data.Name}}",
|
name: "{{.Data.Name}}",
|
||||||
apiKeyName: "{{.Data.APIKeyName}}",
|
apiKeyName: "{{.Data.ApiKeyName}}",
|
||||||
expiresAt: '{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}',
|
expiresAt: '{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -365,6 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Zadejte kód, který byl zobrazen v předchozím kroku.",
|
"enter_code_displayed_in_previous_step": "Zadejte kód, který byl zobrazen v předchozím kroku.",
|
||||||
"authorize": "Autorizovat",
|
"authorize": "Autorizovat",
|
||||||
"federated_client_credentials": "Údaje o klientovi ve federaci",
|
"federated_client_credentials": "Údaje o klientovi ve federaci",
|
||||||
|
"federated_client_credentials_description": "Federované klientské přihlašovací údaje umožňují ověřování klientů OIDC bez správy dlouhodobých tajných klíčů. Využívají tokeny JWT vydané třetími stranami pro klientská tvrzení, např. tokeny identity pracovního zatížení.",
|
||||||
"add_federated_client_credential": "Přidat údaje federovaného klienta",
|
"add_federated_client_credential": "Přidat údaje federovaného klienta",
|
||||||
"add_another_federated_client_credential": "Přidat dalšího federovaného klienta",
|
"add_another_federated_client_credential": "Přidat dalšího federovaného klienta",
|
||||||
"oidc_allowed_group_count": "Počet povolených skupin",
|
"oidc_allowed_group_count": "Počet povolených skupin",
|
||||||
|
|||||||
@@ -356,7 +356,7 @@
|
|||||||
"login_code_email_success": "Loginkoden er sendt til brugeren.",
|
"login_code_email_success": "Loginkoden er sendt til brugeren.",
|
||||||
"send_email": "Send e-mail",
|
"send_email": "Send e-mail",
|
||||||
"show_code": "Vis kode",
|
"show_code": "Vis kode",
|
||||||
"callback_url_description": "URL(er) angivet af din klient. Tilføjes automatisk, hvis feltet efterlades tomt. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Jokertegn</link> understøttes.",
|
"callback_url_description": "URL(er) angivet af din klient. Tilføjes automatisk, hvis feltet efterlades tomt. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Wildcards</link> understøttes.",
|
||||||
"logout_callback_url_description": "URL(er) angivet af din klient til logout. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Wildcards</link> understøttes.",
|
"logout_callback_url_description": "URL(er) angivet af din klient til logout. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Wildcards</link> understøttes.",
|
||||||
"api_key_expiration": "Udløb af API-nøgle",
|
"api_key_expiration": "Udløb af API-nøgle",
|
||||||
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send en e-mail til brugeren, når deres API-nøgle er ved at udløbe.",
|
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send en e-mail til brugeren, når deres API-nøgle er ved at udløbe.",
|
||||||
@@ -365,6 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Indtast koden, der blev vist i det forrige trin.",
|
"enter_code_displayed_in_previous_step": "Indtast koden, der blev vist i det forrige trin.",
|
||||||
"authorize": "Godkend",
|
"authorize": "Godkend",
|
||||||
"federated_client_credentials": "Federated klientlegitimationsoplysninger",
|
"federated_client_credentials": "Federated klientlegitimationsoplysninger",
|
||||||
|
"federated_client_credentials_description": "Federerede klientlegitimationsoplysninger gør det muligt at autentificere OIDC-klienter uden at skulle administrere langvarige hemmeligheder. De udnytter JWT-tokens udstedt af tredjepartsmyndigheder til klientpåstande, f.eks. identitetstokens for arbejdsbelastning.",
|
||||||
"add_federated_client_credential": "Tilføj federated klientlegitimation",
|
"add_federated_client_credential": "Tilføj federated klientlegitimation",
|
||||||
"add_another_federated_client_credential": "Tilføj endnu en federated klientlegitimation",
|
"add_another_federated_client_credential": "Tilføj endnu en federated klientlegitimation",
|
||||||
"oidc_allowed_group_count": "Tilladt antal grupper",
|
"oidc_allowed_group_count": "Tilladt antal grupper",
|
||||||
@@ -445,7 +446,7 @@
|
|||||||
"no_apps_available": "Ingen apps tilgængelige",
|
"no_apps_available": "Ingen apps tilgængelige",
|
||||||
"contact_your_administrator_for_app_access": "Kontakt din administrator for at få adgang til applikationer.",
|
"contact_your_administrator_for_app_access": "Kontakt din administrator for at få adgang til applikationer.",
|
||||||
"launch": "Start",
|
"launch": "Start",
|
||||||
"client_launch_url": "Kundens lancerings-URL",
|
"client_launch_url": "Start-URL til klient",
|
||||||
"client_launch_url_description": "Den URL, der åbnes, når en bruger starter appen fra siden Mine apps.",
|
"client_launch_url_description": "Den URL, der åbnes, når en bruger starter appen fra siden Mine apps.",
|
||||||
"client_name_description": "Navnet på den klient, der vises i Pocket ID-brugergrænsefladen.",
|
"client_name_description": "Navnet på den klient, der vises i Pocket ID-brugergrænsefladen.",
|
||||||
"revoke_access": "Tilbagekald adgang",
|
"revoke_access": "Tilbagekald adgang",
|
||||||
|
|||||||
@@ -365,6 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Gib den Code ein, der im vorherigen Schritt angezeigt wurde.",
|
"enter_code_displayed_in_previous_step": "Gib den Code ein, der im vorherigen Schritt angezeigt wurde.",
|
||||||
"authorize": "Autorisieren",
|
"authorize": "Autorisieren",
|
||||||
"federated_client_credentials": "Federated Client Credentials",
|
"federated_client_credentials": "Federated Client Credentials",
|
||||||
|
"federated_client_credentials_description": "Mit föderierten Client-Anmeldeinfos kann man OIDC-Clients authentifizieren, ohne sich um langlebige Geheimnisse kümmern zu müssen. Sie nutzen JWT-Token, die von Drittanbietern für Client-Assertions ausgestellt werden, z. B. Workload-Identitätstoken.",
|
||||||
"add_federated_client_credential": "Föderierte Client-Anmeldeinfos hinzufügen",
|
"add_federated_client_credential": "Föderierte Client-Anmeldeinfos hinzufügen",
|
||||||
"add_another_federated_client_credential": "Weitere Anmeldeinformationen für einen Verbundclient hinzufügen",
|
"add_another_federated_client_credential": "Weitere Anmeldeinformationen für einen Verbundclient hinzufügen",
|
||||||
"oidc_allowed_group_count": "Erlaubte Gruppenanzahl",
|
"oidc_allowed_group_count": "Erlaubte Gruppenanzahl",
|
||||||
|
|||||||
@@ -365,6 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Introduce el código que se mostró en el paso anterior.",
|
"enter_code_displayed_in_previous_step": "Introduce el código que se mostró en el paso anterior.",
|
||||||
"authorize": "Autorizar",
|
"authorize": "Autorizar",
|
||||||
"federated_client_credentials": "Credenciales de cliente federadas",
|
"federated_client_credentials": "Credenciales de cliente federadas",
|
||||||
|
"federated_client_credentials_description": "Las credenciales de cliente federadas permiten autenticar clientes OIDC sin gestionar secretos de larga duración. Aprovechan los tokens JWT emitidos por autoridades externas para las afirmaciones de los clientes, por ejemplo, tokens de identidad de carga de trabajo.",
|
||||||
"add_federated_client_credential": "Añadir credenciales de cliente federado",
|
"add_federated_client_credential": "Añadir credenciales de cliente federado",
|
||||||
"add_another_federated_client_credential": "Añadir otra credencial de cliente federado",
|
"add_another_federated_client_credential": "Añadir otra credencial de cliente federado",
|
||||||
"oidc_allowed_group_count": "Recuento de grupos permitidos",
|
"oidc_allowed_group_count": "Recuento de grupos permitidos",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
"my_account": "My Account",
|
"my_account": "Minu konto",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"docs": "Docs",
|
"docs": "Docs",
|
||||||
@@ -365,6 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
|
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
|
||||||
"authorize": "Authorize",
|
"authorize": "Authorize",
|
||||||
"federated_client_credentials": "Federated Client Credentials",
|
"federated_client_credentials": "Federated Client Credentials",
|
||||||
|
"federated_client_credentials_description": "Föderatiivsed kliendi autentimisandmed võimaldavad OIDC-kliente autentida ilma pikaajalisi salajasi andmeid haldamata. Need kasutavad kolmandate osapoolte poolt väljastatud JWT-tokeneid kliendi kinnituste jaoks, nt töökoormuse identiteeditokeneid.",
|
||||||
"add_federated_client_credential": "Add Federated Client Credential",
|
"add_federated_client_credential": "Add Federated Client Credential",
|
||||||
"add_another_federated_client_credential": "Add another federated client credential",
|
"add_another_federated_client_credential": "Add another federated client credential",
|
||||||
"oidc_allowed_group_count": "Allowed Group Count",
|
"oidc_allowed_group_count": "Allowed Group Count",
|
||||||
|
|||||||
@@ -365,6 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Syötä edellisessä vaiheessa näkynyt koodi.",
|
"enter_code_displayed_in_previous_step": "Syötä edellisessä vaiheessa näkynyt koodi.",
|
||||||
"authorize": "Salli",
|
"authorize": "Salli",
|
||||||
"federated_client_credentials": "Federoidut asiakastunnukset",
|
"federated_client_credentials": "Federoidut asiakastunnukset",
|
||||||
|
"federated_client_credentials_description": "Yhdistetyt asiakastunnistetiedot mahdollistavat OIDC-asiakkaiden todentamisen ilman pitkäaikaisten salaisuuksien hallintaa. Ne hyödyntävät kolmansien osapuolten viranomaisten myöntämiä JWT-tunnuksia asiakastodistuksiin, esimerkiksi työkuorman tunnistetunnuksiin.",
|
||||||
"add_federated_client_credential": "Lisää federoitu asiakastunnus",
|
"add_federated_client_credential": "Lisää federoitu asiakastunnus",
|
||||||
"add_another_federated_client_credential": "Lisää toinen federoitu asiakastunnus",
|
"add_another_federated_client_credential": "Lisää toinen federoitu asiakastunnus",
|
||||||
"oidc_allowed_group_count": "Sallittujen ryhmien määrä",
|
"oidc_allowed_group_count": "Sallittujen ryhmien määrä",
|
||||||
|
|||||||
@@ -365,6 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Entrez le code affiché à l'étape précédente.",
|
"enter_code_displayed_in_previous_step": "Entrez le code affiché à l'étape précédente.",
|
||||||
"authorize": "Autoriser",
|
"authorize": "Autoriser",
|
||||||
"federated_client_credentials": "Identifiants client fédérés",
|
"federated_client_credentials": "Identifiants client fédérés",
|
||||||
|
"federated_client_credentials_description": "Les informations d'identification client fédérées permettent d'authentifier les clients OIDC sans avoir à gérer des secrets à long terme. Elles utilisent des jetons JWT émis par des autorités tierces pour les assertions client, par exemple des jetons d'identité de charge de travail.",
|
||||||
"add_federated_client_credential": "Ajouter un identifiant client fédéré",
|
"add_federated_client_credential": "Ajouter un identifiant client fédéré",
|
||||||
"add_another_federated_client_credential": "Ajouter un autre identifiant client fédéré",
|
"add_another_federated_client_credential": "Ajouter un autre identifiant client fédéré",
|
||||||
"oidc_allowed_group_count": "Nombre de groupes autorisés",
|
"oidc_allowed_group_count": "Nombre de groupes autorisés",
|
||||||
|
|||||||
@@ -365,6 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Inserisci il codice visualizzato nel passaggio precedente.",
|
"enter_code_displayed_in_previous_step": "Inserisci il codice visualizzato nel passaggio precedente.",
|
||||||
"authorize": "Autorizza",
|
"authorize": "Autorizza",
|
||||||
"federated_client_credentials": "Identità Federate",
|
"federated_client_credentials": "Identità Federate",
|
||||||
|
"federated_client_credentials_description": "Le credenziali client federate ti permettono di autenticare i client OIDC senza dover gestire segreti a lungo termine. Usano i token JWT rilasciati da autorità terze per le asserzioni dei client, tipo i token di identità del carico di lavoro.",
|
||||||
"add_federated_client_credential": "Aggiungi Identità Federata",
|
"add_federated_client_credential": "Aggiungi Identità Federata",
|
||||||
"add_another_federated_client_credential": "Aggiungi un'altra identità federata",
|
"add_another_federated_client_credential": "Aggiungi un'altra identità federata",
|
||||||
"oidc_allowed_group_count": "Numero Gruppi Consentiti",
|
"oidc_allowed_group_count": "Numero Gruppi Consentiti",
|
||||||
|
|||||||
@@ -365,6 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "前のステップで表示されたコードを入力してください。",
|
"enter_code_displayed_in_previous_step": "前のステップで表示されたコードを入力してください。",
|
||||||
"authorize": "Authorize",
|
"authorize": "Authorize",
|
||||||
"federated_client_credentials": "連携クライアントの資格情報",
|
"federated_client_credentials": "連携クライアントの資格情報",
|
||||||
|
"federated_client_credentials_description": "フェデレーテッドクライアント認証情報は、長期にわたるシークレットを管理せずにOIDCクライアントを認証することを可能にします。これらは、クライアントアサーション(例:ワークロードIDトークン)のためにサードパーティ機関が発行するJWTトークンを活用します。",
|
||||||
"add_federated_client_credential": "Add Federated Client Credential",
|
"add_federated_client_credential": "Add Federated Client Credential",
|
||||||
"add_another_federated_client_credential": "Add another federated client credential",
|
"add_another_federated_client_credential": "Add another federated client credential",
|
||||||
"oidc_allowed_group_count": "許可されたグループ数",
|
"oidc_allowed_group_count": "許可されたグループ数",
|
||||||
|
|||||||
@@ -365,6 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "이전 단계에 표시된 코드를 입력하세요.",
|
"enter_code_displayed_in_previous_step": "이전 단계에 표시된 코드를 입력하세요.",
|
||||||
"authorize": "승인",
|
"authorize": "승인",
|
||||||
"federated_client_credentials": "연동 클라이언트 자격 증명",
|
"federated_client_credentials": "연동 클라이언트 자격 증명",
|
||||||
|
"federated_client_credentials_description": "연방 클라이언트 자격 증명은 장기 비밀을 관리하지 않고도 OIDC 클라이언트를 인증할 수 있게 합니다. 이는 클라이언트 어설션(예: 워크로드 신원 토큰)을 위해 제3자 기관이 발급한 JWT 토큰을 활용합니다.",
|
||||||
"add_federated_client_credential": "연동 클라이언트 자격 증명 추가",
|
"add_federated_client_credential": "연동 클라이언트 자격 증명 추가",
|
||||||
"add_another_federated_client_credential": "다른 연동 클라이언트 자격 증명 추가",
|
"add_another_federated_client_credential": "다른 연동 클라이언트 자격 증명 추가",
|
||||||
"oidc_allowed_group_count": "허용된 그룹 수",
|
"oidc_allowed_group_count": "허용된 그룹 수",
|
||||||
|
|||||||
@@ -365,6 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Voer de code in die in de vorige stap werd getoond.",
|
"enter_code_displayed_in_previous_step": "Voer de code in die in de vorige stap werd getoond.",
|
||||||
"authorize": "Autoriseren",
|
"authorize": "Autoriseren",
|
||||||
"federated_client_credentials": "Federatieve clientreferenties",
|
"federated_client_credentials": "Federatieve clientreferenties",
|
||||||
|
"federated_client_credentials_description": "Met federatieve klantgegevens kun je OIDC-klanten verifiëren zonder dat je langdurige geheimen hoeft te beheren. Ze gebruiken JWT-tokens die door externe instanties zijn uitgegeven voor klantverklaringen, zoals tokens voor werkbelastingidentiteit.",
|
||||||
"add_federated_client_credential": "Federatieve clientreferenties toevoegen",
|
"add_federated_client_credential": "Federatieve clientreferenties toevoegen",
|
||||||
"add_another_federated_client_credential": "Voeg nog een federatieve clientreferentie toe",
|
"add_another_federated_client_credential": "Voeg nog een federatieve clientreferentie toe",
|
||||||
"oidc_allowed_group_count": "Aantal groepen met toegang",
|
"oidc_allowed_group_count": "Aantal groepen met toegang",
|
||||||
|
|||||||
@@ -365,6 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
|
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
|
||||||
"authorize": "Authorize",
|
"authorize": "Authorize",
|
||||||
"federated_client_credentials": "Federated Client Credentials",
|
"federated_client_credentials": "Federated Client Credentials",
|
||||||
|
"federated_client_credentials_description": "Federated client credentials allow authenticating OIDC clients without managing long-lived secrets. They leverage JWT tokens issued by third-party authorities for client assertions, e.g. workload identity tokens.",
|
||||||
"add_federated_client_credential": "Add Federated Client Credential",
|
"add_federated_client_credential": "Add Federated Client Credential",
|
||||||
"add_another_federated_client_credential": "Add another federated client credential",
|
"add_another_federated_client_credential": "Add another federated client credential",
|
||||||
"oidc_allowed_group_count": "Allowed Group Count",
|
"oidc_allowed_group_count": "Allowed Group Count",
|
||||||
|
|||||||
@@ -365,6 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Wprowadź kod wyświetlony w poprzednim kroku.",
|
"enter_code_displayed_in_previous_step": "Wprowadź kod wyświetlony w poprzednim kroku.",
|
||||||
"authorize": "Autoryzuj",
|
"authorize": "Autoryzuj",
|
||||||
"federated_client_credentials": "Połączone poświadczenia klienta",
|
"federated_client_credentials": "Połączone poświadczenia klienta",
|
||||||
|
"federated_client_credentials_description": "Połączone poświadczenia klienta umożliwiają uwierzytelnianie klientów OIDC bez konieczności zarządzania długotrwałymi sekretami. Wykorzystują one tokeny JWT wydane przez zewnętrzne organy do potwierdzania tożsamości klientów, np. tokeny tożsamości obciążenia.",
|
||||||
"add_federated_client_credential": "Dodaj poświadczenia klienta federacyjnego",
|
"add_federated_client_credential": "Dodaj poświadczenia klienta federacyjnego",
|
||||||
"add_another_federated_client_credential": "Dodaj kolejne poświadczenia klienta federacyjnego",
|
"add_another_federated_client_credential": "Dodaj kolejne poświadczenia klienta federacyjnego",
|
||||||
"oidc_allowed_group_count": "Dopuszczalna liczba grup",
|
"oidc_allowed_group_count": "Dopuszczalna liczba grup",
|
||||||
|
|||||||
@@ -365,6 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Digite o código que apareceu na etapa anterior.",
|
"enter_code_displayed_in_previous_step": "Digite o código que apareceu na etapa anterior.",
|
||||||
"authorize": "Autorizar",
|
"authorize": "Autorizar",
|
||||||
"federated_client_credentials": "Credenciais de Cliente Federadas",
|
"federated_client_credentials": "Credenciais de Cliente Federadas",
|
||||||
|
"federated_client_credentials_description": "As credenciais federadas do cliente permitem autenticar clientes OIDC sem precisar gerenciar segredos de longa duração. Elas usam tokens JWT emitidos por autoridades terceirizadas para afirmações do cliente, tipo tokens de identidade de carga de trabalho.",
|
||||||
"add_federated_client_credential": "Adicionar credencial de cliente federado",
|
"add_federated_client_credential": "Adicionar credencial de cliente federado",
|
||||||
"add_another_federated_client_credential": "Adicionar outra credencial de cliente federado",
|
"add_another_federated_client_credential": "Adicionar outra credencial de cliente federado",
|
||||||
"oidc_allowed_group_count": "Total de grupos permitidos",
|
"oidc_allowed_group_count": "Total de grupos permitidos",
|
||||||
|
|||||||
@@ -365,6 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Введите код, который был отображен на предыдущем шаге.",
|
"enter_code_displayed_in_previous_step": "Введите код, который был отображен на предыдущем шаге.",
|
||||||
"authorize": "Авторизовать",
|
"authorize": "Авторизовать",
|
||||||
"federated_client_credentials": "Федеративные учетные данные клиента",
|
"federated_client_credentials": "Федеративные учетные данные клиента",
|
||||||
|
"federated_client_credentials_description": "Федеративные учетные данные клиента позволяют аутентифицировать клиентов OIDC без необходимости управления долгосрочными секретами. Они используют токены JWT, выданные сторонними органами для утверждений клиента, например токены идентификации рабочей нагрузки.",
|
||||||
"add_federated_client_credential": "Добавить федеративные учетные данные клиента",
|
"add_federated_client_credential": "Добавить федеративные учетные данные клиента",
|
||||||
"add_another_federated_client_credential": "Добавить другие федеративные учетные данные клиента",
|
"add_another_federated_client_credential": "Добавить другие федеративные учетные данные клиента",
|
||||||
"oidc_allowed_group_count": "Число разрешенных групп",
|
"oidc_allowed_group_count": "Число разрешенных групп",
|
||||||
|
|||||||
@@ -365,6 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Ange koden som visades i föregående steg.",
|
"enter_code_displayed_in_previous_step": "Ange koden som visades i föregående steg.",
|
||||||
"authorize": "Godkänn",
|
"authorize": "Godkänn",
|
||||||
"federated_client_credentials": "Federerade klientuppgifter",
|
"federated_client_credentials": "Federerade klientuppgifter",
|
||||||
|
"federated_client_credentials_description": "Federerade klientautentiseringsuppgifter gör det möjligt att autentisera OIDC-klienter utan att hantera långlivade hemligheter. De utnyttjar JWT-tokens som utfärdats av tredjepartsmyndigheter för klientpåståenden, t.ex. identitetstokens för arbetsbelastning.",
|
||||||
"add_federated_client_credential": "Lägg till federerad klientuppgift",
|
"add_federated_client_credential": "Lägg till federerad klientuppgift",
|
||||||
"add_another_federated_client_credential": "Lägg till ytterligare en federerad klientuppgift",
|
"add_another_federated_client_credential": "Lägg till ytterligare en federerad klientuppgift",
|
||||||
"oidc_allowed_group_count": "Tillåtet antal grupper",
|
"oidc_allowed_group_count": "Tillåtet antal grupper",
|
||||||
|
|||||||
@@ -365,6 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Önceki adımda görüntülenen kodu girin.",
|
"enter_code_displayed_in_previous_step": "Önceki adımda görüntülenen kodu girin.",
|
||||||
"authorize": "Yetkilendir",
|
"authorize": "Yetkilendir",
|
||||||
"federated_client_credentials": "Birleştirilmiş İstemci Kimlik Bilgileri",
|
"federated_client_credentials": "Birleştirilmiş İstemci Kimlik Bilgileri",
|
||||||
|
"federated_client_credentials_description": "Birleştirilmiş istemci kimlik bilgileri, uzun süreli gizli bilgileri yönetmeden OIDC istemcilerinin kimlik doğrulamasını sağlar. Üçüncü taraf yetkililer tarafından istemci beyanları için verilen JWT belirteçlerini (ör. iş yükü kimlik belirteçleri) kullanır.",
|
||||||
"add_federated_client_credential": "Birleştirilmiş İstemci Kimlik Bilgisi Ekle",
|
"add_federated_client_credential": "Birleştirilmiş İstemci Kimlik Bilgisi Ekle",
|
||||||
"add_another_federated_client_credential": "Başka bir birleştirilmiş istemci kimlik bilgisi ekle",
|
"add_another_federated_client_credential": "Başka bir birleştirilmiş istemci kimlik bilgisi ekle",
|
||||||
"oidc_allowed_group_count": "İzin Verilen Grup Sayısı",
|
"oidc_allowed_group_count": "İzin Verilen Grup Sayısı",
|
||||||
|
|||||||
@@ -365,6 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Введіть код, який було показано на попередньому кроці.",
|
"enter_code_displayed_in_previous_step": "Введіть код, який було показано на попередньому кроці.",
|
||||||
"authorize": "Авторизувати",
|
"authorize": "Авторизувати",
|
||||||
"federated_client_credentials": "Федеративні облікові дані клієнта",
|
"federated_client_credentials": "Федеративні облікові дані клієнта",
|
||||||
|
"federated_client_credentials_description": "Федеративні облікові дані клієнта дозволяють автентифікувати клієнтів OIDC без управління довготривалими секретами. Вони використовують токени JWT, видані сторонніми органами для підтвердження клієнтів, наприклад, токени ідентичності робочого навантаження.",
|
||||||
"add_federated_client_credential": "Додати федеративний обліковий запис клієнта",
|
"add_federated_client_credential": "Додати федеративний обліковий запис клієнта",
|
||||||
"add_another_federated_client_credential": "Додати ще один федеративний обліковий запис клієнта",
|
"add_another_federated_client_credential": "Додати ще один федеративний обліковий запис клієнта",
|
||||||
"oidc_allowed_group_count": "Кількість дозволених груп",
|
"oidc_allowed_group_count": "Кількість дозволених груп",
|
||||||
|
|||||||
@@ -365,6 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Nhập mã đã hiển thị ở bước trước.",
|
"enter_code_displayed_in_previous_step": "Nhập mã đã hiển thị ở bước trước.",
|
||||||
"authorize": "Cho phép",
|
"authorize": "Cho phép",
|
||||||
"federated_client_credentials": "Thông Tin Xác Thực Của Federated Clients",
|
"federated_client_credentials": "Thông Tin Xác Thực Của Federated Clients",
|
||||||
|
"federated_client_credentials_description": "Thông tin xác thực khách hàng liên kết cho phép xác thực các khách hàng OIDC mà không cần quản lý các khóa bí mật có thời hạn dài. Chúng sử dụng các token JWT do các cơ quan thứ ba cấp để xác thực thông tin của khách hàng, ví dụ như các token danh tính công việc.",
|
||||||
"add_federated_client_credential": "Thêm thông tin xác thực cho federated clients",
|
"add_federated_client_credential": "Thêm thông tin xác thực cho federated clients",
|
||||||
"add_another_federated_client_credential": "Thêm một thông tin xác thực cho federated clients khác",
|
"add_another_federated_client_credential": "Thêm một thông tin xác thực cho federated clients khác",
|
||||||
"oidc_allowed_group_count": "Số lượng nhóm được phép",
|
"oidc_allowed_group_count": "Số lượng nhóm được phép",
|
||||||
|
|||||||
@@ -365,6 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "输入在上一步中显示的代码",
|
"enter_code_displayed_in_previous_step": "输入在上一步中显示的代码",
|
||||||
"authorize": "授权",
|
"authorize": "授权",
|
||||||
"federated_client_credentials": "联合身份",
|
"federated_client_credentials": "联合身份",
|
||||||
|
"federated_client_credentials_description": "联合客户端凭证允许在无需管理长期密钥的情况下验证OIDC客户端。该机制利用第三方机构签发的JWT令牌来验证客户端声明,例如工作负载身份令牌。",
|
||||||
"add_federated_client_credential": "添加联合身份",
|
"add_federated_client_credential": "添加联合身份",
|
||||||
"add_another_federated_client_credential": "再添加一个联合身份",
|
"add_another_federated_client_credential": "再添加一个联合身份",
|
||||||
"oidc_allowed_group_count": "允许的群组数量",
|
"oidc_allowed_group_count": "允许的群组数量",
|
||||||
|
|||||||
@@ -365,6 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "請輸入上一步顯示的代碼。",
|
"enter_code_displayed_in_previous_step": "請輸入上一步顯示的代碼。",
|
||||||
"authorize": "授權",
|
"authorize": "授權",
|
||||||
"federated_client_credentials": "聯邦身分",
|
"federated_client_credentials": "聯邦身分",
|
||||||
|
"federated_client_credentials_description": "聯合客戶憑證允許驗證 OIDC 客戶端,無需管理長期存續的機密。此機制利用第三方權威機構發行的 JWT 憑證來驗證客戶端聲明,例如工作負載身分憑證。",
|
||||||
"add_federated_client_credential": "增加聯邦身分",
|
"add_federated_client_credential": "增加聯邦身分",
|
||||||
"add_another_federated_client_credential": "新增另一組聯邦身分",
|
"add_another_federated_client_credential": "新增另一組聯邦身分",
|
||||||
"oidc_allowed_group_count": "允許的群組數量",
|
"oidc_allowed_group_count": "允許的群組數量",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pocket-id-frontend",
|
"name": "pocket-id-frontend",
|
||||||
"version": "2.3.0",
|
"version": "2.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -32,6 +32,8 @@
|
|||||||
aria-invalid={!!error}
|
aria-invalid={!!error}
|
||||||
data-testid={`callback-url-${i + 1}`}
|
data-testid={`callback-url-${i + 1}`}
|
||||||
type="text"
|
type="text"
|
||||||
|
inputmode="url"
|
||||||
|
autocomplete="url"
|
||||||
bind:value={callbackURLs[i]}
|
bind:value={callbackURLs[i]}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
|
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"client_id": "7c21a609-96b5-4011-9900-272b8d31a9d1",
|
"client_id": "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
|
||||||
"code": "federated",
|
"code": "federated",
|
||||||
"code_challenge": null,
|
"code_challenge": null,
|
||||||
"code_challenge_method_sha256": null,
|
"code_challenge_method_sha256": null,
|
||||||
|
|||||||
Reference in New Issue
Block a user