Compare commits

..

21 Commits

Author SHA1 Message Date
Elias Schneider
14f59ce3f3 release: 1.2.0 2025-06-03 22:33:40 +02:00
Elias Schneider
31ad904367 fix: page scrolls up on form submisssion 2025-06-03 21:12:21 +02:00
Elias Schneider
04fcf1110e fix: improve spacing on auth screens 2025-06-03 21:09:32 +02:00
Elias Schneider
eb9b6433ae chore(translations): update translations via Crowdin (#606) 2025-06-02 15:58:52 +02:00
Elias Schneider
b9489b5e9a fix: whitelist authorization header for CORS 2025-06-02 15:55:29 +02:00
Elias Schneider
bd1c69b7b7 Update Crowdin configuration file 2025-06-02 14:17:21 +02:00
Elias Schneider
23dc235bac Update Crowdin configuration file 2025-06-02 14:13:16 +02:00
Elias Schneider
2440379cd1 fix: fallback to primary language if no translation available for specific country 2025-06-02 14:08:32 +02:00
Elias Schneider
6c00aaa3ef fix: allow users to update their locale even when own account update disabled 2025-06-02 11:35:13 +02:00
Elias Schneider
00259f8819 tests: adapt unit test for new app config default value behavior 2025-06-01 20:54:53 +02:00
Elias Schneider
decf8ec70b fix: clear default app config variables from database 2025-06-01 20:46:44 +02:00
Elias Schneider
c24a5546a5 docs: use https in .env.example 2025-05-31 20:51:55 +02:00
Elias Schneider
312421d777 chore(translations): update translations via Crowdin (#599) 2025-05-31 18:44:34 +02:00
Elias Schneider
c42a29a66c chore(translations): update translations via Crowdin (#593) 2025-05-30 21:56:28 -05:00
Elias Schneider
afc317adf7 chore(translations): update translations via Crowdin (#590) 2025-05-29 23:47:58 +02:00
Alessandro (Ale) Segala
256f74d0a3 fix: don't use TOFU for logout callback URLs (#588) 2025-05-29 22:01:23 +02:00
Kyle Mendell
20d3f780a2 feat: auto detect callback url (#583)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-05-29 17:16:10 +02:00
Alessandro (Ale) Segala
6d6dc6646a fix: run jobs at interval instead of specific time (#585) 2025-05-29 17:15:35 +02:00
Alessandro (Ale) Segala
3d402fc0ca fix: small fixes in analytics_job (#582) 2025-05-28 11:12:44 -05:00
Kyle Mendell
b874681824 fix: show LAN for auditlog location for internal networks 2025-05-28 10:52:40 -05:00
Elias Schneider
97cbdfb1ef chore(translations): update translations via Crowdin (#579) 2025-05-28 10:21:03 -05:00
61 changed files with 5956 additions and 6206 deletions

View File

@@ -1,5 +1,5 @@
# See the documentation for more information: https://pocket-id.org/docs/configuration/environment-variables # See the documentation for more information: https://pocket-id.org/docs/configuration/environment-variables
APP_URL=http://localhost:1411 APP_URL=https://your-pocket-id-domain.com
TRUST_PROXY=false TRUST_PROXY=false
MAXMIND_LICENSE_KEY= MAXMIND_LICENSE_KEY=
PUID=1000 PUID=1000

View File

@@ -1 +1 @@
1.1.0 1.2.0

View File

@@ -1,3 +1,24 @@
## [](https://github.com/pocket-id/pocket-id/compare/v1.1.0...v) (2025-06-03)
### Features
* auto detect callback url ([#583](https://github.com/pocket-id/pocket-id/issues/583)) ([20d3f78](https://github.com/pocket-id/pocket-id/commit/20d3f780a2a431d0a48cece0f0764b6e4d53c1b9))
### Bug Fixes
* allow users to update their locale even when own account update disabled ([6c00aaa](https://github.com/pocket-id/pocket-id/commit/6c00aaa3efa75c76d340718698a0f4556e8de268))
* clear default app config variables from database ([decf8ec](https://github.com/pocket-id/pocket-id/commit/decf8ec70b5f6a69fe201d6e4ad60ee62e374ad0))
* don't use TOFU for logout callback URLs ([#588](https://github.com/pocket-id/pocket-id/issues/588)) ([256f74d](https://github.com/pocket-id/pocket-id/commit/256f74d0a348a835107fd5b17b9d57b1e845029e))
* fallback to primary language if no translation available for specific country ([2440379](https://github.com/pocket-id/pocket-id/commit/2440379cd11b4a6da7c52b122ba8f49d7c72ce1d))
* improve spacing on auth screens ([04fcf11](https://github.com/pocket-id/pocket-id/commit/04fcf1110e97b42dc5f0c20e169c569075d1e797))
* page scrolls up on form submisssion ([31ad904](https://github.com/pocket-id/pocket-id/commit/31ad904367e53dd47a15abcce5402dfe84828a14))
* run jobs at interval instead of specific time ([#585](https://github.com/pocket-id/pocket-id/issues/585)) ([6d6dc66](https://github.com/pocket-id/pocket-id/commit/6d6dc6646a39921a604b6c825d3e7e76af6c693b))
* show LAN for auditlog location for internal networks ([b874681](https://github.com/pocket-id/pocket-id/commit/b8746818240fde052e6f3b5db5c3355d7bbfcbda))
* small fixes in analytics_job ([#582](https://github.com/pocket-id/pocket-id/issues/582)) ([3d402fc](https://github.com/pocket-id/pocket-id/commit/3d402fc0ca30626c95b8f7accc274b9f2ab228b9))
* whitelist authorization header for CORS ([b9489b5](https://github.com/pocket-id/pocket-id/commit/b9489b5e9a32a2a3f54d48705e731a7bcf188d20))
## [](https://github.com/pocket-id/pocket-id/compare/v1.0.0...v) (2025-05-28) ## [](https://github.com/pocket-id/pocket-id/compare/v1.0.0...v) (2025-05-28)

View File

@@ -4,6 +4,7 @@ go 1.24.0
require ( require (
github.com/caarlos0/env/v11 v11.3.1 github.com/caarlos0/env/v11 v11.3.1
github.com/cenkalti/backoff/v5 v5.0.2
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21

View File

@@ -17,6 +17,8 @@ github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5m
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=

View File

@@ -70,6 +70,13 @@ type OidcInvalidAuthorizationCodeError struct{}
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" } func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 } func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
type OidcMissingCallbackURLError struct{}
func (e *OidcMissingCallbackURLError) Error() string {
return "unable to detect callback url, it might be necessary for an admin to fix this"
}
func (e *OidcMissingCallbackURLError) HttpStatusCode() int { return 400 }
type OidcInvalidCallbackURLError struct{} type OidcInvalidCallbackURLError struct{}
func (e *OidcInvalidCallbackURLError) Error() string { func (e *OidcInvalidCallbackURLError) Error() string {
@@ -156,13 +163,6 @@ func (e *DuplicateClaimError) Error() string {
} }
func (e *DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest } func (e *DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
type AccountEditNotAllowedError struct{}
func (e *AccountEditNotAllowedError) Error() string {
return "You are not allowed to edit your account"
}
func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
type OidcInvalidCodeVerifierError struct{} type OidcInvalidCodeVerifierError struct{}
func (e *OidcInvalidCodeVerifierError) Error() string { func (e *OidcInvalidCodeVerifierError) Error() string {

View File

@@ -7,7 +7,6 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie" "github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"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/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware" "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/service"
@@ -228,10 +227,6 @@ func (uc *UserController) updateUserHandler(c *gin.Context) {
// @Success 200 {object} dto.UserDto // @Success 200 {object} dto.UserDto
// @Router /api/users/me [put] // @Router /api/users/me [put]
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) { func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
if !uc.appConfigService.GetDbConfig().AllowOwnAccountEdit.IsTrue() {
_ = c.Error(&common.AccountEditNotAllowedError{})
return
}
uc.updateUser(c, true) uc.updateUser(c, true)
} }

View File

@@ -26,7 +26,7 @@ type OidcClientWithAllowedGroupsCountDto struct {
type OidcClientCreateDto struct { type OidcClientCreateDto struct {
Name string `json:"name" binding:"required,max=50"` Name string `json:"name" binding:"required,max=50"`
CallbackURLs []string `json:"callbackURLs" binding:"required"` CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"` LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"` IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"` PkceEnabled bool `json:"pkceEnabled"`

View File

@@ -6,6 +6,10 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"time"
backoff "github.com/cenkalti/backoff/v5"
"github.com/go-co-op/gocron/v2"
"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/service" "github.com/pocket-id/pocket-id/backend/internal/service"
@@ -14,8 +18,17 @@ import (
const heartbeatUrl = "https://analytics.pocket-id.org/heartbeat" const heartbeatUrl = "https://analytics.pocket-id.org/heartbeat"
func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service.AppConfigService, httpClient *http.Client) error { func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service.AppConfigService, httpClient *http.Client) error {
jobs := &AnalyticsJob{appConfig: appConfig, httpClient: httpClient} // Skip if analytics are disabled or not in production environment
return s.registerJob(ctx, "SendHeartbeat", "0 0 * * *", jobs.sendHeartbeat, true) if common.EnvConfig.AnalyticsDisabled || common.EnvConfig.AppEnv != "production" {
return nil
}
// Send every 24 hours
jobs := &AnalyticsJob{
appConfig: appConfig,
httpClient: httpClient,
}
return s.registerJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, true)
} }
type AnalyticsJob struct { type AnalyticsJob struct {
@@ -24,38 +37,50 @@ type AnalyticsJob struct {
} }
// sendHeartbeat sends a heartbeat to the analytics service // sendHeartbeat sends a heartbeat to the analytics service
func (j *AnalyticsJob) sendHeartbeat(ctx context.Context) error { func (j *AnalyticsJob) sendHeartbeat(parentCtx context.Context) error {
// Skip if analytics are disabled or not in production environment // Skip if analytics are disabled or not in production environment
if common.EnvConfig.AnalyticsDisabled || common.EnvConfig.AppEnv != "production" { if common.EnvConfig.AnalyticsDisabled || common.EnvConfig.AppEnv != "production" {
return nil return nil
} }
body := struct { body, err := json.Marshal(struct {
Version string `json:"version"` Version string `json:"version"`
InstanceID string `json:"instance_id"` InstanceID string `json:"instance_id"`
}{ }{
Version: common.Version, Version: common.Version,
InstanceID: j.appConfig.GetDbConfig().InstanceID.Value, InstanceID: j.appConfig.GetDbConfig().InstanceID.Value,
} })
bodyBytes, err := json.Marshal(body)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal heartbeat body: %w", err) return fmt.Errorf("failed to marshal heartbeat body: %w", err)
} }
req, err := http.NewRequestWithContext(ctx, http.MethodPost, heartbeatUrl, bytes.NewBuffer(bodyBytes)) _, err = backoff.Retry(
parentCtx,
func() (struct{}, error) {
ctx, cancel := context.WithTimeout(parentCtx, 20*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, heartbeatUrl, bytes.NewReader(body))
if err != nil {
return struct{}{}, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := j.httpClient.Do(req)
if err != nil {
return struct{}{}, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return struct{}{}, fmt.Errorf("request failed with status code: %d", resp.StatusCode)
}
return struct{}{}, nil
},
backoff.WithBackOff(backoff.NewExponentialBackOff()),
backoff.WithMaxTries(3),
)
if err != nil { if err != nil {
return fmt.Errorf("failed to create heartbeat request: %w", err) return fmt.Errorf("heartbeat request failed: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := j.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to send heartbeat request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("heartbeat request failed with status code: %d", resp.StatusCode)
} }
return nil return nil
} }

View File

@@ -2,7 +2,10 @@ package job
import ( import (
"context" "context"
"log" "fmt"
"log/slog"
"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"
) )
@@ -18,7 +21,8 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *
appConfigService: appConfigService, appConfigService: appConfigService,
} }
return s.registerJob(ctx, "ExpiredApiKeyEmailJob", "0 0 * * *", jobs.checkAndNotifyExpiringApiKeys, false) // Send every day at midnight
return s.registerJob(ctx, "ExpiredApiKeyEmailJob", gocron.CronJob("0 0 * * *", false), jobs.checkAndNotifyExpiringApiKeys, false)
} }
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error { func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {
@@ -29,16 +33,16 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err
apiKeys, err := j.apiKeyService.ListExpiringApiKeys(ctx, 7) apiKeys, err := j.apiKeyService.ListExpiringApiKeys(ctx, 7)
if err != nil { if err != nil {
log.Printf("Failed to list expiring API keys: %v", err) return fmt.Errorf("failed to list expiring API keys: %w", err)
return err
} }
for _, key := range apiKeys { for _, key := range apiKeys {
if key.User.Email == "" { if key.User.Email == "" {
continue continue
} }
if err := j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key); err != nil { err = j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key)
log.Printf("Failed to send email for key %s: %v", key.ID, err) if err != nil {
slog.ErrorContext(ctx, "Failed to send expiring API key notification email", slog.String("key", key.ID), slog.Any("error", err))
} }
} }
return nil return nil

View File

@@ -3,8 +3,11 @@ package job
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"log/slog"
"time" "time"
"github.com/go-co-op/gocron/v2"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/model"
@@ -14,12 +17,14 @@ import (
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
def := gocron.DurationRandomJob(24*time.Hour-2*time.Minute, 24*time.Hour+2*time.Minute)
return errors.Join( return errors.Join(
s.registerJob(ctx, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions, false), s.registerJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
s.registerJob(ctx, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens, false), s.registerJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
s.registerJob(ctx, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes, false), s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
s.registerJob(ctx, "ClearOidcRefreshTokens", "0 3 * * *", jobs.clearOidcRefreshTokens, false), s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
s.registerJob(ctx, "ClearAuditLogs", "0 3 * * *", jobs.clearAuditLogs, false), s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
) )
} }
@@ -29,40 +34,70 @@ type DbCleanupJobs struct {
// ClearWebauthnSessions deletes WebAuthn sessions that have expired // ClearWebauthnSessions deletes WebAuthn sessions that have expired
func (j *DbCleanupJobs) clearWebauthnSessions(ctx context.Context) error { func (j *DbCleanupJobs) clearWebauthnSessions(ctx context.Context) error {
return j.db. st := j.db.
WithContext(ctx). WithContext(ctx).
Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now())). Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now()))
Error if st.Error != nil {
return fmt.Errorf("failed to clean expired WebAuthn sessions: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired WebAuthn sessions", slog.Int64("count", st.RowsAffected))
return nil
} }
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired // ClearOneTimeAccessTokens deletes one-time access tokens that have expired
func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error { func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error {
return j.db. st := j.db.
WithContext(ctx). WithContext(ctx).
Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now())). Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
Error if st.Error != nil {
return fmt.Errorf("failed to clean expired one-time access tokens: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired one-time access tokens", slog.Int64("count", st.RowsAffected))
return nil
} }
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired // ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error { func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error {
return j.db. st := j.db.
WithContext(ctx). WithContext(ctx).
Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now())). Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now()))
Error if st.Error != nil {
return fmt.Errorf("failed to clean expired OIDC authorization codes: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired OIDC authorization codes", slog.Int64("count", st.RowsAffected))
return nil
} }
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired // ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error { func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error {
return j.db. st := j.db.
WithContext(ctx). WithContext(ctx).
Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now())). Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
Error if st.Error != nil {
return fmt.Errorf("failed to clean expired OIDC refresh tokens: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired OIDC refresh tokens", slog.Int64("count", st.RowsAffected))
return nil
} }
// ClearAuditLogs deletes audit logs older than 90 days // ClearAuditLogs deletes audit logs older than 90 days
func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error { func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
return j.db. st := j.db.
WithContext(ctx). WithContext(ctx).
Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90))). Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90)))
Error if st.Error != nil {
return fmt.Errorf("failed to delete old audit logs: %w", st.Error)
}
slog.InfoContext(ctx, "Deleted old audit logs", slog.Int64("count", st.RowsAffected))
return nil
} }

View File

@@ -3,11 +3,13 @@ package job
import ( import (
"context" "context"
"fmt" "fmt"
"log" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"github.com/go-co-op/gocron/v2"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
@@ -17,7 +19,8 @@ import (
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB) error { func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB) error {
jobs := &FileCleanupJobs{db: db} jobs := &FileCleanupJobs{db: db}
return s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", "0 2 * * 0", jobs.clearUnusedDefaultProfilePictures, false) // Run every 24 hours
return s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, false)
} }
type FileCleanupJobs struct { type FileCleanupJobs struct {
@@ -64,13 +67,13 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context)
if _, ok := initialsInUse[initials]; !ok { if _, ok := initialsInUse[initials]; !ok {
filePath := filepath.Join(defaultPicturesDir, filename) filePath := filepath.Join(defaultPicturesDir, filename)
if err := os.Remove(filePath); err != nil { if err := os.Remove(filePath); err != nil {
log.Printf("Failed to delete unused default profile picture %s: %v", filePath, err) slog.ErrorContext(ctx, "Failed to delete unused default profile picture", slog.String("path", filePath), slog.Any("error", err))
} else { } else {
filesDeleted++ filesDeleted++
} }
} }
} }
log.Printf("Deleted %d unused default profile pictures", filesDeleted) slog.Info("Done deleting unused default profile pictures", slog.Int("count", filesDeleted))
return nil return nil
} }

View File

@@ -2,6 +2,9 @@ package job
import ( import (
"context" "context"
"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"
) )
@@ -19,8 +22,8 @@ func (s *Scheduler) RegisterGeoLiteUpdateJobs(ctx context.Context, geoLiteServic
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService} jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
// Register the job to run every day, at 5 minutes past midnight // Run every 24 hours (and right away)
return s.registerJob(ctx, "UpdateGeoLiteDB", "5 * */1 * *", jobs.updateGoeLiteDB, true) return s.registerJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, true)
} }
func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error { func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error {

View File

@@ -2,6 +2,9 @@ package job
import ( import (
"context" "context"
"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"
) )
@@ -15,7 +18,7 @@ func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.L
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
return s.registerJob(ctx, "SyncLdap", "0 * * * *", jobs.syncLdap, true) return s.registerJob(ctx, "SyncLdap", gocron.DurationJob(time.Hour), jobs.syncLdap, true)
} }
func (j *LdapJobs) syncLdap(ctx context.Context) error { func (j *LdapJobs) syncLdap(ctx context.Context) error {

View File

@@ -3,7 +3,7 @@ package job
import ( import (
"context" "context"
"fmt" "fmt"
"log" "log/slog"
"github.com/go-co-op/gocron/v2" "github.com/go-co-op/gocron/v2"
"github.com/google/uuid" "github.com/google/uuid"
@@ -27,7 +27,7 @@ func NewScheduler() (*Scheduler, error) {
// Run the scheduler. // Run the scheduler.
// This function blocks until the context is canceled. // This function blocks until the context is canceled.
func (s *Scheduler) Run(ctx context.Context) error { func (s *Scheduler) Run(ctx context.Context) error {
log.Println("Starting job scheduler") slog.Info("Starting job scheduler")
s.scheduler.Start() s.scheduler.Start()
// Block until context is canceled // Block until context is canceled
@@ -35,23 +35,36 @@ func (s *Scheduler) Run(ctx context.Context) error {
err := s.scheduler.Shutdown() err := s.scheduler.Shutdown()
if err != nil { if err != nil {
log.Printf("[WARN] Error shutting down job scheduler: %v", err) slog.Error("Error shutting down job scheduler", slog.Any("error", err))
} else { } else {
log.Println("Job scheduler shut down") slog.Info("Job scheduler shut down")
} }
return nil return nil
} }
func (s *Scheduler) registerJob(ctx context.Context, name string, interval string, 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) error {
jobOptions := []gocron.JobOption{ jobOptions := []gocron.JobOption{
gocron.WithContext(ctx), gocron.WithContext(ctx),
gocron.WithEventListeners( gocron.WithEventListeners(
gocron.BeforeJobRuns(func(jobID uuid.UUID, jobName string) {
slog.Info("Starting job",
slog.String("name", name),
slog.String("id", jobID.String()),
)
}),
gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) { gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) {
log.Printf("Job %q run successfully", name) slog.Info("Job run successfully",
slog.String("name", name),
slog.String("id", jobID.String()),
)
}), }),
gocron.AfterJobRunsWithError(func(jobID uuid.UUID, jobName string, err error) { gocron.AfterJobRunsWithError(func(jobID uuid.UUID, jobName string, err error) {
log.Printf("Job %q failed with error: %v", name, err) slog.Error("Job failed with error",
slog.String("name", name),
slog.String("id", jobID.String()),
slog.Any("error", err),
)
}), }),
), ),
} }
@@ -60,11 +73,7 @@ func (s *Scheduler) registerJob(ctx context.Context, name string, interval strin
jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately())) jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately()))
} }
_, err := s.scheduler.NewJob( _, err := s.scheduler.NewJob(def, gocron.NewTask(job), jobOptions...)
gocron.CronJob(interval, false),
gocron.NewTask(job),
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)

View File

@@ -26,6 +26,7 @@ func (m *CorsMiddleware) Add() gin.HandlerFunc {
} }
c.Writer.Header().Set("Access-Control-Allow-Origin", "*") c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Authorization")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST") c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST")
// Preflight request // Preflight request

View File

@@ -432,11 +432,6 @@ func (s *AppConfigService) loadDbConfigInternal(ctx context.Context, tx *gorm.DB
// Iterate through all values loaded from the database // Iterate through all values loaded from the database
for _, v := range loaded { for _, v := range loaded {
// If the value is empty, it means we are using the default value
if v.Value == "" {
continue
}
// Find the field in the struct whose "key" tag matches, then update that // Find the field in the struct whose "key" tag matches, then update that
err = dest.UpdateField(v.Key, v.Value, false) err = dest.UpdateField(v.Key, v.Value, false)

View File

@@ -47,9 +47,8 @@ func TestLoadDbConfig(t *testing.T) {
// Populate the config table with some initial values // Populate the config table with some initial values
err := db. err := db.
Create([]model.AppConfigVariable{ Create([]model.AppConfigVariable{
// Should be set to the default value because it's an empty string
{Key: "appName", Value: ""},
// Overrides default value // Overrides default value
{Key: "appName", Value: "Test App"},
{Key: "sessionDuration", Value: "5"}, {Key: "sessionDuration", Value: "5"},
// Does not have a default value // Does not have a default value
{Key: "smtpHost", Value: "example"}, {Key: "smtpHost", Value: "example"},
@@ -66,6 +65,7 @@ func TestLoadDbConfig(t *testing.T) {
// Values should match expected ones // Values should match expected ones
expect := service.getDefaultDbConfig() expect := service.getDefaultDbConfig()
expect.AppName.Value = "Test App"
expect.SessionDuration.Value = "5" expect.SessionDuration.Value = "5"
expect.SmtpHost.Value = "example" expect.SmtpHost.Value = "example"
require.Equal(t, service.GetDbConfig(), expect) require.Equal(t, service.GetDbConfig(), expect)

View File

@@ -72,7 +72,7 @@ func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string
} }
for _, ipNet := range privateLanIPNets { for _, ipNet := range privateLanIPNets {
if ipNet.Contains(ip) { if ipNet.Contains(ip) {
return "Internal Network", "LAN/Docker/k8s", nil return "Internal Network", "LAN", nil
} }
} }
for _, ipNet := range localhostIPNets { for _, ipNet := range localhostIPNets {

View File

@@ -73,7 +73,7 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
} }
// Get the callback URL of the client. Return an error if the provided callback URL is not allowed // Get the callback URL of the client. Return an error if the provided callback URL is not allowed
callbackURL, err := s.getCallbackURL(client.CallbackURLs, input.CallbackURL) callbackURL, err := s.getCallbackURL(&client, input.CallbackURL, tx, ctx)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@@ -947,13 +947,12 @@ func (s *OidcService) ValidateEndSession(ctx context.Context, input dto.OidcLogo
return "", &common.OidcNoCallbackURLError{} return "", &common.OidcNoCallbackURLError{}
} }
callbackURL, err := s.getCallbackURL(userAuthorizedOIDCClient.Client.LogoutCallbackURLs, input.PostLogoutRedirectUri) callbackURL, err := s.getLogoutCallbackURL(&userAuthorizedOIDCClient.Client, input.PostLogoutRedirectUri)
if err != nil { if err != nil {
return "", err return "", err
} }
return callbackURL, nil return callbackURL, nil
} }
func (s *OidcService) createAuthorizationCode(ctx context.Context, clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string, tx *gorm.DB) (string, error) { func (s *OidcService) createAuthorizationCode(ctx context.Context, clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string, tx *gorm.DB) (string, error) {
@@ -1006,11 +1005,52 @@ func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, c
return encodedVerifierHash == codeChallenge return encodedVerifierHash == codeChallenge
} }
func (s *OidcService) getCallbackURL(urls []string, inputCallbackURL string) (callbackURL string, err error) { func (s *OidcService) getCallbackURL(client *model.OidcClient, inputCallbackURL string, tx *gorm.DB, ctx context.Context) (callbackURL string, err error) {
// If no input callback URL provided, use the first configured URL
if inputCallbackURL == "" { if inputCallbackURL == "" {
return urls[0], nil if len(client.CallbackURLs) > 0 {
return client.CallbackURLs[0], nil
}
// If no URLs are configured and no input URL, this is an error
return "", &common.OidcMissingCallbackURLError{}
} }
// If URLs are already configured, validate against them
if len(client.CallbackURLs) > 0 {
matched, err := s.getCallbackURLFromList(client.CallbackURLs, inputCallbackURL)
if err != nil {
return "", err
} else if matched == "" {
return "", &common.OidcInvalidCallbackURLError{}
}
return matched, nil
}
// If no URLs are configured, trust and store the first URL (TOFU)
err = s.addCallbackURLToClient(ctx, client, inputCallbackURL, tx)
if err != nil {
return "", err
}
return inputCallbackURL, nil
}
func (s *OidcService) getLogoutCallbackURL(client *model.OidcClient, inputLogoutCallbackURL string) (callbackURL string, err error) {
if inputLogoutCallbackURL == "" {
return client.LogoutCallbackURLs[0], nil
}
matched, err := s.getCallbackURLFromList(client.LogoutCallbackURLs, inputLogoutCallbackURL)
if err != nil {
return "", err
} else if matched == "" {
return "", &common.OidcInvalidCallbackURLError{}
}
return matched, nil
}
func (s *OidcService) getCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL string, err error) {
for _, callbackPattern := range urls { for _, callbackPattern := range urls {
regexPattern := "^" + strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$" regexPattern := "^" + strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$"
matched, err := regexp.MatchString(regexPattern, inputCallbackURL) matched, err := regexp.MatchString(regexPattern, inputCallbackURL)
@@ -1022,7 +1062,19 @@ func (s *OidcService) getCallbackURL(urls []string, inputCallbackURL string) (ca
} }
} }
return "", &common.OidcInvalidCallbackURLError{} return "", nil
}
func (s *OidcService) addCallbackURLToClient(ctx context.Context, client *model.OidcClient, callbackURL string, tx *gorm.DB) error {
// Add the new callback URL to the existing list
client.CallbackURLs = append(client.CallbackURLs, callbackURL)
err := tx.WithContext(ctx).Save(client).Error
if err != nil {
return err
}
return nil
} }
func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.OidcDeviceAuthorizationRequestDto) (*dto.OidcDeviceAuthorizationResponseDto, error) { func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.OidcDeviceAuthorizationRequestDto) (*dto.OidcDeviceAuthorizationResponseDto, error) {

View File

@@ -294,10 +294,10 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
// Check if this is an LDAP user and LDAP is enabled // Check if this is an LDAP user and LDAP is enabled
isLdapUser := user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() isLdapUser := user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue()
allowOwnAccountEdit := s.appConfigService.GetDbConfig().AllowOwnAccountEdit.IsTrue()
// For LDAP users, only allow updating the locale unless it's an LDAP sync // For LDAP users or if own account editing is not allowed, only allow updating the locale unless it's an LDAP sync
if !isLdapSync && isLdapUser { if !isLdapSync && (isLdapUser || (!allowOwnAccountEdit && !updateOwnUser)) {
// Only update the locale for LDAP users
user.Locale = updatedUser.Locale user.Locale = updatedUser.Locale
} else { } else {
user.FirstName = updatedUser.FirstName user.FirstName = updatedUser.FirstName

View File

@@ -0,0 +1 @@
-- No rollback is needed for this migration.

View File

@@ -0,0 +1 @@
DELETE FROM app_config_variables WHERE value = '';

View File

@@ -0,0 +1 @@
-- No rollback is needed for this migration.

View File

@@ -0,0 +1 @@
DELETE FROM app_config_variables WHERE value = '';

View File

@@ -1,4 +1,4 @@
files: files:
- source: /frontend/messages/en-US.json - source: /frontend/messages/en.json
translation: /%original_path%/%locale%.json translation: /%original_path%/%two_letters_code%.json
pull_request_title: 'chore(translations): update translations via Crowdin' pull_request_title: 'chore(translations): update translations via Crowdin'

View File

@@ -40,8 +40,8 @@
"an_unknown_error_occurred": "Došlo k neznámé chybě", "an_unknown_error_occurred": "Došlo k neznámé chybě",
"authentication_process_was_aborted": "Proces přihlášení byl přerušen", "authentication_process_was_aborted": "Proces přihlášení byl přerušen",
"error_occurred_with_authenticator": "Došlo k chybě s autentifikátorem", "error_occurred_with_authenticator": "Došlo k chybě s autentifikátorem",
"authenticator_does_not_support_discoverable_credentials": "Autentifikátor nepodporuje zobrazitelné přihlašovací údaje", "authenticator_does_not_support_discoverable_credentials": "Autentifikátor nepodporuje vyhledatelné přihlašovací údaje",
"authenticator_does_not_support_resident_keys": "Autentikátor nepodporuje rezidentní klíče.", "authenticator_does_not_support_resident_keys": "Autentikátor nepodporuje rezidentní klíče",
"passkey_was_previously_registered": "Tento přístupový klíč byl již dříve zaregistrován", "passkey_was_previously_registered": "Tento přístupový klíč byl již dříve zaregistrován",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Autentikátor nepodporuje žádný z požadovaných algoritmů", "authenticator_does_not_support_any_of_the_requested_algorithms": "Autentikátor nepodporuje žádný z požadovaných algoritmů",
"authenticator_timed_out": "Vypršel časový limit autentifikátoru", "authenticator_timed_out": "Vypršel časový limit autentifikátoru",
@@ -340,13 +340,14 @@
"login_code_email_success": "Přihlašovací kód byl odeslán uživateli.", "login_code_email_success": "Přihlašovací kód byl odeslán uživateli.",
"send_email": "Odeslat e-mail", "send_email": "Odeslat e-mail",
"show_code": "Zobrazit kód", "show_code": "Zobrazit kód",
"callback_url_description": "URL poskytnuté klientem. Klientské zástupné znaky (*) jsou podporovány, ale raději se jim vyhýbejte, pro lepší bezpečnost.", "callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "Vypršení platnosti API klíče", "api_key_expiration": "Vypršení platnosti API klíče",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Pošlete uživateli e-mail, jakmile jejich API klíč brzy vyprší.", "send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Pošlete uživateli e-mail, jakmile jejich API klíč brzy vyprší.",
"authorize_device": "Autorizovat zařízení", "authorize_device": "Autorizovat zařízení",
"the_device_has_been_authorized": "Zařízení bylo autorizováno.", "the_device_has_been_authorized": "Zařízení bylo autorizováno.",
"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",
"oidc_allowed_group_count": "Allowed Group Count", "oidc_allowed_group_count": "Počet povolených skupin",
"unrestricted": "Unrestricted" "unrestricted": "Bez omezení"
} }

View File

@@ -340,13 +340,14 @@
"login_code_email_success": "Der Login-Code wurde an den Benutzer gesendet.", "login_code_email_success": "Der Login-Code wurde an den Benutzer gesendet.",
"send_email": "E-Mail senden", "send_email": "E-Mail senden",
"show_code": "Code anzeigen", "show_code": "Code anzeigen",
"callback_url_description": "URL(s) die von deinem Client bereitgestellt werden. Wildcards (*) werden unterstützt, sollten für bessere Sicherheit jedoch lieber vermieden werden.", "callback_url_description": "URL(s) die von deinem Client bereitgestellt werden. Automatische Ergänzung bei leerem Feld. Wildcards (*) werden unterstützt, sollten für bessere Sicherheit jedoch vermieden werden.",
"logout_callback_url_description": "URL(s) die von deinem Client für die Abmeldung bereitgestellt werden. Wildcards (*) werden unterstützt, sollten für bessere Sicherheit jedoch vermieden werden.",
"api_key_expiration": "API Key Ablauf", "api_key_expiration": "API Key Ablauf",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Sende eine E-Mail an den Benutzer, wenn sein API Key ablaufen wird.", "send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Sende eine E-Mail an den Benutzer, wenn sein API Key ablaufen wird.",
"authorize_device": "Gerät autorisieren", "authorize_device": "Gerät autorisieren",
"the_device_has_been_authorized": "Das Gerät wurde autorisiert.", "the_device_has_been_authorized": "Das Gerät wurde autorisiert.",
"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",
"oidc_allowed_group_count": "Allowed Group Count", "oidc_allowed_group_count": "Erlaubte Gruppenanzahl",
"unrestricted": "Unrestricted" "unrestricted": "Uneingeschränkt"
} }

View File

@@ -340,7 +340,8 @@
"login_code_email_success": "The login code has been sent to the user.", "login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email", "send_email": "Send Email",
"show_code": "Show Code", "show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.", "callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration", "api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.", "send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
"authorize_device": "Authorize Device", "authorize_device": "Authorize Device",

View File

@@ -103,75 +103,75 @@
"client": "Cliente", "client": "Cliente",
"unknown": "Desconocido", "unknown": "Desconocido",
"account_details_updated_successfully": "Detalles de la cuenta actualizados exitosamente", "account_details_updated_successfully": "Detalles de la cuenta actualizados exitosamente",
"profile_picture_updated_successfully": "Profile picture updated successfully. It may take a few minutes to update.", "profile_picture_updated_successfully": "Imagen de perfil actualizada correctamente. Puede tardar unos minutos en actualizarse.",
"account_settings": "Account Settings", "account_settings": "Configuración de la cuenta",
"passkey_missing": "Passkey missing", "passkey_missing": "Passkey no encontrada",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Please add a passkey to prevent losing access to your account.", "please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Por favor, añade una clave de acceso o passkey para evitar que pierdas el acceso a tu cuenta.",
"single_passkey_configured": "Single Passkey Configured", "single_passkey_configured": "Clave única configurada",
"it_is_recommended_to_add_more_than_one_passkey": "It is recommended to add more than one passkey to avoid losing access to your account.", "it_is_recommended_to_add_more_than_one_passkey": "Se recomienda añadir más de una clave de acceso para evitar perder el acceso a tu cuenta.",
"account_details": "Account Details", "account_details": "Detalles de la cuenta",
"passkeys": "Passkeys", "passkeys": "Claves de acceso",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Manage your passkeys that you can use to authenticate yourself.", "manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Administra las claves de acceso que te permiten autenticarte.",
"add_passkey": "Add Passkey", "add_passkey": "Añade una clave de acceso",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Create a one-time login code to sign in from a different device without a passkey.", "create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Crea un código de inicio de sesión único para iniciar sesión desde un dispositivo diferente sin una clave.",
"create": "Create", "create": "Crear",
"first_name": "First name", "first_name": "Nombre",
"last_name": "Last name", "last_name": "Apellido",
"username": "Username", "username": "Nombre de usuario",
"save": "Save", "save": "Guardar",
"username_can_only_contain": "Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols", "username_can_only_contain": "El nombre de usuario solo puede contener letras minúsculas, números, guiones bajos, puntos, guiones y símbolos '@'",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Sign in using the following code. The code will expire in 15 minutes.", "sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Inicia sesión usando el siguiente código. El código caducará en 15 minutos.",
"or_visit": "or visit", "or_visit": "o visita",
"added_on": "Añadido el", "added_on": "Añadido el",
"rename": "Renombrar", "rename": "Renombrar",
"delete": "Borrar", "delete": "Borrar",
"are_you_sure_you_want_to_delete_this_passkey": "¿Está seguro de que desea eliminar esta passkey?", "are_you_sure_you_want_to_delete_this_passkey": "¿Está seguro de que desea eliminar esta passkey?",
"passkey_deleted_successfully": "Passkey eliminada con éxito", "passkey_deleted_successfully": "Passkey eliminada con éxito",
"delete_passkey_name": "Borrar {passkeyName}", "delete_passkey_name": "Borrar {passkeyName}",
"passkey_name_updated_successfully": "Passkey name updated successfully", "passkey_name_updated_successfully": "Nombre de la clave de acceso actualizado correctamente",
"name_passkey": "Name Passkey", "name_passkey": "Nombre para la clave de acceso",
"name_your_passkey_to_easily_identify_it_later": "Name your passkey to easily identify it later.", "name_your_passkey_to_easily_identify_it_later": "Nombra tu clave de acceso para poder identificarla fácilmente más tarde.",
"create_api_key": "Create API Key", "create_api_key": "Crear API Key",
"add_a_new_api_key_for_programmatic_access": "Add a new API key for programmatic access.", "add_a_new_api_key_for_programmatic_access": "Añade una nueva API key para el acceso programático.",
"add_api_key": "Add API Key", "add_api_key": "Añade una API Key",
"manage_api_keys": "Manage API Keys", "manage_api_keys": "Gestiona las API Keys",
"api_key_created": "API Key Created", "api_key_created": "API Key creada",
"for_security_reasons_this_key_will_only_be_shown_once": "For security reasons, this key will only be shown once. Please store it securely.", "for_security_reasons_this_key_will_only_be_shown_once": "Por razones de seguridad, esta clave sólo se mostrará una vez. Por favor, guárdala de forma segura.",
"description": "Description", "description": "Descripción",
"api_key": "API Key", "api_key": "API Key",
"close": "Cerrar", "close": "Cerrar",
"name_to_identify_this_api_key": "Name to identify this API key.", "name_to_identify_this_api_key": "Nombra esta API Key para identificarla.",
"expires_at": "Expira el", "expires_at": "Expira el",
"when_this_api_key_will_expire": "Cuando esta clave de API caducará.", "when_this_api_key_will_expire": "Cuando esta clave de API caducará.",
"optional_description_to_help_identify_this_keys_purpose": "Optional description to help identify this key's purpose.", "optional_description_to_help_identify_this_keys_purpose": "Descripción opcional para ayudar a identificar el propósito de esta clave.",
"name_must_be_at_least_3_characters": "El nombre debe tener al menos 3 caracteres", "name_must_be_at_least_3_characters": "El nombre debe tener al menos 3 caracteres",
"name_cannot_exceed_50_characters": "El nombre no puede exceder los 50 caracteres", "name_cannot_exceed_50_characters": "El nombre no puede exceder los 50 caracteres",
"expiration_date_must_be_in_the_future": "La fecha de caducidad debe ser en el futuro", "expiration_date_must_be_in_the_future": "La fecha de caducidad debe ser en el futuro",
"revoke_api_key": "Revoke API Key", "revoke_api_key": "Invalidar API Key",
"never": "Nunca", "never": "Nunca",
"revoke": "Revoke", "revoke": "Invalidar",
"api_key_revoked_successfully": "La clave API se ha revocado con éxito", "api_key_revoked_successfully": "La clave API se ha revocado con éxito",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Are you sure you want to revoke the API key \"{apiKeyName}\"? This will break any integrations using this key.", "are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "¿Estás seguro de que deseas invalidar la API Key \"{apiKeyName}\"? Esto romperá cualquier integración que esté usando esta clave.",
"last_used": "Utilizado por última vez", "last_used": "Utilizado por última vez",
"actions": "Acciones", "actions": "Acciones",
"images_updated_successfully": "Images updated successfully", "images_updated_successfully": "Imágenes actualizadas correctamente",
"general": "General", "general": "General",
"configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.", "configure_smtp_to_send_emails": "Habilita las notificaciones por correo electrónico para alertar a los usuarios cuando se detecta un inicio de sesión desde un nuevo dispositivo o ubicación.",
"ldap": "LDAP", "ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.", "configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configura los ajustes LDAP para sincronizar usuarios y grupos desde un servidor LDAP.",
"images": "Images", "images": "Imágenes",
"update": "Update", "update": "Actualización",
"email_configuration_updated_successfully": "Email configuration updated successfully", "email_configuration_updated_successfully": "Configuración de correo electrónico actualizada correctamente",
"save_changes_question": "Save changes?", "save_changes_question": "¿Guardar los cambios?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "Tienes que guardar los cambios antes de enviar un correo electrónico de prueba. ¿Quieres guardar ahora?", "you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "Tienes que guardar los cambios antes de enviar un correo electrónico de prueba. ¿Quieres guardar ahora?",
"save_and_send": "Guardar y enviar", "save_and_send": "Guardar y enviar",
"test_email_sent_successfully": "Correo electrónico de prueba enviado con éxito a tu dirección de correo electrónico.", "test_email_sent_successfully": "Correo electrónico de prueba enviado con éxito a tu dirección de correo electrónico.",
"failed_to_send_test_email": "Failed to send test email. Check the server logs for more information.", "failed_to_send_test_email": "Error al enviar el email de prueba. Revisa los registros del servidor para más información.",
"smtp_configuration": "SMTP Configuration", "smtp_configuration": "Configuración SMTP",
"smtp_host": "SMTP Host", "smtp_host": "Servidor SMTP",
"smtp_port": "SMTP Port", "smtp_port": "Puerto SMTP",
"smtp_user": "SMTP User", "smtp_user": "Usuario SMTP",
"smtp_password": "SMTP Password", "smtp_password": "Contraseña SMTP",
"smtp_from": "SMTP From", "smtp_from": "SMTP From",
"smtp_tls_option": "SMTP TLS Option", "smtp_tls_option": "SMTP TLS Option",
"email_tls_option": "Email TLS Option", "email_tls_option": "Email TLS Option",
@@ -340,7 +340,8 @@
"login_code_email_success": "The login code has been sent to the user.", "login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email", "send_email": "Send Email",
"show_code": "Show Code", "show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.", "callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration", "api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.", "send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
"authorize_device": "Authorize Device", "authorize_device": "Authorize Device",

View File

@@ -184,7 +184,7 @@
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.", "allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"email_login_code_from_admin": "Email Login Code from Admin", "email_login_code_from_admin": "Email Login Code from Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.", "allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
"send_test_email": "", "send_test_email": "Send test email",
"application_configuration_updated_successfully": "Mise à jour de l'application avec succès", "application_configuration_updated_successfully": "Mise à jour de l'application avec succès",
"application_name": "Nom de l'application", "application_name": "Nom de l'application",
"session_duration": "Durée de la session", "session_duration": "Durée de la session",
@@ -340,11 +340,14 @@
"login_code_email_success": "The login code has been sent to the user.", "login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email", "send_email": "Send Email",
"show_code": "Show Code", "show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.", "callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration", "api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.", "send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
"authorize_device": "Authorize Device", "authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.", "the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.", "enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize" "authorize": "Authorize",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
} }

View File

@@ -340,13 +340,14 @@
"login_code_email_success": "Il codice di accesso è stato inviato all'utente.", "login_code_email_success": "Il codice di accesso è stato inviato all'utente.",
"send_email": "Invia email", "send_email": "Invia email",
"show_code": "Mostra codice", "show_code": "Mostra codice",
"callback_url_description": "URL forniti dal tuo client. Wildcard (*) sono supportati, ma meglio evitarli per una migliore sicurezza.", "callback_url_description": "URL forniti dal client. Verrà automaticamente aggiunto se lasciato vuoto. I caratteri jolly (*) sono supportati, ma è meglio evitarli per maggiore sicurezza.",
"logout_callback_url_description": "URL forniti dal client per il logout. I caratteri jolly (*) sono supportati, ma meglio evitarli per una migliore sicurezza.",
"api_key_expiration": "Scadenza Chiave API", "api_key_expiration": "Scadenza Chiave API",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Invia un'email all'utente quando la sua chiave API sta per scadere.", "send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Invia un'email all'utente quando la sua chiave API sta per scadere.",
"authorize_device": "Autorizza Dispositivo", "authorize_device": "Autorizza Dispositivo",
"the_device_has_been_authorized": "Il dispositivo è stato autorizzato.", "the_device_has_been_authorized": "Il dispositivo è stato autorizzato.",
"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",
"oidc_allowed_group_count": "Allowed Group Count", "oidc_allowed_group_count": "Numero Gruppi Consentiti",
"unrestricted": "Unrestricted" "unrestricted": "Illimitati"
} }

View File

@@ -340,7 +340,8 @@
"login_code_email_success": "The login code has been sent to the user.", "login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email", "send_email": "Send Email",
"show_code": "Show Code", "show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.", "callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration", "api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.", "send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
"authorize_device": "Authorize Device", "authorize_device": "Authorize Device",

View File

@@ -340,7 +340,8 @@
"login_code_email_success": "Kod logowania został wysłany do użytkownika.", "login_code_email_success": "Kod logowania został wysłany do użytkownika.",
"send_email": "Wyślij e-mail", "send_email": "Wyślij e-mail",
"show_code": "Pokaż kod", "show_code": "Pokaż kod",
"callback_url_description": "URL-e podane przez twojego klienta. Wildcardy (*) są obsługiwane, ale najlepiej ich unikać dla lepszej bezpieczeństwa.", "callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "Wygaszenie klucza API", "api_key_expiration": "Wygaszenie klucza API",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Wyślij e-mail do użytkownika, gdy jego klucz API ma wygasnąć.", "send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Wyślij e-mail do użytkownika, gdy jego klucz API ma wygasnąć.",
"authorize_device": "Autoryzuj urządzenie", "authorize_device": "Autoryzuj urządzenie",

View File

@@ -340,7 +340,8 @@
"login_code_email_success": "The login code has been sent to the user.", "login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email", "send_email": "Send Email",
"show_code": "Show Code", "show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.", "callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration", "api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.", "send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
"authorize_device": "Authorize Device", "authorize_device": "Authorize Device",

View File

@@ -1,352 +0,0 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "My Account",
"logout": "Logout",
"confirm": "Confirm",
"key": "Key",
"value": "Value",
"remove_custom_claim": "Remove custom claim",
"add_custom_claim": "Add custom claim",
"add_another": "Add another",
"select_a_date": "Select a date",
"select_file": "Select File",
"profile_picture": "Profile Picture",
"profile_picture_is_managed_by_ldap_server": "The profile picture is managed by the LDAP server and cannot be changed here.",
"click_profile_picture_to_upload_custom": "Click on the profile picture to upload a custom one from your files.",
"image_should_be_in_format": "The image should be in PNG or JPEG format.",
"items_per_page": "Items per page",
"no_items_found": "No items found",
"search": "Search...",
"expand_card": "Expand card",
"copied": "Copied",
"click_to_copy": "Click to copy",
"something_went_wrong": "Something went wrong",
"go_back_to_home": "Go back to home",
"dont_have_access_to_your_passkey": "Don't have access to your passkey?",
"login_background": "Login background",
"logo": "Logo",
"login_code": "Login Code",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Create a login code that the user can use to sign in without a passkey once.",
"one_hour": "1 hour",
"twelve_hours": "12 hours",
"one_day": "1 day",
"one_week": "1 week",
"one_month": "1 month",
"expiration": "Expiration",
"generate_code": "Generate Code",
"name": "Name",
"browser_unsupported": "Browser unsupported",
"this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please use an alternative sign in method.",
"an_unknown_error_occurred": "An unknown error occurred",
"authentication_process_was_aborted": "The authentication process was aborted",
"error_occurred_with_authenticator": "An error occurred with the authenticator",
"authenticator_does_not_support_discoverable_credentials": "The authenticator does not support discoverable credentials",
"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",
"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",
"client_wants_to_access_the_following_information": "<b>{client}</b> wants to access the following information:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Do you want to sign in to <b>{client}</b> with your {appName} account?",
"email": "Email",
"view_your_email_address": "View your email address",
"profile": "Profile",
"view_your_profile_information": "View your profile information",
"groups": "Groups",
"view_the_groups_you_are_a_member_of": "View the groups you are a member of",
"cancel": "Cancel",
"sign_in": "Sign in",
"try_again": "Try again",
"client_logo": "Client Logo",
"sign_out": "Sign out",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Do you want to sign out of Pocket ID with the account <b>{username}</b>?",
"sign_in_to_appname": "Sign in to {appName}",
"please_try_to_sign_in_again": "Please try to sign in again.",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Authenticate yourself with your passkey to access the admin panel.",
"authenticate": "Authenticate",
"appname_setup": "{appName} Setup",
"please_try_again": "Please try again.",
"you_are_about_to_sign_in_to_the_initial_admin_account": "You're about to sign in to the initial admin account. Anyone with this link can access the account until a passkey is added. Please set up a passkey as soon as possible to prevent unauthorized access.",
"continue": "Continue",
"alternative_sign_in": "Alternative Sign In",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.",
"use_your_passkey_instead": "Use your passkey instead?",
"email_login": "Email Login",
"enter_a_login_code_to_sign_in": "Enter a login code to sign in.",
"request_a_login_code_via_email": "Request a login code via email.",
"go_back": "Go back",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "An email has been sent to the provided email, if it exists in the system.",
"enter_code": "Enter code",
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Enter your email address to receive an email with a login code.",
"your_email": "Your email",
"submit": "Submit",
"enter_the_code_you_received_to_sign_in": "Enter the code you received to sign in.",
"code": "Code",
"invalid_redirect_url": "Invalid redirect URL",
"audit_log": "Audit Log",
"users": "Users",
"user_groups": "User Groups",
"oidc_clients": "OIDC Clients",
"api_keys": "API Keys",
"application_configuration": "Application Configuration",
"settings": "Settings",
"update_pocket_id": "Update Pocket ID",
"powered_by": "Powered by",
"see_your_account_activities_from_the_last_3_months": "See your account activities from the last 3 months.",
"time": "Time",
"event": "Event",
"approximate_location": "Approximate Location",
"ip_address": "IP Address",
"device": "Device",
"client": "Client",
"unknown": "Unknown",
"account_details_updated_successfully": "Account details updated successfully",
"profile_picture_updated_successfully": "Profile picture updated successfully. It may take a few minutes to update.",
"account_settings": "Account Settings",
"passkey_missing": "Passkey missing",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Please add a passkey to prevent losing access to your account.",
"single_passkey_configured": "Single Passkey Configured",
"it_is_recommended_to_add_more_than_one_passkey": "It is recommended to add more than one passkey to avoid losing access to your account.",
"account_details": "Account Details",
"passkeys": "Passkeys",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Manage your passkeys that you can use to authenticate yourself.",
"add_passkey": "Add Passkey",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Create a one-time login code to sign in from a different device without a passkey.",
"create": "Create",
"first_name": "First name",
"last_name": "Last name",
"username": "Username",
"save": "Save",
"username_can_only_contain": "Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Sign in using the following code. The code will expire in 15 minutes.",
"or_visit": "or visit",
"added_on": "Added on",
"rename": "Rename",
"delete": "Delete",
"are_you_sure_you_want_to_delete_this_passkey": "Are you sure you want to delete this passkey?",
"passkey_deleted_successfully": "Passkey deleted successfully",
"delete_passkey_name": "Delete {passkeyName}",
"passkey_name_updated_successfully": "Passkey name updated successfully",
"name_passkey": "Name Passkey",
"name_your_passkey_to_easily_identify_it_later": "Name your passkey to easily identify it later.",
"create_api_key": "Create API Key",
"add_a_new_api_key_for_programmatic_access": "Add a new API key for programmatic access.",
"add_api_key": "Add API Key",
"manage_api_keys": "Manage API Keys",
"api_key_created": "API Key Created",
"for_security_reasons_this_key_will_only_be_shown_once": "For security reasons, this key will only be shown once. Please store it securely.",
"description": "Description",
"api_key": "API Key",
"close": "Close",
"name_to_identify_this_api_key": "Name to identify this API key.",
"expires_at": "Expires At",
"when_this_api_key_will_expire": "When this API key will expire.",
"optional_description_to_help_identify_this_keys_purpose": "Optional description to help identify this key's purpose.",
"name_must_be_at_least_3_characters": "Name must be at least 3 characters",
"name_cannot_exceed_50_characters": "Name cannot exceed 50 characters",
"expiration_date_must_be_in_the_future": "Expiration date must be in the future",
"revoke_api_key": "Revoke API Key",
"never": "Never",
"revoke": "Revoke",
"api_key_revoked_successfully": "API key revoked successfully",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Are you sure you want to revoke the API key \"{apiKeyName}\"? This will break any integrations using this key.",
"last_used": "Last Used",
"actions": "Actions",
"images_updated_successfully": "Images updated successfully",
"general": "General",
"configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.",
"images": "Images",
"update": "Update",
"email_configuration_updated_successfully": "Email configuration updated successfully",
"save_changes_question": "Save changes?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "You have to save the changes before sending a test email. Do you want to save now?",
"save_and_send": "Save and send",
"test_email_sent_successfully": "Test email sent successfully to your email address.",
"failed_to_send_test_email": "Failed to send test email. Check the server logs for more information.",
"smtp_configuration": "SMTP Configuration",
"smtp_host": "SMTP Host",
"smtp_port": "SMTP Port",
"smtp_user": "SMTP User",
"smtp_password": "SMTP Password",
"smtp_from": "SMTP From",
"smtp_tls_option": "SMTP TLS Option",
"email_tls_option": "Email TLS Option",
"skip_certificate_verification": "Skip Certificate Verification",
"this_can_be_useful_for_selfsigned_certificates": "This can be useful for self-signed certificates.",
"enabled_emails": "Enabled Emails",
"email_login_notification": "Email Login Notification",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send an email to the user when they log in from a new device.",
"emai_login_code_requested_by_user": "Email Login Code Requested by User",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"email_login_code_from_admin": "Email Login Code from Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
"send_test_email": "Send test email",
"application_configuration_updated_successfully": "Application configuration updated successfully",
"application_name": "Application Name",
"session_duration": "Session Duration",
"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",
"client_configuration": "Client Configuration",
"ldap_url": "LDAP URL",
"ldap_bind_dn": "LDAP Bind DN",
"ldap_bind_password": "LDAP Bind Password",
"ldap_base_dn": "LDAP Base DN",
"user_search_filter": "User Search Filter",
"the_search_filter_to_use_to_search_or_sync_users": "The Search filter to use to search/sync users.",
"groups_search_filter": "Groups Search Filter",
"the_search_filter_to_use_to_search_or_sync_groups": "The Search filter to use to search/sync groups.",
"attribute_mapping": "Attribute Mapping",
"user_unique_identifier_attribute": "User Unique Identifier Attribute",
"the_value_of_this_attribute_should_never_change": "The value of this attribute should never change.",
"username_attribute": "Username Attribute",
"user_mail_attribute": "User Mail Attribute",
"user_first_name_attribute": "User First Name Attribute",
"user_last_name_attribute": "User Last Name Attribute",
"user_profile_picture_attribute": "User Profile Picture Attribute",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "The value of this attribute can either be a URL, a binary or a base64 encoded image.",
"group_members_attribute": "Group Members Attribute",
"the_attribute_to_use_for_querying_members_of_a_group": "The attribute to use for querying members of a group.",
"group_unique_identifier_attribute": "Group Unique Identifier Attribute",
"group_name_attribute": "Group Name Attribute",
"admin_group_name": "Admin Group Name",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Members of this group will have Admin Privileges in Pocket ID.",
"disable": "Disable",
"sync_now": "Sync now",
"enable": "Enable",
"user_created_successfully": "User created successfully",
"create_user": "Create User",
"add_a_new_user_to_appname": "Add a new user to {appName}",
"add_user": "Add User",
"manage_users": "Manage Users",
"admin_privileges": "Admin Privileges",
"admins_have_full_access_to_the_admin_panel": "Admins have full access to the admin panel.",
"delete_firstname_lastname": "Delete {firstName} {lastName}",
"are_you_sure_you_want_to_delete_this_user": "Are you sure you want to delete this user?",
"user_deleted_successfully": "User deleted successfully",
"role": "Role",
"source": "Source",
"admin": "Admin",
"user": "User",
"local": "Local",
"toggle_menu": "Toggle menu",
"edit": "Edit",
"user_groups_updated_successfully": "User groups updated successfully",
"user_updated_successfully": "User updated successfully",
"custom_claims_updated_successfully": "Custom claims updated successfully",
"back": "Back",
"user_details_firstname_lastname": "User Details {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Manage which groups this user belongs to.",
"custom_claims": "Custom Claims",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested.",
"user_group_created_successfully": "User group created successfully",
"create_user_group": "Create User Group",
"create_a_new_group_that_can_be_assigned_to_users": "Create a new group that can be assigned to users.",
"add_group": "Add Group",
"manage_user_groups": "Manage User Groups",
"friendly_name": "Friendly Name",
"name_that_will_be_displayed_in_the_ui": "Name that will be displayed in the UI",
"name_that_will_be_in_the_groups_claim": "Name that will be in the \"groups\" claim",
"delete_name": "Delete {name}",
"are_you_sure_you_want_to_delete_this_user_group": "Are you sure you want to delete this user group?",
"user_group_deleted_successfully": "User group deleted successfully",
"user_count": "User Count",
"user_group_updated_successfully": "User group updated successfully",
"users_updated_successfully": "Users updated successfully",
"user_group_details_name": "User Group Details {name}",
"assign_users_to_this_group": "Assign users to this group.",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested. Custom claims defined on the user will be prioritized if there are conflicts.",
"oidc_client_created_successfully": "OIDC client created successfully",
"create_oidc_client": "Create OIDC Client",
"add_a_new_oidc_client_to_appname": "Add a new OIDC client to {appName}.",
"add_oidc_client": "Add OIDC Client",
"manage_oidc_clients": "Manage OIDC Clients",
"one_time_link": "One Time Link",
"use_this_link_to_sign_in_once": "Use this link to sign in once. This is needed for users who haven't added a passkey yet or have lost it.",
"add": "Add",
"callback_urls": "Callback URLs",
"logout_callback_urls": "Logout Callback URLs",
"public_client": "Public Client",
"public_clients_description": "Public clients do not have a client secret and use PKCE instead. Enable this if your client is a SPA or mobile app.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange is a security feature to prevent CSRF and authorization code interception attacks.",
"name_logo": "{name} logo",
"change_logo": "Change Logo",
"upload_logo": "Upload Logo",
"remove_logo": "Remove Logo",
"are_you_sure_you_want_to_delete_this_oidc_client": "Are you sure you want to delete this OIDC client?",
"oidc_client_deleted_successfully": "OIDC client deleted successfully",
"authorization_url": "Authorization URL",
"oidc_discovery_url": "OIDC Discovery URL",
"token_url": "Token URL",
"userinfo_url": "Userinfo URL",
"logout_url": "Logout URL",
"certificate_url": "Certificate URL",
"enabled": "Enabled",
"disabled": "Disabled",
"oidc_client_updated_successfully": "OIDC client updated successfully",
"create_new_client_secret": "Create new client secret",
"are_you_sure_you_want_to_create_a_new_client_secret": "Are you sure you want to create a new client secret? The old one will be invalidated.",
"generate": "Generate",
"new_client_secret_created_successfully": "New client secret created successfully",
"allowed_user_groups_updated_successfully": "Allowed user groups updated successfully",
"oidc_client_name": "OIDC Client {name}",
"client_id": "Client ID",
"client_secret": "Client secret",
"show_more_details": "Show more details",
"allowed_user_groups": "Allowed User Groups",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Add user groups to this client to restrict access to users in these groups. If no user groups are selected, all users will have access to this client.",
"favicon": "Favicon",
"light_mode_logo": "Light Mode Logo",
"dark_mode_logo": "Dark Mode Logo",
"background_image": "Background Image",
"language": "Language",
"reset_profile_picture_question": "Reset profile picture?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "This will remove the uploaded image, and reset the profile picture to default. Do you want to continue?",
"reset": "Reset",
"reset_to_default": "Reset to default",
"profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.",
"select_the_language_you_want_to_use": "Select the language you want to use. Some languages may not be fully translated.",
"personal": "Personal",
"global": "Global",
"all_users": "All Users",
"all_events": "All Events",
"all_clients": "All Clients",
"global_audit_log": "Global Audit Log",
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
"token_sign_in": "Token Sign In",
"client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization",
"disable_animations": "Disable Animations",
"turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI.",
"user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
"user_disabled_successfully": "User has been disabled successfully.",
"user_enabled_successfully": "User has been enabled successfully.",
"status": "Status",
"disable_firstname_lastname": "Disable {firstName} {lastName}",
"are_you_sure_you_want_to_disable_this_user": "Are you sure you want to disable this user? They will not be able to log in or access any services.",
"ldap_soft_delete_users": "Keep disabled users from LDAP.",
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.",
"login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email",
"show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
"authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
}

View File

@@ -340,13 +340,14 @@
"login_code_email_success": "Код входа был отправлен пользователю.", "login_code_email_success": "Код входа был отправлен пользователю.",
"send_email": "Отправить письмо", "send_email": "Отправить письмо",
"show_code": "Показать код", "show_code": "Показать код",
"callback_url_description": "URL-адреса, предоставленные клиентом. Поддерживаются wildcard-адреса (*), но лучше всего избегать их для лучшей безопасности.", "callback_url_description": "URL-адрес(а) предоставленные вашим клиентом. Будет автоматически добавлен если оставить пустым. Маски (*) поддерживаются, но лучше избегайте их для повышения безопасности.",
"logout_callback_url_description": "URL-адрес(а), предоставленный вашим клиентом для выхода. Маски (*) поддерживаются, но лучше избегайте их для повышения безопасности.",
"api_key_expiration": "Истечение срока действия API ключа", "api_key_expiration": "Истечение срока действия API ключа",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Отправлять пользователю письмо, когда истечет срок действия API ключа.", "send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Отправлять пользователю письмо, когда истечет срок действия API ключа.",
"authorize_device": "Авторизовать устройство", "authorize_device": "Авторизовать устройство",
"the_device_has_been_authorized": "Устройство авторизовано.", "the_device_has_been_authorized": "Устройство авторизовано.",
"enter_code_displayed_in_previous_step": "Введите код, который был отображен на предыдущем шаге.", "enter_code_displayed_in_previous_step": "Введите код, который был отображен на предыдущем шаге.",
"authorize": "Авторизируйте", "authorize": "Авторизируйте",
"oidc_allowed_group_count": "Allowed Group Count", "oidc_allowed_group_count": "Кол-во разрешенных групп",
"unrestricted": "Unrestricted" "unrestricted": "Не ограничено"
} }

View File

@@ -341,6 +341,13 @@
"send_email": "发送电子邮件", "send_email": "发送电子邮件",
"show_code": "显示登录码", "show_code": "显示登录码",
"callback_url_description": "由您的客户端提供的 URL。支持通配符 (*),但为了更好的安全性最好避免使用。", "callback_url_description": "由您的客户端提供的 URL。支持通配符 (*),但为了更好的安全性最好避免使用。",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API 密钥过期", "api_key_expiration": "API 密钥过期",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "当用户的 API 密钥即将过期时,向其发送电子邮件。" "send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "当用户的 API 密钥即将过期时,向其发送电子邮件。",
"authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
} }

11002
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "1.1.0", "version": "1.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -26,8 +26,8 @@
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@inlang/paraglide-js": "^2.0.0", "@inlang/paraglide-js": "^2.0.13",
"@inlang/plugin-m-function-matcher": "^2.0.7", "@inlang/plugin-m-function-matcher": "^2.0.10",
"@inlang/plugin-message-format": "^4.0.0", "@inlang/plugin-message-format": "^4.0.0",
"@internationalized/date": "^3.7.0", "@internationalized/date": "^3.7.0",
"@playwright/test": "^1.50.0", "@playwright/test": "^1.50.0",

View File

@@ -1,18 +1,7 @@
{ {
"$schema": "https://inlang.com/schema/project-settings", "$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en-US", "baseLocale": "en",
"locales": [ "locales": ["cs", "de", "en", "es", "fr", "it", "nl", "pl", "pt-BR", "ru", "zh-CN"],
"en-US",
"nl-NL",
"ru-RU",
"de-DE",
"fr-FR",
"cs-CZ",
"pt-BR",
"it-IT",
"zh-CN",
"pl-PL"
],
"modules": [ "modules": [
"./node_modules/@inlang/plugin-message-format/dist/index.js", "./node_modules/@inlang/plugin-message-format/dist/index.js",
"./node_modules/@inlang/plugin-m-function-matcher/dist/index.js" "./node_modules/@inlang/plugin-m-function-matcher/dist/index.js"

View File

@@ -1,18 +1,6 @@
import { paraglideMiddleware } from '$lib/paraglide/server'; import type { HandleServerError } from '@sveltejs/kit';
import type { Handle, HandleServerError } from '@sveltejs/kit';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
// Handle to use the paraglide middleware
const paraglideHandle: Handle = ({ event, resolve }) => {
return paraglideMiddleware(event.request, ({ locale }) => {
return resolve(event, {
transformPageChunk: ({ html }) => html.replace('%lang%', locale)
});
});
};
export const handle: Handle = paraglideHandle;
export const handleError: HandleServerError = async ({ error, message, status }) => { export const handleError: HandleServerError = async ({ error, message, status }) => {
if (error instanceof AxiosError) { if (error instanceof AxiosError) {
message = error.response?.data.error || message; message = error.response?.data.error || message;

View File

@@ -3,6 +3,7 @@
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import { cn } from '$lib/utils/style'; import { cn } from '$lib/utils/style';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { MediaQuery } from 'svelte/reactivity';
import * as Card from './ui/card'; import * as Card from './ui/card';
let { let {
@@ -14,72 +15,75 @@
showAlternativeSignInMethodButton?: boolean; showAlternativeSignInMethodButton?: boolean;
animate?: boolean; animate?: boolean;
} = $props(); } = $props();
const isDesktop = new MediaQuery('min-width: 1024px');
</script> </script>
<!-- Desktop with sliding reveal animation --> {#if isDesktop.current}
<div class="hidden h-screen items-center overflow-hidden text-center lg:flex"> <div class="h-screen items-center overflow-hidden text-center">
<!-- Content area that fades in after background slides --> <div
<div class="relative z-10 flex h-full w-[650px] p-16 {cn(
class="relative z-10 flex h-full w-[650px] p-16 {cn( showAlternativeSignInMethodButton && 'pb-0',
showAlternativeSignInMethodButton && 'pb-0', animate && 'animate-delayed-fade'
animate && 'animate-delayed-fade' )}"
)}" >
> <div class="flex h-full w-full flex-col overflow-hidden">
<div class="flex h-full w-full flex-col overflow-hidden"> <div class="relative flex flex-grow flex-col items-center justify-center overflow-auto">
<div class="relative flex flex-grow flex-col items-center justify-center overflow-auto"> {@render children()}
{@render children()} </div>
{#if showAlternativeSignInMethodButton}
<div
class="mb-4 flex items-center justify-center"
style={animate ? 'animation-delay: 1000ms;' : ''}
>
<a
href={page.url.pathname == '/login'
? '/login/alternative'
: `/login/alternative?redirect=${encodeURIComponent(
page.url.pathname + page.url.search
)}`}
class="text-muted-foreground text-xs transition-colors hover:underline"
>
{m.dont_have_access_to_your_passkey()}
</a>
</div>
{/if}
</div> </div>
{#if showAlternativeSignInMethodButton} </div>
<div
class="mb-4 flex items-center justify-center" <!-- Background image with slide animation -->
style={animate ? 'animation-delay: 1000ms;' : ''} <div class="{cn(animate && 'animate-slide-bg-container')} absolute top-0 right-0 bottom-0 z-0">
> <img
src="/api/application-configuration/background-image"
class="h-screen rounded-l-[60px] object-cover {animate
? 'w-full'
: 'w-[calc(100vw-650px)]'}"
alt={m.login_background()}
/>
</div>
</div>
{:else}
<div
class="flex h-screen items-center justify-center bg-[url('/api/application-configuration/background-image')] bg-cover bg-center text-center"
>
<Card.Root class="mx-3 w-full max-w-md" style={animate ? 'animation-delay: 200ms;' : ''}>
<Card.CardContent
class="px-4 py-10 sm:p-10 {showAlternativeSignInMethodButton ? 'pb-3 sm:pb-3' : ''}"
>
{@render children()}
{#if showAlternativeSignInMethodButton}
<a <a
href={page.url.pathname == '/login' href={page.url.pathname == '/login'
? '/login/alternative' ? '/login/alternative'
: `/login/alternative?redirect=${encodeURIComponent( : `/login/alternative?redirect=${encodeURIComponent(
page.url.pathname + page.url.search page.url.pathname + page.url.search
)}`} )}`}
class="text-muted-foreground text-xs transition-colors hover:underline" class="text-muted-foreground mt-7 flex justify-center text-xs transition-colors hover:underline"
> >
{m.dont_have_access_to_your_passkey()} {m.dont_have_access_to_your_passkey()}
</a> </a>
</div> {/if}
{/if} </Card.CardContent>
</div> </Card.Root>
</div> </div>
{/if}
<!-- Background image with slide animation -->
<div class="{cn(animate && 'animate-slide-bg-container')} absolute top-0 right-0 bottom-0 z-0">
<img
src="/api/application-configuration/background-image"
class="h-screen rounded-l-[60px] object-cover {animate ? 'w-full' : 'w-[calc(100vw-650px)]'}"
alt={m.login_background()}
/>
</div>
</div>
<!-- Mobile -->
<div
class="flex h-screen items-center justify-center bg-[url('/api/application-configuration/background-image')] bg-cover bg-center text-center lg:hidden"
>
<Card.Root class="mx-3 w-full max-w-md" style={animate ? 'animation-delay: 200ms;' : ''}>
<Card.CardContent
class="px-4 py-10 sm:p-10 {showAlternativeSignInMethodButton ? 'pb-3 sm:pb-3' : ''}"
>
{@render children()}
{#if showAlternativeSignInMethodButton}
<a
href={page.url.pathname == '/login'
? '/login/alternative'
: `/login/alternative?redirect=${encodeURIComponent(
page.url.pathname + page.url.search
)}`}
class="text-muted-foreground mt-7 flex justify-center text-xs transition-colors hover:underline"
>
{m.dont_have_access_to_your_passkey()}
</a>
{/if}
</Card.CardContent>
</Card.Root>
</div>

View File

@@ -7,7 +7,7 @@ export type OidcClientMetaData = {
}; };
export type OidcClient = OidcClientMetaData & { export type OidcClient = OidcClientMetaData & {
callbackURLs: [string, ...string[]]; callbackURLs: string[]; // No longer requires at least one URL
logoutCallbackURLs: string[]; logoutCallbackURLs: string[];
isPublic: boolean; isPublic: boolean;
pkceEnabled: boolean; pkceEnabled: boolean;

View File

@@ -0,0 +1,6 @@
export function preventDefault(fn: (event: Event) => void): (event: Event) => void {
return function (this: unknown, event) {
event.preventDefault();
fn.call(this, event);
};
}

View File

@@ -103,9 +103,9 @@
})} })}
</p> </p>
{:else if authorizationRequired} {:else if authorizationRequired}
<div transition:slide={{ duration: 300 }}> <div class="w-full max-w-[450px]" transition:slide={{ duration: 300 }}>
<Card.Root class="mt-6 mb-10"> <Card.Root class="mt-6 mb-10">
<Card.Header class="pb-5"> <Card.Header>
<p class="text-muted-foreground text-start"> <p class="text-muted-foreground text-start">
{@html m.client_wants_to_access_the_following_information({ client: client.name })} {@html m.client_wants_to_access_the_following_information({ client: client.name })}
</p> </p>
@@ -138,18 +138,14 @@
</Card.Root> </Card.Root>
</div> </div>
{/if} {/if}
<!-- Wrap the buttons in a container with the same width as in the login code page --> <div class="flex w-full max-w-[450px] gap-2">
<div class="w-full max-w-[450px]"> <Button onclick={() => history.back()} class="flex-1" variant="secondary">{m.cancel()}</Button
<div class="mt-8 flex justify-between gap-2"> >
<Button onclick={() => history.back()} class="flex-1" variant="secondary" {#if !errorMessage}
>{m.cancel()}</Button <Button class="flex-1" {isLoading} onclick={authorize}>{m.sign_in()}</Button>
> {:else}
{#if !errorMessage} <Button class="flex-1" onclick={() => (errorMessage = null)}>{m.try_again()}</Button>
<Button class="flex-1" {isLoading} onclick={authorize}>{m.sign_in()}</Button> {/if}
{:else}
<Button class="flex-1" onclick={() => (errorMessage = null)}>{m.try_again()}</Button>
{/if}
</div>
</div> </div>
</SignInWrapper> </SignInWrapper>
{/if} {/if}

View File

@@ -11,6 +11,7 @@
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import type { OidcDeviceCodeInfo } from '$lib/types/oidc.type'; import type { OidcDeviceCodeInfo } from '$lib/types/oidc.type';
import { getAxiosErrorMessage } from '$lib/utils/error-util'; import { getAxiosErrorMessage } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
import { startAuthentication } from '@simplewebauthn/browser'; import { startAuthentication } from '@simplewebauthn/browser';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
@@ -89,7 +90,7 @@
{:else if success} {:else if success}
<p class="text-muted-foreground mt-2">{m.the_device_has_been_authorized()}</p> <p class="text-muted-foreground mt-2">{m.the_device_has_been_authorized()}</p>
{:else if authorizationRequired} {:else if authorizationRequired}
<div transition:slide={{ duration: 300 }}> <div class="w-full max-w-[450px]" transition:slide={{ duration: 300 }}>
<Card.Root class="mt-6"> <Card.Root class="mt-6">
<Card.Header class="pb-5"> <Card.Header class="pb-5">
<p class="text-muted-foreground text-start"> <p class="text-muted-foreground text-start">
@@ -105,19 +106,19 @@
</div> </div>
{:else} {:else}
<p class="text-muted-foreground mt-2">{m.enter_code_displayed_in_previous_step()}</p> <p class="text-muted-foreground mt-2">{m.enter_code_displayed_in_previous_step()}</p>
<form id="device-code-form" onsubmit={authorize} class="w-full max-w-[450px]"> <form id="device-code-form" onsubmit={preventDefault(authorize)} class="w-full max-w-[450px]">
<Input id="user-code" class="mt-7" placeholder={m.code()} bind:value={userCode} type="text" /> <Input id="user-code" class="mt-7" placeholder={m.code()} bind:value={userCode} type="text" />
</form> </form>
{/if} {/if}
{#if !success} {#if !success}
<div class="mt-10 flex w-full justify-stretch gap-2"> <div class="mt-10 flex w-full max-w-[450px] gap-2">
<Button href="/" class="w-full" variant="secondary">{m.cancel()}</Button> <Button href="/" class="flex-1" variant="secondary">{m.cancel()}</Button>
{#if !errorMessage} {#if !errorMessage}
<Button form="device-code-form" class="w-full" onclick={authorize} {isLoading} <Button form="device-code-form" class="flex-1" onclick={authorize} {isLoading}
>{m.authorize()}</Button >{m.authorize()}</Button
> >
{:else} {:else}
<Button class="w-full" onclick={() => (errorMessage = null)}>{m.try_again()}</Button> <Button class="flex-1" onclick={() => (errorMessage = null)}>{m.try_again()}</Button>
{/if} {/if}
</div> </div>
{/if} {/if}

View File

@@ -1,15 +1,16 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state';
import SignInWrapper from '$lib/components/login-wrapper.svelte'; import SignInWrapper from '$lib/components/login-wrapper.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import Input from '$lib/components/ui/input/input.svelte'; import Input from '$lib/components/ui/input/input.svelte';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import userStore from '$lib/stores/user-store.js'; import userStore from '$lib/stores/user-store.js';
import { getAxiosErrorMessage } from '$lib/utils/error-util'; import { getAxiosErrorMessage } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte'; import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte';
import { page } from '$app/state';
import { m } from '$lib/paraglide/messages';
let { data } = $props(); let { data } = $props();
let code = $state(data.code ?? ''); let code = $state(data.code ?? '');
@@ -59,13 +60,7 @@
{:else} {:else}
<p class="text-muted-foreground mt-2">{m.enter_the_code_you_received_to_sign_in()}</p> <p class="text-muted-foreground mt-2">{m.enter_the_code_you_received_to_sign_in()}</p>
{/if} {/if}
<form <form onsubmit={preventDefault(authenticate)} class="w-full max-w-[450px]">
onsubmit={(e) => {
e.preventDefault();
authenticate();
}}
class="w-full max-w-[450px]"
>
<Input id="Email" class="mt-7" placeholder={m.code()} bind:value={code} type="text" /> <Input id="Email" class="mt-7" placeholder={m.code()} bind:value={code} type="text" />
<div class="mt-8 flex justify-between gap-2"> <div class="mt-8 flex justify-between gap-2">
<Button variant="secondary" class="flex-1" href={'/login/alternative' + page.url.search} <Button variant="secondary" class="flex-1" href={'/login/alternative' + page.url.search}

View File

@@ -3,10 +3,11 @@
import SignInWrapper from '$lib/components/login-wrapper.svelte'; import SignInWrapper from '$lib/components/login-wrapper.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import Input from '$lib/components/ui/input/input.svelte'; import Input from '$lib/components/ui/input/input.svelte';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte'; import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte';
import { m } from '$lib/paraglide/messages'; import { preventDefault } from '$lib/utils/event-util';
const { data } = $props(); const { data } = $props();
@@ -58,13 +59,7 @@
> >
</div> </div>
{:else} {:else}
<form <form onsubmit={preventDefault(requestEmail)} class="w-full max-w-[450px]">
onsubmit={(e) => {
e.preventDefault();
requestEmail();
}}
class="w-full max-w-[450px]"
>
<p class="text-muted-foreground mt-2" in:fade> <p class="text-muted-foreground mt-2" in:fade>
{m.enter_your_email_address_to_receive_an_email_with_a_login_code()} {m.enter_your_email_address_to_receive_an_email_with_a_login_code()}
</p> </p>

View File

@@ -6,6 +6,7 @@
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import type { UserCreate } from '$lib/types/user.type'; import type { UserCreate } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { z } from 'zod'; import { z } from 'zod';
@@ -64,7 +65,7 @@
} }
</script> </script>
<form onsubmit={onSubmit} class="space-y-6"> <form onsubmit={preventDefault(onSubmit)} class="space-y-6">
<!-- Profile Picture Section --> <!-- Profile Picture Section -->
<ProfilePictureSettings <ProfilePictureSettings
{userId} {userId}

View File

@@ -8,15 +8,16 @@
const currentLocale = getLocale(); const currentLocale = getLocale();
const locales = { const locales = {
'cs-CZ': 'Čeština', cs: 'Čeština',
'de-DE': 'Deutsch', de: 'Deutsch',
'en-US': 'English', en: 'English',
'fr-FR': 'Français', es: 'Español',
'nl-NL': 'Nederlands', fr: 'Français',
'pl-PL': 'Polski', it: 'Italiano',
nl: 'Nederlands',
pl: 'Polski',
'pt-BR': 'Português brasileiro', 'pt-BR': 'Português brasileiro',
'ru-RU': 'Русский', ru: 'Русский',
'it-IT': 'Italiano',
'zh-CN': '简体中文' 'zh-CN': '简体中文'
}; };

View File

@@ -7,6 +7,7 @@
import WebAuthnService from '$lib/services/webauthn-service'; import WebAuthnService from '$lib/services/webauthn-service';
import type { Passkey } from '$lib/types/passkey.type'; import type { Passkey } from '$lib/types/passkey.type';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
let { let {
@@ -49,7 +50,7 @@
<Dialog.Title>{m.name_passkey()}</Dialog.Title> <Dialog.Title>{m.name_passkey()}</Dialog.Title>
<Dialog.Description>{m.name_your_passkey_to_easily_identify_it_later()}</Dialog.Description> <Dialog.Description>{m.name_your_passkey_to_easily_identify_it_later()}</Dialog.Description>
</Dialog.Header> </Dialog.Header>
<form onsubmit={onSubmit}> <form onsubmit={preventDefault(onSubmit)}>
<div class="grid items-center gap-4 sm:grid-cols-4"> <div class="grid items-center gap-4 sm:grid-cols-4">
<Label for="name" class="sm:text-right">{m.name()}</Label> <Label for="name" class="sm:text-right">{m.name()}</Label>
<Input id="name" bind:value={name} class="col-span-3" /> <Input id="name" bind:value={name} class="col-span-3" />

View File

@@ -3,6 +3,7 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import type { ApiKeyCreate } from '$lib/types/api-key.type'; import type { ApiKeyCreate } from '$lib/types/api-key.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { z } from 'zod'; import { z } from 'zod';
@@ -52,7 +53,7 @@
} }
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={preventDefault(onSubmit)}>
<div class="grid grid-cols-1 items-start gap-5 md:grid-cols-2"> <div class="grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<FormInput <FormInput
label={m.name()} label={m.name()}

View File

@@ -9,6 +9,7 @@
import AppConfigService from '$lib/services/app-config-service'; import AppConfigService from '$lib/services/app-config-service';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration'; import type { AllAppConfig } from '$lib/types/application-configuration';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { z } from 'zod'; import { z } from 'zod';
@@ -94,7 +95,7 @@
} }
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={preventDefault(onSubmit)}>
<fieldset disabled={$appConfigStore.uiConfigDisabled}> <fieldset disabled={$appConfigStore.uiConfigDisabled}>
<h4 class="text-lg font-semibold">{m.smtp_configuration()}</h4> <h4 class="text-lg font-semibold">{m.smtp_configuration()}</h4>
<div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2"> <div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2">

View File

@@ -5,6 +5,7 @@
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration'; import type { AllAppConfig } from '$lib/types/application-configuration';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { z } from 'zod'; import { z } from 'zod';
@@ -45,7 +46,7 @@
} }
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={preventDefault(onSubmit)}>
<fieldset class="flex flex-col gap-5" disabled={$appConfigStore.uiConfigDisabled}> <fieldset class="flex flex-col gap-5" disabled={$appConfigStore.uiConfigDisabled}>
<div class="flex flex-col gap-5"> <div class="flex flex-col gap-5">
<FormInput label={m.application_name()} bind:input={$inputs.appName} /> <FormInput label={m.application_name()} bind:input={$inputs.appName} />

View File

@@ -7,6 +7,7 @@
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration'; import type { AllAppConfig } from '$lib/types/application-configuration';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { z } from 'zod'; import { z } from 'zod';
@@ -103,7 +104,7 @@
} }
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={preventDefault(onSubmit)}>
<h4 class="text-lg font-semibold">{m.client_configuration()}</h4> <h4 class="text-lg font-semibold">{m.client_configuration()}</h4>
<fieldset disabled={$appConfigStore.uiConfigDisabled}> <fieldset disabled={$appConfigStore.uiConfigDisabled}>
<div class="mt-4 grid grid-cols-1 items-start gap-5 md:grid-cols-2"> <div class="mt-4 grid grid-cols-1 items-start gap-5 md:grid-cols-2">

View File

@@ -9,21 +9,21 @@
let { let {
label, label,
description,
callbackURLs = $bindable(), callbackURLs = $bindable(),
error = $bindable(null), error = $bindable(null),
allowEmpty = false,
...restProps ...restProps
}: HTMLAttributes<HTMLDivElement> & { }: HTMLAttributes<HTMLDivElement> & {
label: string; label: string;
description: string;
callbackURLs: string[]; callbackURLs: string[];
error?: string | null; error?: string | null;
allowEmpty?: boolean;
children?: Snippet; children?: Snippet;
} = $props(); } = $props();
</script> </script>
<div {...restProps}> <div {...restProps}>
<FormInput {label} description={m.callback_url_description()}> <FormInput {label} {description}>
<div class="flex flex-col gap-y-2"> <div class="flex flex-col gap-y-2">
{#each callbackURLs as _, i} {#each callbackURLs as _, i}
<div class="flex gap-x-2"> <div class="flex gap-x-2">
@@ -32,15 +32,13 @@
data-testid={`callback-url-${i + 1}`} data-testid={`callback-url-${i + 1}`}
bind:value={callbackURLs[i]} bind:value={callbackURLs[i]}
/> />
{#if callbackURLs.length > 1 || allowEmpty} <Button
<Button variant="outline"
variant="outline" size="sm"
size="sm" onclick={() => (callbackURLs = callbackURLs.filter((_, index) => index !== i))}
onclick={() => (callbackURLs = callbackURLs.filter((_, index) => index !== i))} >
> <LucideMinus class="size-4" />
<LucideMinus class="size-4" /> </Button>
</Button>
{/if}
</div> </div>
{/each} {/each}
</div> </div>

View File

@@ -10,6 +10,7 @@
OidcClientCreate, OidcClientCreate,
OidcClientCreateWithLogo OidcClientCreateWithLogo
} from '$lib/types/oidc.type'; } from '$lib/types/oidc.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { z } from 'zod'; import { z } from 'zod';
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte'; import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
@@ -30,7 +31,7 @@
const client: OidcClientCreate = { const client: OidcClientCreate = {
name: existingClient?.name || '', name: existingClient?.name || '',
callbackURLs: existingClient?.callbackURLs || [''], callbackURLs: existingClient?.callbackURLs || [],
logoutCallbackURLs: existingClient?.logoutCallbackURLs || [], logoutCallbackURLs: existingClient?.logoutCallbackURLs || [],
isPublic: existingClient?.isPublic || false, isPublic: existingClient?.isPublic || false,
pkceEnabled: existingClient?.pkceEnabled || false pkceEnabled: existingClient?.pkceEnabled || false
@@ -38,7 +39,7 @@
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(2).max(50), name: z.string().min(2).max(50),
callbackURLs: z.array(z.string().nonempty()).nonempty(), callbackURLs: z.array(z.string().nonempty()).default([]),
logoutCallbackURLs: z.array(z.string().nonempty()), logoutCallbackURLs: z.array(z.string().nonempty()),
isPublic: z.boolean(), isPublic: z.boolean(),
pkceEnabled: z.boolean() pkceEnabled: z.boolean()
@@ -78,20 +79,21 @@
} }
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={preventDefault(onSubmit)}>
<div class="grid grid-cols-1 gap-x-3 gap-y-7 sm:flex-row md:grid-cols-2"> <div class="grid grid-cols-1 gap-x-3 gap-y-7 sm:flex-row md:grid-cols-2">
<FormInput label={m.name()} class="w-full" bind:input={$inputs.name} /> <FormInput label={m.name()} class="w-full" bind:input={$inputs.name} />
<div></div> <div></div>
<OidcCallbackUrlInput <OidcCallbackUrlInput
label={m.callback_urls()} label={m.callback_urls()}
description={m.callback_url_description()}
class="w-full" class="w-full"
bind:callbackURLs={$inputs.callbackURLs.value} bind:callbackURLs={$inputs.callbackURLs.value}
bind:error={$inputs.callbackURLs.error} bind:error={$inputs.callbackURLs.error}
/> />
<OidcCallbackUrlInput <OidcCallbackUrlInput
label={m.logout_callback_urls()} label={m.logout_callback_urls()}
description={m.logout_callback_url_description()}
class="w-full" class="w-full"
allowEmpty
bind:callbackURLs={$inputs.logoutCallbackURLs.value} bind:callbackURLs={$inputs.logoutCallbackURLs.value}
bind:error={$inputs.logoutCallbackURLs.error} bind:error={$inputs.logoutCallbackURLs.error}
/> />

View File

@@ -4,6 +4,7 @@
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import type { UserGroupCreate } from '$lib/types/user-group.type'; import type { UserGroupCreate } from '$lib/types/user-group.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { z } from 'zod'; import { z } from 'zod';
@@ -56,7 +57,7 @@
} }
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={preventDefault(onSubmit)}>
<fieldset disabled={inputDisabled}> <fieldset disabled={inputDisabled}>
<div class="flex flex-col gap-3 sm:flex-row"> <div class="flex flex-col gap-3 sm:flex-row">
<div class="w-full"> <div class="w-full">

View File

@@ -5,6 +5,7 @@
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import type { User, UserCreate } from '$lib/types/user.type'; import type { User, UserCreate } from '$lib/types/user.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { z } from 'zod'; import { z } from 'zod';
@@ -54,7 +55,7 @@
} }
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={preventDefault(onSubmit)}>
<fieldset disabled={inputDisabled}> <fieldset disabled={inputDisabled}>
<div class="grid grid-cols-1 items-start gap-5 md:grid-cols-2"> <div class="grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<FormInput label={m.first_name()} bind:input={$inputs.firstName} /> <FormInput label={m.first_name()} bind:input={$inputs.firstName} />

View File

@@ -11,13 +11,12 @@ test("Create OIDC client", async ({ page }) => {
await page.getByRole("button", { name: "Add OIDC Client" }).click(); await page.getByRole("button", { name: "Add OIDC Client" }).click();
await page.getByLabel("Name").fill(oidcClient.name); await page.getByLabel("Name").fill(oidcClient.name);
await page.getByRole("button", { name: "Add" }).nth(1).click();
await page.getByTestId("callback-url-1").fill(oidcClient.callbackUrl); await page.getByTestId("callback-url-1").fill(oidcClient.callbackUrl);
await page.getByRole("button", { name: "Add another" }).click(); await page.getByRole("button", { name: "Add another" }).click();
await page.getByTestId("callback-url-2").fill(oidcClient.secondCallbackUrl!); await page.getByTestId("callback-url-2").fill(oidcClient.secondCallbackUrl!);
await page await page.getByLabel("logo").setInputFiles("assets/pingvin-share-logo.png");
.getByLabel("logo")
.setInputFiles("assets/pingvin-share-logo.png");
await page.getByRole("button", { name: "Save" }).click(); await page.getByRole("button", { name: "Save" }).click();
const clientId = await page.getByTestId("client-id").textContent(); const clientId = await page.getByTestId("client-id").textContent();
@@ -53,9 +52,7 @@ test("Edit OIDC client", async ({ page }) => {
.getByTestId("callback-url-1") .getByTestId("callback-url-1")
.first() .first()
.fill("http://nextcloud-updated/auth/callback"); .fill("http://nextcloud-updated/auth/callback");
await page await page.getByLabel("logo").setInputFiles("assets/nextcloud-logo.png");
.getByLabel("logo")
.setInputFiles("assets/nextcloud-logo.png");
await page.getByRole("button", { name: "Save" }).click(); await page.getByRole("button", { name: "Save" }).click();
await expect(page.locator('[data-type="success"]')).toHaveText( await expect(page.locator('[data-type="success"]')).toHaveText(