mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-03-27 17:56:36 +00:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a06d9d21e4 | ||
|
|
cbecbd088f | ||
|
|
3c42a713ce | ||
|
|
e7e0176316 | ||
|
|
0551502586 | ||
|
|
5251cd9799 | ||
|
|
673e5841aa | ||
|
|
dc6558522e | ||
|
|
724c41cb7a | ||
|
|
fc52bd4efb | ||
|
|
2701754e73 | ||
|
|
3700bd942d | ||
|
|
2b5401dd2f | ||
|
|
95e9af4bbf | ||
|
|
0c039cc88c | ||
|
|
192f71a13c | ||
|
|
f90f21b620 | ||
|
|
d71966f996 | ||
|
|
cad80e7d74 | ||
|
|
832b7fbff4 | ||
|
|
e3905cf315 | ||
|
|
b59e35cb59 | ||
|
|
a675d075d1 | ||
|
|
2f56d16f98 | ||
|
|
f4eb8db509 | ||
|
|
e7bd66d1a7 | ||
|
|
1d06817065 | ||
|
|
34890235ba | ||
|
|
27ca713cd4 | ||
|
|
e0fc4cc01b | ||
|
|
01141b8c0f | ||
|
|
8fecc22888 | ||
|
|
d7f19ad5e5 | ||
|
|
45bcdb4b1d | ||
|
|
89349dc1ad | ||
|
|
6159e0bf96 | ||
|
|
4d22c2dbcf | ||
|
|
590e495c1d | ||
|
|
3a339e3319 | ||
|
|
d98db79d5e | ||
|
|
7d2a9b3345 |
106
.github/workflows/pr-quality.yml
vendored
Normal file
106
.github/workflows/pr-quality.yml
vendored
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
name: PR Quality
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pr-quality:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: peakoss/anti-slop@v0
|
||||||
|
with:
|
||||||
|
# General Settings
|
||||||
|
max-failures: 4
|
||||||
|
|
||||||
|
# PR Branch Checks
|
||||||
|
allowed-target-branches: "main"
|
||||||
|
blocked-target-branches: ""
|
||||||
|
allowed-source-branches: ""
|
||||||
|
blocked-source-branches: ""
|
||||||
|
|
||||||
|
# PR Quality Checks
|
||||||
|
max-negative-reactions: 0
|
||||||
|
require-maintainer-can-modify: true
|
||||||
|
|
||||||
|
# PR Title Checks
|
||||||
|
require-conventional-title: true
|
||||||
|
|
||||||
|
# PR Description Checks
|
||||||
|
require-description: true
|
||||||
|
max-description-length: 2500
|
||||||
|
max-emoji-count: 0
|
||||||
|
max-code-references: 0
|
||||||
|
require-linked-issue: false
|
||||||
|
blocked-terms: ""
|
||||||
|
blocked-issue-numbers: ""
|
||||||
|
|
||||||
|
# PR Template Checks
|
||||||
|
require-pr-template: true
|
||||||
|
strict-pr-template-sections: ""
|
||||||
|
optional-pr-template-sections: "Issues"
|
||||||
|
max-additional-pr-template-sections: 3
|
||||||
|
|
||||||
|
# Commit Message Checks
|
||||||
|
max-commit-message-length: 500
|
||||||
|
require-conventional-commits: false
|
||||||
|
require-commit-author-match: true
|
||||||
|
blocked-commit-authors: ""
|
||||||
|
|
||||||
|
# File Checks
|
||||||
|
allowed-file-extensions: ""
|
||||||
|
allowed-paths: ""
|
||||||
|
blocked-paths: |
|
||||||
|
SECURITY.md
|
||||||
|
LICENSE
|
||||||
|
require-final-newline: false
|
||||||
|
max-added-comments: 0
|
||||||
|
|
||||||
|
# User Checks
|
||||||
|
detect-spam-usernames: true
|
||||||
|
min-account-age: 30
|
||||||
|
max-daily-forks: 7
|
||||||
|
min-profile-completeness: 4
|
||||||
|
|
||||||
|
# Merge Checks
|
||||||
|
min-repo-merged-prs: 0
|
||||||
|
min-repo-merge-ratio: 0
|
||||||
|
min-global-merge-ratio: 30
|
||||||
|
global-merge-ratio-exclude-own: false
|
||||||
|
|
||||||
|
# Exemptions
|
||||||
|
exempt-draft-prs: false
|
||||||
|
exempt-bots: |
|
||||||
|
actions-user
|
||||||
|
dependabot[bot]
|
||||||
|
renovate[bot]
|
||||||
|
github-actions[bot]
|
||||||
|
exempt-users: ""
|
||||||
|
exempt-author-association: "OWNER,MEMBER,COLLABORATOR"
|
||||||
|
exempt-label: "quality/exempt"
|
||||||
|
exempt-pr-label: ""
|
||||||
|
exempt-all-milestones: false
|
||||||
|
exempt-all-pr-milestones: false
|
||||||
|
exempt-milestones: ""
|
||||||
|
exempt-pr-milestones: ""
|
||||||
|
|
||||||
|
# PR Success Actions
|
||||||
|
success-add-pr-labels: "quality/verified"
|
||||||
|
|
||||||
|
# PR Failure Actions
|
||||||
|
failure-remove-pr-labels: ""
|
||||||
|
failure-remove-all-pr-labels: true
|
||||||
|
failure-add-pr-labels: "quality/rejected"
|
||||||
|
failure-pr-message: |
|
||||||
|
This PR did not pass quality checks so it will be closed.
|
||||||
|
See the [workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}) for details on which checks failed.
|
||||||
|
|
||||||
|
If you believe this is a mistake please let us know.
|
||||||
|
|
||||||
|
close-pr: true
|
||||||
|
lock-pr: false
|
||||||
63
CHANGELOG.md
63
CHANGELOG.md
@@ -1,3 +1,66 @@
|
|||||||
|
## v2.5.0
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- better error messages when there's another instance of Pocket ID running ([#1370](https://github.com/pocket-id/pocket-id/pull/1370) by @ItalyPaleAle)
|
||||||
|
- move tooltip inside of form input to prevent shifting ([#1369](https://github.com/pocket-id/pocket-id/pull/1369) by @GameTec-live)
|
||||||
|
- derive LDAP admin access from group membership ([#1374](https://github.com/pocket-id/pocket-id/pull/1374) by @kmendell)
|
||||||
|
- avoid fmt.Sprintf on custom GeoLiteDBUrl without %s placeholder ([#1384](https://github.com/pocket-id/pocket-id/pull/1384) by @choyri)
|
||||||
|
- show a warning when SQLite DB is stored on NFS/SMB/FUSE ([#1381](https://github.com/pocket-id/pocket-id/pull/1381) by @ItalyPaleAle)
|
||||||
|
- empty background restore after reboot ([#1379](https://github.com/pocket-id/pocket-id/pull/1379) by @taoso)
|
||||||
|
- allow one-char username on signup ([#1378](https://github.com/pocket-id/pocket-id/pull/1378) by @taoso)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- allow use of svg, png, and ico images types for favicon ([#1289](https://github.com/pocket-id/pocket-id/pull/1289) by @taoso)
|
||||||
|
- allow clearing background image ([#1290](https://github.com/pocket-id/pocket-id/pull/1290) by @taoso)
|
||||||
|
- add `token_endpoint_auth_methods_supported` to `.well-known` ([#1388](https://github.com/pocket-id/pocket-id/pull/1388) by @owenvoke)
|
||||||
|
- add TRUSTED_PLATFORM environment variable for gin ([#1372](https://github.com/pocket-id/pocket-id/pull/1372) by @choyri)
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- add pr quality action ([e3905cf](https://github.com/pocket-id/pocket-id/commit/e3905cf3159fe0370778b0d7d3be64b4246d19be) by @stonith404)
|
||||||
|
- separate querying LDAP and updating DB during sync ([#1371](https://github.com/pocket-id/pocket-id/pull/1371) by @ItalyPaleAle)
|
||||||
|
- bump google.golang.org/grpc from 1.79.1 to 1.79.3 in /backend in the go_modules group across 1 directory ([#1391](https://github.com/pocket-id/pocket-id/pull/1391) by @dependabot[bot])
|
||||||
|
- Improve Latvian translations in lv.json ([#1382](https://github.com/pocket-id/pocket-id/pull/1382) by @Raito00)
|
||||||
|
- ignore linter on app image bootstrap ([5251cd9](https://github.com/pocket-id/pocket-id/commit/5251cd97994177c96cb6f9ab3f88ca31367b5b55) by @kmendell)
|
||||||
|
- upgrade dependencies ([e7e0176](https://github.com/pocket-id/pocket-id/commit/e7e0176316857186b9683e2f0cb0686189f86cfb) by @kmendell)
|
||||||
|
- upgrade dependencies ([3c42a71](https://github.com/pocket-id/pocket-id/commit/3c42a713ce91b4061ffcf86d92cbb19294359ff8) by @kmendell)
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.4.0...v2.5.0
|
||||||
|
|
||||||
|
## v2.4.0
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- improve wildcard matching by using `go-urlpattern` ([#1332](https://github.com/pocket-id/pocket-id/pull/1332) by @stonith404)
|
||||||
|
- federated client credentials not working if sub ≠ client_id ([#1342](https://github.com/pocket-id/pocket-id/pull/1342) by @ItalyPaleAle)
|
||||||
|
- handle IPv6 addresses in callback URLs ([#1355](https://github.com/pocket-id/pocket-id/pull/1355) by @ItalyPaleAle)
|
||||||
|
- wildcard callback URLs blocked by browser-native URL validation ([#1359](https://github.com/pocket-id/pocket-id/pull/1359) by @Copilot)
|
||||||
|
- one-time-access-token route should get user ID from URL only ([#1358](https://github.com/pocket-id/pocket-id/pull/1358) by @ItalyPaleAle)
|
||||||
|
- various fixes in background jobs ([#1362](https://github.com/pocket-id/pocket-id/pull/1362) by @ItalyPaleAle)
|
||||||
|
- use URL keyboard type for callback URL inputs ([a675d07](https://github.com/pocket-id/pocket-id/commit/a675d075d1ab9b7ff8160f1cfc35bc0ea1f1980a) by @stonith404)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- allow first name and display name to be optional ([#1288](https://github.com/pocket-id/pocket-id/pull/1288) by @taoso)
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- bump svelte from 5.53.2 to 5.53.5 in the npm_and_yarn group across 1 directory ([#1348](https://github.com/pocket-id/pocket-id/pull/1348) by @dependabot[bot])
|
||||||
|
- bump @sveltejs/kit from 2.53.0 to 2.53.3 in the npm_and_yarn group across 1 directory ([#1349](https://github.com/pocket-id/pocket-id/pull/1349) by @dependabot[bot])
|
||||||
|
- update AAGUIDs ([#1354](https://github.com/pocket-id/pocket-id/pull/1354) by @github-actions[bot])
|
||||||
|
- add Português files ([01141b8](https://github.com/pocket-id/pocket-id/commit/01141b8c0f2e96a40fd876d3206e49a694fd12c4) by @kmendell)
|
||||||
|
- add Latvian files ([e0fc4cc](https://github.com/pocket-id/pocket-id/commit/e0fc4cc01bd51e5a97e46aad78a493a668049220) by @kmendell)
|
||||||
|
- fix wrong seed data ([e7bd66d](https://github.com/pocket-id/pocket-id/commit/e7bd66d1a77c89dde542b4385ba01dc0d432e434) by @stonith404)
|
||||||
|
- fix wrong seed data in `database.json` ([f4eb8db](https://github.com/pocket-id/pocket-id/commit/f4eb8db50993edacd90e919b39a5c6d9dd4924c7) by @stonith404)
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
|
||||||
|
- frontend performance optimizations ([#1344](https://github.com/pocket-id/pocket-id/pull/1344) by @ItalyPaleAle)
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.3.0...v2.4.0
|
||||||
|
|
||||||
## v2.3.0
|
## v2.3.0
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -58,9 +60,16 @@ func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) e
|
|||||||
return fmt.Errorf("failed to create sub FS: %w", err)
|
return fmt.Errorf("failed to create sub FS: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheMaxAge := time.Hour * 24
|
// Load a map of all files to see which ones are available pre-compressed
|
||||||
fileServer := NewFileServerWithCaching(http.FS(distFS), int(cacheMaxAge.Seconds()))
|
preCompressed, err := listPreCompressedAssets(distFS)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to index pre-compressed frontend assets: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init the file server
|
||||||
|
fileServer := NewFileServerWithCaching(http.FS(distFS), preCompressed)
|
||||||
|
|
||||||
|
// Handler for Gin
|
||||||
handler := func(c *gin.Context) {
|
handler := func(c *gin.Context) {
|
||||||
path := strings.TrimPrefix(c.Request.URL.Path, "/")
|
path := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||||||
|
|
||||||
@@ -108,34 +117,138 @@ func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) e
|
|||||||
type FileServerWithCaching struct {
|
type FileServerWithCaching struct {
|
||||||
root http.FileSystem
|
root http.FileSystem
|
||||||
lastModified time.Time
|
lastModified time.Time
|
||||||
cacheMaxAge int
|
|
||||||
lastModifiedHeaderValue string
|
lastModifiedHeaderValue string
|
||||||
cacheControlHeaderValue string
|
preCompressed preCompressedMap
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFileServerWithCaching(root http.FileSystem, maxAge int) *FileServerWithCaching {
|
func NewFileServerWithCaching(root http.FileSystem, preCompressed preCompressedMap) *FileServerWithCaching {
|
||||||
return &FileServerWithCaching{
|
return &FileServerWithCaching{
|
||||||
root: root,
|
root: root,
|
||||||
lastModified: time.Now(),
|
lastModified: time.Now(),
|
||||||
cacheMaxAge: maxAge,
|
|
||||||
lastModifiedHeaderValue: time.Now().UTC().Format(http.TimeFormat),
|
lastModifiedHeaderValue: time.Now().UTC().Format(http.TimeFormat),
|
||||||
cacheControlHeaderValue: fmt.Sprintf("public, max-age=%d", maxAge),
|
preCompressed: preCompressed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *FileServerWithCaching) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (f *FileServerWithCaching) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
// Check if the client has a cached version
|
// First, set cache headers
|
||||||
if ifModifiedSince := r.Header.Get("If-Modified-Since"); ifModifiedSince != "" {
|
// Check if the request is for an immutable asset
|
||||||
ifModifiedSinceTime, err := time.Parse(http.TimeFormat, ifModifiedSince)
|
if isImmutableAsset(r) {
|
||||||
if err == nil && f.lastModified.Before(ifModifiedSinceTime.Add(1*time.Second)) {
|
// Set the cache control header as immutable with a long expiration
|
||||||
// Client's cached version is up to date
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
w.WriteHeader(http.StatusNotModified)
|
} else {
|
||||||
return
|
// Check if the client has a cached version
|
||||||
|
ifModifiedSince := r.Header.Get("If-Modified-Since")
|
||||||
|
if ifModifiedSince != "" {
|
||||||
|
ifModifiedSinceTime, err := time.Parse(http.TimeFormat, ifModifiedSince)
|
||||||
|
if err == nil && f.lastModified.Before(ifModifiedSinceTime.Add(1*time.Second)) {
|
||||||
|
// Client's cached version is up to date
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache other assets for up to 24 hours, but set Last-Modified too
|
||||||
|
w.Header().Set("Last-Modified", f.lastModifiedHeaderValue)
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the asset is available pre-compressed
|
||||||
|
_, ok := f.preCompressed[r.URL.Path]
|
||||||
|
if ok {
|
||||||
|
// Add a "Vary" with "Accept-Encoding" so CDNs are aware that content is pre-compressed
|
||||||
|
w.Header().Add("Vary", "Accept-Encoding")
|
||||||
|
|
||||||
|
// Select the encoding if any
|
||||||
|
ext, ce := f.selectEncoding(r)
|
||||||
|
if ext != "" {
|
||||||
|
// Set the content type explicitly before changing the path
|
||||||
|
ct := mime.TypeByExtension(path.Ext(r.URL.Path))
|
||||||
|
if ct != "" {
|
||||||
|
w.Header().Set("Content-Type", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make the serve return the encoded content
|
||||||
|
w.Header().Set("Content-Encoding", ce)
|
||||||
|
r.URL.Path += "." + ext
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Last-Modified", f.lastModifiedHeaderValue)
|
|
||||||
w.Header().Set("Cache-Control", f.cacheControlHeaderValue)
|
|
||||||
|
|
||||||
http.FileServer(f.root).ServeHTTP(w, r)
|
http.FileServer(f.root).ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *FileServerWithCaching) selectEncoding(r *http.Request) (ext string, contentEnc string) {
|
||||||
|
available, ok := f.preCompressed[r.URL.Path]
|
||||||
|
if !ok {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the client accepts compressed files
|
||||||
|
acceptEncoding := strings.TrimSpace(strings.ToLower(r.Header.Get("Accept-Encoding")))
|
||||||
|
if acceptEncoding == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer brotli over gzip when both are accepted.
|
||||||
|
if available.br && (acceptEncoding == "*" || acceptEncoding == "br" || strings.Contains(acceptEncoding, "br")) {
|
||||||
|
return "br", "br"
|
||||||
|
}
|
||||||
|
if available.gz && (acceptEncoding == "gzip" || strings.Contains(acceptEncoding, "gzip")) {
|
||||||
|
return "gz", "gzip"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func isImmutableAsset(r *http.Request) bool {
|
||||||
|
switch {
|
||||||
|
// Fonts
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/fonts/"):
|
||||||
|
return true
|
||||||
|
|
||||||
|
// Compiled SvelteKit assets
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/_app/immutable/"):
|
||||||
|
return true
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type preCompressedMap map[string]struct {
|
||||||
|
br bool
|
||||||
|
gz bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func listPreCompressedAssets(distFS fs.FS) (preCompressedMap, error) {
|
||||||
|
preCompressed := make(preCompressedMap, 0)
|
||||||
|
err := fs.WalkDir(distFS, ".", func(path string, d fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(path, ".br"):
|
||||||
|
originalPath := "/" + strings.TrimSuffix(path, ".br")
|
||||||
|
entry := preCompressed[originalPath]
|
||||||
|
entry.br = true
|
||||||
|
preCompressed[originalPath] = entry
|
||||||
|
case strings.HasSuffix(path, ".gz"):
|
||||||
|
originalPath := "/" + strings.TrimSuffix(path, ".gz")
|
||||||
|
entry := preCompressed[originalPath]
|
||||||
|
entry.gz = true
|
||||||
|
preCompressed[originalPath] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return preCompressed, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ require (
|
|||||||
github.com/cenkalti/backoff/v5 v5.0.3
|
github.com/cenkalti/backoff/v5 v5.0.3
|
||||||
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/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
|
||||||
github.com/emersion/go-smtp v0.24.0
|
github.com/emersion/go-smtp v0.24.0
|
||||||
github.com/gin-contrib/slog v1.2.0
|
github.com/gin-contrib/slog v1.2.0
|
||||||
@@ -74,6 +75,7 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/bits-and-blooms/bitset v1.14.3 // indirect
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
@@ -123,6 +125,7 @@ require (
|
|||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/nlnwa/whatwg-url v0.5.0 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||||
@@ -161,7 +164,7 @@ require (
|
|||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
||||||
google.golang.org/grpc v1.79.1 // indirect
|
google.golang.org/grpc v1.79.3 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/libc v1.68.0 // indirect
|
modernc.org/libc v1.68.0 // indirect
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
|
|||||||
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||||
|
github.com/bits-and-blooms/bitset v1.14.3 h1:Gd2c8lSNf9pKXom5JtD7AaKO8o7fGQ2LtFj1436qilA=
|
||||||
|
github.com/bits-and-blooms/bitset v1.14.3/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
@@ -88,6 +91,8 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
|
|||||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1 h1:RW22Y3QjGrb97NUA8yupdFcaqg//+hMI2fZrETBvQ4s=
|
||||||
|
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1/go.mod h1:mnVcdqOeYg0HvT6veRo7wINa1mJ+lC/R4ig2lWcapSI=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||||
@@ -257,6 +262,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
|
|||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/nlnwa/whatwg-url v0.5.0 h1:l71cqfqG44+VCQZQX3wD4bwheFWicPxuwaCimLEfpDo=
|
||||||
|
github.com/nlnwa/whatwg-url v0.5.0/go.mod h1:X/ejnFFVbaOWdSul+cnlsSHviCzGZJdvPkgc9zD8IY8=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
@@ -320,6 +327,7 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT
|
|||||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0 h1:yOYhGNPZseueTTvWp5iBD3/CthrmvayUXYEX862dDi4=
|
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0 h1:yOYhGNPZseueTTvWp5iBD3/CthrmvayUXYEX862dDi4=
|
||||||
@@ -385,6 +393,9 @@ golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
|||||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||||
@@ -392,34 +403,65 @@ golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkN
|
|||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
@@ -428,8 +470,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:
|
|||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
@@ -20,6 +21,8 @@ import (
|
|||||||
|
|
||||||
// initApplicationImages copies the images from the embedded directory to the storage backend
|
// initApplicationImages copies the images from the embedded directory to the storage backend
|
||||||
// and returns a map containing the detected file extensions in the application-images directory.
|
// and returns a map containing the detected file extensions in the application-images directory.
|
||||||
|
//
|
||||||
|
//nolint:gocognit
|
||||||
func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage) (map[string]string, error) {
|
func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage) (map[string]string, error) {
|
||||||
// Previous versions of images
|
// Previous versions of images
|
||||||
// If these are found, they are deleted
|
// If these are found, they are deleted
|
||||||
@@ -76,6 +79,18 @@ func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage)
|
|||||||
dstNameToExt[nameWithoutExt] = ext
|
dstNameToExt[nameWithoutExt] = ext
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initedPath := path.Join("application-images", ".inited")
|
||||||
|
if _, _, err := fileStorage.Open(ctx, initedPath); err == nil {
|
||||||
|
return dstNameToExt, nil
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("failed to read .inited: %w", err)
|
||||||
|
} else {
|
||||||
|
err := fileStorage.Save(ctx, initedPath, strings.NewReader(""))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to store .inited: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Copy images from the images directory to the application-images directory if they don't already exist
|
// Copy images from the images directory to the application-images directory if they don't already exist
|
||||||
for _, sourceFile := range sourceFiles {
|
for _, sourceFile := range sourceFiles {
|
||||||
if sourceFile.IsDir() {
|
if sourceFile.IsDir() {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package bootstrap
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"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/job"
|
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
)
|
)
|
||||||
@@ -60,7 +62,9 @@ func Bootstrap(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
waitUntil, err := svc.appLockService.Acquire(ctx, false)
|
waitUntil, err := svc.appLockService.Acquire(ctx, false)
|
||||||
if err != nil {
|
if errors.Is(err, service.ErrLockUnavailable) {
|
||||||
|
return errors.New("it appears that there's already one instance of Pocket ID running; running multiple replicas of Pocket ID is currently not supported")
|
||||||
|
} else if err != nil {
|
||||||
return fmt.Errorf("failed to acquire application lock: %w", err)
|
return fmt.Errorf("failed to acquire application lock: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ func NewDatabase() (db *gorm.DB, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run migrations
|
// Run migrations
|
||||||
if err := utils.MigrateDatabase(sqlDb); err != nil {
|
err = utils.MigrateDatabase(sqlDb)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to run migrations: %w", err)
|
return nil, fmt.Errorf("failed to run migrations: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +43,10 @@ func NewDatabase() (db *gorm.DB, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ConnectDatabase() (db *gorm.DB, err error) {
|
func ConnectDatabase() (db *gorm.DB, err error) {
|
||||||
var dialector gorm.Dialector
|
var (
|
||||||
|
dialector gorm.Dialector
|
||||||
|
sqliteNetworkFilesystem bool
|
||||||
|
)
|
||||||
|
|
||||||
// Choose the correct database provider
|
// Choose the correct database provider
|
||||||
var onConnFn func(conn *sql.DB)
|
var onConnFn func(conn *sql.DB)
|
||||||
@@ -63,6 +67,14 @@ func ConnectDatabase() (db *gorm.DB, err error) {
|
|||||||
if err := ensureSqliteDatabaseDir(dbPath); err != nil {
|
if err := ensureSqliteDatabaseDir(dbPath); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sqliteNetworkFilesystem, err = utils.IsNetworkedFileSystem(filepath.Dir(dbPath))
|
||||||
|
if err != nil {
|
||||||
|
// Log the error only
|
||||||
|
slog.Warn("Failed to detect filesystem type for the SQLite database directory", slog.String("path", filepath.Dir(dbPath)), slog.Any("error", err))
|
||||||
|
} else if sqliteNetworkFilesystem {
|
||||||
|
slog.Warn("⚠️⚠️⚠️ SQLite databases should not be stored on a networked file system like NFS, SMB, or FUSE, as there's a risk of crashes and even database corruption", slog.String("path", filepath.Dir(dbPath)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Before we connect, also make sure that there's a temporary folder for SQLite to write its data
|
// Before we connect, also make sure that there's a temporary folder for SQLite to write its data
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
|
|||||||
_ = r.SetTrustedProxies(nil)
|
_ = r.SetTrustedProxies(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if common.EnvConfig.TrustedPlatform != "" {
|
||||||
|
r.TrustedPlatform = common.EnvConfig.TrustedPlatform
|
||||||
|
}
|
||||||
|
|
||||||
if common.EnvConfig.TracingEnabled {
|
if common.EnvConfig.TracingEnabled {
|
||||||
r.Use(otelgin.Middleware(common.Name))
|
r.Use(otelgin.Middleware(common.Name))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,11 +119,10 @@ func acquireImportLock(ctx context.Context, db *gorm.DB, force bool) error {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
waitUntil, err := appLockService.Acquire(opCtx, force)
|
waitUntil, err := appLockService.Acquire(opCtx, force)
|
||||||
if err != nil {
|
if errors.Is(err, service.ErrLockUnavailable) {
|
||||||
if errors.Is(err, service.ErrLockUnavailable) {
|
//nolint:staticcheck
|
||||||
//nolint:staticcheck
|
return errors.New("Pocket ID must be stopped before importing data; please stop the running instance or run with --forcefully-acquire-lock to terminate the other instance")
|
||||||
return errors.New("Pocket ID must be stopped before importing data; please stop the running instance or run with --forcefully-acquire-lock to terminate the other instance")
|
} else if err != nil {
|
||||||
}
|
|
||||||
return fmt.Errorf("failed to acquire application lock: %w", err)
|
return fmt.Errorf("failed to acquire application lock: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type EnvConfigSchema struct {
|
|||||||
DbProvider DbProvider
|
DbProvider DbProvider
|
||||||
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
|
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
|
||||||
TrustProxy bool `env:"TRUST_PROXY"`
|
TrustProxy bool `env:"TRUST_PROXY"`
|
||||||
|
TrustedPlatform string `env:"TRUSTED_PLATFORM"`
|
||||||
AuditLogRetentionDays int `env:"AUDIT_LOG_RETENTION_DAYS"`
|
AuditLogRetentionDays int `env:"AUDIT_LOG_RETENTION_DAYS"`
|
||||||
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
|
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
|
||||||
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
|
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ type AlreadyInUseError struct {
|
|||||||
func (e *AlreadyInUseError) Error() string {
|
func (e *AlreadyInUseError) Error() string {
|
||||||
return e.Property + " is already in use"
|
return e.Property + " is already in use"
|
||||||
}
|
}
|
||||||
func (e *AlreadyInUseError) HttpStatusCode() int { return 400 }
|
func (e *AlreadyInUseError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
func (e *AlreadyInUseError) Is(target error) bool {
|
func (e *AlreadyInUseError) Is(target error) bool {
|
||||||
// Ignore the field property when checking if an error is of the type AlreadyInUseError
|
// Ignore the field property when checking if an error is of the type AlreadyInUseError
|
||||||
@@ -31,26 +31,26 @@ func (e *AlreadyInUseError) Is(target error) bool {
|
|||||||
type SetupAlreadyCompletedError struct{}
|
type SetupAlreadyCompletedError struct{}
|
||||||
|
|
||||||
func (e *SetupAlreadyCompletedError) Error() string { return "setup already completed" }
|
func (e *SetupAlreadyCompletedError) Error() string { return "setup already completed" }
|
||||||
func (e *SetupAlreadyCompletedError) HttpStatusCode() int { return 400 }
|
func (e *SetupAlreadyCompletedError) HttpStatusCode() int { return http.StatusConflict }
|
||||||
|
|
||||||
type TokenInvalidOrExpiredError struct{}
|
type TokenInvalidOrExpiredError struct{}
|
||||||
|
|
||||||
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
|
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
|
||||||
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
|
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||||
|
|
||||||
type DeviceCodeInvalid struct{}
|
type DeviceCodeInvalid struct{}
|
||||||
|
|
||||||
func (e *DeviceCodeInvalid) Error() string {
|
func (e *DeviceCodeInvalid) Error() string {
|
||||||
return "one time access code must be used on the device it was generated for"
|
return "one time access code must be used on the device it was generated for"
|
||||||
}
|
}
|
||||||
func (e *DeviceCodeInvalid) HttpStatusCode() int { return 400 }
|
func (e *DeviceCodeInvalid) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||||
|
|
||||||
type TokenInvalidError struct{}
|
type TokenInvalidError struct{}
|
||||||
|
|
||||||
func (e *TokenInvalidError) Error() string {
|
func (e *TokenInvalidError) Error() string {
|
||||||
return "Token is invalid"
|
return "Token is invalid"
|
||||||
}
|
}
|
||||||
func (e *TokenInvalidError) HttpStatusCode() int { return 400 }
|
func (e *TokenInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||||
|
|
||||||
type OidcMissingAuthorizationError struct{}
|
type OidcMissingAuthorizationError struct{}
|
||||||
|
|
||||||
@@ -60,46 +60,51 @@ func (e *OidcMissingAuthorizationError) HttpStatusCode() int { return http.Statu
|
|||||||
type OidcGrantTypeNotSupportedError struct{}
|
type OidcGrantTypeNotSupportedError struct{}
|
||||||
|
|
||||||
func (e *OidcGrantTypeNotSupportedError) Error() string { return "grant type not supported" }
|
func (e *OidcGrantTypeNotSupportedError) Error() string { return "grant type not supported" }
|
||||||
func (e *OidcGrantTypeNotSupportedError) HttpStatusCode() int { return 400 }
|
func (e *OidcGrantTypeNotSupportedError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
type OidcMissingClientCredentialsError struct{}
|
type OidcMissingClientCredentialsError struct{}
|
||||||
|
|
||||||
func (e *OidcMissingClientCredentialsError) Error() string { return "client id or secret not provided" }
|
func (e *OidcMissingClientCredentialsError) Error() string { return "client id or secret not provided" }
|
||||||
func (e *OidcMissingClientCredentialsError) HttpStatusCode() int { return 400 }
|
func (e *OidcMissingClientCredentialsError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
type OidcClientSecretInvalidError struct{}
|
type OidcClientSecretInvalidError struct{}
|
||||||
|
|
||||||
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
|
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
|
||||||
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return 400 }
|
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||||
|
|
||||||
type OidcClientAssertionInvalidError struct{}
|
type OidcClientAssertionInvalidError struct{}
|
||||||
|
|
||||||
func (e *OidcClientAssertionInvalidError) Error() string { return "invalid client assertion" }
|
func (e *OidcClientAssertionInvalidError) Error() string { return "invalid client assertion" }
|
||||||
func (e *OidcClientAssertionInvalidError) HttpStatusCode() int { return 400 }
|
func (e *OidcClientAssertionInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||||
|
|
||||||
type OidcInvalidAuthorizationCodeError struct{}
|
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 http.StatusBadRequest }
|
||||||
|
|
||||||
|
type OidcClientNotFoundError struct{}
|
||||||
|
|
||||||
|
func (e *OidcClientNotFoundError) Error() string { return "client not found" }
|
||||||
|
func (e *OidcClientNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
|
||||||
|
|
||||||
type OidcMissingCallbackURLError struct{}
|
type OidcMissingCallbackURLError struct{}
|
||||||
|
|
||||||
func (e *OidcMissingCallbackURLError) Error() string {
|
func (e *OidcMissingCallbackURLError) Error() string {
|
||||||
return "unable to detect callback url, it might be necessary for an admin to fix this"
|
return "unable to detect callback url, it might be necessary for an admin to fix this"
|
||||||
}
|
}
|
||||||
func (e *OidcMissingCallbackURLError) HttpStatusCode() int { return 400 }
|
func (e *OidcMissingCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
type OidcInvalidCallbackURLError struct{}
|
type OidcInvalidCallbackURLError struct{}
|
||||||
|
|
||||||
func (e *OidcInvalidCallbackURLError) Error() string {
|
func (e *OidcInvalidCallbackURLError) Error() string {
|
||||||
return "invalid callback URL, it might be necessary for an admin to fix this"
|
return "invalid callback URL, it might be necessary for an admin to fix this"
|
||||||
}
|
}
|
||||||
func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return 400 }
|
func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
type FileTypeNotSupportedError struct{}
|
type FileTypeNotSupportedError struct{}
|
||||||
|
|
||||||
func (e *FileTypeNotSupportedError) Error() string { return "file type not supported" }
|
func (e *FileTypeNotSupportedError) Error() string { return "file type not supported" }
|
||||||
func (e *FileTypeNotSupportedError) HttpStatusCode() int { return 400 }
|
func (e *FileTypeNotSupportedError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
type FileTooLargeError struct {
|
type FileTooLargeError struct {
|
||||||
MaxSize string
|
MaxSize string
|
||||||
@@ -134,6 +139,20 @@ func (e *TooManyRequestsError) Error() string {
|
|||||||
}
|
}
|
||||||
func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
|
func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
|
||||||
|
|
||||||
|
type UserIdNotProvidedError struct{}
|
||||||
|
|
||||||
|
func (e *UserIdNotProvidedError) Error() string {
|
||||||
|
return "User id not provided"
|
||||||
|
}
|
||||||
|
func (e *UserIdNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type UserNotFoundError struct{}
|
||||||
|
|
||||||
|
func (e *UserNotFoundError) Error() string {
|
||||||
|
return "User not found"
|
||||||
|
}
|
||||||
|
func (e *UserNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
|
||||||
|
|
||||||
type ClientIdOrSecretNotProvidedError struct{}
|
type ClientIdOrSecretNotProvidedError struct{}
|
||||||
|
|
||||||
func (e *ClientIdOrSecretNotProvidedError) Error() string {
|
func (e *ClientIdOrSecretNotProvidedError) Error() string {
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -34,6 +36,7 @@ func NewAppImagesController(
|
|||||||
group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler)
|
group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler)
|
||||||
group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), controller.updateDefaultProfilePicture)
|
group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), controller.updateDefaultProfilePicture)
|
||||||
|
|
||||||
|
group.DELETE("/application-images/background", authMiddleware.Add(), controller.deleteBackgroundImageHandler)
|
||||||
group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), controller.deleteDefaultProfilePicture)
|
group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), controller.deleteDefaultProfilePicture)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,12 +195,27 @@ func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) {
|
|||||||
ctx.Status(http.StatusNoContent)
|
ctx.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deleteBackgroundImageHandler godoc
|
||||||
|
// @Summary Delete background image
|
||||||
|
// @Description Delete the application background image
|
||||||
|
// @Tags Application Images
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/application-images/background [delete]
|
||||||
|
func (c *AppImagesController) deleteBackgroundImageHandler(ctx *gin.Context) {
|
||||||
|
if err := c.appImagesService.DeleteImage(ctx.Request.Context(), "background"); err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// updateFaviconHandler godoc
|
// updateFaviconHandler godoc
|
||||||
// @Summary Update favicon
|
// @Summary Update favicon
|
||||||
// @Description Update the application favicon
|
// @Description Update the application favicon
|
||||||
// @Tags Application Images
|
// @Tags Application Images
|
||||||
// @Accept multipart/form-data
|
// @Accept multipart/form-data
|
||||||
// @Param file formData file true "Favicon file (.ico)"
|
// @Param file formData file true "Favicon file (.svg/.png/.ico)"
|
||||||
// @Success 204 "No Content"
|
// @Success 204 "No Content"
|
||||||
// @Router /api/application-images/favicon [put]
|
// @Router /api/application-images/favicon [put]
|
||||||
func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
|
func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
|
||||||
@@ -208,8 +226,9 @@ func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fileType := utils.GetFileExtension(file.Filename)
|
fileType := utils.GetFileExtension(file.Filename)
|
||||||
if fileType != "ico" {
|
mimeType := utils.GetImageMimeType(strings.ToLower(fileType))
|
||||||
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
|
if !slices.Contains([]string{"image/svg+xml", "image/png", "image/x-icon"}, mimeType) {
|
||||||
|
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".svg or .png or .ico"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -335,11 +335,13 @@ func (oc *OidcController) introspectTokenHandler(c *gin.Context) {
|
|||||||
)
|
)
|
||||||
creds.ClientID, creds.ClientSecret, ok = utils.OAuthClientBasicAuth(c.Request)
|
creds.ClientID, creds.ClientSecret, ok = utils.OAuthClientBasicAuth(c.Request)
|
||||||
if !ok {
|
if !ok {
|
||||||
// If there's no basic auth, check if we have a bearer token
|
// If there's no basic auth, check if we have a bearer token (used as client assertion)
|
||||||
bearer, ok := utils.BearerAuth(c.Request)
|
bearer, ok := utils.BearerAuth(c.Request)
|
||||||
if ok {
|
if ok {
|
||||||
creds.ClientAssertionType = service.ClientAssertionTypeJWTBearer
|
creds.ClientAssertionType = service.ClientAssertionTypeJWTBearer
|
||||||
creds.ClientAssertion = bearer
|
creds.ClientAssertion = bearer
|
||||||
|
// When using client assertions, client_id can be passed as a form field
|
||||||
|
creds.ClientID = input.ClientID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,8 +664,13 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
|
func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
|
||||||
|
// Per RFC 8628 (OAuth 2.0 Device Authorization Grant), parameters for the device authorization request MUST be sent in the body of the POST request
|
||||||
|
// Gin's "ShouldBind" by default reads from the query string too, so we need to reset all query string args before invoking ShouldBind
|
||||||
|
c.Request.URL.RawQuery = ""
|
||||||
|
|
||||||
var input dto.OidcDeviceAuthorizationRequestDto
|
var input dto.OidcDeviceAuthorizationRequestDto
|
||||||
if err := c.ShouldBind(&input); err != nil {
|
err := c.ShouldBind(&input)
|
||||||
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
"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"
|
||||||
@@ -322,22 +323,34 @@ func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context)
|
|||||||
|
|
||||||
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bool) {
|
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bool) {
|
||||||
var input dto.OneTimeAccessTokenCreateDto
|
var input dto.OneTimeAccessTokenCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
err := c.ShouldBindJSON(&input)
|
||||||
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var ttl time.Duration
|
var (
|
||||||
|
userID string
|
||||||
|
ttl time.Duration
|
||||||
|
)
|
||||||
if own {
|
if own {
|
||||||
input.UserID = c.GetString("userID")
|
// Get user ID from context and force the default TTL
|
||||||
|
userID = c.GetString("userID")
|
||||||
ttl = defaultOneTimeAccessTokenDuration
|
ttl = defaultOneTimeAccessTokenDuration
|
||||||
} else {
|
} else {
|
||||||
|
// Get user ID from URL parameter, and optional TTL from body
|
||||||
|
userID = c.Param("id")
|
||||||
ttl = input.TTL.Duration
|
ttl = input.TTL.Duration
|
||||||
if ttl <= 0 {
|
if ttl <= 0 {
|
||||||
ttl = defaultOneTimeAccessTokenDuration
|
ttl = defaultOneTimeAccessTokenDuration
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
token, err := uc.oneTimeAccessService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, ttl)
|
if userID == "" {
|
||||||
|
_ = c.Error(&common.UserIdNotProvidedError{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := uc.oneTimeAccessService.CreateOneTimeAccessToken(c.Request.Context(), userID, ttl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
|
|||||||
"id_token_signing_alg_values_supported": []string{alg.String()},
|
"id_token_signing_alg_values_supported": []string{alg.String()},
|
||||||
"authorization_response_iss_parameter_supported": true,
|
"authorization_response_iss_parameter_supported": true,
|
||||||
"code_challenge_methods_supported": []string{"plain", "S256"},
|
"code_challenge_methods_supported": []string{"plain", "S256"},
|
||||||
|
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"},
|
||||||
}
|
}
|
||||||
return json.Marshal(config)
|
return json.Marshal(config)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,8 @@ type OidcCreateTokensDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OidcIntrospectDto struct {
|
type OidcIntrospectDto struct {
|
||||||
Token string `form:"token" binding:"required"`
|
Token string `form:"token" binding:"required"`
|
||||||
|
ClientID string `form:"client_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcUpdateAllowedUserGroupsDto struct {
|
type OidcUpdateAllowedUserGroupsDto struct {
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ package dto
|
|||||||
import "github.com/pocket-id/pocket-id/backend/internal/utils"
|
import "github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
|
||||||
type OneTimeAccessTokenCreateDto struct {
|
type OneTimeAccessTokenCreateDto struct {
|
||||||
UserID string `json:"userId"`
|
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
|
||||||
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
|
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
type SignUpDto struct {
|
type SignUpDto struct {
|
||||||
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
|
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
|
||||||
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
||||||
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
|
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
|
||||||
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ type UserDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserCreateDto struct {
|
type UserCreateDto struct {
|
||||||
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
|
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
|
||||||
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
||||||
EmailVerified bool `json:"emailVerified"`
|
EmailVerified bool `json:"emailVerified"`
|
||||||
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
|
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
|
||||||
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||||
DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
|
DisplayName string `json:"displayName" binding:"max=100" unorm:"nfc"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
Locale *string `json:"locale"`
|
Locale *string `json:"locale"`
|
||||||
Disabled bool `json:"disabled"`
|
Disabled bool `json:"disabled"`
|
||||||
|
|||||||
@@ -33,14 +33,24 @@ func TestUserCreateDto_Validate(t *testing.T) {
|
|||||||
},
|
},
|
||||||
wantErr: "Field validation for 'Username' failed on the 'required' tag",
|
wantErr: "Field validation for 'Username' failed on the 'required' tag",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "missing first name",
|
||||||
|
input: UserCreateDto{
|
||||||
|
Username: "testuser",
|
||||||
|
Email: new("test@example.com"),
|
||||||
|
LastName: "Doe",
|
||||||
|
},
|
||||||
|
wantErr: "",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "missing display name",
|
name: "missing display name",
|
||||||
input: UserCreateDto{
|
input: UserCreateDto{
|
||||||
|
Username: "testuser",
|
||||||
Email: new("test@example.com"),
|
Email: new("test@example.com"),
|
||||||
FirstName: "John",
|
FirstName: "John",
|
||||||
LastName: "Doe",
|
LastName: "Doe",
|
||||||
},
|
},
|
||||||
wantErr: "Field validation for 'DisplayName' failed on the 'required' tag",
|
wantErr: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "username contains invalid characters",
|
name: "username contains invalid characters",
|
||||||
@@ -73,7 +83,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
|
|||||||
LastName: "Doe",
|
LastName: "Doe",
|
||||||
DisplayName: "John Doe",
|
DisplayName: "John Doe",
|
||||||
},
|
},
|
||||||
wantErr: "Field validation for 'FirstName' failed on the 'required' tag",
|
wantErr: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "last name too long",
|
name: "last name too long",
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/url"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
@@ -15,7 +13,8 @@ import (
|
|||||||
// [a-zA-Z0-9] : The username must start with an alphanumeric character
|
// [a-zA-Z0-9] : The username must start with an alphanumeric character
|
||||||
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
|
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
|
||||||
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
|
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
|
||||||
var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$")
|
// (...)? : This allows single-character usernames (just one alphanumeric character)
|
||||||
|
var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a-zA-Z0-9])?$")
|
||||||
|
|
||||||
var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
|
var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
|
||||||
|
|
||||||
@@ -67,19 +66,6 @@ func ValidateClientID(clientID string) bool {
|
|||||||
|
|
||||||
// ValidateCallbackURL validates callback URLs with support for wildcards
|
// ValidateCallbackURL validates callback URLs with support for wildcards
|
||||||
func ValidateCallbackURL(raw string) bool {
|
func ValidateCallbackURL(raw string) bool {
|
||||||
// Don't validate if it contains a wildcard
|
err := utils.ValidateCallbackURLPattern(raw)
|
||||||
if strings.Contains(raw, "*") {
|
return err == nil
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := url.Parse(raw)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if !u.IsAbs() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ func TestValidateUsername(t *testing.T) {
|
|||||||
{"starts with symbol", ".username", false},
|
{"starts with symbol", ".username", false},
|
||||||
{"ends with non-alphanumeric", "username-", false},
|
{"ends with non-alphanumeric", "username-", false},
|
||||||
{"contains space", "user name", false},
|
{"contains space", "user name", false},
|
||||||
|
{"valid single char", "a", true},
|
||||||
{"empty", "", false},
|
{"empty", "", false},
|
||||||
{"only special chars", "-._@", false},
|
{"only special chars", "-._@", false},
|
||||||
{"valid long", "a1234567890_b.c-d@e", true},
|
{"valid long", "a1234567890_b.c-d@e", true},
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service
|
|||||||
appConfig: appConfig,
|
appConfig: appConfig,
|
||||||
httpClient: httpClient,
|
httpClient: httpClient,
|
||||||
}
|
}
|
||||||
return s.RegisterJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, true)
|
return s.RegisterJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, service.RegisterJobOpts{RunImmediately: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnalyticsJob struct {
|
type AnalyticsJob struct {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send every day at midnight
|
// Send every day at midnight
|
||||||
return s.RegisterJob(ctx, "ExpiredApiKeyEmailJob", gocron.CronJob("0 0 * * *", false), jobs.checkAndNotifyExpiringApiKeys, false)
|
return s.RegisterJob(ctx, "ExpiredApiKeyEmailJob", gocron.CronJob("0 0 * * *", false), jobs.checkAndNotifyExpiringApiKeys, service.RegisterJobOpts{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {
|
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {
|
||||||
@@ -42,7 +42,11 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err
|
|||||||
}
|
}
|
||||||
err = j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key)
|
err = j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Failed to send expiring API key notification email", slog.String("key", key.ID), slog.Any("error", err))
|
slog.ErrorContext(ctx, "Failed to send expiring API key notification email",
|
||||||
|
slog.String("key", key.ID),
|
||||||
|
slog.String("user", key.User.ID),
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -7,28 +7,37 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-co-op/gocron/v2"
|
backoff "github.com/cenkalti/backoff/v5"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) error {
|
func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) error {
|
||||||
jobs := &DbCleanupJobs{db: db}
|
jobs := &DbCleanupJobs{db: db}
|
||||||
|
|
||||||
// Run every 24 hours (but with some jitter so they don't run at the exact same time), and now
|
newBackOff := func() *backoff.ExponentialBackOff {
|
||||||
def := gocron.DurationRandomJob(24*time.Hour-2*time.Minute, 24*time.Hour+2*time.Minute)
|
bo := backoff.NewExponentialBackOff()
|
||||||
|
bo.Multiplier = 4
|
||||||
|
bo.RandomizationFactor = 0.1
|
||||||
|
bo.InitialInterval = time.Second
|
||||||
|
bo.MaxInterval = 45 * time.Second
|
||||||
|
return bo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use exponential backoff for each DB cleanup job so transient query failures are retried automatically rather than causing an immediate job failure
|
||||||
return errors.Join(
|
return errors.Join(
|
||||||
s.RegisterJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
|
s.RegisterJob(ctx, "ClearWebauthnSessions", jobDefWithJitter(24*time.Hour), jobs.clearWebauthnSessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||||
s.RegisterJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
|
s.RegisterJob(ctx, "ClearOneTimeAccessTokens", jobDefWithJitter(24*time.Hour), jobs.clearOneTimeAccessTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||||
s.RegisterJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
|
s.RegisterJob(ctx, "ClearSignupTokens", jobDefWithJitter(24*time.Hour), jobs.clearSignupTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||||
s.RegisterJob(ctx, "ClearEmailVerificationTokens", def, jobs.clearEmailVerificationTokens, true),
|
s.RegisterJob(ctx, "ClearEmailVerificationTokens", jobDefWithJitter(24*time.Hour), jobs.clearEmailVerificationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||||
s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
|
s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", jobDefWithJitter(24*time.Hour), jobs.clearOidcAuthorizationCodes, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||||
s.RegisterJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
|
s.RegisterJob(ctx, "ClearOidcRefreshTokens", jobDefWithJitter(24*time.Hour), jobs.clearOidcRefreshTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||||
s.RegisterJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true),
|
s.RegisterJob(ctx, "ClearReauthenticationTokens", jobDefWithJitter(24*time.Hour), jobs.clearReauthenticationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||||
s.RegisterJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
|
s.RegisterJob(ctx, "ClearAuditLogs", jobDefWithJitter(24*time.Hour), jobs.clearAuditLogs, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,20 +13,26 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB, fileStorage storage.FileStorage) error {
|
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB, fileStorage storage.FileStorage) error {
|
||||||
jobs := &FileCleanupJobs{db: db, fileStorage: fileStorage}
|
jobs := &FileCleanupJobs{db: db, fileStorage: fileStorage}
|
||||||
|
|
||||||
err := s.RegisterJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, false)
|
var errs []error
|
||||||
|
errs = append(errs,
|
||||||
|
s.RegisterJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, service.RegisterJobOpts{}),
|
||||||
|
)
|
||||||
|
|
||||||
// Only necessary for file system storage
|
// Only necessary for file system storage
|
||||||
if fileStorage.Type() == storage.TypeFileSystem {
|
if fileStorage.Type() == storage.TypeFileSystem {
|
||||||
err = errors.Join(err, s.RegisterJob(ctx, "ClearOrphanedTempFiles", gocron.DurationJob(12*time.Hour), jobs.clearOrphanedTempFiles, true))
|
errs = append(errs,
|
||||||
|
s.RegisterJob(ctx, "ClearOrphanedTempFiles", gocron.DurationJob(12*time.Hour), jobs.clearOrphanedTempFiles, service.RegisterJobOpts{RunImmediately: true}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileCleanupJobs struct {
|
type FileCleanupJobs struct {
|
||||||
@@ -68,7 +74,8 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context)
|
|||||||
// If these initials aren't used by any user, delete the file
|
// If these initials aren't used by any user, delete the file
|
||||||
if _, ok := initialsInUse[initials]; !ok {
|
if _, ok := initialsInUse[initials]; !ok {
|
||||||
filePath := path.Join(defaultPicturesDir, filename)
|
filePath := path.Join(defaultPicturesDir, filename)
|
||||||
if err := j.fileStorage.Delete(ctx, filePath); err != nil {
|
err = j.fileStorage.Delete(ctx, filePath)
|
||||||
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "Failed to delete unused default profile picture", slog.String("path", filePath), slog.Any("error", err))
|
slog.ErrorContext(ctx, "Failed to delete unused default profile picture", slog.String("path", filePath), slog.Any("error", err))
|
||||||
} else {
|
} else {
|
||||||
filesDeleted++
|
filesDeleted++
|
||||||
@@ -95,8 +102,9 @@ func (j *FileCleanupJobs) clearOrphanedTempFiles(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := j.fileStorage.Delete(ctx, p.Path); err != nil {
|
rErr := j.fileStorage.Delete(ctx, p.Path)
|
||||||
slog.ErrorContext(ctx, "Failed to delete temp file", slog.String("path", p.Path), slog.Any("error", err))
|
if rErr != nil {
|
||||||
|
slog.ErrorContext(ctx, "Failed to delete temp file", slog.String("path", p.Path), slog.Any("error", rErr))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
deleted++
|
deleted++
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func (s *Scheduler) RegisterGeoLiteUpdateJobs(ctx context.Context, geoLiteServic
|
|||||||
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
|
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
|
||||||
|
|
||||||
// Run every 24 hours (and right away)
|
// Run every 24 hours (and right away)
|
||||||
return s.RegisterJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, true)
|
return s.RegisterJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, service.RegisterJobOpts{RunImmediately: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error {
|
func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error {
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-co-op/gocron/v2"
|
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,8 +15,8 @@ type LdapJobs struct {
|
|||||||
func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.LdapService, appConfigService *service.AppConfigService) error {
|
func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.LdapService, appConfigService *service.AppConfigService) error {
|
||||||
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
|
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
|
||||||
|
|
||||||
// Register the job to run every hour
|
// Register the job to run every hour (with some jitter)
|
||||||
return s.RegisterJob(ctx, "SyncLdap", gocron.DurationJob(time.Hour), jobs.syncLdap, true)
|
return s.RegisterJob(ctx, "SyncLdap", jobDefWithJitter(time.Hour), jobs.syncLdap, service.RegisterJobOpts{RunImmediately: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *LdapJobs) syncLdap(ctx context.Context) error {
|
func (j *LdapJobs) syncLdap(ctx context.Context) error {
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
backoff "github.com/cenkalti/backoff/v5"
|
||||||
"github.com/go-co-op/gocron/v2"
|
"github.com/go-co-op/gocron/v2"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Scheduler struct {
|
type Scheduler struct {
|
||||||
@@ -33,16 +37,12 @@ func (s *Scheduler) RemoveJob(name string) error {
|
|||||||
if job.Name() == name {
|
if job.Name() == name {
|
||||||
err := s.scheduler.RemoveJob(job.ID())
|
err := s.scheduler.RemoveJob(job.ID())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errs = append(errs, fmt.Errorf("failed to unqueue job %q with ID %q: %w", name, job.ID().String(), err))
|
errs = append(errs, fmt.Errorf("failed to dequeue job %q with ID %q: %w", name, job.ID().String(), err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(errs) > 0 {
|
return errors.Join(errs...)
|
||||||
return errors.Join(errs...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the scheduler.
|
// Run the scheduler.
|
||||||
@@ -64,7 +64,29 @@ func (s *Scheduler) Run(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool, extraOptions ...gocron.JobOption) error {
|
func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, jobFn func(ctx context.Context) error, opts service.RegisterJobOpts) error {
|
||||||
|
// If a BackOff strategy is provided, wrap the job with retry logic
|
||||||
|
if opts.BackOff != nil {
|
||||||
|
origJob := jobFn
|
||||||
|
jobFn = func(ctx context.Context) error {
|
||||||
|
_, err := backoff.Retry(
|
||||||
|
ctx,
|
||||||
|
func() (struct{}, error) {
|
||||||
|
return struct{}{}, origJob(ctx)
|
||||||
|
},
|
||||||
|
backoff.WithBackOff(opts.BackOff),
|
||||||
|
backoff.WithNotify(func(err error, d time.Duration) {
|
||||||
|
slog.WarnContext(ctx, "Job failed, retrying",
|
||||||
|
slog.String("name", name),
|
||||||
|
slog.Any("error", err),
|
||||||
|
slog.Duration("retryIn", d),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
jobOptions := []gocron.JobOption{
|
jobOptions := []gocron.JobOption{
|
||||||
gocron.WithContext(ctx),
|
gocron.WithContext(ctx),
|
||||||
gocron.WithName(name),
|
gocron.WithName(name),
|
||||||
@@ -91,13 +113,13 @@ func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.Job
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
if runImmediately {
|
if opts.RunImmediately {
|
||||||
jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately()))
|
jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately()))
|
||||||
}
|
}
|
||||||
|
|
||||||
jobOptions = append(jobOptions, extraOptions...)
|
jobOptions = append(jobOptions, opts.ExtraOptions...)
|
||||||
|
|
||||||
_, err := s.scheduler.NewJob(def, gocron.NewTask(job), jobOptions...)
|
_, err := s.scheduler.NewJob(def, gocron.NewTask(jobFn), jobOptions...)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to register job %q: %w", name, err)
|
return fmt.Errorf("failed to register job %q: %w", name, err)
|
||||||
@@ -105,3 +127,9 @@ func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.Job
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func jobDefWithJitter(interval time.Duration) gocron.JobDefinition {
|
||||||
|
const jitter = 5 * time.Minute
|
||||||
|
|
||||||
|
return gocron.DurationRandomJob(interval-jitter, interval+jitter)
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ type ScimJobs struct {
|
|||||||
func (s *Scheduler) RegisterScimJobs(ctx context.Context, scimService *service.ScimService) error {
|
func (s *Scheduler) RegisterScimJobs(ctx context.Context, scimService *service.ScimService) error {
|
||||||
jobs := &ScimJobs{scimService: scimService}
|
jobs := &ScimJobs{scimService: scimService}
|
||||||
|
|
||||||
// Register the job to run every hour
|
// Register the job to run every hour (with some jitter)
|
||||||
return s.RegisterJob(ctx, "SyncScim", gocron.DurationJob(time.Hour), jobs.SyncScim, true)
|
return s.RegisterJob(ctx, "SyncScim", gocron.DurationJob(time.Hour), jobs.SyncScim, service.RegisterJobOpts{RunImmediately: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *ScimJobs) SyncScim(ctx context.Context) error {
|
func (j *ScimJobs) SyncScim(ctx context.Context) error {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func (u User) WebAuthnDisplayName() string {
|
|||||||
if u.DisplayName != "" {
|
if u.DisplayName != "" {
|
||||||
return u.DisplayName
|
return u.DisplayName
|
||||||
}
|
}
|
||||||
return u.FirstName + " " + u.LastName
|
return u.FullName()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u User) WebAuthnIcon() string { return "" }
|
func (u User) WebAuthnIcon() string { return "" }
|
||||||
@@ -76,7 +76,16 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u User) FullName() string {
|
func (u User) FullName() string {
|
||||||
return u.FirstName + " " + u.LastName
|
fullname := strings.TrimSpace(u.FirstName + " " + u.LastName)
|
||||||
|
if fullname != "" {
|
||||||
|
return fullname
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.DisplayName != "" {
|
||||||
|
return u.DisplayName
|
||||||
|
}
|
||||||
|
|
||||||
|
return u.Username
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u User) Initials() string {
|
func (u User) Initials() string {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
@@ -205,36 +206,33 @@ func (s *ApiKeyService) ListExpiringApiKeys(ctx context.Context, daysAhead int)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ApiKeyService) SendApiKeyExpiringSoonEmail(ctx context.Context, apiKey model.ApiKey) error {
|
func (s *ApiKeyService) SendApiKeyExpiringSoonEmail(ctx context.Context, apiKey model.ApiKey) error {
|
||||||
user := apiKey.User
|
if apiKey.User.Email == nil {
|
||||||
|
|
||||||
if user.ID == "" {
|
|
||||||
if err := s.db.WithContext(ctx).First(&user, "id = ?", apiKey.UserID).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if user.Email == nil {
|
|
||||||
return &common.UserEmailNotSetError{}
|
return &common.UserEmailNotSetError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
err := SendEmail(ctx, s.emailService, email.Address{
|
err := SendEmail(ctx, s.emailService, email.Address{
|
||||||
Name: user.FullName(),
|
Name: apiKey.User.FullName(),
|
||||||
Email: *user.Email,
|
Email: *apiKey.User.Email,
|
||||||
}, ApiKeyExpiringSoonTemplate, &ApiKeyExpiringSoonTemplateData{
|
}, ApiKeyExpiringSoonTemplate, &ApiKeyExpiringSoonTemplateData{
|
||||||
ApiKeyName: apiKey.Name,
|
ApiKeyName: apiKey.Name,
|
||||||
ExpiresAt: apiKey.ExpiresAt.ToTime(),
|
ExpiresAt: apiKey.ExpiresAt.ToTime(),
|
||||||
Name: user.FirstName,
|
Name: apiKey.User.FirstName,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("error sending notification email: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark the API key as having had an expiration email sent
|
// Mark the API key as having had an expiration email sent
|
||||||
return s.db.WithContext(ctx).
|
err = s.db.WithContext(ctx).
|
||||||
Model(&model.ApiKey{}).
|
Model(&model.ApiKey{}).
|
||||||
Where("id = ?", apiKey.ID).
|
Where("id = ?", apiKey.ID).
|
||||||
Update("expiration_email_sent", true).
|
Update("expiration_email_sent", true).
|
||||||
Error
|
Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error recording expiration sent email in database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ApiKeyService) initStaticApiKeyUser(ctx context.Context) (user model.User, err error) {
|
func (s *ApiKeyService) initStaticApiKeyUser(ctx context.Context) (user model.User, err error) {
|
||||||
|
|||||||
@@ -73,7 +73,10 @@ func (lv *lockValue) Unmarshal(raw string) error {
|
|||||||
// Acquire obtains the lock. When force is true, the lock is stolen from any existing owner.
|
// Acquire obtains the lock. When force is true, the lock is stolen from any existing owner.
|
||||||
// If the lock is forcefully acquired, it blocks until the previous lock has expired.
|
// If the lock is forcefully acquired, it blocks until the previous lock has expired.
|
||||||
func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil time.Time, err error) {
|
func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil time.Time, err error) {
|
||||||
tx := s.db.Begin()
|
tx := s.db.WithContext(ctx).Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("begin lock transaction: %w", tx.Error)
|
||||||
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
}()
|
}()
|
||||||
@@ -93,7 +96,8 @@ func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil tim
|
|||||||
|
|
||||||
var prevLock lockValue
|
var prevLock lockValue
|
||||||
if prevLockRaw != "" {
|
if prevLockRaw != "" {
|
||||||
if err := prevLock.Unmarshal(prevLockRaw); err != nil {
|
err = prevLock.Unmarshal(prevLockRaw)
|
||||||
|
if err != nil {
|
||||||
return time.Time{}, fmt.Errorf("decode existing lock value: %w", err)
|
return time.Time{}, fmt.Errorf("decode existing lock value: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,7 +143,8 @@ func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil tim
|
|||||||
return time.Time{}, fmt.Errorf("lock acquisition failed: %w", res.Error)
|
return time.Time{}, fmt.Errorf("lock acquisition failed: %w", res.Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit().Error; err != nil {
|
err = tx.Commit().Error
|
||||||
|
if err != nil {
|
||||||
return time.Time{}, fmt.Errorf("commit lock acquisition: %w", err)
|
return time.Time{}, fmt.Errorf("commit lock acquisition: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +179,8 @@ func (s *AppLockService) RunRenewal(ctx context.Context) error {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil
|
return nil
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if err := s.renew(ctx); err != nil {
|
err := s.renew(ctx)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("renew lock: %w", err)
|
return fmt.Errorf("renew lock: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,33 +189,43 @@ func (s *AppLockService) RunRenewal(ctx context.Context) error {
|
|||||||
|
|
||||||
// Release releases the lock if it is held by this process.
|
// Release releases the lock if it is held by this process.
|
||||||
func (s *AppLockService) Release(ctx context.Context) error {
|
func (s *AppLockService) Release(ctx context.Context) error {
|
||||||
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
db, err := s.db.DB()
|
||||||
defer cancel()
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get DB connection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var query string
|
var query string
|
||||||
switch s.db.Name() {
|
switch s.db.Name() {
|
||||||
case "sqlite":
|
case "sqlite":
|
||||||
query = `
|
query = `
|
||||||
DELETE FROM kv
|
DELETE FROM kv
|
||||||
WHERE key = ?
|
WHERE key = ?
|
||||||
AND json_extract(value, '$.lock_id') = ?
|
AND json_extract(value, '$.lock_id') = ?
|
||||||
`
|
`
|
||||||
case "postgres":
|
case "postgres":
|
||||||
query = `
|
query = `
|
||||||
DELETE FROM kv
|
DELETE FROM kv
|
||||||
WHERE key = $1
|
WHERE key = $1
|
||||||
AND value::json->>'lock_id' = $2
|
AND value::json->>'lock_id' = $2
|
||||||
`
|
`
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported database dialect: %s", s.db.Name())
|
return fmt.Errorf("unsupported database dialect: %s", s.db.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
res := s.db.WithContext(opCtx).Exec(query, lockKey, s.lockID)
|
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
if res.Error != nil {
|
defer cancel()
|
||||||
return fmt.Errorf("release lock failed: %w", res.Error)
|
|
||||||
|
res, err := db.ExecContext(opCtx, query, lockKey, s.lockID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("release lock failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if res.RowsAffected == 0 {
|
count, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to count affected rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
slog.Warn("Application lock not held by this process, cannot release",
|
slog.Warn("Application lock not held by this process, cannot release",
|
||||||
slog.Int64("process_id", s.processID),
|
slog.Int64("process_id", s.processID),
|
||||||
slog.String("host_id", s.hostID),
|
slog.String("host_id", s.hostID),
|
||||||
@@ -225,6 +241,11 @@ func (s *AppLockService) Release(ctx context.Context) error {
|
|||||||
|
|
||||||
// renew tries to renew the lock, retrying up to renewRetries times (sleeping 1s between attempts).
|
// renew tries to renew the lock, retrying up to renewRetries times (sleeping 1s between attempts).
|
||||||
func (s *AppLockService) renew(ctx context.Context) error {
|
func (s *AppLockService) renew(ctx context.Context) error {
|
||||||
|
db, err := s.db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get DB connection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 1; attempt <= renewRetries; attempt++ {
|
for attempt := 1; attempt <= renewRetries; attempt++ {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -246,42 +267,56 @@ func (s *AppLockService) renew(ctx context.Context) error {
|
|||||||
switch s.db.Name() {
|
switch s.db.Name() {
|
||||||
case "sqlite":
|
case "sqlite":
|
||||||
query = `
|
query = `
|
||||||
UPDATE kv
|
UPDATE kv
|
||||||
SET value = ?
|
SET value = ?
|
||||||
WHERE key = ?
|
WHERE key = ?
|
||||||
AND json_extract(value, '$.lock_id') = ?
|
AND json_extract(value, '$.lock_id') = ?
|
||||||
AND json_extract(value, '$.expires_at') > ?
|
AND json_extract(value, '$.expires_at') > ?
|
||||||
`
|
`
|
||||||
case "postgres":
|
case "postgres":
|
||||||
query = `
|
query = `
|
||||||
UPDATE kv
|
UPDATE kv
|
||||||
SET value = $1
|
SET value = $1
|
||||||
WHERE key = $2
|
WHERE key = $2
|
||||||
AND value::json->>'lock_id' = $3
|
AND value::json->>'lock_id' = $3
|
||||||
AND ((value::json->>'expires_at')::bigint > $4)
|
AND ((value::json->>'expires_at')::bigint > $4)
|
||||||
`
|
`
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported database dialect: %s", s.db.Name())
|
return fmt.Errorf("unsupported database dialect: %s", s.db.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
res := s.db.WithContext(opCtx).Exec(query, raw, lockKey, s.lockID, nowUnix)
|
res, err := db.ExecContext(opCtx, query, raw, lockKey, s.lockID, nowUnix)
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
switch {
|
// Query succeeded, but may have updated 0 rows
|
||||||
case res.Error != nil:
|
if err == nil {
|
||||||
lastErr = fmt.Errorf("lock renewal failed: %w", res.Error)
|
count, err := res.RowsAffected()
|
||||||
case res.RowsAffected == 0:
|
if err != nil {
|
||||||
// Must be after checking res.Error
|
return fmt.Errorf("failed to count affected rows: %w", err)
|
||||||
return ErrLockLost
|
}
|
||||||
default:
|
|
||||||
|
// If no rows were updated, we lost the lock
|
||||||
|
if count == 0 {
|
||||||
|
return ErrLockLost
|
||||||
|
}
|
||||||
|
|
||||||
|
// All good
|
||||||
slog.Debug("Renewed application lock",
|
slog.Debug("Renewed application lock",
|
||||||
slog.Int64("process_id", s.processID),
|
slog.Int64("process_id", s.processID),
|
||||||
slog.String("host_id", s.hostID),
|
slog.String("host_id", s.hostID),
|
||||||
|
slog.Duration("duration", time.Since(now)),
|
||||||
)
|
)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we're here, we have an error that can be retried
|
||||||
|
slog.Debug("Application lock renewal attempt failed",
|
||||||
|
slog.Any("error", err),
|
||||||
|
slog.Duration("duration", time.Since(now)),
|
||||||
|
)
|
||||||
|
lastErr = fmt.Errorf("lock renewal failed: %w", err)
|
||||||
|
|
||||||
// Wait before next attempt or cancel if context is done
|
// Wait before next attempt or cancel if context is done
|
||||||
if attempt < renewRetries {
|
if attempt < renewRetries {
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -49,6 +49,23 @@ func readLockValue(t *testing.T, db *gorm.DB) lockValue {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lockDatabaseForWrite(t *testing.T, db *gorm.DB) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tx := db.Begin()
|
||||||
|
require.NoError(t, tx.Error)
|
||||||
|
|
||||||
|
// Keep a write transaction open to block other queries.
|
||||||
|
err := tx.Exec(
|
||||||
|
`INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING`,
|
||||||
|
lockKey,
|
||||||
|
`{"expires_at":0}`,
|
||||||
|
).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return tx
|
||||||
|
}
|
||||||
|
|
||||||
func TestAppLockServiceAcquire(t *testing.T) {
|
func TestAppLockServiceAcquire(t *testing.T) {
|
||||||
t.Run("creates new lock when none exists", func(t *testing.T) {
|
t.Run("creates new lock when none exists", func(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
@@ -99,6 +116,66 @@ func TestAppLockServiceAcquire(t *testing.T) {
|
|||||||
require.Equal(t, service.hostID, stored.HostID)
|
require.Equal(t, service.hostID, stored.HostID)
|
||||||
require.Greater(t, stored.ExpiresAt, time.Now().Unix())
|
require.Greater(t, stored.ExpiresAt, time.Now().Unix())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("force acquisition returns wait duration when stealing active lock", func(t *testing.T) {
|
||||||
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
service := newTestAppLockService(t, db)
|
||||||
|
|
||||||
|
existing := lockValue{
|
||||||
|
ProcessID: 99,
|
||||||
|
HostID: "other-host",
|
||||||
|
LockID: "other-lock-id",
|
||||||
|
ExpiresAt: time.Now().Add(ttl).Unix(),
|
||||||
|
}
|
||||||
|
insertLock(t, db, existing)
|
||||||
|
|
||||||
|
waitUntil, err := service.Acquire(context.Background(), true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.WithinDuration(t, time.Unix(existing.ExpiresAt, 0), waitUntil, time.Second)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("force acquisition does not wait when lock id is unchanged", func(t *testing.T) {
|
||||||
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
service := newTestAppLockService(t, db)
|
||||||
|
|
||||||
|
insertLock(t, db, lockValue{
|
||||||
|
ProcessID: 99,
|
||||||
|
HostID: "other-host",
|
||||||
|
LockID: service.lockID,
|
||||||
|
ExpiresAt: time.Now().Add(ttl).Unix(),
|
||||||
|
})
|
||||||
|
|
||||||
|
waitUntil, err := service.Acquire(context.Background(), true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, waitUntil.IsZero())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns error when existing lock value is invalid JSON", func(t *testing.T) {
|
||||||
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
service := newTestAppLockService(t, db)
|
||||||
|
|
||||||
|
raw := "this-is-not-json"
|
||||||
|
err := db.Create(&model.KV{Key: lockKey, Value: &raw}).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = service.Acquire(context.Background(), false)
|
||||||
|
require.ErrorContains(t, err, "decode existing lock value")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) {
|
||||||
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
service := newTestAppLockService(t, db)
|
||||||
|
|
||||||
|
tx := lockDatabaseForWrite(t, db)
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := service.Acquire(ctx, false)
|
||||||
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||||
|
require.ErrorContains(t, err, "begin lock transaction")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAppLockServiceRelease(t *testing.T) {
|
func TestAppLockServiceRelease(t *testing.T) {
|
||||||
@@ -134,6 +211,24 @@ func TestAppLockServiceRelease(t *testing.T) {
|
|||||||
stored := readLockValue(t, db)
|
stored := readLockValue(t, db)
|
||||||
require.Equal(t, existing, stored)
|
require.Equal(t, existing, stored)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) {
|
||||||
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
service := newTestAppLockService(t, db)
|
||||||
|
|
||||||
|
_, err := service.Acquire(context.Background(), false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tx := lockDatabaseForWrite(t, db)
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err = service.Release(ctx)
|
||||||
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||||
|
require.ErrorContains(t, err, "release lock failed")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAppLockServiceRenew(t *testing.T) {
|
func TestAppLockServiceRenew(t *testing.T) {
|
||||||
@@ -186,4 +281,21 @@ func TestAppLockServiceRenew(t *testing.T) {
|
|||||||
err = service.renew(context.Background())
|
err = service.renew(context.Background())
|
||||||
require.ErrorIs(t, err, ErrLockLost)
|
require.ErrorIs(t, err, ErrLockLost)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) {
|
||||||
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
service := newTestAppLockService(t, db)
|
||||||
|
|
||||||
|
_, err := service.Acquire(context.Background(), false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tx := lockDatabaseForWrite(t, db)
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err = service.renew(ctx)
|
||||||
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
|||||||
Nonce: "nonce",
|
Nonce: "nonce",
|
||||||
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||||
UserID: users[1].ID,
|
UserID: users[1].ID,
|
||||||
ClientID: oidcClients[2].ID,
|
ClientID: oidcClients[3].ID,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, authCode := range authCodes {
|
for _, authCode := range authCodes {
|
||||||
|
|||||||
@@ -150,7 +150,8 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Addr
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send the email
|
// Send the email
|
||||||
if err := srv.sendEmailContent(client, toEmail, c); err != nil {
|
err = srv.sendEmailContent(client, toEmail, c)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("send email content: %w", err)
|
return fmt.Errorf("send email content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -112,7 +113,11 @@ func (s *GeoLiteService) UpdateDatabase(parentCtx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("Updating GeoLite2 City database")
|
slog.Info("Updating GeoLite2 City database")
|
||||||
downloadUrl := fmt.Sprintf(common.EnvConfig.GeoLiteDBUrl, common.EnvConfig.MaxMindLicenseKey)
|
|
||||||
|
downloadUrl := common.EnvConfig.GeoLiteDBUrl
|
||||||
|
if strings.Contains(downloadUrl, "%s") {
|
||||||
|
downloadUrl = fmt.Sprintf(downloadUrl, common.EnvConfig.MaxMindLicenseKey)
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Minute)
|
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type LdapService struct {
|
|||||||
userService *UserService
|
userService *UserService
|
||||||
groupService *UserGroupService
|
groupService *UserGroupService
|
||||||
fileStorage storage.FileStorage
|
fileStorage storage.FileStorage
|
||||||
|
clientFactory func() (ldapClient, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type savePicture struct {
|
type savePicture struct {
|
||||||
@@ -43,8 +44,33 @@ type savePicture struct {
|
|||||||
picture string
|
picture string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ldapDesiredUser struct {
|
||||||
|
ldapID string
|
||||||
|
input dto.UserCreateDto
|
||||||
|
picture string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ldapDesiredGroup struct {
|
||||||
|
ldapID string
|
||||||
|
input dto.UserGroupCreateDto
|
||||||
|
memberUsernames []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ldapDesiredState struct {
|
||||||
|
users []ldapDesiredUser
|
||||||
|
userIDs map[string]struct{}
|
||||||
|
groups []ldapDesiredGroup
|
||||||
|
groupIDs map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ldapClient interface {
|
||||||
|
Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error)
|
||||||
|
Bind(username, password string) error
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *AppConfigService, userService *UserService, groupService *UserGroupService, fileStorage storage.FileStorage) *LdapService {
|
func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *AppConfigService, userService *UserService, groupService *UserGroupService, fileStorage storage.FileStorage) *LdapService {
|
||||||
return &LdapService{
|
service := &LdapService{
|
||||||
db: db,
|
db: db,
|
||||||
httpClient: httpClient,
|
httpClient: httpClient,
|
||||||
appConfigService: appConfigService,
|
appConfigService: appConfigService,
|
||||||
@@ -52,9 +78,12 @@ func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *AppC
|
|||||||
groupService: groupService,
|
groupService: groupService,
|
||||||
fileStorage: fileStorage,
|
fileStorage: fileStorage,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
service.clientFactory = service.createClient
|
||||||
|
return service
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *LdapService) createClient() (*ldap.Conn, error) {
|
func (s *LdapService) createClient() (ldapClient, error) {
|
||||||
dbConfig := s.appConfigService.GetDbConfig()
|
dbConfig := s.appConfigService.GetDbConfig()
|
||||||
|
|
||||||
if !dbConfig.LdapEnabled.IsTrue() {
|
if !dbConfig.LdapEnabled.IsTrue() {
|
||||||
@@ -79,24 +108,33 @@ func (s *LdapService) createClient() (*ldap.Conn, error) {
|
|||||||
|
|
||||||
func (s *LdapService) SyncAll(ctx context.Context) error {
|
func (s *LdapService) SyncAll(ctx context.Context) error {
|
||||||
// Setup LDAP connection
|
// Setup LDAP connection
|
||||||
client, err := s.createClient()
|
client, err := s.clientFactory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create LDAP client: %w", err)
|
return fmt.Errorf("failed to create LDAP client: %w", err)
|
||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
// Start a transaction
|
// First, we fetch all users and group from LDAP, which is our "desired state"
|
||||||
tx := s.db.Begin()
|
desiredState, err := s.fetchDesiredState(ctx, client)
|
||||||
defer func() {
|
if err != nil {
|
||||||
tx.Rollback()
|
return fmt.Errorf("failed to fetch LDAP state: %w", err)
|
||||||
}()
|
}
|
||||||
|
|
||||||
savePictures, deleteFiles, err := s.SyncUsers(ctx, tx, client)
|
// Start a transaction
|
||||||
|
tx := s.db.WithContext(ctx).Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
return fmt.Errorf("failed to begin database transaction: %w", tx.Error)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Reconcile users
|
||||||
|
savePictures, deleteFiles, err := s.reconcileUsers(ctx, tx, desiredState.users, desiredState.userIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to sync users: %w", err)
|
return fmt.Errorf("failed to sync users: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.SyncGroups(ctx, tx, client)
|
// Reconcile groups
|
||||||
|
err = s.reconcileGroups(ctx, tx, desiredState.groups, desiredState.groupIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to sync groups: %w", err)
|
return fmt.Errorf("failed to sync groups: %w", err)
|
||||||
}
|
}
|
||||||
@@ -129,10 +167,59 @@ func (s *LdapService) SyncAll(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:gocognit
|
func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient) (ldapDesiredState, error) {
|
||||||
func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.Conn) error {
|
// Fetch users first so we can use their DNs when resolving group members
|
||||||
|
users, userIDs, usernamesByDN, err := s.fetchUsersFromLDAP(ctx, client)
|
||||||
|
if err != nil {
|
||||||
|
return ldapDesiredState{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then fetch groups to complete the desired LDAP state snapshot
|
||||||
|
groups, groupIDs, err := s.fetchGroupsFromLDAP(ctx, client, usernamesByDN)
|
||||||
|
if err != nil {
|
||||||
|
return ldapDesiredState{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply user admin flags from the desired group membership snapshot.
|
||||||
|
// This intentionally uses the configured group member attribute rather than
|
||||||
|
// relying on a user-side reverse-membership attribute such as memberOf.
|
||||||
|
s.applyAdminGroupMembership(users, groups)
|
||||||
|
|
||||||
|
return ldapDesiredState{
|
||||||
|
users: users,
|
||||||
|
userIDs: userIDs,
|
||||||
|
groups: groups,
|
||||||
|
groupIDs: groupIDs,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser, desiredGroups []ldapDesiredGroup) {
|
||||||
|
dbConfig := s.appConfigService.GetDbConfig()
|
||||||
|
if dbConfig.LdapAdminGroupName.Value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
adminUsernames := make(map[string]struct{})
|
||||||
|
for _, group := range desiredGroups {
|
||||||
|
if group.input.Name != dbConfig.LdapAdminGroupName.Value {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, username := range group.memberUsernames {
|
||||||
|
adminUsernames[username] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range desiredUsers {
|
||||||
|
_, isAdmin := adminUsernames[desiredUsers[i].input.Username]
|
||||||
|
desiredUsers[i].input.IsAdmin = desiredUsers[i].input.IsAdmin || isAdmin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient, usernamesByDN map[string]string) (desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, err error) {
|
||||||
dbConfig := s.appConfigService.GetDbConfig()
|
dbConfig := s.appConfigService.GetDbConfig()
|
||||||
|
|
||||||
|
// Query LDAP for all groups we want to manage
|
||||||
searchAttrs := []string{
|
searchAttrs := []string{
|
||||||
dbConfig.LdapAttributeGroupName.Value,
|
dbConfig.LdapAttributeGroupName.Value,
|
||||||
dbConfig.LdapAttributeGroupUniqueIdentifier.Value,
|
dbConfig.LdapAttributeGroupUniqueIdentifier.Value,
|
||||||
@@ -149,90 +236,42 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
|
|||||||
)
|
)
|
||||||
result, err := client.Search(searchReq)
|
result, err := client.Search(searchReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to query LDAP: %w", err)
|
return nil, nil, fmt.Errorf("failed to query LDAP groups: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a mapping for groups that exist
|
// Build the in-memory desired state for groups
|
||||||
ldapGroupIDs := make(map[string]struct{}, len(result.Entries))
|
ldapGroupIDs = make(map[string]struct{}, len(result.Entries))
|
||||||
|
desiredGroups = make([]ldapDesiredGroup, 0, len(result.Entries))
|
||||||
|
|
||||||
for _, value := range result.Entries {
|
for _, value := range result.Entries {
|
||||||
ldapId := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
|
ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
|
||||||
|
|
||||||
// Skip groups without a valid LDAP ID
|
// Skip groups without a valid LDAP ID
|
||||||
if ldapId == "" {
|
if ldapID == "" {
|
||||||
slog.Warn("Skipping LDAP group without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
|
slog.Warn("Skipping LDAP group without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
ldapGroupIDs[ldapId] = struct{}{}
|
ldapGroupIDs[ldapID] = struct{}{}
|
||||||
|
|
||||||
// Try to find the group in the database
|
|
||||||
var databaseGroup model.UserGroup
|
|
||||||
err = tx.
|
|
||||||
WithContext(ctx).
|
|
||||||
Where("ldap_id = ?", ldapId).
|
|
||||||
First(&databaseGroup).
|
|
||||||
Error
|
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
// This could error with ErrRecordNotFound and we want to ignore that here
|
|
||||||
return fmt.Errorf("failed to query for LDAP group ID '%s': %w", ldapId, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get group members and add to the correct Group
|
// Get group members and add to the correct Group
|
||||||
groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.Value)
|
groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.Value)
|
||||||
membersUserId := make([]string, 0, len(groupMembers))
|
memberUsernames := make([]string, 0, len(groupMembers))
|
||||||
for _, member := range groupMembers {
|
for _, member := range groupMembers {
|
||||||
username := getDNProperty(dbConfig.LdapAttributeUserUsername.Value, member)
|
username := s.resolveGroupMemberUsername(ctx, client, member, usernamesByDN)
|
||||||
|
|
||||||
// If username extraction fails, try to query LDAP directly for the user
|
|
||||||
if username == "" {
|
if username == "" {
|
||||||
// Query LDAP to get the user by their DN
|
|
||||||
userSearchReq := ldap.NewSearchRequest(
|
|
||||||
member,
|
|
||||||
ldap.ScopeBaseObject,
|
|
||||||
0, 0, 0, false,
|
|
||||||
"(objectClass=*)",
|
|
||||||
[]string{dbConfig.LdapAttributeUserUsername.Value, dbConfig.LdapAttributeUserUniqueIdentifier.Value},
|
|
||||||
[]ldap.Control{},
|
|
||||||
)
|
|
||||||
|
|
||||||
userResult, err := client.Search(userSearchReq)
|
|
||||||
if err != nil || len(userResult.Entries) == 0 {
|
|
||||||
slog.WarnContext(ctx, "Could not resolve group member DN", slog.String("member", member), slog.Any("error", err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)
|
|
||||||
if username == "" {
|
|
||||||
slog.WarnContext(ctx, "Could not extract username from group member DN", slog.String("member", member))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
username = norm.NFC.String(username)
|
|
||||||
|
|
||||||
var databaseUser model.User
|
|
||||||
err = tx.
|
|
||||||
WithContext(ctx).
|
|
||||||
Where("username = ? AND ldap_id IS NOT NULL", username).
|
|
||||||
First(&databaseUser).
|
|
||||||
Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
// The user collides with a non-LDAP user, so we skip it
|
|
||||||
continue
|
continue
|
||||||
} else if err != nil {
|
|
||||||
return fmt.Errorf("failed to query for existing user '%s': %w", username, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
membersUserId = append(membersUserId, databaseUser.ID)
|
memberUsernames = append(memberUsernames, username)
|
||||||
}
|
}
|
||||||
|
|
||||||
syncGroup := dto.UserGroupCreateDto{
|
syncGroup := dto.UserGroupCreateDto{
|
||||||
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
||||||
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
||||||
LdapID: ldapId,
|
LdapID: ldapID,
|
||||||
}
|
}
|
||||||
dto.Normalize(syncGroup)
|
dto.Normalize(&syncGroup)
|
||||||
|
|
||||||
err = syncGroup.Validate()
|
err = syncGroup.Validate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -240,66 +279,21 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if databaseGroup.ID == "" {
|
desiredGroups = append(desiredGroups, ldapDesiredGroup{
|
||||||
newGroup, err := s.groupService.createInternal(ctx, syncGroup, tx)
|
ldapID: ldapID,
|
||||||
if err != nil {
|
input: syncGroup,
|
||||||
return fmt.Errorf("failed to create group '%s': %w", syncGroup.Name, err)
|
memberUsernames: memberUsernames,
|
||||||
}
|
})
|
||||||
|
|
||||||
_, err = s.groupService.updateUsersInternal(ctx, newGroup.ID, membersUserId, tx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to sync users for group '%s': %w", syncGroup.Name, err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_, err = s.groupService.updateInternal(ctx, databaseGroup.ID, syncGroup, true, tx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to update group '%s': %w", syncGroup.Name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = s.groupService.updateUsersInternal(ctx, databaseGroup.ID, membersUserId, tx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to sync users for group '%s': %w", syncGroup.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all LDAP groups from the database
|
return desiredGroups, ldapGroupIDs, nil
|
||||||
var ldapGroupsInDb []model.UserGroup
|
|
||||||
err = tx.
|
|
||||||
WithContext(ctx).
|
|
||||||
Find(&ldapGroupsInDb, "ldap_id IS NOT NULL").
|
|
||||||
Select("ldap_id").
|
|
||||||
Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to fetch groups from database: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete groups that no longer exist in LDAP
|
|
||||||
for _, group := range ldapGroupsInDb {
|
|
||||||
if _, exists := ldapGroupIDs[*group.LdapID]; exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tx.
|
|
||||||
WithContext(ctx).
|
|
||||||
Delete(&model.UserGroup{}, "ldap_id = ?", group.LdapID).
|
|
||||||
Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to delete group '%s': %w", group.Name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("Deleted group", slog.String("group", group.Name))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:gocognit
|
func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient) (desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, usernamesByDN map[string]string, err error) {
|
||||||
func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.Conn) (savePictures []savePicture, deleteFiles []string, err error) {
|
|
||||||
dbConfig := s.appConfigService.GetDbConfig()
|
dbConfig := s.appConfigService.GetDbConfig()
|
||||||
|
|
||||||
|
// Query LDAP for all users we want to manage
|
||||||
searchAttrs := []string{
|
searchAttrs := []string{
|
||||||
"memberOf",
|
|
||||||
"sn",
|
"sn",
|
||||||
"cn",
|
"cn",
|
||||||
dbConfig.LdapAttributeUserUniqueIdentifier.Value,
|
dbConfig.LdapAttributeUserUniqueIdentifier.Value,
|
||||||
@@ -323,59 +317,29 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
|||||||
|
|
||||||
result, err := client.Search(searchReq)
|
result, err := client.Search(searchReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("failed to query LDAP: %w", err)
|
return nil, nil, nil, fmt.Errorf("failed to query LDAP users: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a mapping for users that exist
|
// Build the in-memory desired state for users and a DN lookup for group membership resolution
|
||||||
ldapUserIDs := make(map[string]struct{}, len(result.Entries))
|
ldapUserIDs = make(map[string]struct{}, len(result.Entries))
|
||||||
savePictures = make([]savePicture, 0, len(result.Entries))
|
usernamesByDN = make(map[string]string, len(result.Entries))
|
||||||
|
desiredUsers = make([]ldapDesiredUser, 0, len(result.Entries))
|
||||||
|
|
||||||
for _, value := range result.Entries {
|
for _, value := range result.Entries {
|
||||||
ldapId := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.Value))
|
username := norm.NFC.String(value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value))
|
||||||
|
if normalizedDN := normalizeLDAPDN(value.DN); normalizedDN != "" && username != "" {
|
||||||
|
usernamesByDN[normalizedDN] = username
|
||||||
|
}
|
||||||
|
|
||||||
|
ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.Value))
|
||||||
|
|
||||||
// Skip users without a valid LDAP ID
|
// Skip users without a valid LDAP ID
|
||||||
if ldapId == "" {
|
if ldapID == "" {
|
||||||
slog.Warn("Skipping LDAP user without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeUserUniqueIdentifier.Value))
|
slog.Warn("Skipping LDAP user without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeUserUniqueIdentifier.Value))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
ldapUserIDs[ldapId] = struct{}{}
|
ldapUserIDs[ldapID] = struct{}{}
|
||||||
|
|
||||||
// Get the user from the database
|
|
||||||
var databaseUser model.User
|
|
||||||
err = tx.
|
|
||||||
WithContext(ctx).
|
|
||||||
Where("ldap_id = ?", ldapId).
|
|
||||||
First(&databaseUser).
|
|
||||||
Error
|
|
||||||
|
|
||||||
// If a user is found (even if disabled), enable them since they're now back in LDAP
|
|
||||||
if databaseUser.ID != "" && databaseUser.Disabled {
|
|
||||||
err = tx.
|
|
||||||
WithContext(ctx).
|
|
||||||
Model(&model.User{}).
|
|
||||||
Where("id = ?", databaseUser.ID).
|
|
||||||
Update("disabled", false).
|
|
||||||
Error
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("failed to enable user %s: %w", databaseUser.Username, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
// This could error with ErrRecordNotFound and we want to ignore that here
|
|
||||||
return nil, nil, fmt.Errorf("failed to query for LDAP user ID '%s': %w", ldapId, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is admin by checking if they are in the admin group
|
|
||||||
isAdmin := false
|
|
||||||
for _, group := range value.GetAttributeValues("memberOf") {
|
|
||||||
if getDNProperty(dbConfig.LdapAttributeGroupName.Value, group) == dbConfig.LdapAdminGroupName.Value {
|
|
||||||
isAdmin = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
newUser := dto.UserCreateDto{
|
newUser := dto.UserCreateDto{
|
||||||
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
|
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
|
||||||
@@ -384,15 +348,17 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
|||||||
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
|
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
|
||||||
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
|
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
|
||||||
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
|
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
|
||||||
IsAdmin: isAdmin,
|
// Admin status is computed after groups are loaded so it can use the
|
||||||
LdapID: ldapId,
|
// configured group member attribute instead of a hard-coded memberOf.
|
||||||
|
IsAdmin: false,
|
||||||
|
LdapID: ldapID,
|
||||||
}
|
}
|
||||||
|
|
||||||
if newUser.DisplayName == "" {
|
if newUser.DisplayName == "" {
|
||||||
newUser.DisplayName = strings.TrimSpace(newUser.FirstName + " " + newUser.LastName)
|
newUser.DisplayName = strings.TrimSpace(newUser.FirstName + " " + newUser.LastName)
|
||||||
}
|
}
|
||||||
|
|
||||||
dto.Normalize(newUser)
|
dto.Normalize(&newUser)
|
||||||
|
|
||||||
err = newUser.Validate()
|
err = newUser.Validate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -400,53 +366,201 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
userID := databaseUser.ID
|
desiredUsers = append(desiredUsers, ldapDesiredUser{
|
||||||
if databaseUser.ID == "" {
|
ldapID: ldapID,
|
||||||
createdUser, err := s.userService.createUserInternal(ctx, newUser, true, tx)
|
input: newUser,
|
||||||
if errors.Is(err, &common.AlreadyInUseError{}) {
|
picture: value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.Value),
|
||||||
slog.Warn("Skipping creating LDAP user", slog.String("username", newUser.Username), slog.Any("error", err))
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return desiredUsers, ldapUserIDs, usernamesByDN, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client ldapClient, member string, usernamesByDN map[string]string) string {
|
||||||
|
dbConfig := s.appConfigService.GetDbConfig()
|
||||||
|
|
||||||
|
// First try the DN cache we built while loading users
|
||||||
|
username, exists := usernamesByDN[normalizeLDAPDN(member)]
|
||||||
|
if exists && username != "" {
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then try to extract the username directly from the DN
|
||||||
|
username = getDNProperty(dbConfig.LdapAttributeUserUsername.Value, member)
|
||||||
|
if username != "" {
|
||||||
|
return norm.NFC.String(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// As a fallback, query LDAP for the referenced entry
|
||||||
|
userSearchReq := ldap.NewSearchRequest(
|
||||||
|
member,
|
||||||
|
ldap.ScopeBaseObject,
|
||||||
|
0, 0, 0, false,
|
||||||
|
"(objectClass=*)",
|
||||||
|
[]string{dbConfig.LdapAttributeUserUsername.Value},
|
||||||
|
[]ldap.Control{},
|
||||||
|
)
|
||||||
|
|
||||||
|
userResult, err := client.Search(userSearchReq)
|
||||||
|
if err != nil || len(userResult.Entries) == 0 {
|
||||||
|
slog.WarnContext(ctx, "Could not resolve group member DN", slog.String("member", member), slog.Any("error", err))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)
|
||||||
|
if username == "" {
|
||||||
|
slog.WarnContext(ctx, "Could not extract username from group member DN", slog.String("member", member))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return norm.NFC.String(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}) error {
|
||||||
|
// Load the current LDAP-managed state from the database
|
||||||
|
ldapGroupsInDB, ldapGroupsByID, err := s.loadLDAPGroupsInDB(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to fetch groups from database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, ldapUsersByUsername, err := s.loadLDAPUsersInDB(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to fetch users from database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply creates and updates to match the desired LDAP group state
|
||||||
|
for _, desiredGroup := range desiredGroups {
|
||||||
|
memberUserIDs := make([]string, 0, len(desiredGroup.memberUsernames))
|
||||||
|
for _, username := range desiredGroup.memberUsernames {
|
||||||
|
databaseUser, exists := ldapUsersByUsername[username]
|
||||||
|
if !exists {
|
||||||
|
// The user collides with a non-LDAP user or was skipped during user sync, so we ignore it
|
||||||
continue
|
continue
|
||||||
} else if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("error creating user '%s': %w", newUser.Username, err)
|
|
||||||
}
|
|
||||||
userID = createdUser.ID
|
|
||||||
} else {
|
|
||||||
_, err = s.userService.updateUserInternal(ctx, databaseUser.ID, newUser, false, true, tx)
|
|
||||||
if errors.Is(err, &common.AlreadyInUseError{}) {
|
|
||||||
slog.Warn("Skipping updating LDAP user", slog.String("username", newUser.Username), slog.Any("error", err))
|
|
||||||
continue
|
|
||||||
} else if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("error updating user '%s': %w", newUser.Username, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
memberUserIDs = append(memberUserIDs, databaseUser.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save profile picture
|
databaseGroup := ldapGroupsByID[desiredGroup.ldapID]
|
||||||
pictureString := value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.Value)
|
if databaseGroup.ID == "" {
|
||||||
if pictureString != "" {
|
newGroup, err := s.groupService.createInternal(ctx, desiredGroup.input, tx)
|
||||||
// Storage operations must be executed outside of a transaction
|
if err != nil {
|
||||||
savePictures = append(savePictures, savePicture{
|
return fmt.Errorf("failed to create group '%s': %w", desiredGroup.input.Name, err)
|
||||||
userID: databaseUser.ID,
|
}
|
||||||
username: userID,
|
ldapGroupsByID[desiredGroup.ldapID] = newGroup
|
||||||
picture: pictureString,
|
|
||||||
})
|
_, err = s.groupService.updateUsersInternal(ctx, newGroup.ID, memberUserIDs, tx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to sync users for group '%s': %w", desiredGroup.input.Name, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.groupService.updateInternal(ctx, databaseGroup.ID, desiredGroup.input, true, tx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update group '%s': %w", desiredGroup.input.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.groupService.updateUsersInternal(ctx, databaseGroup.ID, memberUserIDs, tx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to sync users for group '%s': %w", desiredGroup.input.Name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all LDAP users from the database
|
// Delete groups that are no longer present in LDAP
|
||||||
var ldapUsersInDb []model.User
|
for _, group := range ldapGroupsInDB {
|
||||||
err = tx.
|
if group.LdapID == nil {
|
||||||
WithContext(ctx).
|
continue
|
||||||
Find(&ldapUsersInDb, "ldap_id IS NOT NULL").
|
}
|
||||||
Select("id, username, ldap_id, disabled").
|
|
||||||
Error
|
if _, exists := ldapGroupIDs[*group.LdapID]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.
|
||||||
|
WithContext(ctx).
|
||||||
|
Delete(&model.UserGroup{}, "ldap_id = ?", *group.LdapID).
|
||||||
|
Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete group '%s': %w", group.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Deleted group", slog.String("group", group.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:gocognit
|
||||||
|
func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}) (savePictures []savePicture, deleteFiles []string, err error) {
|
||||||
|
dbConfig := s.appConfigService.GetDbConfig()
|
||||||
|
|
||||||
|
// Load the current LDAP-managed state from the database
|
||||||
|
ldapUsersInDB, ldapUsersByID, _, err := s.loadLDAPUsersInDB(ctx, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("failed to fetch users from database: %w", err)
|
return nil, nil, fmt.Errorf("failed to fetch users from database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark users as disabled or delete users that no longer exist in LDAP
|
// Apply creates and updates to match the desired LDAP user state
|
||||||
deleteFiles = make([]string, 0, len(ldapUserIDs))
|
savePictures = make([]savePicture, 0, len(desiredUsers))
|
||||||
for _, user := range ldapUsersInDb {
|
|
||||||
// Skip if the user ID exists in the fetched LDAP results
|
for _, desiredUser := range desiredUsers {
|
||||||
|
databaseUser := ldapUsersByID[desiredUser.ldapID]
|
||||||
|
|
||||||
|
// If a user is found (even if disabled), enable them since they're now back in LDAP.
|
||||||
|
if databaseUser.ID != "" && databaseUser.Disabled {
|
||||||
|
err = tx.
|
||||||
|
WithContext(ctx).
|
||||||
|
Model(&model.User{}).
|
||||||
|
Where("id = ?", databaseUser.ID).
|
||||||
|
Update("disabled", false).
|
||||||
|
Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to enable user %s: %w", databaseUser.Username, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseUser.Disabled = false
|
||||||
|
ldapUsersByID[desiredUser.ldapID] = databaseUser
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := databaseUser.ID
|
||||||
|
if databaseUser.ID == "" {
|
||||||
|
createdUser, err := s.userService.createUserInternal(ctx, desiredUser.input, true, tx)
|
||||||
|
if errors.Is(err, &common.AlreadyInUseError{}) {
|
||||||
|
slog.Warn("Skipping creating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err))
|
||||||
|
continue
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("error creating user '%s': %w", desiredUser.input.Username, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userID = createdUser.ID
|
||||||
|
ldapUsersByID[desiredUser.ldapID] = createdUser
|
||||||
|
} else {
|
||||||
|
_, err = s.userService.updateUserInternal(ctx, databaseUser.ID, desiredUser.input, false, true, tx)
|
||||||
|
if errors.Is(err, &common.AlreadyInUseError{}) {
|
||||||
|
slog.Warn("Skipping updating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err))
|
||||||
|
continue
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("error updating user '%s': %w", desiredUser.input.Username, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if desiredUser.picture != "" {
|
||||||
|
savePictures = append(savePictures, savePicture{
|
||||||
|
userID: userID,
|
||||||
|
username: desiredUser.input.Username,
|
||||||
|
picture: desiredUser.picture,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable or delete users that are no longer present in LDAP
|
||||||
|
deleteFiles = make([]string, 0, len(ldapUsersInDB))
|
||||||
|
for _, user := range ldapUsersInDB {
|
||||||
|
if user.LdapID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if _, exists := ldapUserIDs[*user.LdapID]; exists {
|
if _, exists := ldapUserIDs[*user.LdapID]; exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -458,29 +572,73 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
|||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("Disabled user", slog.String("username", user.Username))
|
slog.Info("Disabled user", slog.String("username", user.Username))
|
||||||
} else {
|
continue
|
||||||
err = s.userService.deleteUserInternal(ctx, tx, user.ID, true)
|
|
||||||
if err != nil {
|
|
||||||
target := &common.LdapUserUpdateError{}
|
|
||||||
if errors.As(err, &target) {
|
|
||||||
return nil, nil, fmt.Errorf("failed to delete user %s: LDAP user must be disabled before deletion", user.Username)
|
|
||||||
}
|
|
||||||
return nil, nil, fmt.Errorf("failed to delete user %s: %w", user.Username, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("Deleted user", slog.String("username", user.Username))
|
|
||||||
|
|
||||||
// Storage operations must be executed outside of a transaction
|
|
||||||
deleteFiles = append(deleteFiles, path.Join("profile-pictures", user.ID+".png"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = s.userService.deleteUserInternal(ctx, tx, user.ID, true)
|
||||||
|
if err != nil {
|
||||||
|
target := &common.LdapUserUpdateError{}
|
||||||
|
if errors.As(err, &target) {
|
||||||
|
return nil, nil, fmt.Errorf("failed to delete user %s: LDAP user must be disabled before deletion", user.Username)
|
||||||
|
}
|
||||||
|
return nil, nil, fmt.Errorf("failed to delete user %s: %w", user.Username, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Deleted user", slog.String("username", user.Username))
|
||||||
|
deleteFiles = append(deleteFiles, path.Join("profile-pictures", user.ID+".png"))
|
||||||
}
|
}
|
||||||
|
|
||||||
return savePictures, deleteFiles, nil
|
return savePictures, deleteFiles, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *LdapService) loadLDAPUsersInDB(ctx context.Context, tx *gorm.DB) (users []model.User, byLdapID map[string]model.User, byUsername map[string]model.User, err error) {
|
||||||
|
// Load all LDAP-managed users and index them by LDAP ID and by username
|
||||||
|
err = tx.
|
||||||
|
WithContext(ctx).
|
||||||
|
Select("id, username, ldap_id, disabled").
|
||||||
|
Where("ldap_id IS NOT NULL").
|
||||||
|
Find(&users).
|
||||||
|
Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
byLdapID = make(map[string]model.User, len(users))
|
||||||
|
byUsername = make(map[string]model.User, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
byLdapID[*user.LdapID] = user
|
||||||
|
byUsername[user.Username] = user
|
||||||
|
}
|
||||||
|
|
||||||
|
return users, byLdapID, byUsername, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LdapService) loadLDAPGroupsInDB(ctx context.Context, tx *gorm.DB) ([]model.UserGroup, map[string]model.UserGroup, error) {
|
||||||
|
var groups []model.UserGroup
|
||||||
|
|
||||||
|
// Load all LDAP-managed groups and index them by LDAP ID
|
||||||
|
err := tx.
|
||||||
|
WithContext(ctx).
|
||||||
|
Select("id, name, ldap_id").
|
||||||
|
Where("ldap_id IS NOT NULL").
|
||||||
|
Find(&groups).
|
||||||
|
Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
groupsByID := make(map[string]model.UserGroup, len(groups))
|
||||||
|
for _, group := range groups {
|
||||||
|
groupsByID[*group.LdapID] = group
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups, groupsByID, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *LdapService) saveProfilePicture(parentCtx context.Context, userId string, pictureString string) error {
|
func (s *LdapService) saveProfilePicture(parentCtx context.Context, userId string, pictureString string) error {
|
||||||
var reader io.ReadSeeker
|
var reader io.ReadSeeker
|
||||||
|
|
||||||
|
// Accept either a URL, a base64-encoded payload, or raw binary data
|
||||||
_, err := url.ParseRequestURI(pictureString)
|
_, err := url.ParseRequestURI(pictureString)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
ctx, cancel := context.WithTimeout(parentCtx, 15*time.Second)
|
ctx, cancel := context.WithTimeout(parentCtx, 15*time.Second)
|
||||||
@@ -522,6 +680,31 @@ func (s *LdapService) saveProfilePicture(parentCtx context.Context, userId strin
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeLDAPDN returns a canonical lowercase form of a DN for use as a map key.
|
||||||
|
// Different LDAP servers may format the same DN with varying attribute type casing (e.g. "CN=" vs "cn=") or extra whitespace (e.g. "dc=example, dc=com").
|
||||||
|
// Without normalization, cache lookups in usernamesByDN would miss when a member attribute value uses a different format than the DN returned in the search entry
|
||||||
|
//
|
||||||
|
// ldap.ParseDN is used instead of simple lowercasing because it correctly handles multi-valued RDNs (joined with "+") and strips inter-component whitespace.
|
||||||
|
// If parsing fails for any reason, we fall back to a simple lowercase+trim.
|
||||||
|
func normalizeLDAPDN(dn string) string {
|
||||||
|
parsed, err := ldap.ParseDN(dn)
|
||||||
|
if err != nil {
|
||||||
|
return strings.ToLower(strings.TrimSpace(dn))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct the DN in a canonical form: lowercase type=lowercase value, with RDN components separated by "," and multi-value attributes by "+"
|
||||||
|
parts := make([]string, 0, len(parsed.RDNs))
|
||||||
|
for _, rdn := range parsed.RDNs {
|
||||||
|
attrs := make([]string, 0, len(rdn.Attributes))
|
||||||
|
for _, attr := range rdn.Attributes {
|
||||||
|
attrs = append(attrs, strings.ToLower(attr.Type)+"="+strings.ToLower(attr.Value))
|
||||||
|
}
|
||||||
|
parts = append(parts, strings.Join(attrs, "+"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
// getDNProperty returns the value of a property from a LDAP identifier
|
// getDNProperty returns the value of a property from a LDAP identifier
|
||||||
// See: https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ldap/distinguished-names
|
// See: https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ldap/distinguished-names
|
||||||
func getDNProperty(property string, str string) string {
|
func getDNProperty(property string, str string) string {
|
||||||
|
|||||||
@@ -1,9 +1,368 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-ldap/ldap/v3"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||||
|
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type fakeLDAPClient struct {
|
||||||
|
searchFn func(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeLDAPClient) Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||||
|
if c.searchFn == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.searchFn(searchRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeLDAPClient) Bind(_, _ string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeLDAPClient) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLdapServiceSyncAllReconcilesUsersAndGroups(t *testing.T) {
|
||||||
|
service, db := newTestLdapService(t, newFakeLDAPClient(
|
||||||
|
ldapSearchResult(
|
||||||
|
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
|
||||||
|
"entryUUID": {"u-alice"},
|
||||||
|
"uid": {"alice"},
|
||||||
|
"mail": {"alice@example.com"},
|
||||||
|
"givenName": {"Alice"},
|
||||||
|
"sn": {"Jones"},
|
||||||
|
"displayName": {""},
|
||||||
|
}),
|
||||||
|
ldapEntry("uid=bob,ou=people,dc=example,dc=com", map[string][]string{
|
||||||
|
"entryUUID": {"u-bob"},
|
||||||
|
"uid": {"bob"},
|
||||||
|
"mail": {"bob@example.com"},
|
||||||
|
"givenName": {"Bob"},
|
||||||
|
"sn": {"Brown"},
|
||||||
|
"displayName": {""},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
ldapSearchResult(
|
||||||
|
ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
|
||||||
|
"entryUUID": {"g-admins"},
|
||||||
|
"cn": {"admins"},
|
||||||
|
"member": {"uid=alice,ou=people,dc=example,dc=com"},
|
||||||
|
}),
|
||||||
|
ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{
|
||||||
|
"entryUUID": {"g-team"},
|
||||||
|
"cn": {"team"},
|
||||||
|
"member": {
|
||||||
|
"UID=Alice, OU=People, DC=example, DC=com",
|
||||||
|
"uid=bob, ou=people, dc=example, dc=com",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
aliceLdapID := "u-alice"
|
||||||
|
missingLdapID := "u-missing"
|
||||||
|
teamLdapID := "g-team"
|
||||||
|
oldGroupLdapID := "g-old"
|
||||||
|
|
||||||
|
require.NoError(t, db.Create(&model.User{
|
||||||
|
Username: "alice-old",
|
||||||
|
Email: new("alice-old@example.com"),
|
||||||
|
EmailVerified: true,
|
||||||
|
FirstName: "Old",
|
||||||
|
LastName: "Name",
|
||||||
|
DisplayName: "Old Name",
|
||||||
|
LdapID: &aliceLdapID,
|
||||||
|
Disabled: true,
|
||||||
|
}).Error)
|
||||||
|
|
||||||
|
require.NoError(t, db.Create(&model.User{
|
||||||
|
Username: "missing",
|
||||||
|
Email: new("missing@example.com"),
|
||||||
|
EmailVerified: true,
|
||||||
|
FirstName: "Missing",
|
||||||
|
LastName: "User",
|
||||||
|
DisplayName: "Missing User",
|
||||||
|
LdapID: &missingLdapID,
|
||||||
|
}).Error)
|
||||||
|
|
||||||
|
require.NoError(t, db.Create(&model.UserGroup{
|
||||||
|
Name: "team-old",
|
||||||
|
FriendlyName: "team-old",
|
||||||
|
LdapID: &teamLdapID,
|
||||||
|
}).Error)
|
||||||
|
|
||||||
|
require.NoError(t, db.Create(&model.UserGroup{
|
||||||
|
Name: "old-group",
|
||||||
|
FriendlyName: "old-group",
|
||||||
|
LdapID: &oldGroupLdapID,
|
||||||
|
}).Error)
|
||||||
|
|
||||||
|
require.NoError(t, service.SyncAll(t.Context()))
|
||||||
|
|
||||||
|
var alice model.User
|
||||||
|
require.NoError(t, db.First(&alice, "ldap_id = ?", aliceLdapID).Error)
|
||||||
|
assert.Equal(t, "alice", alice.Username)
|
||||||
|
assert.Equal(t, new("alice@example.com"), alice.Email)
|
||||||
|
assert.Equal(t, "Alice", alice.FirstName)
|
||||||
|
assert.Equal(t, "Jones", alice.LastName)
|
||||||
|
assert.Equal(t, "Alice Jones", alice.DisplayName)
|
||||||
|
assert.True(t, alice.IsAdmin)
|
||||||
|
assert.False(t, alice.Disabled)
|
||||||
|
|
||||||
|
var bob model.User
|
||||||
|
require.NoError(t, db.First(&bob, "ldap_id = ?", "u-bob").Error)
|
||||||
|
assert.Equal(t, "bob", bob.Username)
|
||||||
|
assert.Equal(t, "Bob Brown", bob.DisplayName)
|
||||||
|
|
||||||
|
var missing model.User
|
||||||
|
require.NoError(t, db.First(&missing, "ldap_id = ?", missingLdapID).Error)
|
||||||
|
assert.True(t, missing.Disabled)
|
||||||
|
|
||||||
|
var oldGroupCount int64
|
||||||
|
require.NoError(t, db.Model(&model.UserGroup{}).Where("ldap_id = ?", oldGroupLdapID).Count(&oldGroupCount).Error)
|
||||||
|
assert.Zero(t, oldGroupCount)
|
||||||
|
|
||||||
|
var team model.UserGroup
|
||||||
|
require.NoError(t, db.Preload("Users").First(&team, "ldap_id = ?", teamLdapID).Error)
|
||||||
|
assert.Equal(t, "team", team.Name)
|
||||||
|
assert.Equal(t, "team", team.FriendlyName)
|
||||||
|
assert.ElementsMatch(t, []string{"alice", "bob"}, usernames(team.Users))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLdapServiceSyncAllHandlesDuplicateLDAPIDsInSingleRun(t *testing.T) {
|
||||||
|
service, db := newTestLdapService(t, newFakeLDAPClient(
|
||||||
|
ldapSearchResult(
|
||||||
|
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
|
||||||
|
"entryUUID": {"u-dup"},
|
||||||
|
"uid": {"alice"},
|
||||||
|
"mail": {"alice@example.com"},
|
||||||
|
"givenName": {"Alice"},
|
||||||
|
"sn": {"Doe"},
|
||||||
|
"displayName": {"Alice Doe"},
|
||||||
|
}),
|
||||||
|
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
|
||||||
|
"entryUUID": {"u-dup"},
|
||||||
|
"uid": {"alice"},
|
||||||
|
"mail": {"alice@example.com"},
|
||||||
|
"givenName": {"Alicia"},
|
||||||
|
"sn": {"Doe"},
|
||||||
|
"displayName": {"Alicia Doe"},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
ldapSearchResult(
|
||||||
|
ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{
|
||||||
|
"entryUUID": {"g-dup"},
|
||||||
|
"cn": {"team"},
|
||||||
|
"member": {"uid=alice,ou=people,dc=example,dc=com"},
|
||||||
|
}),
|
||||||
|
ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{
|
||||||
|
"entryUUID": {"g-dup"},
|
||||||
|
"cn": {"team-renamed"},
|
||||||
|
"member": {"uid=alice,ou=people,dc=example,dc=com"},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
require.NoError(t, service.SyncAll(t.Context()))
|
||||||
|
|
||||||
|
var users []model.User
|
||||||
|
require.NoError(t, db.Find(&users, "ldap_id = ?", "u-dup").Error)
|
||||||
|
require.Len(t, users, 1)
|
||||||
|
assert.Equal(t, "alice", users[0].Username)
|
||||||
|
assert.Equal(t, "Alicia", users[0].FirstName)
|
||||||
|
assert.Equal(t, "Alicia Doe", users[0].DisplayName)
|
||||||
|
|
||||||
|
var groups []model.UserGroup
|
||||||
|
require.NoError(t, db.Preload("Users").Find(&groups, "ldap_id = ?", "g-dup").Error)
|
||||||
|
require.Len(t, groups, 1)
|
||||||
|
assert.Equal(t, "team-renamed", groups[0].Name)
|
||||||
|
assert.Equal(t, "team-renamed", groups[0].FriendlyName)
|
||||||
|
assert.ElementsMatch(t, []string{"alice"}, usernames(groups[0].Users))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLdapServiceSyncAllSetsAdminFromGroupMembership(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
appConfig *model.AppConfig
|
||||||
|
groupEntry *ldap.Entry
|
||||||
|
groupName string
|
||||||
|
groupLookup string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "memberOf missing on user",
|
||||||
|
appConfig: defaultTestLDAPAppConfig(),
|
||||||
|
groupEntry: ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
|
||||||
|
"entryUUID": {"g-admins"},
|
||||||
|
"cn": {"admins"},
|
||||||
|
"member": {"uid=testadmin,ou=people,dc=example,dc=com"},
|
||||||
|
}),
|
||||||
|
groupName: "admins",
|
||||||
|
groupLookup: "g-admins",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "configured group name attribute differs from DN RDN",
|
||||||
|
appConfig: func() *model.AppConfig {
|
||||||
|
cfg := defaultTestLDAPAppConfig()
|
||||||
|
cfg.LdapAttributeGroupName = model.AppConfigVariable{Value: "displayName"}
|
||||||
|
cfg.LdapAdminGroupName = model.AppConfigVariable{Value: "pocketid.admin"}
|
||||||
|
return cfg
|
||||||
|
}(),
|
||||||
|
groupEntry: ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
|
||||||
|
"entryUUID": {"g-display-admins"},
|
||||||
|
"cn": {"admins"},
|
||||||
|
"displayName": {"pocketid.admin"},
|
||||||
|
"member": {"uid=testadmin,ou=people,dc=example,dc=com"},
|
||||||
|
}),
|
||||||
|
groupName: "pocketid.admin",
|
||||||
|
groupLookup: "g-display-admins",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
service, db := newTestLdapServiceWithAppConfig(t, tt.appConfig, newFakeLDAPClient(
|
||||||
|
ldapSearchResult(
|
||||||
|
ldapEntry("uid=testadmin,ou=people,dc=example,dc=com", map[string][]string{
|
||||||
|
"entryUUID": {"u-testadmin"},
|
||||||
|
"uid": {"testadmin"},
|
||||||
|
"mail": {"testadmin@example.com"},
|
||||||
|
"givenName": {"Test"},
|
||||||
|
"sn": {"Admin"},
|
||||||
|
"displayName": {""},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
ldapSearchResult(tt.groupEntry),
|
||||||
|
))
|
||||||
|
|
||||||
|
require.NoError(t, service.SyncAll(t.Context()))
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
require.NoError(t, db.First(&user, "ldap_id = ?", "u-testadmin").Error)
|
||||||
|
assert.True(t, user.IsAdmin)
|
||||||
|
|
||||||
|
var group model.UserGroup
|
||||||
|
require.NoError(t, db.Preload("Users").First(&group, "ldap_id = ?", tt.groupLookup).Error)
|
||||||
|
assert.Equal(t, tt.groupName, group.Name)
|
||||||
|
assert.ElementsMatch(t, []string{"testadmin"}, usernames(group.Users))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestLdapService(t *testing.T, client ldapClient) (*LdapService, *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return newTestLdapServiceWithAppConfig(t, defaultTestLDAPAppConfig(), client)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *model.AppConfig, client ldapClient) (*LdapService, *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
|
||||||
|
fileStorage, err := storage.NewDatabaseStorage(db)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
appConfig := NewTestAppConfigService(appConfigModel)
|
||||||
|
|
||||||
|
groupService := NewUserGroupService(db, appConfig, nil)
|
||||||
|
userService := NewUserService(
|
||||||
|
db,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
appConfig,
|
||||||
|
NewCustomClaimService(db),
|
||||||
|
NewAppImagesService(map[string]string{}, fileStorage),
|
||||||
|
nil,
|
||||||
|
fileStorage,
|
||||||
|
)
|
||||||
|
|
||||||
|
service := NewLdapService(db, &http.Client{}, appConfig, userService, groupService, fileStorage)
|
||||||
|
service.clientFactory = func() (ldapClient, error) {
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return service, db
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultTestLDAPAppConfig() *model.AppConfig {
|
||||||
|
return &model.AppConfig{
|
||||||
|
RequireUserEmail: model.AppConfigVariable{Value: "false"},
|
||||||
|
LdapEnabled: model.AppConfigVariable{Value: "true"},
|
||||||
|
LdapBase: model.AppConfigVariable{Value: "dc=example,dc=com"},
|
||||||
|
LdapUserSearchFilter: model.AppConfigVariable{Value: "(objectClass=person)"},
|
||||||
|
LdapUserGroupSearchFilter: model.AppConfigVariable{Value: "(objectClass=groupOfNames)"},
|
||||||
|
LdapAttributeUserUniqueIdentifier: model.AppConfigVariable{Value: "entryUUID"},
|
||||||
|
LdapAttributeUserUsername: model.AppConfigVariable{Value: "uid"},
|
||||||
|
LdapAttributeUserEmail: model.AppConfigVariable{Value: "mail"},
|
||||||
|
LdapAttributeUserFirstName: model.AppConfigVariable{Value: "givenName"},
|
||||||
|
LdapAttributeUserLastName: model.AppConfigVariable{Value: "sn"},
|
||||||
|
LdapAttributeUserDisplayName: model.AppConfigVariable{Value: "displayName"},
|
||||||
|
LdapAttributeUserProfilePicture: model.AppConfigVariable{Value: "jpegPhoto"},
|
||||||
|
LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"},
|
||||||
|
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{Value: "entryUUID"},
|
||||||
|
LdapAttributeGroupName: model.AppConfigVariable{Value: "cn"},
|
||||||
|
LdapAdminGroupName: model.AppConfigVariable{Value: "admins"},
|
||||||
|
LdapSoftDeleteUsers: model.AppConfigVariable{Value: "true"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeLDAPClient(userResult, groupResult *ldap.SearchResult) ldapClient {
|
||||||
|
return &fakeLDAPClient{
|
||||||
|
searchFn: func(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||||
|
switch searchRequest.Filter {
|
||||||
|
case "(objectClass=person)":
|
||||||
|
return userResult, nil
|
||||||
|
case "(objectClass=groupOfNames)":
|
||||||
|
return groupResult, nil
|
||||||
|
default:
|
||||||
|
return &ldap.SearchResult{}, nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ldapSearchResult(entries ...*ldap.Entry) *ldap.SearchResult {
|
||||||
|
return &ldap.SearchResult{Entries: entries}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ldapEntry(dn string, attrs map[string][]string) *ldap.Entry {
|
||||||
|
entry := &ldap.Entry{
|
||||||
|
DN: dn,
|
||||||
|
Attributes: make([]*ldap.EntryAttribute, 0, len(attrs)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, values := range attrs {
|
||||||
|
entry.Attributes = append(entry.Attributes, &ldap.EntryAttribute{
|
||||||
|
Name: name,
|
||||||
|
Values: values,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func usernames(users []model.User) []string {
|
||||||
|
result := make([]string, 0, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
result = append(result, user.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetDNProperty(t *testing.T) {
|
func TestGetDNProperty(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -64,10 +423,58 @@ func TestGetDNProperty(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result := getDNProperty(tt.property, tt.dn)
|
result := getDNProperty(tt.property, tt.dn)
|
||||||
if result != tt.expectedResult {
|
assert.Equalf(t, tt.expectedResult, result, "getDNProperty(%q, %q)", tt.property, tt.dn)
|
||||||
t.Errorf("getDNProperty(%q, %q) = %q, want %q",
|
})
|
||||||
tt.property, tt.dn, result, tt.expectedResult)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeLDAPDN(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "already normalized",
|
||||||
|
input: "cn=alice,dc=example,dc=com",
|
||||||
|
expected: "cn=alice,dc=example,dc=com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "uppercase attribute types",
|
||||||
|
input: "CN=Alice,DC=example,DC=com",
|
||||||
|
expected: "cn=alice,dc=example,dc=com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "spaces after commas",
|
||||||
|
input: "cn=alice, dc=example, dc=com",
|
||||||
|
expected: "cn=alice,dc=example,dc=com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "uppercase types and spaces",
|
||||||
|
input: "CN=Alice, DC=example, DC=com",
|
||||||
|
expected: "cn=alice,dc=example,dc=com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multi-valued RDN",
|
||||||
|
input: "cn=alice+uid=a123,dc=example,dc=com",
|
||||||
|
expected: "cn=alice+uid=a123,dc=example,dc=com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid DN falls back to lowercase+trim",
|
||||||
|
input: " NOT A VALID DN ",
|
||||||
|
expected: "not a valid dn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
input: "",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := normalizeLDAPDN(tt.input)
|
||||||
|
assert.Equalf(t, tt.expected, result, "normalizeLDAPDN(%q)", tt.input)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,9 +505,7 @@ func TestConvertLdapIdToString(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got := convertLdapIdToString(tt.input)
|
got := convertLdapIdToString(tt.input)
|
||||||
if got != tt.expected {
|
assert.Equal(t, tt.expected, got)
|
||||||
t.Errorf("Expected %q, got %q", tt.expected, got)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -404,7 +404,7 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if authorizationCodeMetaData.ClientID != input.ClientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
|
if authorizationCodeMetaData.ClientID != input.ClientID || authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
|
||||||
return CreatedTokens{}, &common.OidcInvalidAuthorizationCodeError{}
|
return CreatedTokens{}, &common.OidcInvalidAuthorizationCodeError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1644,34 +1644,19 @@ func clientAuthCredentialsFromCreateTokensDto(d *dto.OidcCreateTokensDto) Client
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *gorm.DB, input ClientAuthCredentials, allowPublicClientsWithoutAuth bool) (client *model.OidcClient, err error) {
|
func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *gorm.DB, input ClientAuthCredentials, allowPublicClientsWithoutAuth bool) (client *model.OidcClient, err error) {
|
||||||
isClientAssertion := input.ClientAssertionType == ClientAssertionTypeJWTBearer && input.ClientAssertion != ""
|
if input.ClientID == "" {
|
||||||
|
|
||||||
// Determine the client ID based on the authentication method
|
|
||||||
var clientID string
|
|
||||||
switch {
|
|
||||||
case isClientAssertion:
|
|
||||||
// Extract client ID from the JWT assertion's 'sub' claim
|
|
||||||
clientID, err = s.extractClientIDFromAssertion(input.ClientAssertion)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed to extract client ID from assertion", "error", err)
|
|
||||||
return nil, &common.OidcClientAssertionInvalidError{}
|
|
||||||
}
|
|
||||||
case input.ClientID != "":
|
|
||||||
// Use the provided client ID for other authentication methods
|
|
||||||
clientID = input.ClientID
|
|
||||||
default:
|
|
||||||
return nil, &common.OidcMissingClientCredentialsError{}
|
return nil, &common.OidcMissingClientCredentialsError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the OIDC client's configuration
|
// Load the OIDC client's configuration
|
||||||
err = tx.
|
err = tx.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
First(&client, "id = ?", clientID).
|
First(&client, "id = ?", input.ClientID).
|
||||||
Error
|
Error
|
||||||
if err != nil {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) && isClientAssertion {
|
slog.WarnContext(ctx, "Client not found", slog.String("client", input.ClientID))
|
||||||
return nil, &common.OidcClientAssertionInvalidError{}
|
return nil, &common.OidcClientNotFoundError{}
|
||||||
}
|
} else if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1686,7 +1671,7 @@ func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *g
|
|||||||
return client, nil
|
return client, nil
|
||||||
|
|
||||||
// Next, check if we want to use client assertions from federated identities
|
// Next, check if we want to use client assertions from federated identities
|
||||||
case isClientAssertion:
|
case input.ClientAssertionType == ClientAssertionTypeJWTBearer && input.ClientAssertion != "":
|
||||||
err = s.verifyClientAssertionFromFederatedIdentities(ctx, client, input)
|
err = s.verifyClientAssertionFromFederatedIdentities(ctx, client, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.WarnContext(ctx, "Invalid assertion for client", slog.String("client", client.ID), slog.Any("error", err))
|
slog.WarnContext(ctx, "Invalid assertion for client", slog.String("client", client.ID), slog.Any("error", err))
|
||||||
@@ -1783,36 +1768,20 @@ func (s *OidcService) verifyClientAssertionFromFederatedIdentities(ctx context.C
|
|||||||
// (Note: we don't use jwt.WithIssuer() because that would be redundant)
|
// (Note: we don't use jwt.WithIssuer() because that would be redundant)
|
||||||
_, err = jwt.Parse(assertion,
|
_, err = jwt.Parse(assertion,
|
||||||
jwt.WithValidate(true),
|
jwt.WithValidate(true),
|
||||||
|
|
||||||
jwt.WithAcceptableSkew(clockSkew),
|
jwt.WithAcceptableSkew(clockSkew),
|
||||||
jwt.WithKeySet(jwks, jws.WithInferAlgorithmFromKey(true), jws.WithUseDefault(true)),
|
jwt.WithKeySet(jwks, jws.WithInferAlgorithmFromKey(true), jws.WithUseDefault(true)),
|
||||||
jwt.WithAudience(audience),
|
jwt.WithAudience(audience),
|
||||||
jwt.WithSubject(subject),
|
jwt.WithSubject(subject),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("client assertion is not valid: %w", err)
|
return fmt.Errorf("client assertion could not be verified: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we're here, the assertion is valid
|
// If we're here, the assertion is valid
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractClientIDFromAssertion extracts the client_id from the JWT assertion's 'sub' claim
|
|
||||||
func (s *OidcService) extractClientIDFromAssertion(assertion string) (string, error) {
|
|
||||||
// Parse the JWT without verification first to get the claims
|
|
||||||
insecureToken, err := jwt.ParseInsecure([]byte(assertion))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to parse JWT assertion: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract the subject claim which must be the client_id according to RFC 7523
|
|
||||||
sub, ok := insecureToken.Subject()
|
|
||||||
if !ok || sub == "" {
|
|
||||||
return "", fmt.Errorf("missing or invalid 'sub' claim in JWT assertion")
|
|
||||||
}
|
|
||||||
|
|
||||||
return sub, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes []string) (*dto.OidcClientPreviewDto, error) {
|
func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes []string) (*dto.OidcClientPreviewDto, error) {
|
||||||
tx := s.db.Begin()
|
tx := s.db.Begin()
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|||||||
@@ -229,6 +229,12 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
|||||||
Subject: federatedClient.ID,
|
Subject: federatedClient.ID,
|
||||||
JWKS: federatedClientIssuer + "/jwks.json",
|
JWKS: federatedClientIssuer + "/jwks.json",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Issuer: "federated-issuer-2",
|
||||||
|
Audience: federatedClientAudience,
|
||||||
|
Subject: "my-federated-client",
|
||||||
|
JWKS: federatedClientIssuer + "/jwks.json",
|
||||||
|
},
|
||||||
{Issuer: federatedClientIssuerDefaults},
|
{Issuer: federatedClientIssuerDefaults},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -461,6 +467,43 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
|||||||
|
|
||||||
// Generate a token
|
// Generate a token
|
||||||
input := dto.OidcCreateTokensDto{
|
input := dto.OidcCreateTokensDto{
|
||||||
|
ClientID: federatedClient.ID,
|
||||||
|
ClientAssertion: string(signedToken),
|
||||||
|
ClientAssertionType: ClientAssertionTypeJWTBearer,
|
||||||
|
}
|
||||||
|
createdToken, err := s.createTokenFromClientCredentials(t.Context(), input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, token)
|
||||||
|
|
||||||
|
// Verify the token
|
||||||
|
claims, err := s.jwtService.VerifyOAuthAccessToken(createdToken.AccessToken)
|
||||||
|
require.NoError(t, err, "Failed to verify generated token")
|
||||||
|
|
||||||
|
// Check the claims
|
||||||
|
subject, ok := claims.Subject()
|
||||||
|
_ = assert.True(t, ok, "User ID not found in token") &&
|
||||||
|
assert.Equal(t, "client-"+federatedClient.ID, subject, "Token subject should match federated client ID with prefix")
|
||||||
|
audience, ok := claims.Audience()
|
||||||
|
_ = assert.True(t, ok, "Audience not found in token") &&
|
||||||
|
assert.Equal(t, []string{federatedClient.ID}, audience, "Audience should contain the federated client ID")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Succeeds with valid assertion and custom subject", func(t *testing.T) {
|
||||||
|
// Create JWT for federated identity
|
||||||
|
token, err := jwt.NewBuilder().
|
||||||
|
Issuer("federated-issuer-2").
|
||||||
|
Audience([]string{federatedClientAudience}).
|
||||||
|
Subject("my-federated-client").
|
||||||
|
IssuedAt(time.Now()).
|
||||||
|
Expiration(time.Now().Add(10 * time.Minute)).
|
||||||
|
Build()
|
||||||
|
require.NoError(t, err)
|
||||||
|
signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), privateJWK))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Generate a token
|
||||||
|
input := dto.OidcCreateTokensDto{
|
||||||
|
ClientID: federatedClient.ID,
|
||||||
ClientAssertion: string(signedToken),
|
ClientAssertion: string(signedToken),
|
||||||
ClientAssertionType: ClientAssertionTypeJWTBearer,
|
ClientAssertionType: ClientAssertionTypeJWTBearer,
|
||||||
}
|
}
|
||||||
@@ -483,6 +526,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("Fails with invalid assertion", func(t *testing.T) {
|
t.Run("Fails with invalid assertion", func(t *testing.T) {
|
||||||
input := dto.OidcCreateTokensDto{
|
input := dto.OidcCreateTokensDto{
|
||||||
|
ClientID: confidentialClient.ID,
|
||||||
ClientAssertion: "invalid.jwt.token",
|
ClientAssertion: "invalid.jwt.token",
|
||||||
ClientAssertionType: ClientAssertionTypeJWTBearer,
|
ClientAssertionType: ClientAssertionTypeJWTBearer,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Con
|
|||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
user, err := s.userService.GetUser(ctx, userID)
|
user, err := s.userService.getUserInternal(ctx, userID, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -131,8 +131,32 @@ func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Con
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *OneTimeAccessService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) {
|
func (s *OneTimeAccessService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) {
|
||||||
token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, s.db)
|
tx := s.db.Begin()
|
||||||
return token, err
|
defer func() {
|
||||||
|
tx.Rollback()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Load the user to ensure it exists
|
||||||
|
_, err = s.userService.getUserInternal(ctx, userID, tx)
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return "", &common.UserNotFoundError{}
|
||||||
|
} else if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the one-time access token
|
||||||
|
token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, tx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit
|
||||||
|
err = tx.Commit().Error
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OneTimeAccessService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, withDeviceToken bool, tx *gorm.DB) (token string, deviceToken *string, err error) {
|
func (s *OneTimeAccessService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, withDeviceToken bool, tx *gorm.DB) (token string, deviceToken *string, err error) {
|
||||||
|
|||||||
25
backend/internal/service/scheduler.go
Normal file
25
backend/internal/service/scheduler.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
backoff "github.com/cenkalti/backoff/v5"
|
||||||
|
"github.com/go-co-op/gocron/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterJobOpts holds optional configuration for registering a scheduled job.
|
||||||
|
type RegisterJobOpts struct {
|
||||||
|
// RunImmediately runs the job immediately after registration.
|
||||||
|
RunImmediately bool
|
||||||
|
// ExtraOptions are additional gocron job options.
|
||||||
|
ExtraOptions []gocron.JobOption
|
||||||
|
// BackOff is an optional backoff strategy. If non-nil, the job will be wrapped
|
||||||
|
// with automatic retry logic using the provided backoff on transient failures.
|
||||||
|
BackOff backoff.BackOff
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scheduler is an interface for registering and managing background jobs.
|
||||||
|
type Scheduler interface {
|
||||||
|
RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, opts RegisterJobOpts) error
|
||||||
|
RemoveJob(name string) error
|
||||||
|
}
|
||||||
@@ -34,11 +34,6 @@ const scimErrorBodyLimit = 4096
|
|||||||
|
|
||||||
type scimSyncAction int
|
type scimSyncAction int
|
||||||
|
|
||||||
type Scheduler interface {
|
|
||||||
RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool, extraOptions ...gocron.JobOption) error
|
|
||||||
RemoveJob(name string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
scimActionNone scimSyncAction = iota
|
scimActionNone scimSyncAction = iota
|
||||||
scimActionCreated
|
scimActionCreated
|
||||||
@@ -149,7 +144,7 @@ func (s *ScimService) ScheduleSync() {
|
|||||||
|
|
||||||
err := s.scheduler.RegisterJob(
|
err := s.scheduler.RegisterJob(
|
||||||
context.Background(), jobName,
|
context.Background(), jobName,
|
||||||
gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(start)), s.SyncAll, false)
|
gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(start)), s.SyncAll, RegisterJobOpts{})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to schedule SCIM sync", slog.Any("error", err))
|
slog.Error("Failed to schedule SCIM sync", slog.Any("error", err))
|
||||||
@@ -168,7 +163,8 @@ func (s *ScimService) SyncAll(ctx context.Context) error {
|
|||||||
errs = append(errs, ctx.Err())
|
errs = append(errs, ctx.Err())
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if err := s.SyncServiceProvider(ctx, provider.ID); err != nil {
|
err = s.SyncServiceProvider(ctx, provider.ID)
|
||||||
|
if err != nil {
|
||||||
errs = append(errs, fmt.Errorf("failed to sync SCIM provider %s: %w", provider.ID, err))
|
errs = append(errs, fmt.Errorf("failed to sync SCIM provider %s: %w", provider.ID, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,26 +206,20 @@ func (s *ScimService) SyncServiceProvider(ctx context.Context, serviceProviderID
|
|||||||
}
|
}
|
||||||
|
|
||||||
var errs []error
|
var errs []error
|
||||||
var userStats scimSyncStats
|
|
||||||
var groupStats scimSyncStats
|
|
||||||
|
|
||||||
// Sync users first, so that groups can reference them
|
// Sync users first, so that groups can reference them
|
||||||
if stats, err := s.syncUsers(ctx, provider, users, &userResources); err != nil {
|
userStats, err := s.syncUsers(ctx, provider, users, &userResources)
|
||||||
errs = append(errs, err)
|
if err != nil {
|
||||||
userStats = stats
|
errs = append(errs, err)
|
||||||
} else {
|
}
|
||||||
userStats = stats
|
|
||||||
}
|
groupStats, err := s.syncGroups(ctx, provider, groups, groupResources.Resources, userResources.Resources)
|
||||||
|
|
||||||
stats, err := s.syncGroups(ctx, provider, groups, groupResources.Resources, userResources.Resources)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errs = append(errs, err)
|
errs = append(errs, err)
|
||||||
groupStats = stats
|
|
||||||
} else {
|
|
||||||
groupStats = stats
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(errs) > 0 {
|
if len(errs) > 0 {
|
||||||
|
err = errors.Join(errs...)
|
||||||
slog.WarnContext(ctx, "SCIM sync completed with errors",
|
slog.WarnContext(ctx, "SCIM sync completed with errors",
|
||||||
slog.String("provider_id", provider.ID),
|
slog.String("provider_id", provider.ID),
|
||||||
slog.Int("error_count", len(errs)),
|
slog.Int("error_count", len(errs)),
|
||||||
@@ -240,12 +230,14 @@ func (s *ScimService) SyncServiceProvider(ctx context.Context, serviceProviderID
|
|||||||
slog.Int("groups_updated", groupStats.Updated),
|
slog.Int("groups_updated", groupStats.Updated),
|
||||||
slog.Int("groups_deleted", groupStats.Deleted),
|
slog.Int("groups_deleted", groupStats.Deleted),
|
||||||
slog.Duration("duration", time.Since(start)),
|
slog.Duration("duration", time.Since(start)),
|
||||||
|
slog.Any("error", err),
|
||||||
)
|
)
|
||||||
return errors.Join(errs...)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
provider.LastSyncedAt = new(datatype.DateTime(time.Now()))
|
provider.LastSyncedAt = new(datatype.DateTime(time.Now()))
|
||||||
if err := s.db.WithContext(ctx).Save(&provider).Error; err != nil {
|
err = s.db.WithContext(ctx).Save(&provider).Error
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +265,7 @@ func (s *ScimService) syncUsers(
|
|||||||
|
|
||||||
// Update or create users
|
// Update or create users
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
existing := getResourceByExternalID[dto.ScimUser](u.ID, resourceList.Resources)
|
existing := getResourceByExternalID(u.ID, resourceList.Resources)
|
||||||
|
|
||||||
action, created, err := s.syncUser(ctx, provider, u, existing)
|
action, created, err := s.syncUser(ctx, provider, u, existing)
|
||||||
if created != nil && existing == nil {
|
if created != nil && existing == nil {
|
||||||
@@ -434,7 +426,7 @@ func (s *ScimService) syncGroup(
|
|||||||
// Prepare group members
|
// Prepare group members
|
||||||
members := make([]dto.ScimGroupMember, len(group.Users))
|
members := make([]dto.ScimGroupMember, len(group.Users))
|
||||||
for i, user := range group.Users {
|
for i, user := range group.Users {
|
||||||
userResource := getResourceByExternalID[dto.ScimUser](user.ID, userResources)
|
userResource := getResourceByExternalID(user.ID, userResources)
|
||||||
if userResource == nil {
|
if userResource == nil {
|
||||||
// Groups depend on user IDs already being provisioned
|
// Groups depend on user IDs already being provisioned
|
||||||
return scimActionNone, fmt.Errorf("cannot sync group %s: user %s is not provisioned in SCIM provider", group.ID, user.ID)
|
return scimActionNone, fmt.Errorf("cannot sync group %s: user %s is not provisioned in SCIM provider", group.ID, user.ID)
|
||||||
|
|||||||
@@ -96,7 +96,10 @@ func (s *UserGroupService) Delete(ctx context.Context, id string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.scimService.ScheduleSync()
|
if s.scimService != nil {
|
||||||
|
s.scimService.ScheduleSync()
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +129,10 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro
|
|||||||
return model.UserGroup{}, err
|
return model.UserGroup{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.scimService.ScheduleSync()
|
if s.scimService != nil {
|
||||||
|
s.scimService.ScheduleSync()
|
||||||
|
}
|
||||||
|
|
||||||
return group, nil
|
return group, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +181,10 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
|
|||||||
return model.UserGroup{}, err
|
return model.UserGroup{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.scimService.ScheduleSync()
|
if s.scimService != nil {
|
||||||
|
s.scimService.ScheduleSync()
|
||||||
|
}
|
||||||
|
|
||||||
return group, nil
|
return group, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +247,10 @@ func (s *UserGroupService) updateUsersInternal(ctx context.Context, id string, u
|
|||||||
return model.UserGroup{}, err
|
return model.UserGroup{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.scimService.ScheduleSync()
|
if s.scimService != nil {
|
||||||
|
s.scimService.ScheduleSync()
|
||||||
|
}
|
||||||
|
|
||||||
return group, nil
|
return group, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,6 +327,9 @@ func (s *UserGroupService) UpdateAllowedOidcClient(ctx context.Context, id strin
|
|||||||
return model.UserGroup{}, err
|
return model.UserGroup{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.scimService.ScheduleSync()
|
if s.scimService != nil {
|
||||||
|
s.scimService.ScheduleSync()
|
||||||
|
}
|
||||||
|
|
||||||
return group, nil
|
return group, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,7 +225,10 @@ func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userI
|
|||||||
return fmt.Errorf("failed to delete user: %w", err)
|
return fmt.Errorf("failed to delete user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.scimService.ScheduleSync()
|
if s.scimService != nil {
|
||||||
|
s.scimService.ScheduleSync()
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +313,10 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.scimService.ScheduleSync()
|
if s.scimService != nil {
|
||||||
|
s.scimService.ScheduleSync()
|
||||||
|
}
|
||||||
|
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,7 +462,10 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
|
|||||||
return user, err
|
return user, err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.scimService.ScheduleSync()
|
if s.scimService != nil {
|
||||||
|
s.scimService.ScheduleSync()
|
||||||
|
}
|
||||||
|
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,7 +524,10 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
|
|||||||
return model.User{}, err
|
return model.User{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.scimService.ScheduleSync()
|
if s.scimService != nil {
|
||||||
|
s.scimService.ScheduleSync()
|
||||||
|
}
|
||||||
|
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,7 +588,10 @@ func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, user
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.scimService.ScheduleSync()
|
if s.scimService != nil {
|
||||||
|
s.scimService.ScheduleSync()
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,31 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
"regexp"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dunglas/go-urlpattern"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetCallbackURLFromList returns the first callback URL that matches the input callback URL
|
// ValidateCallbackURLPattern checks if the given callback URL pattern
|
||||||
|
// is valid according to the rules defined in this package.
|
||||||
|
func ValidateCallbackURLPattern(pattern string) error {
|
||||||
|
if pattern == "*" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern, _, _ = strings.Cut(pattern, "#")
|
||||||
|
pattern = normalizeToURLPatternStandard(pattern)
|
||||||
|
|
||||||
|
_, err := urlpattern.New(pattern, "", nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCallbackURLFromList returns the first callback URL that matches the input callback URL.
|
||||||
func GetCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL string, err error) {
|
func GetCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL string, err error) {
|
||||||
// Special case for Loopback Interface Redirection. Quoting from RFC 8252 section 7.3:
|
// Special case for Loopback Interface Redirection. Quoting from RFC 8252 section 7.3:
|
||||||
// https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
|
// https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
|
||||||
@@ -17,17 +34,7 @@ func GetCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL
|
|||||||
// time of the request for loopback IP redirect URIs, to accommodate
|
// time of the request for loopback IP redirect URIs, to accommodate
|
||||||
// clients that obtain an available ephemeral port from the operating
|
// clients that obtain an available ephemeral port from the operating
|
||||||
// system at the time of the request.
|
// system at the time of the request.
|
||||||
loopbackCallbackURLWithoutPort := ""
|
loopbackCallbackURLWithoutPort := loopbackURLWithWildcardPort(inputCallbackURL)
|
||||||
u, _ := url.Parse(inputCallbackURL)
|
|
||||||
|
|
||||||
if u != nil && u.Scheme == "http" {
|
|
||||||
host := u.Hostname()
|
|
||||||
ip := net.ParseIP(host)
|
|
||||||
if host == "localhost" || (ip != nil && ip.IsLoopback()) {
|
|
||||||
u.Host = host
|
|
||||||
loopbackCallbackURLWithoutPort = u.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, pattern := range urls {
|
for _, pattern := range urls {
|
||||||
// Try the original callback first
|
// Try the original callback first
|
||||||
@@ -54,6 +61,28 @@ func GetCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loopbackURLWithWildcardPort(input string) string {
|
||||||
|
u, _ := url.Parse(input)
|
||||||
|
|
||||||
|
if u == nil || u.Scheme != "http" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
host := u.Hostname()
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
if host != "localhost" && (ip == nil || !ip.IsLoopback()) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// For IPv6 loopback hosts, brackets are required when serializing without a port.
|
||||||
|
if strings.Contains(host, ":") {
|
||||||
|
u.Host = "[" + host + "]"
|
||||||
|
} else {
|
||||||
|
u.Host = host
|
||||||
|
}
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
// matchCallbackURL checks if the input callback URL matches the given pattern.
|
// matchCallbackURL checks if the input callback URL matches the given pattern.
|
||||||
// It supports wildcard matching for paths and query parameters.
|
// It supports wildcard matching for paths and query parameters.
|
||||||
//
|
//
|
||||||
@@ -64,143 +93,176 @@ func matchCallbackURL(pattern string, inputCallbackURL string) (matches bool, er
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip fragment part
|
// Strip fragment part.
|
||||||
// The endpoint URI MUST NOT include a fragment component.
|
// The endpoint URI MUST NOT include a fragment component.
|
||||||
// https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2
|
// https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2
|
||||||
pattern, _, _ = strings.Cut(pattern, "#")
|
pattern, _, _ = strings.Cut(pattern, "#")
|
||||||
inputCallbackURL, _, _ = strings.Cut(inputCallbackURL, "#")
|
inputCallbackURL, _, _ = strings.Cut(inputCallbackURL, "#")
|
||||||
|
|
||||||
// Store and strip query part
|
// Store and strip query part
|
||||||
var patternQuery url.Values
|
pattern, patternQuery, err := extractQueryParams(pattern)
|
||||||
if i := strings.Index(pattern, "?"); i >= 0 {
|
if err != nil {
|
||||||
patternQuery, err = url.ParseQuery(pattern[i+1:])
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
pattern = pattern[:i]
|
|
||||||
}
|
|
||||||
var inputQuery url.Values
|
|
||||||
if i := strings.Index(inputCallbackURL, "?"); i >= 0 {
|
|
||||||
inputQuery, err = url.ParseQuery(inputCallbackURL[i+1:])
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
inputCallbackURL = inputCallbackURL[:i]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split both pattern and input parts
|
|
||||||
patternParts, patternPath := splitParts(pattern)
|
|
||||||
inputParts, inputPath := splitParts(inputCallbackURL)
|
|
||||||
|
|
||||||
// Verify everything except the path and query parameters
|
|
||||||
if len(patternParts) != len(inputParts) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, patternPart := range patternParts {
|
|
||||||
matched, err := path.Match(patternPart, inputParts[i])
|
|
||||||
if err != nil || !matched {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify path with wildcard support
|
|
||||||
matched, err := matchPath(patternPath, inputPath)
|
|
||||||
if err != nil || !matched {
|
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify query parameters
|
inputCallbackURL, inputQuery, err := extractQueryParams(inputCallbackURL)
|
||||||
if len(patternQuery) != len(inputQuery) {
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern = normalizeToURLPatternStandard(pattern)
|
||||||
|
|
||||||
|
// Validate query params
|
||||||
|
v := validateQueryParams(patternQuery, inputQuery)
|
||||||
|
if !v {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate the rest of the URL using urlpattern
|
||||||
|
p, err := urlpattern.New(pattern, "", nil)
|
||||||
|
if err != nil {
|
||||||
|
//nolint:nilerr
|
||||||
|
slog.Warn("invalid callback URL pattern, skipping", "pattern", pattern, "error", err)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.Test(inputCallbackURL, ""), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeToURLPatternStandard converts patterns with single asterisk wildcards and globstar wildcards
|
||||||
|
// into a format that can be parsed by the urlpattern package, which uses :param for single segment wildcards
|
||||||
|
// and ** for multi-segment wildcards.
|
||||||
|
// Additionally, it escapes ":" with a backslash inside IPv6 addresses
|
||||||
|
func normalizeToURLPatternStandard(pattern string) string {
|
||||||
|
patternBase, patternPath := extractPath(pattern)
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
result.Grow(len(pattern) + 5) // Add 5 for some extra capacity, hoping to avoid many re-allocations
|
||||||
|
|
||||||
|
// First, process the base
|
||||||
|
|
||||||
|
// 0 = scheme
|
||||||
|
// 1 = hostname (optionally with username/password) - before IPv6 start (no `[` found)
|
||||||
|
// 2 = is matching IPv6 (until `]`)
|
||||||
|
// 3 = after hostname
|
||||||
|
var step int
|
||||||
|
for i := 0; i < len(patternBase); i++ {
|
||||||
|
switch step {
|
||||||
|
case 0:
|
||||||
|
if i > 3 && patternBase[i] == '/' && patternBase[i-1] == '/' && patternBase[i-2] == ':' {
|
||||||
|
// We just passed the scheme
|
||||||
|
step = 1
|
||||||
|
}
|
||||||
|
case 1:
|
||||||
|
switch patternBase[i] {
|
||||||
|
case '/', ']':
|
||||||
|
// No IPv6, skip to end of this logic
|
||||||
|
step = 3
|
||||||
|
case '[':
|
||||||
|
// Start of IPv6 match
|
||||||
|
step = 2
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
if patternBase[i] == '/' || patternBase[i] == ']' || patternBase[i] == '[' {
|
||||||
|
// End of IPv6 match
|
||||||
|
step = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
switch patternBase[i] {
|
||||||
|
case ':':
|
||||||
|
// We are matching an IPv6 block and there's a colon, so escape that
|
||||||
|
result.WriteByte('\\')
|
||||||
|
case '/', ']', '[':
|
||||||
|
// End of IPv6 match
|
||||||
|
step = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the byte
|
||||||
|
result.WriteByte(patternBase[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next, process the path
|
||||||
|
for i := 0; i < len(patternPath); i++ {
|
||||||
|
if patternPath[i] == '*' {
|
||||||
|
// Replace globstar with a single asterisk
|
||||||
|
if i+1 < len(patternPath) && patternPath[i+1] == '*' {
|
||||||
|
result.WriteString("*")
|
||||||
|
i++ // skip next *
|
||||||
|
} else {
|
||||||
|
// Replace single asterisk with :p{index}
|
||||||
|
result.WriteString(":p")
|
||||||
|
result.WriteString(strconv.Itoa(i))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add the byte
|
||||||
|
result.WriteByte(patternPath[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractPath(url string) (base string, path string) {
|
||||||
|
pathStart := -1
|
||||||
|
|
||||||
|
// Look for scheme:// first
|
||||||
|
i := strings.Index(url, "://")
|
||||||
|
if i >= 0 {
|
||||||
|
// Look for the next slash after scheme://
|
||||||
|
rest := url[i+3:]
|
||||||
|
if j := strings.IndexByte(rest, '/'); j >= 0 {
|
||||||
|
pathStart = i + 3 + j
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Otherwise, first slash is path start
|
||||||
|
pathStart = strings.IndexByte(url, '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
if pathStart >= 0 {
|
||||||
|
path = url[pathStart:]
|
||||||
|
base = url[:pathStart]
|
||||||
|
} else {
|
||||||
|
path = ""
|
||||||
|
base = url
|
||||||
|
}
|
||||||
|
|
||||||
|
return base, path
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractQueryParams(rawUrl string) (base string, query url.Values, err error) {
|
||||||
|
if i := strings.IndexByte(rawUrl, '?'); i >= 0 {
|
||||||
|
query, err = url.ParseQuery(rawUrl[i+1:])
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
rawUrl = rawUrl[:i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawUrl, query, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateQueryParams(patternQuery, inputQuery url.Values) bool {
|
||||||
|
if len(patternQuery) != len(inputQuery) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
for patternKey, patternValues := range patternQuery {
|
for patternKey, patternValues := range patternQuery {
|
||||||
inputValues, exists := inputQuery[patternKey]
|
inputValues, exists := inputQuery[patternKey]
|
||||||
if !exists {
|
if !exists {
|
||||||
return false, nil
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(patternValues) != len(inputValues) {
|
if len(patternValues) != len(inputValues) {
|
||||||
return false, nil
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range patternValues {
|
for i := range patternValues {
|
||||||
matched, err := path.Match(patternValues[i], inputValues[i])
|
matched, err := path.Match(patternValues[i], inputValues[i])
|
||||||
if err != nil || !matched {
|
if err != nil || !matched {
|
||||||
return false, err
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
return true
|
||||||
}
|
|
||||||
|
|
||||||
// matchPath matches the input path against the pattern with wildcard support
|
|
||||||
// Supported wildcards:
|
|
||||||
//
|
|
||||||
// '*' matches any sequence of characters except '/'
|
|
||||||
// '**' matches any sequence of characters including '/'
|
|
||||||
func matchPath(pattern string, input string) (matches bool, err error) {
|
|
||||||
var regexPattern strings.Builder
|
|
||||||
regexPattern.WriteString("^")
|
|
||||||
|
|
||||||
runes := []rune(pattern)
|
|
||||||
n := len(runes)
|
|
||||||
|
|
||||||
for i := 0; i < n; {
|
|
||||||
switch runes[i] {
|
|
||||||
case '*':
|
|
||||||
// Check if it's a ** (globstar)
|
|
||||||
if i+1 < n && runes[i+1] == '*' {
|
|
||||||
// globstar = .* (match slashes too)
|
|
||||||
regexPattern.WriteString(".*")
|
|
||||||
i += 2
|
|
||||||
} else {
|
|
||||||
// single * = [^/]* (no slash)
|
|
||||||
regexPattern.WriteString(`[^/]*`)
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
regexPattern.WriteString(regexp.QuoteMeta(string(runes[i])))
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
regexPattern.WriteString("$")
|
|
||||||
|
|
||||||
matched, err := regexp.MatchString(regexPattern.String(), input)
|
|
||||||
return matched, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitParts splits the URL into parts by special characters and returns the path separately
|
|
||||||
func splitParts(s string) (parts []string, path string) {
|
|
||||||
split := func(r rune) bool {
|
|
||||||
return r == ':' || r == '/' || r == '[' || r == ']' || r == '@' || r == '.'
|
|
||||||
}
|
|
||||||
|
|
||||||
pathStart := -1
|
|
||||||
|
|
||||||
// Look for scheme:// first
|
|
||||||
if i := strings.Index(s, "://"); i >= 0 {
|
|
||||||
// Look for the next slash after scheme://
|
|
||||||
rest := s[i+3:]
|
|
||||||
if j := strings.IndexRune(rest, '/'); j >= 0 {
|
|
||||||
pathStart = i + 3 + j
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Otherwise, first slash is path start
|
|
||||||
pathStart = strings.IndexRune(s, '/')
|
|
||||||
}
|
|
||||||
|
|
||||||
if pathStart >= 0 {
|
|
||||||
path = s[pathStart:]
|
|
||||||
base := s[:pathStart]
|
|
||||||
parts = strings.FieldsFunc(base, split)
|
|
||||||
} else {
|
|
||||||
parts = strings.FieldsFunc(s, split)
|
|
||||||
path = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts, path
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,142 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestValidateCallbackURLPattern(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
pattern string
|
||||||
|
shouldError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "exact URL",
|
||||||
|
pattern: "https://example.com/callback",
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard scheme",
|
||||||
|
pattern: "*://example.com/callback",
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard port",
|
||||||
|
pattern: "https://example.com:*/callback",
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partial wildcard port",
|
||||||
|
pattern: "https://example.com:80*/callback",
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard userinfo",
|
||||||
|
pattern: "https://user:*@example.com/callback",
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "glob wildcard",
|
||||||
|
pattern: "*",
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "relative URL",
|
||||||
|
pattern: "/callback",
|
||||||
|
shouldError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing scheme separator",
|
||||||
|
pattern: "https//example.com/callback",
|
||||||
|
shouldError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed wildcard host glob",
|
||||||
|
pattern: "https://exa[mple.com/callback",
|
||||||
|
shouldError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidateCallbackURLPattern(tt.pattern)
|
||||||
|
if tt.shouldError {
|
||||||
|
require.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeToURLPatternStandard(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "exact URL unchanged",
|
||||||
|
input: "https://example.com/callback",
|
||||||
|
expected: "https://example.com/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single wildcard path segment converted to named parameter",
|
||||||
|
input: "https://example.com/api/*/callback",
|
||||||
|
expected: "https://example.com/api/:p5/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single wildcard in path suffix converted to named parameter",
|
||||||
|
input: "https://example.com/test*",
|
||||||
|
expected: "https://example.com/test:p5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "globstar converted to single asterisk",
|
||||||
|
input: "https://example.com/**/callback",
|
||||||
|
expected: "https://example.com/*/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed globstar and single wildcard conversion",
|
||||||
|
input: "https://example.com/**/v1/**/callback/*",
|
||||||
|
expected: "https://example.com/*/v1/*/callback/:p19",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL without path unchanged",
|
||||||
|
input: "https://example.com",
|
||||||
|
expected: "https://example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "relative path conversion",
|
||||||
|
input: "/foo/*/bar",
|
||||||
|
expected: "/foo/:p5/bar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard in hostname is not normalized by this function",
|
||||||
|
input: "https://*.example.com/callback",
|
||||||
|
expected: "https://*.example.com/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 hostname escapes all colons inside address",
|
||||||
|
input: "https://[2001:db8:1:1::a:1]/callback",
|
||||||
|
expected: "https://[2001\\:db8\\:1\\:1\\:\\:a\\:1]/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 hostname with port escapes only address colons",
|
||||||
|
input: "https://[::1]:8080/callback",
|
||||||
|
expected: "https://[\\:\\:1]:8080/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard in query is converted when query is part of input",
|
||||||
|
input: "https://example.com/callback?code=*",
|
||||||
|
expected: "https://example.com/callback?code=:p15",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.expected, normalizeToURLPatternStandard(tt.input))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMatchCallbackURL(t *testing.T) {
|
func TestMatchCallbackURL(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -27,6 +163,18 @@ func TestMatchCallbackURL(t *testing.T) {
|
|||||||
"https://example.com/callback",
|
"https://example.com/callback",
|
||||||
false,
|
false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"exact match - IPv4",
|
||||||
|
"https://10.1.0.1/callback",
|
||||||
|
"https://10.1.0.1/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"exact match - IPv6",
|
||||||
|
"https://[2001:db8:1:1::a:1]/callback",
|
||||||
|
"https://[2001:db8:1:1::a:1]/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
|
||||||
// Scheme
|
// Scheme
|
||||||
{
|
{
|
||||||
@@ -111,6 +259,30 @@ func TestMatchCallbackURL(t *testing.T) {
|
|||||||
"https://example.com:8080/callback",
|
"https://example.com:8080/callback",
|
||||||
true,
|
true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"wildcard port - IPv4",
|
||||||
|
"https://10.1.0.1:*/callback",
|
||||||
|
"https://10.1.0.1:8080/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partial wildcard in port prefix - IPv4",
|
||||||
|
"https://10.1.0.1:80*/callback",
|
||||||
|
"https://10.1.0.1:8080/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wildcard port - IPv6",
|
||||||
|
"https://[2001:db8:1:1::a:1]:*/callback",
|
||||||
|
"https://[2001:db8:1:1::a:1]:8080/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partial wildcard in port prefix - IPv6",
|
||||||
|
"https://[2001:db8:1:1::a:1]:80*/callback",
|
||||||
|
"https://[2001:db8:1:1::a:1]:8080/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
|
||||||
// Path
|
// Path
|
||||||
{
|
{
|
||||||
@@ -131,6 +303,18 @@ func TestMatchCallbackURL(t *testing.T) {
|
|||||||
"https://example.com/callback",
|
"https://example.com/callback",
|
||||||
true,
|
true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"wildcard entire path - IPv4",
|
||||||
|
"https://10.1.0.1/*",
|
||||||
|
"https://10.1.0.1/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wildcard entire path - IPv6",
|
||||||
|
"https://[2001:db8:1:1::a:1]/*",
|
||||||
|
"https://[2001:db8:1:1::a:1]/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"partial wildcard in path prefix",
|
"partial wildcard in path prefix",
|
||||||
"https://example.com/test*",
|
"https://example.com/test*",
|
||||||
@@ -187,12 +371,6 @@ func TestMatchCallbackURL(t *testing.T) {
|
|||||||
"https://example.com/callback",
|
"https://example.com/callback",
|
||||||
false,
|
false,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"unexpected credentials",
|
|
||||||
"https://example.com/callback",
|
|
||||||
"https://user:pass@example.com/callback",
|
|
||||||
false,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"wildcard password",
|
"wildcard password",
|
||||||
"https://user:*@example.com/callback",
|
"https://user:*@example.com/callback",
|
||||||
@@ -347,7 +525,7 @@ func TestMatchCallbackURL(t *testing.T) {
|
|||||||
"backslash instead of forward slash",
|
"backslash instead of forward slash",
|
||||||
"https://example.com/callback",
|
"https://example.com/callback",
|
||||||
"https://example.com\\callback",
|
"https://example.com\\callback",
|
||||||
false,
|
true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"double slash in hostname (protocol smuggling)",
|
"double slash in hostname (protocol smuggling)",
|
||||||
@@ -370,10 +548,11 @@ func TestMatchCallbackURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
matches, err := matchCallbackURL(tt.pattern, tt.input)
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
require.NoError(t, err, tt.name)
|
matches, err := matchCallbackURL(tt.pattern, tt.input)
|
||||||
assert.Equal(t, tt.shouldMatch, matches, tt.name)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.shouldMatch, matches)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,14 +586,21 @@ func TestGetCallbackURLFromList_LoopbackSpecialHandling(t *testing.T) {
|
|||||||
expectMatch: true,
|
expectMatch: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IPv6 loopback with dynamic port",
|
name: "IPv6 loopback with dynamic port - exact match",
|
||||||
urls: []string{"http://[::1]/callback"},
|
urls: []string{"http://[::1]/callback"},
|
||||||
inputCallbackURL: "http://[::1]:8080/callback",
|
inputCallbackURL: "http://[::1]:8080/callback",
|
||||||
expectedURL: "http://[::1]:8080/callback",
|
expectedURL: "http://[::1]:8080/callback",
|
||||||
expectMatch: true,
|
expectMatch: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IPv6 loopback with wildcard path",
|
name: "IPv6 loopback with same port - exact match",
|
||||||
|
urls: []string{"http://[::1]:8080/callback"},
|
||||||
|
inputCallbackURL: "http://[::1]:8080/callback",
|
||||||
|
expectedURL: "http://[::1]:8080/callback",
|
||||||
|
expectMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 loopback with path match",
|
||||||
urls: []string{"http://[::1]/auth/*"},
|
urls: []string{"http://[::1]/auth/*"},
|
||||||
inputCallbackURL: "http://[::1]:8080/auth/callback",
|
inputCallbackURL: "http://[::1]:8080/auth/callback",
|
||||||
expectedURL: "http://[::1]:8080/auth/callback",
|
expectedURL: "http://[::1]:8080/auth/callback",
|
||||||
@@ -441,6 +627,20 @@ func TestGetCallbackURLFromList_LoopbackSpecialHandling(t *testing.T) {
|
|||||||
expectedURL: "http://127.0.0.1:3000/auth/callback",
|
expectedURL: "http://127.0.0.1:3000/auth/callback",
|
||||||
expectMatch: true,
|
expectMatch: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "loopback with path port",
|
||||||
|
urls: []string{"http://127.0.0.1:*/auth/callback"},
|
||||||
|
inputCallbackURL: "http://127.0.0.1:3000/auth/callback",
|
||||||
|
expectedURL: "http://127.0.0.1:3000/auth/callback",
|
||||||
|
expectMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 loopback with path port",
|
||||||
|
urls: []string{"http://[::1]:*/auth/callback"},
|
||||||
|
inputCallbackURL: "http://[::1]:3000/auth/callback",
|
||||||
|
expectedURL: "http://[::1]:3000/auth/callback",
|
||||||
|
expectMatch: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "loopback with path mismatch",
|
name: "loopback with path mismatch",
|
||||||
urls: []string{"http://127.0.0.1/callback"},
|
urls: []string{"http://127.0.0.1/callback"},
|
||||||
@@ -484,6 +684,76 @@ func TestGetCallbackURLFromList_LoopbackSpecialHandling(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoopbackURLWithWildcardPort(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
output string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "localhost http with port strips port",
|
||||||
|
input: "http://localhost:3000/callback",
|
||||||
|
output: "http://localhost/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "localhost http without port stays same",
|
||||||
|
input: "http://localhost/callback",
|
||||||
|
output: "http://localhost/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv4 loopback with port strips port",
|
||||||
|
input: "http://127.0.0.1:8080/callback",
|
||||||
|
output: "http://127.0.0.1/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv4 loopback without port stays same",
|
||||||
|
input: "http://127.0.0.1/callback",
|
||||||
|
output: "http://127.0.0.1/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 loopback with port strips port and keeps brackets",
|
||||||
|
input: "http://[::1]:8080/callback",
|
||||||
|
output: "http://[::1]/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 loopback preserves path query and fragment",
|
||||||
|
input: "http://[::1]:8080/auth/callback?code=123#state",
|
||||||
|
output: "http://[::1]/auth/callback?code=123#state",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "https loopback returns empty",
|
||||||
|
input: "https://127.0.0.1:8080/callback",
|
||||||
|
output: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non loopback host returns empty",
|
||||||
|
input: "http://example.com:8080/callback",
|
||||||
|
output: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non loopback IP returns empty",
|
||||||
|
input: "http://192.168.1.10:8080/callback",
|
||||||
|
output: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed URL returns empty",
|
||||||
|
input: "http://[::1:8080/callback",
|
||||||
|
output: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "relative URL returns empty",
|
||||||
|
input: "/callback",
|
||||||
|
output: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.output, loopbackURLWithWildcardPort(tt.input))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetCallbackURLFromList_MultiplePatterns(t *testing.T) {
|
func TestGetCallbackURLFromList_MultiplePatterns(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -553,246 +823,3 @@ func TestGetCallbackURLFromList_MultiplePatterns(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMatchPath(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
pattern string
|
|
||||||
input string
|
|
||||||
shouldMatch bool
|
|
||||||
}{
|
|
||||||
// Exact matches
|
|
||||||
{
|
|
||||||
name: "exact match",
|
|
||||||
pattern: "/callback",
|
|
||||||
input: "/callback",
|
|
||||||
shouldMatch: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "exact mismatch",
|
|
||||||
pattern: "/callback",
|
|
||||||
input: "/other",
|
|
||||||
shouldMatch: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty paths",
|
|
||||||
pattern: "",
|
|
||||||
input: "",
|
|
||||||
shouldMatch: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Single wildcard (*)
|
|
||||||
{
|
|
||||||
name: "single wildcard matches segment",
|
|
||||||
pattern: "/api/*/callback",
|
|
||||||
input: "/api/v1/callback",
|
|
||||||
shouldMatch: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single wildcard doesn't match multiple segments",
|
|
||||||
pattern: "/api/*/callback",
|
|
||||||
input: "/api/v1/v2/callback",
|
|
||||||
shouldMatch: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single wildcard at end",
|
|
||||||
pattern: "/callback/*",
|
|
||||||
input: "/callback/test",
|
|
||||||
shouldMatch: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single wildcard at start",
|
|
||||||
pattern: "/*/callback",
|
|
||||||
input: "/api/callback",
|
|
||||||
shouldMatch: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple single wildcards",
|
|
||||||
pattern: "/*/test/*",
|
|
||||||
input: "/api/test/callback",
|
|
||||||
shouldMatch: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "partial wildcard prefix",
|
|
||||||
pattern: "/test*",
|
|
||||||
input: "/testing",
|
|
||||||
shouldMatch: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "partial wildcard suffix",
|
|
||||||
pattern: "/*-callback",
|
|
||||||
input: "/oauth-callback",
|
|
||||||
shouldMatch: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "partial wildcard middle",
|
|
||||||
pattern: "/api-*-v1",
|
|
||||||
input: "/api-internal-v1",
|
|
||||||
shouldMatch: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Double wildcard (**)
|
|
||||||
{
|
|
||||||
name: "double wildcard matches multiple segments",
|
|
||||||
pattern: "/api/**/callback",
|
|
||||||
input: "/api/v1/v2/v3/callback",
|
|
||||||
shouldMatch: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "double wildcard matches single segment",
|
|
||||||
pattern: "/api/**/callback",
|
|
||||||
input: "/api/v1/callback",
|
|
||||||
shouldMatch: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "double wildcard doesn't match when pattern has extra slashes",
|
|
||||||
pattern: "/api/**/callback",
|
|
||||||
input: "/api/callback",
|
|
||||||
shouldMatch: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "double wildcard at end",
|
|
||||||
pattern: "/api/**",
|
|
||||||
input: "/api/v1/v2/callback",
|
|
||||||
shouldMatch: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "double wildcard in middle",
|
|
||||||
pattern: "/api/**/v2/**/callback",
|
|
||||||
input: "/api/v1/v2/v3/v4/callback",
|
|
||||||
shouldMatch: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Complex patterns
|
|
||||||
{
|
|
||||||
name: "mix of single and double wildcards",
|
|
||||||
pattern: "/*/api/**/callback",
|
|
||||||
input: "/app/api/v1/v2/callback",
|
|
||||||
shouldMatch: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard with special characters",
|
|
||||||
pattern: "/callback-*",
|
|
||||||
input: "/callback-123",
|
|
||||||
shouldMatch: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "path with query-like string (no special handling)",
|
|
||||||
pattern: "/callback?code=*",
|
|
||||||
input: "/callback?code=abc",
|
|
||||||
shouldMatch: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Edge cases
|
|
||||||
{
|
|
||||||
name: "single wildcard matches empty segment",
|
|
||||||
pattern: "/api/*/callback",
|
|
||||||
input: "/api//callback",
|
|
||||||
shouldMatch: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "pattern longer than input",
|
|
||||||
pattern: "/api/v1/callback",
|
|
||||||
input: "/api",
|
|
||||||
shouldMatch: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "input longer than pattern",
|
|
||||||
pattern: "/api",
|
|
||||||
input: "/api/v1/callback",
|
|
||||||
shouldMatch: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
matches, err := matchPath(tt.pattern, tt.input)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.shouldMatch, matches)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSplitParts(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
expectedParts []string
|
|
||||||
expectedPath string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "simple https URL",
|
|
||||||
input: "https://example.com/callback",
|
|
||||||
expectedParts: []string{"https", "example", "com"},
|
|
||||||
expectedPath: "/callback",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "URL with port",
|
|
||||||
input: "https://example.com:8080/callback",
|
|
||||||
expectedParts: []string{"https", "example", "com", "8080"},
|
|
||||||
expectedPath: "/callback",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "URL with subdomain",
|
|
||||||
input: "https://api.example.com/callback",
|
|
||||||
expectedParts: []string{"https", "api", "example", "com"},
|
|
||||||
expectedPath: "/callback",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "URL with credentials",
|
|
||||||
input: "https://user:pass@example.com/callback",
|
|
||||||
expectedParts: []string{"https", "user", "pass", "example", "com"},
|
|
||||||
expectedPath: "/callback",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "URL without path",
|
|
||||||
input: "https://example.com",
|
|
||||||
expectedParts: []string{"https", "example", "com"},
|
|
||||||
expectedPath: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "URL with deep path",
|
|
||||||
input: "https://example.com/api/v1/callback",
|
|
||||||
expectedParts: []string{"https", "example", "com"},
|
|
||||||
expectedPath: "/api/v1/callback",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "URL with path and query",
|
|
||||||
input: "https://example.com/callback?code=123",
|
|
||||||
expectedParts: []string{"https", "example", "com"},
|
|
||||||
expectedPath: "/callback?code=123",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "URL with trailing slash",
|
|
||||||
input: "https://example.com/",
|
|
||||||
expectedParts: []string{"https", "example", "com"},
|
|
||||||
expectedPath: "/",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "URL with multiple subdomains",
|
|
||||||
input: "https://api.v1.staging.example.com/callback",
|
|
||||||
expectedParts: []string{"https", "api", "v1", "staging", "example", "com"},
|
|
||||||
expectedPath: "/callback",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "URL with port and credentials",
|
|
||||||
input: "https://user:pass@example.com:8080/callback",
|
|
||||||
expectedParts: []string{"https", "user", "pass", "example", "com", "8080"},
|
|
||||||
expectedPath: "/callback",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "scheme with authority separator but no slash",
|
|
||||||
input: "http://example.com",
|
|
||||||
expectedParts: []string{"http", "example", "com"},
|
|
||||||
expectedPath: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
parts, path := splitParts(tt.input)
|
|
||||||
assert.Equal(t, tt.expectedParts, parts, "parts mismatch")
|
|
||||||
assert.Equal(t, tt.expectedPath, path, "path mismatch")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
35
backend/internal/utils/networked_filesystem_linux.go
Normal file
35
backend/internal/utils/networked_filesystem_linux.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filesystem magic values from Linux's include/uapi/linux/magic.h, used by statfs(2).
|
||||||
|
const (
|
||||||
|
nfsSuperMagic = 0x6969
|
||||||
|
smbSuperMagic = 0x517b
|
||||||
|
cifsSuperMagic = 0xff534d42
|
||||||
|
fuseSuperMagic = 0x65735546
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsNetworkedFileSystem reports whether path is on a filesystem that is known to be unsafe for SQLite, specifically NFS, SMB/CIFS, or FUSE mounts.
|
||||||
|
func IsNetworkedFileSystem(path string) (bool, error) {
|
||||||
|
var statfs syscall.Statfs_t
|
||||||
|
err := syscall.Statfs(path, &statfs)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("error executing statfs syscall: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statfs_t.Type is arch-dependent (for example, int32 on some systems and int64 on others).
|
||||||
|
// Normalize through uint32 first so signed values still preserve the Linux bit pattern for magic numbers such as CIFS (0xff534d42), then compare in a wide unsigned form.
|
||||||
|
//nolint:gosec
|
||||||
|
switch uint64(uint32(statfs.Type)) {
|
||||||
|
case nfsSuperMagic, smbSuperMagic, cifsSuperMagic, fuseSuperMagic:
|
||||||
|
return true, nil
|
||||||
|
default:
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
8
backend/internal/utils/networked_filesystem_nonlinux.go
Normal file
8
backend/internal/utils/networked_filesystem_nonlinux.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package utils
|
||||||
|
|
||||||
|
// IsNetworkedFileSystem returns false on non-Linux systems because this detection is only used for Linux-specific statfs(2) filesystem magic values.
|
||||||
|
func IsNetworkedFileSystem(string) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="background-color:#FBFBFB"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">API Key Expiring Soon</h1></td><td align="right" data-id="__react-email-column"><p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Hello <!-- -->{{.Data.Name}}<!-- -->, <br/>This is a reminder that your API key <strong>{{.Data.APIKeyName}}</strong> <!-- -->will expire on <strong>{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}</strong>.</p><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Please generate a new API key if you need continued access.</p></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}
|
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="background-color:#FBFBFB"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">API Key Expiring Soon</h1></td><td align="right" data-id="__react-email-column"><p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Hello <!-- -->{{.Data.Name}}<!-- -->, <br/>This is a reminder that your API key <strong>{{.Data.ApiKeyName}}</strong> <!-- -->will expire on <strong>{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}</strong>.</p><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Please generate a new API key if you need continued access.</p></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}
|
||||||
@@ -6,6 +6,6 @@ API KEY EXPIRING SOON
|
|||||||
Warning
|
Warning
|
||||||
|
|
||||||
Hello {{.Data.Name}},
|
Hello {{.Data.Name}},
|
||||||
This is a reminder that your API key {{.Data.APIKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.
|
This is a reminder that your API key {{.Data.ApiKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.
|
||||||
|
|
||||||
Please generate a new API key if you need continued access.{{end}}
|
Please generate a new API key if you need continued access.{{end}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
-- No-op
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE INDEX IF NOT EXISTS idx_webauthn_sessions_expires_at ON webauthn_sessions (expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_one_time_access_tokens_expires_at ON one_time_access_tokens (expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oidc_authorization_codes_expires_at ON oidc_authorization_codes (expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_expires_at ON oidc_refresh_tokens (expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reauthentication_tokens_expires_at ON reauthentication_tokens (expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_expires_at ON email_verification_tokens (expires_at);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
-- No-op
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
PRAGMA foreign_keys= OFF;
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webauthn_sessions_expires_at ON webauthn_sessions (expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_one_time_access_tokens_expires_at ON one_time_access_tokens (expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oidc_authorization_codes_expires_at ON oidc_authorization_codes (expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_expires_at ON oidc_refresh_tokens (expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reauthentication_tokens_expires_at ON reauthentication_tokens (expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_expires_at ON email_verification_tokens (expires_at);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
@@ -40,7 +40,7 @@ ApiKeyExpiringEmail.TemplateProps = {
|
|||||||
...sharedTemplateProps,
|
...sharedTemplateProps,
|
||||||
data: {
|
data: {
|
||||||
name: "{{.Data.Name}}",
|
name: "{{.Data.Name}}",
|
||||||
apiKeyName: "{{.Data.APIKeyName}}",
|
apiKeyName: "{{.Data.ApiKeyName}}",
|
||||||
expiresAt: '{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}',
|
expiresAt: '{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ yarn.lock
|
|||||||
# Compiled files
|
# Compiled files
|
||||||
.svelte-kit/
|
.svelte-kit/
|
||||||
build/
|
build/
|
||||||
|
src/lib/paraglide/messages
|
||||||
@@ -365,7 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Zadejte kód, který byl zobrazen v předchozím kroku.",
|
"enter_code_displayed_in_previous_step": "Zadejte kód, který byl zobrazen v předchozím kroku.",
|
||||||
"authorize": "Autorizovat",
|
"authorize": "Autorizovat",
|
||||||
"federated_client_credentials": "Údaje o klientovi ve federaci",
|
"federated_client_credentials": "Údaje o klientovi ve federaci",
|
||||||
"federated_client_credentials_description": "Pomocí federovaných přihlašovacích údajů klienta můžete ověřit klienty OIDC pomocí JWT tokenů vydaných třetí stranou.",
|
"federated_client_credentials_description": "Federované klientské přihlašovací údaje umožňují ověřování klientů OIDC bez správy dlouhodobých tajných klíčů. Využívají tokeny JWT vydané třetími stranami pro klientská tvrzení, např. tokeny identity pracovního zatížení.",
|
||||||
"add_federated_client_credential": "Přidat údaje federovaného klienta",
|
"add_federated_client_credential": "Přidat údaje federovaného klienta",
|
||||||
"add_another_federated_client_credential": "Přidat dalšího federovaného klienta",
|
"add_another_federated_client_credential": "Přidat dalšího federovaného klienta",
|
||||||
"oidc_allowed_group_count": "Počet povolených skupin",
|
"oidc_allowed_group_count": "Počet povolených skupin",
|
||||||
|
|||||||
@@ -356,7 +356,7 @@
|
|||||||
"login_code_email_success": "Loginkoden er sendt til brugeren.",
|
"login_code_email_success": "Loginkoden er sendt til brugeren.",
|
||||||
"send_email": "Send e-mail",
|
"send_email": "Send e-mail",
|
||||||
"show_code": "Vis kode",
|
"show_code": "Vis kode",
|
||||||
"callback_url_description": "URL(er) angivet af din klient. Tilføjes automatisk, hvis feltet efterlades tomt. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Jokertegn</link> understøttes.",
|
"callback_url_description": "URL(er) angivet af din klient. Tilføjes automatisk, hvis feltet efterlades tomt. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Wildcards</link> understøttes.",
|
||||||
"logout_callback_url_description": "URL(er) angivet af din klient til logout. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Wildcards</link> understøttes.",
|
"logout_callback_url_description": "URL(er) angivet af din klient til logout. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Wildcards</link> understøttes.",
|
||||||
"api_key_expiration": "Udløb af API-nøgle",
|
"api_key_expiration": "Udløb af API-nøgle",
|
||||||
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send en e-mail til brugeren, når deres API-nøgle er ved at udløbe.",
|
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send en e-mail til brugeren, når deres API-nøgle er ved at udløbe.",
|
||||||
@@ -365,7 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Indtast koden, der blev vist i det forrige trin.",
|
"enter_code_displayed_in_previous_step": "Indtast koden, der blev vist i det forrige trin.",
|
||||||
"authorize": "Godkend",
|
"authorize": "Godkend",
|
||||||
"federated_client_credentials": "Federated klientlegitimationsoplysninger",
|
"federated_client_credentials": "Federated klientlegitimationsoplysninger",
|
||||||
"federated_client_credentials_description": "Ved hjælp af federated klientlegitimationsoplysninger kan du godkende OIDC-klienter med JWT-tokens udstedt af tredjepartsudbydere.",
|
"federated_client_credentials_description": "Federerede klientlegitimationsoplysninger gør det muligt at autentificere OIDC-klienter uden at skulle administrere langvarige hemmeligheder. De udnytter JWT-tokens udstedt af tredjepartsmyndigheder til klientpåstande, f.eks. identitetstokens for arbejdsbelastning.",
|
||||||
"add_federated_client_credential": "Tilføj federated klientlegitimation",
|
"add_federated_client_credential": "Tilføj federated klientlegitimation",
|
||||||
"add_another_federated_client_credential": "Tilføj endnu en federated klientlegitimation",
|
"add_another_federated_client_credential": "Tilføj endnu en federated klientlegitimation",
|
||||||
"oidc_allowed_group_count": "Tilladt antal grupper",
|
"oidc_allowed_group_count": "Tilladt antal grupper",
|
||||||
@@ -446,7 +446,7 @@
|
|||||||
"no_apps_available": "Ingen apps tilgængelige",
|
"no_apps_available": "Ingen apps tilgængelige",
|
||||||
"contact_your_administrator_for_app_access": "Kontakt din administrator for at få adgang til applikationer.",
|
"contact_your_administrator_for_app_access": "Kontakt din administrator for at få adgang til applikationer.",
|
||||||
"launch": "Start",
|
"launch": "Start",
|
||||||
"client_launch_url": "Kundens lancerings-URL",
|
"client_launch_url": "Start-URL til klient",
|
||||||
"client_launch_url_description": "Den URL, der åbnes, når en bruger starter appen fra siden Mine apps.",
|
"client_launch_url_description": "Den URL, der åbnes, når en bruger starter appen fra siden Mine apps.",
|
||||||
"client_name_description": "Navnet på den klient, der vises i Pocket ID-brugergrænsefladen.",
|
"client_name_description": "Navnet på den klient, der vises i Pocket ID-brugergrænsefladen.",
|
||||||
"revoke_access": "Tilbagekald adgang",
|
"revoke_access": "Tilbagekald adgang",
|
||||||
|
|||||||
@@ -365,7 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Gib den Code ein, der im vorherigen Schritt angezeigt wurde.",
|
"enter_code_displayed_in_previous_step": "Gib den Code ein, der im vorherigen Schritt angezeigt wurde.",
|
||||||
"authorize": "Autorisieren",
|
"authorize": "Autorisieren",
|
||||||
"federated_client_credentials": "Federated Client Credentials",
|
"federated_client_credentials": "Federated Client Credentials",
|
||||||
"federated_client_credentials_description": "Mit Hilfe von Verbund-Client-Anmeldeinformationen kannst du OIDC-Clients mit JWT-Tokens authentifizieren, die von Drittanbietern ausgestellt wurden.",
|
"federated_client_credentials_description": "Mit föderierten Client-Anmeldeinfos kann man OIDC-Clients authentifizieren, ohne sich um langlebige Geheimnisse kümmern zu müssen. Sie nutzen JWT-Token, die von Drittanbietern für Client-Assertions ausgestellt werden, z. B. Workload-Identitätstoken.",
|
||||||
"add_federated_client_credential": "Föderierte Client-Anmeldeinfos hinzufügen",
|
"add_federated_client_credential": "Föderierte Client-Anmeldeinfos hinzufügen",
|
||||||
"add_another_federated_client_credential": "Weitere Anmeldeinformationen für einen Verbundclient hinzufügen",
|
"add_another_federated_client_credential": "Weitere Anmeldeinformationen für einen Verbundclient hinzufügen",
|
||||||
"oidc_allowed_group_count": "Erlaubte Gruppenanzahl",
|
"oidc_allowed_group_count": "Erlaubte Gruppenanzahl",
|
||||||
|
|||||||
@@ -365,7 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
|
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
|
||||||
"authorize": "Authorize",
|
"authorize": "Authorize",
|
||||||
"federated_client_credentials": "Federated Client Credentials",
|
"federated_client_credentials": "Federated Client Credentials",
|
||||||
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
|
"federated_client_credentials_description": "Federated client credentials allow authenticating OIDC clients without managing long-lived secrets. They leverage JWT tokens issued by third-party authorities for client assertions, e.g. workload identity tokens.",
|
||||||
"add_federated_client_credential": "Add Federated Client Credential",
|
"add_federated_client_credential": "Add Federated Client Credential",
|
||||||
"add_another_federated_client_credential": "Add another federated client credential",
|
"add_another_federated_client_credential": "Add another federated client credential",
|
||||||
"oidc_allowed_group_count": "Allowed Group Count",
|
"oidc_allowed_group_count": "Allowed Group Count",
|
||||||
|
|||||||
@@ -365,7 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Introduce el código que se mostró en el paso anterior.",
|
"enter_code_displayed_in_previous_step": "Introduce el código que se mostró en el paso anterior.",
|
||||||
"authorize": "Autorizar",
|
"authorize": "Autorizar",
|
||||||
"federated_client_credentials": "Credenciales de cliente federadas",
|
"federated_client_credentials": "Credenciales de cliente federadas",
|
||||||
"federated_client_credentials_description": "Mediante credenciales de cliente federadas, puedes autenticar clientes OIDC utilizando tokens JWT emitidos por autoridades de terceros.",
|
"federated_client_credentials_description": "Las credenciales de cliente federadas permiten autenticar clientes OIDC sin gestionar secretos de larga duración. Aprovechan los tokens JWT emitidos por autoridades externas para las afirmaciones de los clientes, por ejemplo, tokens de identidad de carga de trabajo.",
|
||||||
"add_federated_client_credential": "Añadir credenciales de cliente federado",
|
"add_federated_client_credential": "Añadir credenciales de cliente federado",
|
||||||
"add_another_federated_client_credential": "Añadir otra credencial de cliente federado",
|
"add_another_federated_client_credential": "Añadir otra credencial de cliente federado",
|
||||||
"oidc_allowed_group_count": "Recuento de grupos permitidos",
|
"oidc_allowed_group_count": "Recuento de grupos permitidos",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
"my_account": "My Account",
|
"my_account": "Minu konto",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"docs": "Docs",
|
"docs": "Docs",
|
||||||
@@ -365,7 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
|
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
|
||||||
"authorize": "Authorize",
|
"authorize": "Authorize",
|
||||||
"federated_client_credentials": "Federated Client Credentials",
|
"federated_client_credentials": "Federated Client Credentials",
|
||||||
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
|
"federated_client_credentials_description": "Föderatiivsed kliendi autentimisandmed võimaldavad OIDC-kliente autentida ilma pikaajalisi salajasi andmeid haldamata. Need kasutavad kolmandate osapoolte poolt väljastatud JWT-tokeneid kliendi kinnituste jaoks, nt töökoormuse identiteeditokeneid.",
|
||||||
"add_federated_client_credential": "Add Federated Client Credential",
|
"add_federated_client_credential": "Add Federated Client Credential",
|
||||||
"add_another_federated_client_credential": "Add another federated client credential",
|
"add_another_federated_client_credential": "Add another federated client credential",
|
||||||
"oidc_allowed_group_count": "Allowed Group Count",
|
"oidc_allowed_group_count": "Allowed Group Count",
|
||||||
|
|||||||
@@ -365,7 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Syötä edellisessä vaiheessa näkynyt koodi.",
|
"enter_code_displayed_in_previous_step": "Syötä edellisessä vaiheessa näkynyt koodi.",
|
||||||
"authorize": "Salli",
|
"authorize": "Salli",
|
||||||
"federated_client_credentials": "Federoidut asiakastunnukset",
|
"federated_client_credentials": "Federoidut asiakastunnukset",
|
||||||
"federated_client_credentials_description": "Yhdistettyjen asiakastunnistetietojen avulla voit todentaa OIDC-asiakkaat kolmannen osapuolen myöntämillä JWT-tunnuksilla.",
|
"federated_client_credentials_description": "Yhdistetyt asiakastunnistetiedot mahdollistavat OIDC-asiakkaiden todentamisen ilman pitkäaikaisten salaisuuksien hallintaa. Ne hyödyntävät kolmansien osapuolten viranomaisten myöntämiä JWT-tunnuksia asiakastodistuksiin, esimerkiksi työkuorman tunnistetunnuksiin.",
|
||||||
"add_federated_client_credential": "Lisää federoitu asiakastunnus",
|
"add_federated_client_credential": "Lisää federoitu asiakastunnus",
|
||||||
"add_another_federated_client_credential": "Lisää toinen federoitu asiakastunnus",
|
"add_another_federated_client_credential": "Lisää toinen federoitu asiakastunnus",
|
||||||
"oidc_allowed_group_count": "Sallittujen ryhmien määrä",
|
"oidc_allowed_group_count": "Sallittujen ryhmien määrä",
|
||||||
|
|||||||
@@ -365,7 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Entrez le code affiché à l'étape précédente.",
|
"enter_code_displayed_in_previous_step": "Entrez le code affiché à l'étape précédente.",
|
||||||
"authorize": "Autoriser",
|
"authorize": "Autoriser",
|
||||||
"federated_client_credentials": "Identifiants client fédérés",
|
"federated_client_credentials": "Identifiants client fédérés",
|
||||||
"federated_client_credentials_description": "Avec des identifiants clients fédérés, vous pouvez authentifier des clients OIDC avec des tokens JWT émis par des autorités tierces.",
|
"federated_client_credentials_description": "Les informations d'identification client fédérées permettent d'authentifier les clients OIDC sans avoir à gérer des secrets à long terme. Elles utilisent des jetons JWT émis par des autorités tierces pour les assertions client, par exemple des jetons d'identité de charge de travail.",
|
||||||
"add_federated_client_credential": "Ajouter un identifiant client fédéré",
|
"add_federated_client_credential": "Ajouter un identifiant client fédéré",
|
||||||
"add_another_federated_client_credential": "Ajouter un autre identifiant client fédéré",
|
"add_another_federated_client_credential": "Ajouter un autre identifiant client fédéré",
|
||||||
"oidc_allowed_group_count": "Nombre de groupes autorisés",
|
"oidc_allowed_group_count": "Nombre de groupes autorisés",
|
||||||
|
|||||||
@@ -365,7 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Inserisci il codice visualizzato nel passaggio precedente.",
|
"enter_code_displayed_in_previous_step": "Inserisci il codice visualizzato nel passaggio precedente.",
|
||||||
"authorize": "Autorizza",
|
"authorize": "Autorizza",
|
||||||
"federated_client_credentials": "Identità Federate",
|
"federated_client_credentials": "Identità Federate",
|
||||||
"federated_client_credentials_description": "Utilizzando identità federate, è possibile autenticare i client OIDC utilizzando i token JWT emessi da autorità di terze parti.",
|
"federated_client_credentials_description": "Le credenziali client federate ti permettono di autenticare i client OIDC senza dover gestire segreti a lungo termine. Usano i token JWT rilasciati da autorità terze per le asserzioni dei client, tipo i token di identità del carico di lavoro.",
|
||||||
"add_federated_client_credential": "Aggiungi Identità Federata",
|
"add_federated_client_credential": "Aggiungi Identità Federata",
|
||||||
"add_another_federated_client_credential": "Aggiungi un'altra identità federata",
|
"add_another_federated_client_credential": "Aggiungi un'altra identità federata",
|
||||||
"oidc_allowed_group_count": "Numero Gruppi Consentiti",
|
"oidc_allowed_group_count": "Numero Gruppi Consentiti",
|
||||||
|
|||||||
@@ -365,7 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "前のステップで表示されたコードを入力してください。",
|
"enter_code_displayed_in_previous_step": "前のステップで表示されたコードを入力してください。",
|
||||||
"authorize": "Authorize",
|
"authorize": "Authorize",
|
||||||
"federated_client_credentials": "連携クライアントの資格情報",
|
"federated_client_credentials": "連携クライアントの資格情報",
|
||||||
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
|
"federated_client_credentials_description": "フェデレーテッドクライアント認証情報は、長期にわたるシークレットを管理せずにOIDCクライアントを認証することを可能にします。これらは、クライアントアサーション(例:ワークロードIDトークン)のためにサードパーティ機関が発行するJWTトークンを活用します。",
|
||||||
"add_federated_client_credential": "Add Federated Client Credential",
|
"add_federated_client_credential": "Add Federated Client Credential",
|
||||||
"add_another_federated_client_credential": "Add another federated client credential",
|
"add_another_federated_client_credential": "Add another federated client credential",
|
||||||
"oidc_allowed_group_count": "許可されたグループ数",
|
"oidc_allowed_group_count": "許可されたグループ数",
|
||||||
|
|||||||
@@ -365,7 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "이전 단계에 표시된 코드를 입력하세요.",
|
"enter_code_displayed_in_previous_step": "이전 단계에 표시된 코드를 입력하세요.",
|
||||||
"authorize": "승인",
|
"authorize": "승인",
|
||||||
"federated_client_credentials": "연동 클라이언트 자격 증명",
|
"federated_client_credentials": "연동 클라이언트 자격 증명",
|
||||||
"federated_client_credentials_description": "연동 클라이언트 자격 증명을 이용하여, OIDC 클라이언트를 제3자 인증 기관에서 발급한 JWT 토큰을 이용해 인증할 수 있습니다.",
|
"federated_client_credentials_description": "연방 클라이언트 자격 증명은 장기 비밀을 관리하지 않고도 OIDC 클라이언트를 인증할 수 있게 합니다. 이는 클라이언트 어설션(예: 워크로드 신원 토큰)을 위해 제3자 기관이 발급한 JWT 토큰을 활용합니다.",
|
||||||
"add_federated_client_credential": "연동 클라이언트 자격 증명 추가",
|
"add_federated_client_credential": "연동 클라이언트 자격 증명 추가",
|
||||||
"add_another_federated_client_credential": "다른 연동 클라이언트 자격 증명 추가",
|
"add_another_federated_client_credential": "다른 연동 클라이언트 자격 증명 추가",
|
||||||
"oidc_allowed_group_count": "허용된 그룹 수",
|
"oidc_allowed_group_count": "허용된 그룹 수",
|
||||||
|
|||||||
525
frontend/messages/lv.json
Normal file
525
frontend/messages/lv.json
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
|
"my_account": "Mans konts",
|
||||||
|
"logout": "Izrakstīties",
|
||||||
|
"confirm": "Apstiprināt",
|
||||||
|
"docs": "Dokumentācija",
|
||||||
|
"key": "Atslēga",
|
||||||
|
"value": "Vērtība",
|
||||||
|
"remove_custom_claim": "Noņemt pielāgoto claim",
|
||||||
|
"add_custom_claim": "Pievienot pielāgoto claim",
|
||||||
|
"add_another": "Pievienot vēl vienu",
|
||||||
|
"select_a_date": "Izvēlieties datumu",
|
||||||
|
"select_file": "Izvēlieties failu",
|
||||||
|
"profile_picture": "Profila attēls",
|
||||||
|
"profile_picture_is_managed_by_ldap_server": "Profila attēlu pārvalda LDAP serveris, un to šeit nevar mainīt.",
|
||||||
|
"click_profile_picture_to_upload_custom": "Noklikšķiniet uz profila attēla, lai augšupielādētu savu attēlu no failiem.",
|
||||||
|
"image_should_be_in_format": "Attēlam jābūt PNG, JPEG vai WEBP formātā.",
|
||||||
|
"items_per_page": "Vienumi lapā",
|
||||||
|
"no_items_found": "Vienumi nav atrasti",
|
||||||
|
"select_items": "Izvēlieties vienumus...",
|
||||||
|
"search": "Meklēt...",
|
||||||
|
"expand_card": "Izvērst kartīti",
|
||||||
|
"copied": "Nokopēts",
|
||||||
|
"click_to_copy": "Noklikšķiniet, lai kopētu",
|
||||||
|
"something_went_wrong": "Kaut kas nogāja greizi",
|
||||||
|
"go_back_to_home": "Atgriezties uz sākumlapu",
|
||||||
|
"alternative_sign_in_methods": "Alternatīvas pierakstīšanās metodes",
|
||||||
|
"login_background": "Pieteikšanās fons",
|
||||||
|
"logo": "Logotips",
|
||||||
|
"login_code": "Pieteikšanās kods",
|
||||||
|
"create_a_login_code_to_sign_in_without_a_passkey_once": "Izveidojiet pieteikšanās kodu, ko lietotājs var vienreiz izmantot, lai pierakstītos bez piekļuves atslēgas.",
|
||||||
|
"one_hour": "1 stunda",
|
||||||
|
"twelve_hours": "12 stundas",
|
||||||
|
"one_day": "1 diena",
|
||||||
|
"one_week": "1 nedēļa",
|
||||||
|
"one_month": "1 mēnesis",
|
||||||
|
"expiration": "Derīguma termiņš",
|
||||||
|
"generate_code": "Ģenerēt kodu",
|
||||||
|
"name": "Nosaukums",
|
||||||
|
"browser_unsupported": "Pārlūks netiek atbalstīts",
|
||||||
|
"this_browser_does_not_support_passkeys": "Šis pārlūks neatbalsta piekļuves atslēgas. Lūdzu, izmantojiet alternatīvu pierakstīšanās metodi.",
|
||||||
|
"an_unknown_error_occurred": "Radās nezināma kļūda",
|
||||||
|
"authentication_process_was_aborted": "Autentifikācijas process tika pārtraukts",
|
||||||
|
"error_occurred_with_authenticator": "Radās kļūda ar autentifikatoru",
|
||||||
|
"authenticator_does_not_support_discoverable_credentials": "Autentifikators neatbalsta atklājamus akreditācijas datus",
|
||||||
|
"authenticator_does_not_support_resident_keys": "Autentifikators neatbalsta rezidentās atslēgas",
|
||||||
|
"passkey_was_previously_registered": "Šī piekļuves atslēga jau iepriekš tika reģistrēta",
|
||||||
|
"authenticator_does_not_support_any_of_the_requested_algorithms": "Autentifikators neatbalsta nevienu no pieprasītajiem algoritmiem",
|
||||||
|
"webauthn_error_invalid_rp_id": "Norādītais relying party ID nav derīgs.",
|
||||||
|
"webauthn_error_invalid_domain": "Norādītais domēns nav derīgs.",
|
||||||
|
"contact_administrator_to_fix": "Sazinieties ar administratoru, lai novērstu šo problēmu.",
|
||||||
|
"webauthn_operation_not_allowed_or_timed_out": "Darbība nebija atļauta vai arī iestājās noildze",
|
||||||
|
"webauthn_not_supported_by_browser": "Šis pārlūks neatbalsta piekļuves atslēgas. Lūdzu, izmantojiet alternatīvu pierakstīšanās metodi.",
|
||||||
|
"critical_error_occurred_contact_administrator": "Radās kritiska kļūda. Lūdzu, sazinieties ar administratoru.",
|
||||||
|
"sign_in_to": "Pierakstīties {name}",
|
||||||
|
"client_not_found": "Klients nav atrasts",
|
||||||
|
"client_wants_to_access_the_following_information": "<b>{client}</b> vēlas piekļūt šādai informācijai:",
|
||||||
|
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Vai vēlaties pierakstīties <b>{client}</b> ar savu {appName} kontu?",
|
||||||
|
"email": "E-pasts",
|
||||||
|
"view_your_email_address": "Skatīt savu e-pasta adresi",
|
||||||
|
"profile": "Profils",
|
||||||
|
"view_your_profile_information": "Skatīt savu profila informāciju",
|
||||||
|
"groups": "Grupas",
|
||||||
|
"view_the_groups_you_are_a_member_of": "Skatīt grupas, kuru dalībnieks esat",
|
||||||
|
"cancel": "Atcelt",
|
||||||
|
"sign_in": "Pierakstīties",
|
||||||
|
"try_again": "Mēģināt vēlreiz",
|
||||||
|
"client_logo": "Klienta logotips",
|
||||||
|
"sign_out": "Izrakstīties",
|
||||||
|
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Vai vēlaties izrakstīties no {appName} ar kontu <b>{username}</b>?",
|
||||||
|
"sign_in_to_appname": "Pierakstīties {appName}",
|
||||||
|
"please_try_to_sign_in_again": "Lūdzu, mēģiniet pierakstīties vēlreiz.",
|
||||||
|
"authenticate_with_passkey_to_access_account": "Lai piekļūtu savam kontam, apstipriniet identitāti ar piekļuves atslēgu.",
|
||||||
|
"authenticate": "Autentificēties",
|
||||||
|
"please_try_again": "Lūdzu, mēģiniet vēlreiz.",
|
||||||
|
"continue": "Turpināt",
|
||||||
|
"alternative_sign_in": "Alternatīva pierakstīšanās",
|
||||||
|
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Ja jums nav piekļuves savai piekļuves atslēgai, varat pierakstīties, izmantojot kādu no šīm metodēm.",
|
||||||
|
"use_your_passkey_instead": "Tā vietā izmantot savu piekļuves atslēgu?",
|
||||||
|
"email_login": "Pierakstīšanās ar e-pastu",
|
||||||
|
"enter_a_login_code_to_sign_in": "Ievadiet pieteikšanās kodu, lai pierakstītos.",
|
||||||
|
"sign_in_with_login_code": "Pierakstīties ar pieteikšanās kodu",
|
||||||
|
"request_a_login_code_via_email": "Pieprasīt pieteikšanās kodu pa e-pastu.",
|
||||||
|
"go_back": "Atpakaļ",
|
||||||
|
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Ja norādītais e-pasts pastāv sistēmā, uz to ir nosūtīts e-pasts.",
|
||||||
|
"enter_code": "Ievadiet kodu",
|
||||||
|
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Ievadiet savu e-pasta adresi, lai saņemtu e-pastu ar pieteikšanās kodu.",
|
||||||
|
"your_email": "Jūsu e-pasts",
|
||||||
|
"submit": "Iesniegt",
|
||||||
|
"enter_the_code_you_received_to_sign_in": "Ievadiet saņemto kodu, lai pierakstītos.",
|
||||||
|
"code": "Kods",
|
||||||
|
"invalid_redirect_url": "Nederīgs novirzīšanas URL",
|
||||||
|
"audit_log": "Audita žurnāls",
|
||||||
|
"users": "Lietotāji",
|
||||||
|
"user_groups": "Lietotāju grupas",
|
||||||
|
"oidc_clients": "OIDC klienti",
|
||||||
|
"api_keys": "API atslēgas",
|
||||||
|
"application_configuration": "Lietotnes konfigurācija",
|
||||||
|
"settings": "Iestatījumi",
|
||||||
|
"update_pocket_id": "Atjaunināt Pocket ID",
|
||||||
|
"powered_by": "Darbojas ar",
|
||||||
|
"see_your_recent_account_activities": "Skatiet sava konta aktivitātes konfigurētajā saglabāšanas periodā.",
|
||||||
|
"time": "Laiks",
|
||||||
|
"event": "Notikums",
|
||||||
|
"approximate_location": "Aptuvenā atrašanās vieta",
|
||||||
|
"ip_address": "IP adrese",
|
||||||
|
"device": "Ierīce",
|
||||||
|
"client": "Klients",
|
||||||
|
"unknown": "Nezināms",
|
||||||
|
"account_details_updated_successfully": "Konta dati veiksmīgi atjaunināti",
|
||||||
|
"profile_picture_updated_successfully": "Profila attēls veiksmīgi atjaunināts. Izmaiņu parādīšanās var aizņemt dažas minūtes.",
|
||||||
|
"account_settings": "Konta iestatījumi",
|
||||||
|
"passkey_missing": "Trūkst piekļuves atslēgas",
|
||||||
|
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Lūdzu, pievienojiet piekļuves atslēgu, lai nezaudētu piekļuvi savam kontam.",
|
||||||
|
"single_passkey_configured": "Ir konfigurēta viena piekļuves atslēga",
|
||||||
|
"it_is_recommended_to_add_more_than_one_passkey": "Ieteicams pievienot vairāk nekā vienu piekļuves atslēgu, lai nezaudētu piekļuvi kontam.",
|
||||||
|
"account_details": "Konta dati",
|
||||||
|
"passkeys": "Piekļuves atslēgas",
|
||||||
|
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Pārvaldiet savas piekļuves atslēgas, kuras varat izmantot autentifikācijai.",
|
||||||
|
"add_passkey": "Pievienot piekļuves atslēgu",
|
||||||
|
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Izveidojiet vienreizēju pieteikšanās kodu, lai pierakstītos no citas ierīces bez piekļuves atslēgas.",
|
||||||
|
"create": "Izveidot",
|
||||||
|
"first_name": "Vārds",
|
||||||
|
"last_name": "Uzvārds",
|
||||||
|
"username": "Lietotājvārds",
|
||||||
|
"save": "Saglabāt",
|
||||||
|
"username_can_only_contain": "Lietotājvārds drīkst saturēt tikai mazos burtus, ciparus, pasvītras, punktus, defises un '@' simbolus",
|
||||||
|
"username_must_start_with": "Lietotājvārdam jāsākas ar burtciparu rakstzīmi",
|
||||||
|
"username_must_end_with": "Lietotājvārdam jābeidzas ar burtciparu rakstzīmi",
|
||||||
|
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Pierakstieties, izmantojot šo kodu. Koda derīguma termiņš beigsies pēc 15 minūtēm.",
|
||||||
|
"or_visit": "vai apmeklējiet",
|
||||||
|
"added_on": "Pievienots",
|
||||||
|
"rename": "Pārdēvēt",
|
||||||
|
"delete": "Dzēst",
|
||||||
|
"are_you_sure_you_want_to_delete_this_passkey": "Vai tiešām vēlaties dzēst šo piekļuves atslēgu?",
|
||||||
|
"passkey_deleted_successfully": "Piekļuves atslēga veiksmīgi dzēsta",
|
||||||
|
"delete_passkey_name": "Dzēst {passkeyName}",
|
||||||
|
"passkey_name_updated_successfully": "Piekļuves atslēgas nosaukums veiksmīgi atjaunināts",
|
||||||
|
"name_passkey": "Nosauciet piekļuves atslēgu",
|
||||||
|
"name_your_passkey_to_easily_identify_it_later": "Piešķiriet piekļuves atslēgai nosaukumu, lai vēlāk to būtu viegli atpazīt.",
|
||||||
|
"create_api_key": "Izveidot API atslēgu",
|
||||||
|
"add_a_new_api_key_for_programmatic_access": "Pievienojiet jaunu API atslēgu programmātiskai piekļuvei <link href='https://pocket-id.org/docs/api'>Pocket ID API</link>.",
|
||||||
|
"add_api_key": "Pievienot API atslēgu",
|
||||||
|
"manage_api_keys": "Pārvaldīt API atslēgas",
|
||||||
|
"api_key_created": "API atslēga izveidota",
|
||||||
|
"for_security_reasons_this_key_will_only_be_shown_once": "Drošības apsvērumu dēļ šī atslēga tiks parādīta tikai vienu reizi. Lūdzu, glabājiet to drošā vietā.",
|
||||||
|
"description": "Apraksts",
|
||||||
|
"api_key": "API atslēga",
|
||||||
|
"close": "Aizvērt",
|
||||||
|
"name_to_identify_this_api_key": "Nosaukums šīs API atslēgas atpazīšanai.",
|
||||||
|
"expires_at": "Beidzas",
|
||||||
|
"when_this_api_key_will_expire": "Kad šīs API atslēgas derīguma termiņš beigsies.",
|
||||||
|
"optional_description_to_help_identify_this_keys_purpose": "Neobligāts apraksts, lai palīdzētu saprast šīs atslēgas mērķi.",
|
||||||
|
"expiration_date_must_be_in_the_future": "Derīguma termiņa datumam jābūt nākotnē",
|
||||||
|
"revoke_api_key": "Atsaukt API atslēgu",
|
||||||
|
"never": "Nekad",
|
||||||
|
"revoke": "Atsaukt",
|
||||||
|
"api_key_revoked_successfully": "API atslēga veiksmīgi atsaukta",
|
||||||
|
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Vai tiešām vēlaties atsaukt API atslēgu \"{apiKeyName}\"? Tas pārtrauks visas integrācijas, kas izmanto šo atslēgu.",
|
||||||
|
"last_used": "Pēdējoreiz izmantota",
|
||||||
|
"actions": "Darbības",
|
||||||
|
"images_updated_successfully": "Attēli veiksmīgi atjaunināti. Izmaiņu parādīšanās var aizņemt dažas minūtes.",
|
||||||
|
"general": "Vispārīgi",
|
||||||
|
"configure_smtp_to_send_emails": "Iespējojiet e-pasta paziņojumus, lai brīdinātu lietotājus, kad tiek konstatēta pieteikšanās no jaunas ierīces vai atrašanās vietas.",
|
||||||
|
"ldap": "LDAP",
|
||||||
|
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Konfigurējiet LDAP iestatījumus, lai sinhronizētu lietotājus un grupas no LDAP servera.",
|
||||||
|
"images": "Attēli",
|
||||||
|
"update": "Atjaunināt",
|
||||||
|
"email_configuration_updated_successfully": "E-pasta konfigurācija veiksmīgi atjaunināta",
|
||||||
|
"save_changes_question": "Saglabāt izmaiņas?",
|
||||||
|
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "Pirms testa e-pasta nosūtīšanas ir jāsaglabā izmaiņas. Vai vēlaties saglabāt tagad?",
|
||||||
|
"save_and_send": "Saglabāt un nosūtīt",
|
||||||
|
"test_email_sent_successfully": "Testa e-pasts veiksmīgi nosūtīts uz jūsu e-pasta adresi.",
|
||||||
|
"failed_to_send_test_email": "Neizdevās nosūtīt testa e-pastu. Plašāku informāciju skatiet servera žurnālos.",
|
||||||
|
"smtp_configuration": "SMTP konfigurācija",
|
||||||
|
"smtp_host": "SMTP resursdators",
|
||||||
|
"smtp_port": "SMTP ports",
|
||||||
|
"smtp_user": "SMTP lietotājs",
|
||||||
|
"smtp_password": "SMTP parole",
|
||||||
|
"smtp_from": "SMTP sūtītājs",
|
||||||
|
"smtp_tls_option": "SMTP TLS opcija",
|
||||||
|
"email_tls_option": "E-pasta TLS opcija",
|
||||||
|
"skip_certificate_verification": "Izlaist sertifikāta pārbaudi",
|
||||||
|
"this_can_be_useful_for_selfsigned_certificates": "Tas var būt noderīgi pašparakstītiem sertifikātiem.",
|
||||||
|
"enabled_emails": "Iespējotie e-pasti",
|
||||||
|
"email_login_notification": "Paziņojums par pieteikšanos pa e-pastu",
|
||||||
|
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Nosūtīt e-pastu lietotājam, kad viņš piesakās no jaunas ierīces.",
|
||||||
|
"emai_login_code_requested_by_user": "Lietotāja pieprasīts e-pasta pieteikšanās kods",
|
||||||
|
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Atļauj lietotājiem apiet piekļuves atslēgas, pieprasot pieteikšanās kodu uz savu e-pastu. Tas būtiski samazina drošību, jo ikviens, kam ir piekļuve lietotāja e-pastam, var iegūt piekļuvi.",
|
||||||
|
"email_login_code_from_admin": "Administratora e-pasta pieteikšanās kods",
|
||||||
|
"allows_an_admin_to_send_a_login_code_to_the_user": "Ļauj administratoram nosūtīt lietotājam pieteikšanās kodu pa e-pastu.",
|
||||||
|
"send_test_email": "Nosūtīt testa e-pastu",
|
||||||
|
"application_configuration_updated_successfully": "Lietotnes konfigurācija veiksmīgi atjaunināta",
|
||||||
|
"application_name": "Lietotnes nosaukums",
|
||||||
|
"session_duration": "Sesijas ilgums",
|
||||||
|
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Sesijas ilgums minūtēs, pēc kura lietotājam jāpierakstās atkārtoti.",
|
||||||
|
"enable_self_account_editing": "Atļaut lietotājam rediģēt savu kontu",
|
||||||
|
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Vai lietotājiem jāspēj rediģēt sava konta datus.",
|
||||||
|
"ldap_configuration_updated_successfully": "LDAP konfigurācija veiksmīgi atjaunināta",
|
||||||
|
"ldap_disabled_successfully": "LDAP veiksmīgi atspējots",
|
||||||
|
"ldap_sync_finished": "LDAP sinhronizācija pabeigta",
|
||||||
|
"client_configuration": "Klienta konfigurācija",
|
||||||
|
"ldap_url": "LDAP URL",
|
||||||
|
"ldap_bind_dn": "LDAP Bind DN",
|
||||||
|
"ldap_bind_password": "LDAP Bind parole",
|
||||||
|
"ldap_base_dn": "LDAP Base DN",
|
||||||
|
"user_search_filter": "Lietotāju meklēšanas filtrs",
|
||||||
|
"the_search_filter_to_use_to_search_or_sync_users": "Meklēšanas filtrs, ko izmantot lietotāju meklēšanai/sinhronizēšanai.",
|
||||||
|
"groups_search_filter": "Grupu meklēšanas filtrs",
|
||||||
|
"the_search_filter_to_use_to_search_or_sync_groups": "Meklēšanas filtrs, ko izmantot grupu meklēšanai/sinhronizēšanai.",
|
||||||
|
"attribute_mapping": "Atribūtu kartēšana",
|
||||||
|
"user_unique_identifier_attribute": "Lietotāja unikālā identifikatora atribūts",
|
||||||
|
"the_value_of_this_attribute_should_never_change": "Šī atribūta vērtībai nekad nevajadzētu mainīties.",
|
||||||
|
"username_attribute": "Lietotājvārda atribūts",
|
||||||
|
"user_mail_attribute": "Lietotāja e-pasta atribūts",
|
||||||
|
"user_first_name_attribute": "Lietotāja vārda atribūts",
|
||||||
|
"user_last_name_attribute": "Lietotāja uzvārda atribūts",
|
||||||
|
"user_profile_picture_attribute": "Lietotāja profila attēla atribūts",
|
||||||
|
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "Šī atribūta vērtība var būt URL, bināri dati vai base64 kodēts attēls.",
|
||||||
|
"group_members_attribute": "Grupas dalībnieku atribūts",
|
||||||
|
"the_attribute_to_use_for_querying_members_of_a_group": "Atribūts, ko izmantot grupas dalībnieku vaicājumiem.",
|
||||||
|
"group_unique_identifier_attribute": "Grupas unikālā identifikatora atribūts",
|
||||||
|
"group_rdn_attribute": "Grupas RDN atribūts (DN ietvaros)",
|
||||||
|
"admin_group_name": "Administratoru grupas nosaukums",
|
||||||
|
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Šīs grupas dalībniekiem būs administratora tiesības Pocket ID.",
|
||||||
|
"disable": "Atspējot",
|
||||||
|
"sync_now": "Sinhronizēt tagad",
|
||||||
|
"enable": "Iespējot",
|
||||||
|
"user_created_successfully": "Lietotājs veiksmīgi izveidots",
|
||||||
|
"create_user": "Izveidot lietotāju",
|
||||||
|
"add_a_new_user_to_appname": "Pievienot jaunu lietotāju {appName}",
|
||||||
|
"add_user": "Pievienot lietotāju",
|
||||||
|
"manage_users": "Pārvaldīt lietotājus",
|
||||||
|
"admin_privileges": "Administratora tiesības",
|
||||||
|
"admins_have_full_access_to_the_admin_panel": "Administratoriem ir pilna piekļuve administrācijas panelim.",
|
||||||
|
"delete_firstname_lastname": "Dzēst {firstName} {lastName}",
|
||||||
|
"are_you_sure_you_want_to_delete_this_user": "Vai tiešām vēlaties dzēst šo lietotāju?",
|
||||||
|
"user_deleted_successfully": "Lietotājs veiksmīgi dzēsts",
|
||||||
|
"role": "Loma",
|
||||||
|
"source": "Avots",
|
||||||
|
"admin": "Administrators",
|
||||||
|
"user": "Lietotājs",
|
||||||
|
"local": "Lokāls",
|
||||||
|
"toggle_menu": "Pārslēgt izvēlni",
|
||||||
|
"edit": "Rediģēt",
|
||||||
|
"user_groups_updated_successfully": "Lietotāju grupas veiksmīgi atjauninātas",
|
||||||
|
"user_updated_successfully": "Lietotājs veiksmīgi atjaunināts",
|
||||||
|
"custom_claims_updated_successfully": "Pielāgotie claim veiksmīgi atjaunināti",
|
||||||
|
"back": "Atpakaļ",
|
||||||
|
"user_details_firstname_lastname": "Lietotāja dati {firstName} {lastName}",
|
||||||
|
"manage_which_groups_this_user_belongs_to": "Pārvaldiet, kurām grupām šis lietotājs pieder.",
|
||||||
|
"custom_claims": "Pielāgotie claim",
|
||||||
|
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Pielāgotie claim ir atslēgas-vērtības pāri, ko var izmantot papildu informācijas glabāšanai par lietotāju. Šie claim tiks iekļauti ID tokenā, ja ir pieprasīts scope 'profile'.",
|
||||||
|
"user_group_created_successfully": "Lietotāju grupa veiksmīgi izveidota",
|
||||||
|
"create_user_group": "Izveidot lietotāju grupu",
|
||||||
|
"create_a_new_group_that_can_be_assigned_to_users": "Izveidot jaunu grupu, ko var piešķirt lietotājiem.",
|
||||||
|
"add_group": "Pievienot grupu",
|
||||||
|
"manage_user_groups": "Pārvaldīt lietotāju grupas",
|
||||||
|
"friendly_name": "Draudzīgais nosaukums",
|
||||||
|
"name_that_will_be_displayed_in_the_ui": "Nosaukums, kas tiks parādīts lietotāja saskarnē",
|
||||||
|
"name_that_will_be_in_the_groups_claim": "Nosaukums, kas būs \"groups\" claim",
|
||||||
|
"delete_name": "Dzēst {name}",
|
||||||
|
"are_you_sure_you_want_to_delete_this_user_group": "Vai tiešām vēlaties dzēst šo lietotāju grupu?",
|
||||||
|
"user_group_deleted_successfully": "Lietotāju grupa veiksmīgi dzēsta",
|
||||||
|
"user_count": "Lietotāju skaits",
|
||||||
|
"user_group_updated_successfully": "Lietotāju grupa veiksmīgi atjaunināta",
|
||||||
|
"users_updated_successfully": "Lietotāji veiksmīgi atjaunināti",
|
||||||
|
"user_group_details_name": "Lietotāju grupas dati {name}",
|
||||||
|
"assign_users_to_this_group": "Piešķiriet lietotājus šai grupai.",
|
||||||
|
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Pielāgotie claim ir atslēgas-vērtības pāri, ko var izmantot papildu informācijas glabāšanai par lietotāju. Šie claim tiks iekļauti ID tokenā, ja ir pieprasīts scope 'profile'. Ja ir konflikti, prioritāte būs lietotāja līmenī definētajiem claim.",
|
||||||
|
"oidc_client_created_successfully": "OIDC klients veiksmīgi izveidots",
|
||||||
|
"create_oidc_client": "Izveidot OIDC klientu",
|
||||||
|
"add_a_new_oidc_client_to_appname": "Pievienot jaunu OIDC klientu {appName}.",
|
||||||
|
"add_oidc_client": "Pievienot OIDC klientu",
|
||||||
|
"manage_oidc_clients": "Pārvaldīt OIDC klientus",
|
||||||
|
"one_time_link": "Vienreizēja saite",
|
||||||
|
"use_this_link_to_sign_in_once": "Izmantojiet šo saiti, lai pierakstītos vienu reizi. Tas ir vajadzīgs lietotājiem, kuri vēl nav pievienojuši piekļuves atslēgu vai ir to pazaudējuši.",
|
||||||
|
"add": "Pievienot",
|
||||||
|
"callback_urls": "Callback URL",
|
||||||
|
"logout_callback_urls": "Logout callback URL",
|
||||||
|
"public_client": "Publisks klients",
|
||||||
|
"public_clients_description": "Publiskajiem klientiem nav client secret. Tie ir paredzēti mobilajām, tīmekļa un native lietotnēm, kur noslēpumus nevar droši glabāt.",
|
||||||
|
"pkce": "PKCE",
|
||||||
|
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange ir drošības funkcija, kas palīdz novērst CSRF un autorizācijas koda pārtveršanas uzbrukumus.",
|
||||||
|
"requires_reauthentication": "Nepieciešama atkārtota autentifikācija",
|
||||||
|
"requires_users_to_authenticate_again_on_each_authorization": "Pieprasa lietotājiem autentificēties atkārtoti katrā autorizācijas reizē, pat ja viņi jau ir pierakstījušies",
|
||||||
|
"name_logo": "{name} logotips",
|
||||||
|
"change_logo": "Mainīt logotipu",
|
||||||
|
"upload_logo": "Augšupielādēt logotipu",
|
||||||
|
"remove_logo": "Noņemt logotipu",
|
||||||
|
"are_you_sure_you_want_to_delete_this_oidc_client": "Vai tiešām vēlaties dzēst šo OIDC klientu?",
|
||||||
|
"oidc_client_deleted_successfully": "OIDC klients veiksmīgi dzēsts",
|
||||||
|
"authorization_url": "Autorizācijas URL",
|
||||||
|
"oidc_discovery_url": "OIDC Discovery URL",
|
||||||
|
"token_url": "Token URL",
|
||||||
|
"userinfo_url": "Userinfo URL",
|
||||||
|
"logout_url": "Logout URL",
|
||||||
|
"certificate_url": "Sertifikāta URL",
|
||||||
|
"enabled": "Iespējots",
|
||||||
|
"disabled": "Atspējots",
|
||||||
|
"oidc_client_updated_successfully": "OIDC klients veiksmīgi atjaunināts",
|
||||||
|
"create_new_client_secret": "Izveidot jaunu client secret",
|
||||||
|
"are_you_sure_you_want_to_create_a_new_client_secret": "Vai tiešām vēlaties izveidot jaunu client secret? Vecais tiks padarīts nederīgs.",
|
||||||
|
"generate": "Ģenerēt",
|
||||||
|
"new_client_secret_created_successfully": "Jaunais client secret veiksmīgi izveidots",
|
||||||
|
"oidc_client_name": "OIDC klients {name}",
|
||||||
|
"client_id": "Client ID",
|
||||||
|
"client_secret": "Client secret",
|
||||||
|
"show_more_details": "Rādīt vairāk informācijas",
|
||||||
|
"allowed_user_groups": "Atļautās lietotāju grupas",
|
||||||
|
"allowed_user_groups_description": "Izvēlieties lietotāju grupas, kuru dalībniekiem ir atļauts pierakstīties šajā klientā.",
|
||||||
|
"allowed_user_groups_status_unrestricted_description": "Lietotāju grupu ierobežojumi netiek piemēroti. Šajā klientā var pierakstīties jebkurš lietotājs.",
|
||||||
|
"unrestrict": "Noņemt ierobežojumus",
|
||||||
|
"restrict": "Ierobežot",
|
||||||
|
"user_groups_restriction_updated_successfully": "Lietotāju grupu ierobežojumi veiksmīgi atjaunināti",
|
||||||
|
"allowed_user_groups_updated_successfully": "Atļautās lietotāju grupas veiksmīgi atjauninātas",
|
||||||
|
"favicon": "Favicon",
|
||||||
|
"light_mode_logo": "Gaišā režīma logotips",
|
||||||
|
"dark_mode_logo": "Tumšā režīma logotips",
|
||||||
|
"email_logo": "E-pasta logotips",
|
||||||
|
"background_image": "Fona attēls",
|
||||||
|
"language": "Valoda",
|
||||||
|
"reset_profile_picture_question": "Atiestatīt profila attēlu?",
|
||||||
|
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "Tas noņems augšupielādēto attēlu un atiestatīs profila attēlu uz noklusējuma. Vai vēlaties turpināt?",
|
||||||
|
"reset": "Atiestatīt",
|
||||||
|
"reset_to_default": "Atiestatīt uz noklusējumu",
|
||||||
|
"profile_picture_has_been_reset": "Profila attēls ir atiestatīts. Izmaiņu parādīšanās var aizņemt dažas minūtes.",
|
||||||
|
"select_the_language_you_want_to_use": "Izvēlieties valodu, kuru vēlaties izmantot. Lūdzu, ņemiet vērā, ka daļa teksta var būt automātiski tulkota un neprecīza.",
|
||||||
|
"contribute_to_translation": "Ja pamanāt problēmu, varat palīdzēt uzlabot tulkojumu vietnē <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
|
||||||
|
"personal": "Personīgs",
|
||||||
|
"global": "Globāls",
|
||||||
|
"all_users": "Visi lietotāji",
|
||||||
|
"all_events": "Visi notikumi",
|
||||||
|
"all_clients": "Visi klienti",
|
||||||
|
"all_locations": "Visas atrašanās vietas",
|
||||||
|
"global_audit_log": "Globālais audita žurnāls",
|
||||||
|
"see_all_recent_account_activities": "Skatiet visu lietotāju kontu aktivitātes iestatītajā saglabāšanas periodā.",
|
||||||
|
"token_sign_in": "Pierakstīšanās ar tokenu",
|
||||||
|
"client_authorization": "Klienta autorizācija",
|
||||||
|
"new_client_authorization": "Jauna klienta autorizācija",
|
||||||
|
"device_code_authorization": "Ierīces koda autorizācija",
|
||||||
|
"new_device_code_authorization": "Jauna ierīces koda autorizācija",
|
||||||
|
"passkey_added": "Piekļuves atslēga pievienota",
|
||||||
|
"passkey_removed": "Piekļuves atslēga noņemta",
|
||||||
|
"disable_animations": "Atspējot animācijas",
|
||||||
|
"turn_off_ui_animations": "Izslēgt animācijas visā lietotāja saskarnē.",
|
||||||
|
"user_disabled": "Konts atspējots",
|
||||||
|
"disabled_users_cannot_log_in_or_use_services": "Atspējoti lietotāji nevar pierakstīties vai izmantot pakalpojumus.",
|
||||||
|
"user_disabled_successfully": "Lietotājs veiksmīgi atspējots.",
|
||||||
|
"user_enabled_successfully": "Lietotājs veiksmīgi iespējots.",
|
||||||
|
"status": "Statuss",
|
||||||
|
"disable_firstname_lastname": "Atspējot {firstName} {lastName}",
|
||||||
|
"are_you_sure_you_want_to_disable_this_user": "Vai tiešām vēlaties atspējot šo lietotāju? Viņš nevarēs pierakstīties vai piekļūt nevienam pakalpojumam.",
|
||||||
|
"ldap_soft_delete_users": "Saglabāt no LDAP atspējotus lietotājus.",
|
||||||
|
"ldap_soft_delete_users_description": "Ja iespējots, lietotāji, kas noņemti no LDAP, tiks atspējoti, nevis dzēsti no sistēmas.",
|
||||||
|
"login_code_email_success": "Pieteikšanās kods ir nosūtīts lietotājam.",
|
||||||
|
"send_email": "Sūtīt e-pastu",
|
||||||
|
"show_code": "Rādīt kodu",
|
||||||
|
"callback_url_description": "Jūsu klienta norādītais URL(s). Ja atstāsit tukšu, tas tiks pievienots automātiski. Tiek atbalstīti arī <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>wildcard</link> URL.",
|
||||||
|
"logout_callback_url_description": "Jūsu klienta logout URL(s). Tiek atbalstīti arī <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>wildcard</link> URL.",
|
||||||
|
"api_key_expiration": "API atslēgas derīguma termiņš",
|
||||||
|
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Nosūtīt e-pastu lietotājam, kad viņa API atslēgai drīz beigsies derīguma termiņš.",
|
||||||
|
"authorize_device": "Autorizēt ierīci",
|
||||||
|
"the_device_has_been_authorized": "Ierīce ir autorizēta.",
|
||||||
|
"enter_code_displayed_in_previous_step": "Ievadiet iepriekšējā solī parādīto kodu.",
|
||||||
|
"authorize": "Autorizēt",
|
||||||
|
"federated_client_credentials": "Federētie klienta akreditācijas dati",
|
||||||
|
"federated_client_credentials_description": "Federētie klienta akreditācijas dati ļauj autentificēt OIDC klientus, nepārvaldot ilgtermiņa noslēpumus. Tie izmanto trešo pušu autoritāšu izsniegtus JWT tokenus client assertion vajadzībām, piemēram, workload identity tokenus.",
|
||||||
|
"add_federated_client_credential": "Pievienot federētos klienta akreditācijas datus",
|
||||||
|
"add_another_federated_client_credential": "Pievienot vēl vienus federētos klienta akreditācijas datus",
|
||||||
|
"oidc_allowed_group_count": "Atļauto grupu skaits",
|
||||||
|
"unrestricted": "Neierobežots",
|
||||||
|
"show_advanced_options": "Rādīt papildu opcijas",
|
||||||
|
"hide_advanced_options": "Paslēpt papildu opcijas",
|
||||||
|
"oidc_data_preview": "OIDC datu priekšskatījums",
|
||||||
|
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Priekšskatīt OIDC datus, kas tiktu nosūtīti dažādiem lietotājiem",
|
||||||
|
"id_token": "ID tokens",
|
||||||
|
"access_token": "Piekļuves tokens",
|
||||||
|
"userinfo": "Userinfo",
|
||||||
|
"id_token_payload": "ID tokena saturs",
|
||||||
|
"access_token_payload": "Piekļuves tokena saturs",
|
||||||
|
"userinfo_endpoint_response": "Userinfo galapunkta atbilde",
|
||||||
|
"copy": "Kopēt",
|
||||||
|
"no_preview_data_available": "Nav pieejamu priekšskatījuma datu",
|
||||||
|
"copy_all": "Kopēt visu",
|
||||||
|
"preview": "Priekšskatījums",
|
||||||
|
"preview_for_user": "Priekšskatījums lietotājam {name}",
|
||||||
|
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Priekšskatīt OIDC datus, kas tiktu nosūtīti šim lietotājam",
|
||||||
|
"show": "Rādīt",
|
||||||
|
"select_an_option": "Izvēlieties opciju",
|
||||||
|
"select_user": "Izvēlieties lietotāju",
|
||||||
|
"error": "Kļūda",
|
||||||
|
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Izvēlieties akcenta krāsu, lai pielāgotu Pocket ID izskatu.",
|
||||||
|
"accent_color": "Akcenta krāsa",
|
||||||
|
"custom_accent_color": "Pielāgota akcenta krāsa",
|
||||||
|
"custom_accent_color_description": "Ievadiet pielāgotu krāsu derīgos CSS krāsu formātos (piemēram, hex, rgb, hsl).",
|
||||||
|
"color_value": "Krāsas vērtība",
|
||||||
|
"apply": "Pielietot",
|
||||||
|
"signup_token": "Reģistrācijas tokens",
|
||||||
|
"create_a_signup_token_to_allow_new_user_registration": "Izveidojiet reģistrācijas tokenu, lai atļautu jaunu lietotāju reģistrāciju.",
|
||||||
|
"usage_limit": "Lietošanas limits",
|
||||||
|
"number_of_times_token_can_be_used": "Cik reižu tokenu var izmantot.",
|
||||||
|
"expires": "Beidzas",
|
||||||
|
"signup": "Reģistrēties",
|
||||||
|
"user_creation": "Lietotāju izveide",
|
||||||
|
"configure_user_creation": "Pārvaldiet lietotāju izveides iestatījumus, tostarp reģistrācijas metodes un noklusējuma tiesības jaunajiem lietotājiem.",
|
||||||
|
"user_creation_groups_description": "Automātiski piešķirt šīs grupas jauniem lietotājiem pēc reģistrācijas.",
|
||||||
|
"user_creation_claims_description": "Automātiski piešķirt šos pielāgotos claim jaunajiem lietotājiem pēc reģistrācijas.",
|
||||||
|
"user_creation_updated_successfully": "Lietotāju izveides iestatījumi veiksmīgi atjaunināti.",
|
||||||
|
"signup_disabled_description": "Lietotāju reģistrācija ir pilnībā atspējota. Jaunus kontus var izveidot tikai administratori.",
|
||||||
|
"signup_requires_valid_token": "Konta izveidei ir nepieciešams derīgs reģistrācijas tokens",
|
||||||
|
"validating_signup_token": "Notiek reģistrācijas tokena pārbaude",
|
||||||
|
"go_to_login": "Doties uz pieteikšanos",
|
||||||
|
"signup_to_appname": "Reģistrēties {appName}",
|
||||||
|
"create_your_account_to_get_started": "Izveidojiet savu kontu, lai sāktu.",
|
||||||
|
"initial_account_creation_description": "Lūdzu, izveidojiet savu kontu, lai sāktu. Piekļuves atslēgu varēsiet iestatīt vēlāk.",
|
||||||
|
"setup_your_passkey": "Iestatiet savu piekļuves atslēgu",
|
||||||
|
"create_a_passkey_to_securely_access_your_account": "Izveidojiet piekļuves atslēgu, lai droši piekļūtu savam kontam. Tas būs galvenais veids, kā pierakstīties.",
|
||||||
|
"skip_for_now": "Pagaidām izlaist",
|
||||||
|
"account_created": "Konts izveidots",
|
||||||
|
"enable_user_signups": "Iespējot lietotāju reģistrāciju",
|
||||||
|
"enable_user_signups_description": "Nosakiet, kā lietotāji var reģistrēt jaunus kontus Pocket ID.",
|
||||||
|
"user_signups_are_disabled": "Lietotāju reģistrācija šobrīd ir atspējota",
|
||||||
|
"create_signup_token": "Izveidot reģistrācijas tokenu",
|
||||||
|
"view_active_signup_tokens": "Skatīt aktīvos reģistrācijas tokenus",
|
||||||
|
"manage_signup_tokens": "Pārvaldīt reģistrācijas tokenus",
|
||||||
|
"view_and_manage_active_signup_tokens": "Skatīt un pārvaldīt aktīvos reģistrācijas tokenus.",
|
||||||
|
"signup_token_deleted_successfully": "Reģistrācijas tokens veiksmīgi dzēsts.",
|
||||||
|
"expired": "Beidzies",
|
||||||
|
"used_up": "Izlietots",
|
||||||
|
"active": "Aktīvs",
|
||||||
|
"usage": "Lietojums",
|
||||||
|
"created": "Izveidots",
|
||||||
|
"token": "Tokens",
|
||||||
|
"loading": "Ielādē",
|
||||||
|
"delete_signup_token": "Dzēst reģistrācijas tokenu",
|
||||||
|
"are_you_sure_you_want_to_delete_this_signup_token": "Vai tiešām vēlaties dzēst šo reģistrācijas tokenu? Šo darbību nevar atsaukt.",
|
||||||
|
"signup_with_token": "Reģistrēties ar tokenu",
|
||||||
|
"signup_with_token_description": "Lietotāji var reģistrēties tikai ar derīgu reģistrācijas tokenu, ko izveidojis administrators.",
|
||||||
|
"signup_open": "Atvērta reģistrācija",
|
||||||
|
"signup_open_description": "Jebkurš var izveidot jaunu kontu bez ierobežojumiem.",
|
||||||
|
"of": "no",
|
||||||
|
"skip_passkey_setup": "Izlaist piekļuves atslēgas iestatīšanu",
|
||||||
|
"skip_passkey_setup_description": "Ir ļoti ieteicams iestatīt piekļuves atslēgu, jo bez tās pēc sesijas beigām jūs zaudēsiet piekļuvi savam kontam.",
|
||||||
|
"my_apps": "Manas lietotnes",
|
||||||
|
"no_apps_available": "Nav pieejamu lietotņu",
|
||||||
|
"contact_your_administrator_for_app_access": "Sazinieties ar administratoru, lai saņemtu piekļuvi lietotnēm.",
|
||||||
|
"launch": "Atvērt",
|
||||||
|
"client_launch_url": "Klienta palaišanas URL",
|
||||||
|
"client_launch_url_description": "URL, kas tiks atvērts, kad lietotājs palaiž lietotni no lapas Manas lietotnes.",
|
||||||
|
"client_name_description": "Klienta nosaukums, kas tiek rādīts Pocket ID saskarnē.",
|
||||||
|
"revoke_access": "Atsaukt piekļuvi",
|
||||||
|
"revoke_access_description": "Atsaukt piekļuvi <b>{clientName}</b>. <b>{clientName}</b> vairs nevarēs piekļūt jūsu konta informācijai.",
|
||||||
|
"revoke_access_successful": "Piekļuve {clientName} veiksmīgi atsaukta.",
|
||||||
|
"last_signed_in_ago": "Pēdējoreiz pierakstījās pirms {time}",
|
||||||
|
"invalid_client_id": "Client ID drīkst saturēt tikai burtus, ciparus, pasvītras un defises",
|
||||||
|
"custom_client_id_description": "Iestatiet pielāgotu client ID, ja tas ir nepieciešams jūsu lietotnei. Pretējā gadījumā atstājiet tukšu, lai ģenerētu nejaušu ID.",
|
||||||
|
"generated": "Ģenerēts",
|
||||||
|
"administration": "Administrēšana",
|
||||||
|
"group_rdn_attribute_description": "Atribūts, ko izmanto grupas distinguished name (DN).",
|
||||||
|
"display_name_attribute": "Parādāmā nosaukuma atribūts",
|
||||||
|
"display_name": "Parādāmais nosaukums",
|
||||||
|
"configure_application_images": "Konfigurēt lietotnes attēlus",
|
||||||
|
"ui_config_disabled_info_title": "UI konfigurācija ir atspējota",
|
||||||
|
"ui_config_disabled_info_description": "UI konfigurācija ir atspējota, jo lietotnes konfigurācijas iestatījumi tiek pārvaldīti ar vides mainīgajiem. Dažus iestatījumus var nebūt iespējams rediģēt.",
|
||||||
|
"logo_from_url_description": "Ielīmējiet tiešu attēla URL (svg, png, webp). Ikonas var atrast vietnēs <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> vai <link href=\"https://dashboardicons.com\">Dashboard Icons</link>.",
|
||||||
|
"invalid_url": "Nederīgs URL",
|
||||||
|
"require_user_email": "Pieprasīt e-pasta adresi",
|
||||||
|
"require_user_email_description": "Pieprasa lietotājiem e-pasta adresi. Ja tas ir atspējots, lietotāji bez e-pasta adreses nevarēs izmantot funkcijas, kurām tā ir nepieciešama.",
|
||||||
|
"view": "Skatīt",
|
||||||
|
"toggle_columns": "Pārslēgt kolonnas",
|
||||||
|
"locale": "Lokalizācija",
|
||||||
|
"ldap_id": "LDAP ID",
|
||||||
|
"reauthentication": "Atkārtota autentifikācija",
|
||||||
|
"clear_filters": "Notīrīt filtrus",
|
||||||
|
"default_profile_picture": "Noklusējuma profila attēls",
|
||||||
|
"light": "Gaišs",
|
||||||
|
"dark": "Tumšs",
|
||||||
|
"system": "Sistēma",
|
||||||
|
"signup_token_user_groups_description": "Automātiski piešķirt šīs grupas lietotājiem, kuri reģistrējas ar šo tokenu.",
|
||||||
|
"allowed_oidc_clients": "Atļautie OIDC klienti",
|
||||||
|
"allowed_oidc_clients_description": "Izvēlieties OIDC klientus, kuros šīs lietotāju grupas dalībniekiem ir atļauts pierakstīties.",
|
||||||
|
"unrestrict_oidc_client": "Noņemt ierobežojumus klientam {clientName}",
|
||||||
|
"confirm_unrestrict_oidc_client_description": "Vai tiešām vēlaties noņemt ierobežojumus OIDC klientam <b>{clientName}</b>? Tas noņems visas grupu piesaistes šim klientam, un jebkurš lietotājs varēs tajā pierakstīties.",
|
||||||
|
"allowed_oidc_clients_updated_successfully": "Atļautie OIDC klienti veiksmīgi atjaunināti",
|
||||||
|
"yes": "Jā",
|
||||||
|
"no": "Nē",
|
||||||
|
"restricted": "Ierobežots",
|
||||||
|
"scim_provisioning": "SCIM provisioning",
|
||||||
|
"scim_provisioning_description": "SCIM provisioning ļauj automātiski provisionēt un deprovisionēt lietotājus un grupas no jūsu OIDC klienta. Uzziniet vairāk <link href='https://pocket-id.org/docs/configuration/scim'>dokumentācijā</link>.",
|
||||||
|
"scim_endpoint": "SCIM galapunkts",
|
||||||
|
"scim_token": "SCIM tokens",
|
||||||
|
"last_successful_sync_at": "Pēdējā veiksmīgā sinhronizācija: {time}",
|
||||||
|
"scim_configuration_updated_successfully": "SCIM konfigurācija veiksmīgi atjaunināta.",
|
||||||
|
"scim_enabled_successfully": "SCIM veiksmīgi iespējots.",
|
||||||
|
"scim_disabled_successfully": "SCIM veiksmīgi atspējots.",
|
||||||
|
"disable_scim_provisioning": "Atspējot SCIM provisioning",
|
||||||
|
"disable_scim_provisioning_confirm_description": "Vai tiešām vēlaties atspējot SCIM provisioning klientam <b>{clientName}</b>? Tas apturēs visu automātisko lietotāju un grupu provisionēšanu un deprovisionēšanu.",
|
||||||
|
"scim_sync_failed": "SCIM sinhronizācija neizdevās. Plašāku informāciju skatiet servera žurnālos.",
|
||||||
|
"scim_sync_successful": "SCIM sinhronizācija veiksmīgi pabeigta.",
|
||||||
|
"save_and_sync": "Saglabāt un sinhronizēt",
|
||||||
|
"scim_save_changes_description": "Pirms SCIM sinhronizācijas sākšanas ir jāsaglabā izmaiņas. Vai vēlaties saglabāt tagad?",
|
||||||
|
"scopes": "Scope",
|
||||||
|
"issuer_url": "Issuer URL",
|
||||||
|
"smtp_field_required_when_other_provided": "Obligāts, ja ir norādīts jebkurš cits SMTP iestatījums",
|
||||||
|
"smtp_field_required_when_email_enabled": "Obligāts, ja ir iespējoti e-pasta paziņojumi",
|
||||||
|
"renew": "Atjaunot",
|
||||||
|
"renew_api_key": "Atjaunot API atslēgu",
|
||||||
|
"renew_api_key_description": "Atjaunojot API atslēgu, tiks ģenerēta jauna atslēga. Neaizmirstiet atjaunināt visas integrācijas, kas izmanto šo atslēgu.",
|
||||||
|
"api_key_renewed": "API atslēga atjaunota",
|
||||||
|
"app_config_home_page": "Sākumlapa",
|
||||||
|
"app_config_home_page_description": "Lapa, uz kuru lietotāji tiek novirzīti pēc pierakstīšanās.",
|
||||||
|
"email_verification_warning": "Apstipriniet savu e-pasta adresi",
|
||||||
|
"email_verification_warning_description": "Jūsu e-pasta adrese vēl nav apstiprināta. Lūdzu, apstipriniet to pēc iespējas ātrāk.",
|
||||||
|
"email_verification": "E-pasta apstiprināšana",
|
||||||
|
"email_verification_description": "Nosūtīt lietotājiem apstiprinājuma e-pastu, kad viņi reģistrējas vai maina savu e-pasta adresi.",
|
||||||
|
"email_verification_success_title": "E-pasta adrese veiksmīgi apstiprināta",
|
||||||
|
"email_verification_success_description": "Jūsu e-pasta adrese ir veiksmīgi apstiprināta.",
|
||||||
|
"email_verification_error_title": "E-pasta apstiprināšana neizdevās",
|
||||||
|
"mark_as_unverified": "Atzīmēt kā neapstiprinātu",
|
||||||
|
"mark_as_verified": "Atzīmēt kā apstiprinātu",
|
||||||
|
"email_verification_sent": "Apstiprinājuma e-pasts veiksmīgi nosūtīts.",
|
||||||
|
"emails_verified_by_default": "E-pasti pēc noklusējuma ir apstiprināti",
|
||||||
|
"emails_verified_by_default_description": "Ja iespējots, lietotāju e-pasta adreses pēc noklusējuma tiks atzīmētas kā apstiprinātas reģistrācijas laikā vai pēc e-pasta adreses maiņas."
|
||||||
|
}
|
||||||
@@ -365,7 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Voer de code in die in de vorige stap werd getoond.",
|
"enter_code_displayed_in_previous_step": "Voer de code in die in de vorige stap werd getoond.",
|
||||||
"authorize": "Autoriseren",
|
"authorize": "Autoriseren",
|
||||||
"federated_client_credentials": "Federatieve clientreferenties",
|
"federated_client_credentials": "Federatieve clientreferenties",
|
||||||
"federated_client_credentials_description": "Met federatieve clientreferenties kun je OIDC-clients verifiëren met JWT-tokens die zijn uitgegeven door andere instanties.",
|
"federated_client_credentials_description": "Met federatieve klantgegevens kun je OIDC-klanten verifiëren zonder dat je langdurige geheimen hoeft te beheren. Ze gebruiken JWT-tokens die door externe instanties zijn uitgegeven voor klantverklaringen, zoals tokens voor werkbelastingidentiteit.",
|
||||||
"add_federated_client_credential": "Federatieve clientreferenties toevoegen",
|
"add_federated_client_credential": "Federatieve clientreferenties toevoegen",
|
||||||
"add_another_federated_client_credential": "Voeg nog een federatieve clientreferentie toe",
|
"add_another_federated_client_credential": "Voeg nog een federatieve clientreferentie toe",
|
||||||
"oidc_allowed_group_count": "Aantal groepen met toegang",
|
"oidc_allowed_group_count": "Aantal groepen met toegang",
|
||||||
|
|||||||
@@ -102,7 +102,7 @@
|
|||||||
"see_your_recent_account_activities": "See your account activities within the configured retention period.",
|
"see_your_recent_account_activities": "See your account activities within the configured retention period.",
|
||||||
"time": "Time",
|
"time": "Time",
|
||||||
"event": "Event",
|
"event": "Event",
|
||||||
"approximate_location": "Approximate Location",
|
"approximate_location": "Omtrentlig plassering",
|
||||||
"ip_address": "IP adresse",
|
"ip_address": "IP adresse",
|
||||||
"device": "Enhet",
|
"device": "Enhet",
|
||||||
"client": "Klient",
|
"client": "Klient",
|
||||||
@@ -365,7 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
|
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
|
||||||
"authorize": "Authorize",
|
"authorize": "Authorize",
|
||||||
"federated_client_credentials": "Federated Client Credentials",
|
"federated_client_credentials": "Federated Client Credentials",
|
||||||
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
|
"federated_client_credentials_description": "Federated client credentials allow authenticating OIDC clients without managing long-lived secrets. They leverage JWT tokens issued by third-party authorities for client assertions, e.g. workload identity tokens.",
|
||||||
"add_federated_client_credential": "Add Federated Client Credential",
|
"add_federated_client_credential": "Add Federated Client Credential",
|
||||||
"add_another_federated_client_credential": "Add another federated client credential",
|
"add_another_federated_client_credential": "Add another federated client credential",
|
||||||
"oidc_allowed_group_count": "Allowed Group Count",
|
"oidc_allowed_group_count": "Allowed Group Count",
|
||||||
|
|||||||
@@ -365,7 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Wprowadź kod wyświetlony w poprzednim kroku.",
|
"enter_code_displayed_in_previous_step": "Wprowadź kod wyświetlony w poprzednim kroku.",
|
||||||
"authorize": "Autoryzuj",
|
"authorize": "Autoryzuj",
|
||||||
"federated_client_credentials": "Połączone poświadczenia klienta",
|
"federated_client_credentials": "Połączone poświadczenia klienta",
|
||||||
"federated_client_credentials_description": "Korzystając z połączonych poświadczeń klienta, możecie uwierzytelnić klientów OIDC za pomocą tokenów JWT wydanych przez zewnętrzne organy.",
|
"federated_client_credentials_description": "Połączone poświadczenia klienta umożliwiają uwierzytelnianie klientów OIDC bez konieczności zarządzania długotrwałymi sekretami. Wykorzystują one tokeny JWT wydane przez zewnętrzne organy do potwierdzania tożsamości klientów, np. tokeny tożsamości obciążenia.",
|
||||||
"add_federated_client_credential": "Dodaj poświadczenia klienta federacyjnego",
|
"add_federated_client_credential": "Dodaj poświadczenia klienta federacyjnego",
|
||||||
"add_another_federated_client_credential": "Dodaj kolejne poświadczenia klienta federacyjnego",
|
"add_another_federated_client_credential": "Dodaj kolejne poświadczenia klienta federacyjnego",
|
||||||
"oidc_allowed_group_count": "Dopuszczalna liczba grup",
|
"oidc_allowed_group_count": "Dopuszczalna liczba grup",
|
||||||
|
|||||||
@@ -365,7 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Digite o código que apareceu na etapa anterior.",
|
"enter_code_displayed_in_previous_step": "Digite o código que apareceu na etapa anterior.",
|
||||||
"authorize": "Autorizar",
|
"authorize": "Autorizar",
|
||||||
"federated_client_credentials": "Credenciais de Cliente Federadas",
|
"federated_client_credentials": "Credenciais de Cliente Federadas",
|
||||||
"federated_client_credentials_description": "Ao utilizar credenciais de cliente federadas, é possível autenticar clientes OIDC usando tokens JWT emitidos por autoridades de terceiros.",
|
"federated_client_credentials_description": "As credenciais federadas do cliente permitem autenticar clientes OIDC sem precisar gerenciar segredos de longa duração. Elas usam tokens JWT emitidos por autoridades terceirizadas para afirmações do cliente, tipo tokens de identidade de carga de trabalho.",
|
||||||
"add_federated_client_credential": "Adicionar credencial de cliente federado",
|
"add_federated_client_credential": "Adicionar credencial de cliente federado",
|
||||||
"add_another_federated_client_credential": "Adicionar outra credencial de cliente federado",
|
"add_another_federated_client_credential": "Adicionar outra credencial de cliente federado",
|
||||||
"oidc_allowed_group_count": "Total de grupos permitidos",
|
"oidc_allowed_group_count": "Total de grupos permitidos",
|
||||||
|
|||||||
525
frontend/messages/pt.json
Normal file
525
frontend/messages/pt.json
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
|
"my_account": "My Account",
|
||||||
|
"logout": "Logout",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"docs": "Docs",
|
||||||
|
"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, JPEG or WEBP format.",
|
||||||
|
"items_per_page": "Items per page",
|
||||||
|
"no_items_found": "No items found",
|
||||||
|
"select_items": "Select items...",
|
||||||
|
"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",
|
||||||
|
"alternative_sign_in_methods": "Alternative Sign In Methods",
|
||||||
|
"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",
|
||||||
|
"webauthn_error_invalid_rp_id": "The configured relying party ID is invalid.",
|
||||||
|
"webauthn_error_invalid_domain": "The configured domain is invalid.",
|
||||||
|
"contact_administrator_to_fix": "Contact your administrator to fix this issue.",
|
||||||
|
"webauthn_operation_not_allowed_or_timed_out": "The operation was not allowed or timed out",
|
||||||
|
"webauthn_not_supported_by_browser": "Passkeys are not supported by this browser. Please use an alternative sign in method.",
|
||||||
|
"critical_error_occurred_contact_administrator": "A critical error occurred. Please contact your administrator.",
|
||||||
|
"sign_in_to": "Sign in to {name}",
|
||||||
|
"client_not_found": "Client not found",
|
||||||
|
"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 {appName} with the account <b>{username}</b>?",
|
||||||
|
"sign_in_to_appname": "Sign in to {appName}",
|
||||||
|
"please_try_to_sign_in_again": "Please try to sign in again.",
|
||||||
|
"authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
|
||||||
|
"authenticate": "Authenticate",
|
||||||
|
"please_try_again": "Please try again.",
|
||||||
|
"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.",
|
||||||
|
"sign_in_with_login_code": "Sign in with login code",
|
||||||
|
"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_recent_account_activities": "See your account activities within the configured retention period.",
|
||||||
|
"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",
|
||||||
|
"username_must_start_with": "Username must start with an alphanumeric character",
|
||||||
|
"username_must_end_with": "Username must end with an alphanumeric character",
|
||||||
|
"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 to the <link href='https://pocket-id.org/docs/api'>Pocket ID API</link>.",
|
||||||
|
"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.",
|
||||||
|
"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. It may take a few minutes to update.",
|
||||||
|
"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 significantly reduces security as anyone with access to the user's email can gain entry.",
|
||||||
|
"email_login_code_from_admin": "Email Login Code from Admin",
|
||||||
|
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
|
||||||
|
"send_test_email": "Send test email",
|
||||||
|
"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.",
|
||||||
|
"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_rdn_attribute": "Group RDN Attribute (in DN)",
|
||||||
|
"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. They are designed for mobile, web, and native applications where secrets cannot be securely stored.",
|
||||||
|
"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.",
|
||||||
|
"requires_reauthentication": "Requires Re-Authentication",
|
||||||
|
"requires_users_to_authenticate_again_on_each_authorization": "Requires users to authenticate again on each authorization, even if already signed in",
|
||||||
|
"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",
|
||||||
|
"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",
|
||||||
|
"allowed_user_groups_description": "Select the user groups whose members are allowed to sign in to this client.",
|
||||||
|
"allowed_user_groups_status_unrestricted_description": "No user group restrictions are applied. Any user can sign in to this client.",
|
||||||
|
"unrestrict": "Unrestrict",
|
||||||
|
"restrict": "Restrict",
|
||||||
|
"user_groups_restriction_updated_successfully": "User groups restriction updated successfully",
|
||||||
|
"allowed_user_groups_updated_successfully": "Allowed user groups updated successfully",
|
||||||
|
"favicon": "Favicon",
|
||||||
|
"light_mode_logo": "Light Mode Logo",
|
||||||
|
"dark_mode_logo": "Dark Mode Logo",
|
||||||
|
"email_logo": "Email 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. Please note that some text may be automatically translated and could be inaccurate.",
|
||||||
|
"contribute_to_translation": "If you find an issue you're welcome to contribute to the translation on <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
|
||||||
|
"personal": "Personal",
|
||||||
|
"global": "Global",
|
||||||
|
"all_users": "All Users",
|
||||||
|
"all_events": "All Events",
|
||||||
|
"all_clients": "All Clients",
|
||||||
|
"all_locations": "All Locations",
|
||||||
|
"global_audit_log": "Global Audit Log",
|
||||||
|
"see_all_recent_account_activities": "View the account activities of all users during the set retention period.",
|
||||||
|
"token_sign_in": "Token Sign In",
|
||||||
|
"client_authorization": "Client Authorization",
|
||||||
|
"new_client_authorization": "New Client Authorization",
|
||||||
|
"device_code_authorization": "Device Code Authorization",
|
||||||
|
"new_device_code_authorization": "New Device Code Authorization",
|
||||||
|
"passkey_added": "Passkey Added",
|
||||||
|
"passkey_removed": "Passkey Removed",
|
||||||
|
"disable_animations": "Disable Animations",
|
||||||
|
"turn_off_ui_animations": "Turn off animations throughout the UI.",
|
||||||
|
"user_disabled": "Account Disabled",
|
||||||
|
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
|
||||||
|
"user_disabled_successfully": "User has been disabled successfully.",
|
||||||
|
"user_enabled_successfully": "User has been enabled successfully.",
|
||||||
|
"status": "Status",
|
||||||
|
"disable_firstname_lastname": "Disable {firstName} {lastName}",
|
||||||
|
"are_you_sure_you_want_to_disable_this_user": "Are you sure you want to disable this user? They will not be able to log in or access any services.",
|
||||||
|
"ldap_soft_delete_users": "Keep disabled users from LDAP.",
|
||||||
|
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.",
|
||||||
|
"login_code_email_success": "The login code has been sent to the user.",
|
||||||
|
"send_email": "Send Email",
|
||||||
|
"show_code": "Show Code",
|
||||||
|
"callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Wildcards</link> are supported.",
|
||||||
|
"logout_callback_url_description": "URL(s) provided by your client for logout. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Wildcards</link> are supported.",
|
||||||
|
"api_key_expiration": "API Key Expiration",
|
||||||
|
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
|
||||||
|
"authorize_device": "Authorize Device",
|
||||||
|
"the_device_has_been_authorized": "The device has been authorized.",
|
||||||
|
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
|
||||||
|
"authorize": "Authorize",
|
||||||
|
"federated_client_credentials": "Federated Client Credentials",
|
||||||
|
"federated_client_credentials_description": "Federated client credentials allow authenticating OIDC clients without managing long-lived secrets. They leverage JWT tokens issued by third-party authorities for client assertions, e.g. workload identity tokens.",
|
||||||
|
"add_federated_client_credential": "Add Federated Client Credential",
|
||||||
|
"add_another_federated_client_credential": "Add another federated client credential",
|
||||||
|
"oidc_allowed_group_count": "Allowed Group Count",
|
||||||
|
"unrestricted": "Unrestricted",
|
||||||
|
"show_advanced_options": "Show Advanced Options",
|
||||||
|
"hide_advanced_options": "Hide Advanced Options",
|
||||||
|
"oidc_data_preview": "OIDC Data Preview",
|
||||||
|
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
|
||||||
|
"id_token": "ID Token",
|
||||||
|
"access_token": "Access Token",
|
||||||
|
"userinfo": "Userinfo",
|
||||||
|
"id_token_payload": "ID Token Payload",
|
||||||
|
"access_token_payload": "Access Token Payload",
|
||||||
|
"userinfo_endpoint_response": "Userinfo Endpoint Response",
|
||||||
|
"copy": "Copy",
|
||||||
|
"no_preview_data_available": "No preview data available",
|
||||||
|
"copy_all": "Copy All",
|
||||||
|
"preview": "Preview",
|
||||||
|
"preview_for_user": "Preview for {name}",
|
||||||
|
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
|
||||||
|
"show": "Show",
|
||||||
|
"select_an_option": "Select an option",
|
||||||
|
"select_user": "Select User",
|
||||||
|
"error": "Error",
|
||||||
|
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Select an accent color to customize the appearance of Pocket ID.",
|
||||||
|
"accent_color": "Accent Color",
|
||||||
|
"custom_accent_color": "Custom Accent Color",
|
||||||
|
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
|
||||||
|
"color_value": "Color Value",
|
||||||
|
"apply": "Apply",
|
||||||
|
"signup_token": "Signup Token",
|
||||||
|
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
|
||||||
|
"usage_limit": "Usage Limit",
|
||||||
|
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
|
||||||
|
"expires": "Expires",
|
||||||
|
"signup": "Sign Up",
|
||||||
|
"user_creation": "User Creation",
|
||||||
|
"configure_user_creation": "Manage user creation settings, including signup methods and default permissions for new users.",
|
||||||
|
"user_creation_groups_description": "Assign these groups automatically to new users upon signup.",
|
||||||
|
"user_creation_claims_description": "Assign these custom claims automatically to new users upon signup.",
|
||||||
|
"user_creation_updated_successfully": "User creation settings updated successfully.",
|
||||||
|
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
|
||||||
|
"signup_requires_valid_token": "A valid signup token is required to create an account",
|
||||||
|
"validating_signup_token": "Validating signup token",
|
||||||
|
"go_to_login": "Go to login",
|
||||||
|
"signup_to_appname": "Sign Up to {appName}",
|
||||||
|
"create_your_account_to_get_started": "Create your account to get started.",
|
||||||
|
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
|
||||||
|
"setup_your_passkey": "Set up your passkey",
|
||||||
|
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
|
||||||
|
"skip_for_now": "Skip for now",
|
||||||
|
"account_created": "Account Created",
|
||||||
|
"enable_user_signups": "Enable User Signups",
|
||||||
|
"enable_user_signups_description": "Decide how users can sign up for new accounts in Pocket ID.",
|
||||||
|
"user_signups_are_disabled": "User signups are currently disabled",
|
||||||
|
"create_signup_token": "Create Signup Token",
|
||||||
|
"view_active_signup_tokens": "View Active Signup Tokens",
|
||||||
|
"manage_signup_tokens": "Manage Signup Tokens",
|
||||||
|
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
|
||||||
|
"signup_token_deleted_successfully": "Signup token deleted successfully.",
|
||||||
|
"expired": "Expired",
|
||||||
|
"used_up": "Used Up",
|
||||||
|
"active": "Active",
|
||||||
|
"usage": "Usage",
|
||||||
|
"created": "Created",
|
||||||
|
"token": "Token",
|
||||||
|
"loading": "Loading",
|
||||||
|
"delete_signup_token": "Delete Signup Token",
|
||||||
|
"are_you_sure_you_want_to_delete_this_signup_token": "Are you sure you want to delete this signup token? This action cannot be undone.",
|
||||||
|
"signup_with_token": "Signup with token",
|
||||||
|
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.",
|
||||||
|
"signup_open": "Open Signup",
|
||||||
|
"signup_open_description": "Anyone can create a new account without restrictions.",
|
||||||
|
"of": "of",
|
||||||
|
"skip_passkey_setup": "Skip Passkey Setup",
|
||||||
|
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires.",
|
||||||
|
"my_apps": "My Apps",
|
||||||
|
"no_apps_available": "No apps available",
|
||||||
|
"contact_your_administrator_for_app_access": "Contact your administrator to get access to applications.",
|
||||||
|
"launch": "Launch",
|
||||||
|
"client_launch_url": "Client Launch URL",
|
||||||
|
"client_launch_url_description": "The URL that will be opened when a user launches the app from the My Apps page.",
|
||||||
|
"client_name_description": "The name of the client that shows in the Pocket ID UI.",
|
||||||
|
"revoke_access": "Revoke Access",
|
||||||
|
"revoke_access_description": "Revoke access to <b>{clientName}</b>. <b>{clientName}</b> will no longer be able to access your account information.",
|
||||||
|
"revoke_access_successful": "The access to {clientName} has been successfully revoked.",
|
||||||
|
"last_signed_in_ago": "Last signed in {time} ago",
|
||||||
|
"invalid_client_id": "Client ID can only contain letters, numbers, underscores, and hyphens",
|
||||||
|
"custom_client_id_description": "Set a custom client ID if this is required by your application. Otherwise, leave it blank to generate a random one.",
|
||||||
|
"generated": "Generated",
|
||||||
|
"administration": "Administration",
|
||||||
|
"group_rdn_attribute_description": "The attribute used in the groups distinguished name (DN).",
|
||||||
|
"display_name_attribute": "Display Name Attribute",
|
||||||
|
"display_name": "Display Name",
|
||||||
|
"configure_application_images": "Configure Application Images",
|
||||||
|
"ui_config_disabled_info_title": "UI Configuration Disabled",
|
||||||
|
"ui_config_disabled_info_description": "The UI configuration is disabled because the application configuration settings are managed through environment variables. Some settings may not be editable.",
|
||||||
|
"logo_from_url_description": "Paste a direct image URL (svg, png, webp). Find icons at <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> or <link href=\"https://dashboardicons.com\">Dashboard Icons</link>.",
|
||||||
|
"invalid_url": "Invalid URL",
|
||||||
|
"require_user_email": "Require Email Address",
|
||||||
|
"require_user_email_description": "Requires users to have an email address. If disabled, the users without an email address won't be able to use features that require an email address.",
|
||||||
|
"view": "View",
|
||||||
|
"toggle_columns": "Toggle columns",
|
||||||
|
"locale": "Locale",
|
||||||
|
"ldap_id": "LDAP ID",
|
||||||
|
"reauthentication": "Re-authentication",
|
||||||
|
"clear_filters": "Clear Filters",
|
||||||
|
"default_profile_picture": "Default Profile Picture",
|
||||||
|
"light": "Light",
|
||||||
|
"dark": "Dark",
|
||||||
|
"system": "System",
|
||||||
|
"signup_token_user_groups_description": "Automatically assign these groups to users who sign up using this token.",
|
||||||
|
"allowed_oidc_clients": "Allowed OIDC Clients",
|
||||||
|
"allowed_oidc_clients_description": "Select the OIDC clients that members of this user group are allowed to sign in to.",
|
||||||
|
"unrestrict_oidc_client": "Unrestrict {clientName}",
|
||||||
|
"confirm_unrestrict_oidc_client_description": "Are you sure you want to unrestrict the OIDC client <b>{clientName}</b>? This will remove all group assignments for this client and any user will be able to sign in.",
|
||||||
|
"allowed_oidc_clients_updated_successfully": "Allowed OIDC clients updated successfully",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
|
"restricted": "Restricted",
|
||||||
|
"scim_provisioning": "SCIM Provisioning",
|
||||||
|
"scim_provisioning_description": "SCIM provisioning allows you to automatically provision and deprovision users and groups from your OIDC client. Learn more in the <link href='https://pocket-id.org/docs/configuration/scim'>docs</link>.",
|
||||||
|
"scim_endpoint": "SCIM Endpoint",
|
||||||
|
"scim_token": "SCIM Token",
|
||||||
|
"last_successful_sync_at": "Last successful sync: {time}",
|
||||||
|
"scim_configuration_updated_successfully": "SCIM configuration updated successfully.",
|
||||||
|
"scim_enabled_successfully": "SCIM enabled successfully.",
|
||||||
|
"scim_disabled_successfully": "SCIM disabled successfully.",
|
||||||
|
"disable_scim_provisioning": "Disable SCIM Provisioning",
|
||||||
|
"disable_scim_provisioning_confirm_description": "Are you sure you want to disable SCIM provisioning for <b>{clientName}</b>? This will stop all automatic user and group provisioning and deprovisioning.",
|
||||||
|
"scim_sync_failed": "SCIM sync failed. Check the server logs for more information.",
|
||||||
|
"scim_sync_successful": "The SCIM sync has been completed successfully.",
|
||||||
|
"save_and_sync": "Save and Sync",
|
||||||
|
"scim_save_changes_description": "You have to save the changes before starting a SCIM sync. Do you want to save now?",
|
||||||
|
"scopes": "Scopes",
|
||||||
|
"issuer_url": "Issuer URL",
|
||||||
|
"smtp_field_required_when_other_provided": "Required when any SMTP setting is provided",
|
||||||
|
"smtp_field_required_when_email_enabled": "Required when email notifications are enabled",
|
||||||
|
"renew": "Renew",
|
||||||
|
"renew_api_key": "Renew API Key",
|
||||||
|
"renew_api_key_description": "Renewing the API key will generate a new key. Make sure to update any integrations using this key.",
|
||||||
|
"api_key_renewed": "API key renewed",
|
||||||
|
"app_config_home_page": "Home Page",
|
||||||
|
"app_config_home_page_description": "The page users are redirected to after signing in.",
|
||||||
|
"email_verification_warning": "Verify your email address",
|
||||||
|
"email_verification_warning_description": "Your email address is not verified yet. Please verify it as soon as possible.",
|
||||||
|
"email_verification": "Email Verification",
|
||||||
|
"email_verification_description": "Send a verification email to users when they sign up or change their email address.",
|
||||||
|
"email_verification_success_title": "Email Verified Successfully",
|
||||||
|
"email_verification_success_description": "Your email address has been verified successfully.",
|
||||||
|
"email_verification_error_title": "Email Verification Failed",
|
||||||
|
"mark_as_unverified": "Mark as unverified",
|
||||||
|
"mark_as_verified": "Mark as verified",
|
||||||
|
"email_verification_sent": "Verification email sent successfully.",
|
||||||
|
"emails_verified_by_default": "Emails verified by default",
|
||||||
|
"emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed."
|
||||||
|
}
|
||||||
@@ -365,7 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Введите код, который был отображен на предыдущем шаге.",
|
"enter_code_displayed_in_previous_step": "Введите код, который был отображен на предыдущем шаге.",
|
||||||
"authorize": "Авторизовать",
|
"authorize": "Авторизовать",
|
||||||
"federated_client_credentials": "Федеративные учетные данные клиента",
|
"federated_client_credentials": "Федеративные учетные данные клиента",
|
||||||
"federated_client_credentials_description": "Используя федеративные учетные данные клиента, вы можете аутентифицировать клиентов OIDC с помощью токенов JWT, выпущенных сторонними поставщиками удостоверений.",
|
"federated_client_credentials_description": "Федеративные учетные данные клиента позволяют аутентифицировать клиентов OIDC без необходимости управления долгосрочными секретами. Они используют токены JWT, выданные сторонними органами для утверждений клиента, например токены идентификации рабочей нагрузки.",
|
||||||
"add_federated_client_credential": "Добавить федеративные учетные данные клиента",
|
"add_federated_client_credential": "Добавить федеративные учетные данные клиента",
|
||||||
"add_another_federated_client_credential": "Добавить другие федеративные учетные данные клиента",
|
"add_another_federated_client_credential": "Добавить другие федеративные учетные данные клиента",
|
||||||
"oidc_allowed_group_count": "Число разрешенных групп",
|
"oidc_allowed_group_count": "Число разрешенных групп",
|
||||||
|
|||||||
@@ -365,7 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Ange koden som visades i föregående steg.",
|
"enter_code_displayed_in_previous_step": "Ange koden som visades i föregående steg.",
|
||||||
"authorize": "Godkänn",
|
"authorize": "Godkänn",
|
||||||
"federated_client_credentials": "Federerade klientuppgifter",
|
"federated_client_credentials": "Federerade klientuppgifter",
|
||||||
"federated_client_credentials_description": "Med hjälp av federerade klientuppgifter kan du autentisera OIDC-klienter med JWT-tokens som utfärdats av externa auktoriteter.",
|
"federated_client_credentials_description": "Federerade klientautentiseringsuppgifter gör det möjligt att autentisera OIDC-klienter utan att hantera långlivade hemligheter. De utnyttjar JWT-tokens som utfärdats av tredjepartsmyndigheter för klientpåståenden, t.ex. identitetstokens för arbetsbelastning.",
|
||||||
"add_federated_client_credential": "Lägg till federerad klientuppgift",
|
"add_federated_client_credential": "Lägg till federerad klientuppgift",
|
||||||
"add_another_federated_client_credential": "Lägg till ytterligare en federerad klientuppgift",
|
"add_another_federated_client_credential": "Lägg till ytterligare en federerad klientuppgift",
|
||||||
"oidc_allowed_group_count": "Tillåtet antal grupper",
|
"oidc_allowed_group_count": "Tillåtet antal grupper",
|
||||||
|
|||||||
@@ -365,7 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Önceki adımda görüntülenen kodu girin.",
|
"enter_code_displayed_in_previous_step": "Önceki adımda görüntülenen kodu girin.",
|
||||||
"authorize": "Yetkilendir",
|
"authorize": "Yetkilendir",
|
||||||
"federated_client_credentials": "Birleştirilmiş İstemci Kimlik Bilgileri",
|
"federated_client_credentials": "Birleştirilmiş İstemci Kimlik Bilgileri",
|
||||||
"federated_client_credentials_description": "Birleşik istemci kimlik bilgilerini kullanarak, üçüncü taraf otoriteleri tarafından verilen JWT token'ları kullanarak OIDC istemcilerinin kimliklerini doğrulayabilirsiniz.",
|
"federated_client_credentials_description": "Birleştirilmiş istemci kimlik bilgileri, uzun süreli gizli bilgileri yönetmeden OIDC istemcilerinin kimlik doğrulamasını sağlar. Üçüncü taraf yetkililer tarafından istemci beyanları için verilen JWT belirteçlerini (ör. iş yükü kimlik belirteçleri) kullanır.",
|
||||||
"add_federated_client_credential": "Birleştirilmiş İstemci Kimlik Bilgisi Ekle",
|
"add_federated_client_credential": "Birleştirilmiş İstemci Kimlik Bilgisi Ekle",
|
||||||
"add_another_federated_client_credential": "Başka bir birleştirilmiş istemci kimlik bilgisi ekle",
|
"add_another_federated_client_credential": "Başka bir birleştirilmiş istemci kimlik bilgisi ekle",
|
||||||
"oidc_allowed_group_count": "İzin Verilen Grup Sayısı",
|
"oidc_allowed_group_count": "İzin Verilen Grup Sayısı",
|
||||||
|
|||||||
@@ -365,7 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Введіть код, який було показано на попередньому кроці.",
|
"enter_code_displayed_in_previous_step": "Введіть код, який було показано на попередньому кроці.",
|
||||||
"authorize": "Авторизувати",
|
"authorize": "Авторизувати",
|
||||||
"federated_client_credentials": "Федеративні облікові дані клієнта",
|
"federated_client_credentials": "Федеративні облікові дані клієнта",
|
||||||
"federated_client_credentials_description": "За допомогою федеративних облікових даних клієнта ви можете автентифікувати клієнтів OIDC за допомогою токенів JWT, виданих третіми сторонами.",
|
"federated_client_credentials_description": "Федеративні облікові дані клієнта дозволяють автентифікувати клієнтів OIDC без управління довготривалими секретами. Вони використовують токени JWT, видані сторонніми органами для підтвердження клієнтів, наприклад, токени ідентичності робочого навантаження.",
|
||||||
"add_federated_client_credential": "Додати федеративний обліковий запис клієнта",
|
"add_federated_client_credential": "Додати федеративний обліковий запис клієнта",
|
||||||
"add_another_federated_client_credential": "Додати ще один федеративний обліковий запис клієнта",
|
"add_another_federated_client_credential": "Додати ще один федеративний обліковий запис клієнта",
|
||||||
"oidc_allowed_group_count": "Кількість дозволених груп",
|
"oidc_allowed_group_count": "Кількість дозволених груп",
|
||||||
@@ -504,22 +504,22 @@
|
|||||||
"issuer_url": "URL емітента",
|
"issuer_url": "URL емітента",
|
||||||
"smtp_field_required_when_other_provided": "Обов'язково, якщо вказано будь-який параметр SMTP",
|
"smtp_field_required_when_other_provided": "Обов'язково, якщо вказано будь-який параметр SMTP",
|
||||||
"smtp_field_required_when_email_enabled": "Обов'язково, якщо увімкнено сповіщення електронною поштою",
|
"smtp_field_required_when_email_enabled": "Обов'язково, якщо увімкнено сповіщення електронною поштою",
|
||||||
"renew": "Оновити",
|
"renew": "Поновити",
|
||||||
"renew_api_key": "Оновити API-ключ",
|
"renew_api_key": "Поновити API-ключ",
|
||||||
"renew_api_key_description": "Оновлення API-ключа призведе до створення нового ключа. Обов'язково оновіть усі інтеграції, що використовують цей ключ.",
|
"renew_api_key_description": "Поновлення API-ключа згенерує новий ключ. Переконайтеся, що ви оновили всі інтеграції, які його використовують.",
|
||||||
"api_key_renewed": "API-ключ оновлено",
|
"api_key_renewed": "API-ключ поновлено",
|
||||||
"app_config_home_page": "Головна сторінка",
|
"app_config_home_page": "Головна сторінка",
|
||||||
"app_config_home_page_description": "Сторінка, на яку користувачі перенаправляються після входу.",
|
"app_config_home_page_description": "Сторінка, на яку користувачі перенаправляються після входу.",
|
||||||
"email_verification_warning": "Підтвердьте свою адресу електронної пошти",
|
"email_verification_warning": "Підтвердьте свою адресу електронної пошти",
|
||||||
"email_verification_warning_description": "Ваша електронна адреса ще не підтверджена. Будь ласка, підтвердьте її якомога швидше.",
|
"email_verification_warning_description": "Ваша електронна адреса ще не підтверджена. Будь ласка, підтвердьте її якомога швидше.",
|
||||||
"email_verification": "Перевірка електронної адреси",
|
"email_verification": "Підтвердження електронної пошти",
|
||||||
"email_verification_description": "Надсилайте користувачам підтверджувальний лист електронною поштою, коли вони реєструються або змінюють свою адресу електронної пошти.",
|
"email_verification_description": "Надсилати лист підтвердження користувачам під час реєстрації або зміни електронної адреси.",
|
||||||
"email_verification_success_title": "Електронна адреса успішно підтверджена",
|
"email_verification_success_title": "Електронна адреса успішно підтверджена",
|
||||||
"email_verification_success_description": "Ваша електронна адреса була успішно підтверджена.",
|
"email_verification_success_description": "Ваша електронна адреса була успішно підтверджена.",
|
||||||
"email_verification_error_title": "Перевірка електронної адреси не вдалася",
|
"email_verification_error_title": "Перевірка електронної адреси не вдалася",
|
||||||
"mark_as_unverified": "Позначити як неперевірене",
|
"mark_as_unverified": "Позначити як непідтверджену",
|
||||||
"mark_as_verified": "Позначити як перевірене",
|
"mark_as_verified": "Позначити як підтверджену",
|
||||||
"email_verification_sent": "Електронний лист для підтвердження надіслано успішно.",
|
"email_verification_sent": "Електронний лист для підтвердження успішно надіслано.",
|
||||||
"emails_verified_by_default": "Електронні листи перевіряються за замовчуванням",
|
"emails_verified_by_default": "Електронні адреси підтверджені за замовчуванням",
|
||||||
"emails_verified_by_default_description": "Якщо ця опція увімкнена, адреси електронної пошти користувачів будуть позначатися як підтверджені за замовчуванням під час реєстрації або при зміні адреси електронної пошти."
|
"emails_verified_by_default_description": "Якщо увімкнено, електронні адреси користувачів будуть автоматично позначатися як підтверджені під час реєстрації або зміни електронної адреси."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -365,7 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Nhập mã đã hiển thị ở bước trước.",
|
"enter_code_displayed_in_previous_step": "Nhập mã đã hiển thị ở bước trước.",
|
||||||
"authorize": "Cho phép",
|
"authorize": "Cho phép",
|
||||||
"federated_client_credentials": "Thông Tin Xác Thực Của Federated Clients",
|
"federated_client_credentials": "Thông Tin Xác Thực Của Federated Clients",
|
||||||
"federated_client_credentials_description": "Sử dụng thông tin xác thực của federated client, bạn có thể xác thực các client OIDC bằng cách sử dụng token JWT được cấp bởi các bên thứ ba.",
|
"federated_client_credentials_description": "Thông tin xác thực khách hàng liên kết cho phép xác thực các khách hàng OIDC mà không cần quản lý các khóa bí mật có thời hạn dài. Chúng sử dụng các token JWT do các cơ quan thứ ba cấp để xác thực thông tin của khách hàng, ví dụ như các token danh tính công việc.",
|
||||||
"add_federated_client_credential": "Thêm thông tin xác thực cho federated clients",
|
"add_federated_client_credential": "Thêm thông tin xác thực cho federated clients",
|
||||||
"add_another_federated_client_credential": "Thêm một thông tin xác thực cho federated clients khác",
|
"add_another_federated_client_credential": "Thêm một thông tin xác thực cho federated clients khác",
|
||||||
"oidc_allowed_group_count": "Số lượng nhóm được phép",
|
"oidc_allowed_group_count": "Số lượng nhóm được phép",
|
||||||
|
|||||||
@@ -365,7 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "输入在上一步中显示的代码",
|
"enter_code_displayed_in_previous_step": "输入在上一步中显示的代码",
|
||||||
"authorize": "授权",
|
"authorize": "授权",
|
||||||
"federated_client_credentials": "联合身份",
|
"federated_client_credentials": "联合身份",
|
||||||
"federated_client_credentials_description": "您可以使用联合身份,通过第三方授权机构签发的 JWT 令牌,对 OIDC 客户端进行认证。",
|
"federated_client_credentials_description": "联合客户端凭证允许在无需管理长期密钥的情况下验证OIDC客户端。该机制利用第三方机构签发的JWT令牌来验证客户端声明,例如工作负载身份令牌。",
|
||||||
"add_federated_client_credential": "添加联合身份",
|
"add_federated_client_credential": "添加联合身份",
|
||||||
"add_another_federated_client_credential": "再添加一个联合身份",
|
"add_another_federated_client_credential": "再添加一个联合身份",
|
||||||
"oidc_allowed_group_count": "允许的群组数量",
|
"oidc_allowed_group_count": "允许的群组数量",
|
||||||
|
|||||||
@@ -365,7 +365,7 @@
|
|||||||
"enter_code_displayed_in_previous_step": "請輸入上一步顯示的代碼。",
|
"enter_code_displayed_in_previous_step": "請輸入上一步顯示的代碼。",
|
||||||
"authorize": "授權",
|
"authorize": "授權",
|
||||||
"federated_client_credentials": "聯邦身分",
|
"federated_client_credentials": "聯邦身分",
|
||||||
"federated_client_credentials_description": "使用聯邦身分,您可以透過由第三方授權機構簽發的 JWT 令牌來驗證 OIDC 客戶端。",
|
"federated_client_credentials_description": "聯合客戶憑證允許驗證 OIDC 客戶端,無需管理長期存續的機密。此機制利用第三方權威機構發行的 JWT 憑證來驗證客戶端聲明,例如工作負載身分憑證。",
|
||||||
"add_federated_client_credential": "增加聯邦身分",
|
"add_federated_client_credential": "增加聯邦身分",
|
||||||
"add_another_federated_client_credential": "新增另一組聯邦身分",
|
"add_another_federated_client_credential": "新增另一組聯邦身分",
|
||||||
"oidc_allowed_group_count": "允許的群組數量",
|
"oidc_allowed_group_count": "允許的群組數量",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pocket-id-frontend",
|
"name": "pocket-id-frontend",
|
||||||
"version": "2.3.0",
|
"version": "2.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
"@internationalized/date": "^3.11.0",
|
"@internationalized/date": "^3.11.0",
|
||||||
"@lucide/svelte": "^0.559.0",
|
"@lucide/svelte": "^0.559.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.53.0",
|
"@sveltejs/kit": "^2.53.4",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@types/eslint": "^9.6.1",
|
"@types/eslint": "^9.6.1",
|
||||||
"@types/node": "^24.10.13",
|
"@types/node": "^24.10.13",
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
"prettier-plugin-svelte": "^3.5.0",
|
"prettier-plugin-svelte": "^3.5.0",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"rollup": "^4.59.0",
|
"rollup": "^4.59.0",
|
||||||
"svelte": "^5.53.2",
|
"svelte": "^5.53.6",
|
||||||
"svelte-check": "^4.4.3",
|
"svelte-check": "^4.4.3",
|
||||||
"svelte-sonner": "^1.0.7",
|
"svelte-sonner": "^1.0.7",
|
||||||
"tailwind-variants": "^3.2.2",
|
"tailwind-variants": "^3.2.2",
|
||||||
@@ -58,6 +58,7 @@
|
|||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.56.0",
|
"typescript-eslint": "^8.56.0",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1",
|
||||||
|
"vite-plugin-compression": "^0.5.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,11 @@
|
|||||||
"it",
|
"it",
|
||||||
"ja",
|
"ja",
|
||||||
"ko",
|
"ko",
|
||||||
|
"lv",
|
||||||
"nl",
|
"nl",
|
||||||
"no",
|
"no",
|
||||||
"pl",
|
"pl",
|
||||||
|
"pt",
|
||||||
"pt-BR",
|
"pt-BR",
|
||||||
"ru",
|
"ru",
|
||||||
"sv",
|
"sv",
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
<script module lang="ts">
|
||||||
|
// Persist the last failing background image URL across route remounts so
|
||||||
|
// login pages without a background do not briefly retry the image and stutter.
|
||||||
|
let persistedMissingBackgroundImageUrl: string | undefined;
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { afterNavigate } from '$app/navigation';
|
import { afterNavigate } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
@@ -17,13 +23,32 @@
|
|||||||
showAlternativeSignInMethodButton?: boolean;
|
showAlternativeSignInMethodButton?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
let missingBackgroundImageUrl = $state<string | undefined>(persistedMissingBackgroundImageUrl);
|
||||||
|
let loadedBackgroundImageUrl = $state<string | undefined>();
|
||||||
let isInitialLoad = $state(false);
|
let isInitialLoad = $state(false);
|
||||||
let animate = $derived(isInitialLoad && !$appConfigStore.disableAnimations);
|
let backgroundImageUrl = $derived(cachedBackgroundImage.getUrl());
|
||||||
|
let imageError = $derived(missingBackgroundImageUrl === backgroundImageUrl);
|
||||||
|
let imageLoaded = $derived(loadedBackgroundImageUrl === backgroundImageUrl);
|
||||||
|
let animate = $derived(isInitialLoad && imageLoaded && !$appConfigStore.disableAnimations);
|
||||||
|
|
||||||
afterNavigate((e) => {
|
afterNavigate((e) => {
|
||||||
isInitialLoad = !e?.from?.url;
|
isInitialLoad = !e?.from?.url;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function onBackgroundImageLoad() {
|
||||||
|
loadedBackgroundImageUrl = backgroundImageUrl;
|
||||||
|
if (persistedMissingBackgroundImageUrl === backgroundImageUrl) {
|
||||||
|
persistedMissingBackgroundImageUrl = undefined;
|
||||||
|
missingBackgroundImageUrl = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBackgroundImageError() {
|
||||||
|
loadedBackgroundImageUrl = undefined;
|
||||||
|
persistedMissingBackgroundImageUrl = backgroundImageUrl;
|
||||||
|
missingBackgroundImageUrl = backgroundImageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
const isDesktop = new MediaQuery('min-width: 1024px');
|
const isDesktop = new MediaQuery('min-width: 1024px');
|
||||||
let alternativeSignInButton = $state({
|
let alternativeSignInButton = $state({
|
||||||
href: '/login/alternative',
|
href: '/login/alternative',
|
||||||
@@ -46,9 +71,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isDesktop.current}
|
{#if isDesktop.current}
|
||||||
<div class="h-screen items-center overflow-hidden text-center">
|
<div class="h-screen items-center overflow-hidden text-center flex justify-center">
|
||||||
<div
|
<div
|
||||||
class="relative z-10 flex h-full w-[650px] 2xl:w-[800px] p-16 {cn(
|
class="flex h-full w-[650px] 2xl:w-[800px] p-16 {cn(
|
||||||
showAlternativeSignInMethodButton && 'pb-0'
|
showAlternativeSignInMethodButton && 'pb-0'
|
||||||
)}"
|
)}"
|
||||||
>
|
>
|
||||||
@@ -69,16 +94,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Background image -->
|
{#if !imageError}
|
||||||
<div class="absolute top-0 right-0 left-500px bottom-0 z-0 overflow-hidden rounded-[40px] m-6">
|
<!-- Background image -->
|
||||||
<img
|
<div class="m-6 flex h-[calc(100vh-3rem)] overflow-hidden rounded-[40px]">
|
||||||
src={cachedBackgroundImage.getUrl()}
|
<img
|
||||||
class="{cn(
|
src={backgroundImageUrl}
|
||||||
animate && 'animate-bg-zoom'
|
class="h-full object-cover {cn(animate && 'animate-bg-zoom')}"
|
||||||
)} h-screen object-cover w-[calc(100vw-650px)] 2xl:w-[calc(100vw-800px)]"
|
alt={m.login_background()}
|
||||||
alt={m.login_background()}
|
onload={onBackgroundImageLoad}
|
||||||
/>
|
onerror={onBackgroundImageError}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
firstName: z.string().min(1).max(50),
|
firstName: z.string().max(50),
|
||||||
lastName: emptyToUndefined(z.string().max(50).optional()),
|
lastName: emptyToUndefined(z.string().max(50).optional()),
|
||||||
username: usernameSchema,
|
username: usernameSchema,
|
||||||
email: get(appConfigStore).requireUserEmail ? z.email() : emptyToUndefined(z.email().optional())
|
email: get(appConfigStore).requireUserEmail ? z.email() : emptyToUndefined(z.email().optional())
|
||||||
@@ -52,12 +52,12 @@
|
|||||||
|
|
||||||
<form id="sign-up-form" onsubmit={preventDefault(onSubmit)} class="w-full">
|
<form id="sign-up-form" onsubmit={preventDefault(onSubmit)} class="w-full">
|
||||||
<div class="mt-7 space-y-4">
|
<div class="mt-7 space-y-4">
|
||||||
|
<FormInput label={m.username()} bind:input={$inputs.username} />
|
||||||
|
<FormInput label={m.email()} bind:input={$inputs.email} type="email" />
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
|
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
|
||||||
<FormInput label={m.last_name()} bind:input={$inputs.lastName} />
|
<FormInput label={m.last_name()} bind:input={$inputs.lastName} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormInput label={m.username()} bind:input={$inputs.username} />
|
|
||||||
<FormInput label={m.email()} bind:input={$inputs.email} type="email" />
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ export default class AppConfigService extends APIService {
|
|||||||
cachedBackgroundImage.bustCache();
|
cachedBackgroundImage.bustCache();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
deleteBackgroundImage = async () => {
|
||||||
|
await this.api.delete(`/application-images/background`);
|
||||||
|
cachedBackgroundImage.bustCache();
|
||||||
|
};
|
||||||
|
|
||||||
deleteDefaultProfilePicture = async () => {
|
deleteDefaultProfilePicture = async () => {
|
||||||
await this.api.delete('/application-images/default-profile-picture');
|
await this.api.delete('/application-images/default-profile-picture');
|
||||||
cachedDefaultProfilePicture.bustCache();
|
cachedDefaultProfilePicture.bustCache();
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export default class UserService extends APIService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
createOneTimeAccessToken = async (userId: string = 'me', ttl?: string | number) => {
|
createOneTimeAccessToken = async (userId: string = 'me', ttl?: string | number) => {
|
||||||
const res = await this.api.post(`/users/${userId}/one-time-access-token`, { userId, ttl });
|
const res = await this.api.post(`/users/${userId}/one-time-access-token`, { ttl });
|
||||||
return res.data.token;
|
return res.data.token;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,11 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isRequired(fieldSchema: z.ZodTypeAny): boolean {
|
function isRequired(fieldSchema: z.ZodTypeAny): boolean {
|
||||||
|
// Handle string allow empty
|
||||||
|
if (fieldSchema instanceof z.ZodString) {
|
||||||
|
return fieldSchema.minLength !== null && fieldSchema.minLength > 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle unions like callbackUrlSchema
|
// Handle unions like callbackUrlSchema
|
||||||
if (fieldSchema instanceof z.ZodUnion) {
|
if (fieldSchema instanceof z.ZodUnion) {
|
||||||
return !fieldSchema.def.options.some((o: any) => {
|
return !fieldSchema.def.options.some((o: any) => {
|
||||||
@@ -138,6 +143,7 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
|
|||||||
if (fieldSchema instanceof z.ZodOptional || fieldSchema instanceof z.ZodDefault) {
|
if (fieldSchema instanceof z.ZodOptional || fieldSchema instanceof z.ZodDefault) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const callbackUrlSchema = z
|
|||||||
|
|
||||||
export const usernameSchema = z
|
export const usernameSchema = z
|
||||||
.string()
|
.string()
|
||||||
.min(2)
|
.min(1)
|
||||||
.max(30)
|
.max(30)
|
||||||
.regex(/^[a-zA-Z0-9]/, m.username_must_start_with())
|
.regex(/^[a-zA-Z0-9]/, m.username_must_start_with())
|
||||||
.regex(/[a-zA-Z0-9]$/, m.username_must_end_with())
|
.regex(/[a-zA-Z0-9]$/, m.username_must_end_with())
|
||||||
|
|||||||
@@ -35,9 +35,9 @@
|
|||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
firstName: z.string().min(1).max(50),
|
firstName: z.string().max(50),
|
||||||
lastName: emptyToUndefined(z.string().max(50).optional()),
|
lastName: emptyToUndefined(z.string().max(50).optional()),
|
||||||
displayName: z.string().min(1).max(100),
|
displayName: z.string().max(100),
|
||||||
username: usernameSchema,
|
username: usernameSchema,
|
||||||
email: get(appConfigStore).requireUserEmail ? z.email() : emptyToUndefined(z.email().optional())
|
email: get(appConfigStore).requireUserEmail ? z.email() : emptyToUndefined(z.email().optional())
|
||||||
});
|
});
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
if (!hasManualDisplayNameEdit) {
|
if (!hasManualDisplayNameEdit) {
|
||||||
$inputs.displayName.value = `${$inputs.firstName.value}${
|
$inputs.displayName.value = `${$inputs.firstName.value}${
|
||||||
$inputs.lastName?.value ? ' ' + $inputs.lastName.value : ''
|
$inputs.lastName?.value ? ' ' + $inputs.lastName.value : ''
|
||||||
}`;
|
}`.trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +91,8 @@
|
|||||||
|
|
||||||
<fieldset disabled={userInfoInputDisabled}>
|
<fieldset disabled={userInfoInputDisabled}>
|
||||||
<Field.Group class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<Field.Group class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormInput label={m.username()} bind:input={$inputs.username} />
|
||||||
|
<FormInput label={m.email()} type="email" bind:input={$inputs.email} />
|
||||||
<FormInput label={m.first_name()} bind:input={$inputs.firstName} onInput={onNameInput} />
|
<FormInput label={m.first_name()} bind:input={$inputs.firstName} onInput={onNameInput} />
|
||||||
<FormInput label={m.last_name()} bind:input={$inputs.lastName} onInput={onNameInput} />
|
<FormInput label={m.last_name()} bind:input={$inputs.lastName} onInput={onNameInput} />
|
||||||
<FormInput
|
<FormInput
|
||||||
@@ -98,8 +100,6 @@
|
|||||||
bind:input={$inputs.displayName}
|
bind:input={$inputs.displayName}
|
||||||
onInput={() => (hasManualDisplayNameEdit = true)}
|
onInput={() => (hasManualDisplayNameEdit = true)}
|
||||||
/>
|
/>
|
||||||
<FormInput label={m.username()} bind:input={$inputs.username} />
|
|
||||||
<FormInput label={m.email()} type="email" bind:input={$inputs.email} />
|
|
||||||
</Field.Group>
|
</Field.Group>
|
||||||
|
|
||||||
<div class="flex justify-end pt-4">
|
<div class="flex justify-end pt-4">
|
||||||
|
|||||||
@@ -20,9 +20,11 @@
|
|||||||
it: 'Italiano',
|
it: 'Italiano',
|
||||||
ja: '日本語',
|
ja: '日本語',
|
||||||
ko: '한국어',
|
ko: '한국어',
|
||||||
|
lv: 'Latviešu',
|
||||||
nl: 'Nederlands',
|
nl: 'Nederlands',
|
||||||
no: 'Norsk',
|
no: 'Norsk',
|
||||||
pl: 'Polski',
|
pl: 'Polski',
|
||||||
|
pt: 'Português',
|
||||||
'pt-BR': 'Português brasileiro',
|
'pt-BR': 'Português brasileiro',
|
||||||
ru: 'Русский',
|
ru: 'Русский',
|
||||||
sv: 'Svenska',
|
sv: 'Svenska',
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
logoDark: File | undefined,
|
logoDark: File | undefined,
|
||||||
logoEmail: File | undefined,
|
logoEmail: File | undefined,
|
||||||
defaultProfilePicture: File | null | undefined,
|
defaultProfilePicture: File | null | undefined,
|
||||||
backgroundImage: File | undefined,
|
backgroundImage: File | null | undefined,
|
||||||
favicon: File | undefined
|
favicon: File | undefined
|
||||||
) {
|
) {
|
||||||
const faviconPromise = favicon ? appConfigService.updateFavicon(favicon) : Promise.resolve();
|
const faviconPromise = favicon ? appConfigService.updateFavicon(favicon) : Promise.resolve();
|
||||||
@@ -68,9 +68,12 @@
|
|||||||
? appConfigService.updateDefaultProfilePicture(defaultProfilePicture)
|
? appConfigService.updateDefaultProfilePicture(defaultProfilePicture)
|
||||||
: Promise.resolve();
|
: Promise.resolve();
|
||||||
|
|
||||||
const backgroundImagePromise = backgroundImage
|
const backgroundImagePromise =
|
||||||
? appConfigService.updateBackgroundImage(backgroundImage)
|
backgroundImage === null
|
||||||
: Promise.resolve();
|
? appConfigService.deleteBackgroundImage()
|
||||||
|
: backgroundImage
|
||||||
|
? appConfigService.updateBackgroundImage(backgroundImage)
|
||||||
|
: Promise.resolve();
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
lightLogoPromise,
|
lightLogoPromise,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
logoDark: File | undefined,
|
logoDark: File | undefined,
|
||||||
logoEmail: File | undefined,
|
logoEmail: File | undefined,
|
||||||
defaultProfilePicture: File | null | undefined,
|
defaultProfilePicture: File | null | undefined,
|
||||||
backgroundImage: File | undefined,
|
backgroundImage: File | null | undefined,
|
||||||
favicon: File | undefined
|
favicon: File | undefined
|
||||||
) => void;
|
) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
@@ -26,10 +26,11 @@
|
|||||||
let logoDark = $state<File | undefined>();
|
let logoDark = $state<File | undefined>();
|
||||||
let logoEmail = $state<File | undefined>();
|
let logoEmail = $state<File | undefined>();
|
||||||
let defaultProfilePicture = $state<File | null | undefined>();
|
let defaultProfilePicture = $state<File | null | undefined>();
|
||||||
let backgroundImage = $state<File | undefined>();
|
let backgroundImage = $state<File | null | undefined>();
|
||||||
let favicon = $state<File | undefined>();
|
let favicon = $state<File | undefined>();
|
||||||
|
|
||||||
let defaultProfilePictureSet = $state(true);
|
let defaultProfilePictureSet = $state(true);
|
||||||
|
let backgroundImageSet = $state(true);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-8">
|
<div class="flex flex-col gap-8">
|
||||||
@@ -39,7 +40,7 @@
|
|||||||
label={m.favicon()}
|
label={m.favicon()}
|
||||||
bind:image={favicon}
|
bind:image={favicon}
|
||||||
imageURL="/api/application-images/favicon"
|
imageURL="/api/application-images/favicon"
|
||||||
accept="image/x-icon"
|
accept="image/svg+xml, image/png, image/x-icon"
|
||||||
/>
|
/>
|
||||||
<ApplicationImage
|
<ApplicationImage
|
||||||
id="logo-light"
|
id="logo-light"
|
||||||
@@ -77,10 +78,12 @@
|
|||||||
/>
|
/>
|
||||||
<ApplicationImage
|
<ApplicationImage
|
||||||
id="background-image"
|
id="background-image"
|
||||||
imageClass="h-[350px] max-w-[500px]"
|
imageClass="max-h-[350px] max-w-[500px]"
|
||||||
label={m.background_image()}
|
label={m.background_image()}
|
||||||
|
isResetable
|
||||||
bind:image={backgroundImage}
|
bind:image={backgroundImage}
|
||||||
imageURL={cachedBackgroundImage.getUrl()}
|
imageURL={cachedBackgroundImage.getUrl()}
|
||||||
|
isImageSet={backgroundImageSet}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
|
|||||||
@@ -31,7 +31,9 @@
|
|||||||
<Input
|
<Input
|
||||||
aria-invalid={!!error}
|
aria-invalid={!!error}
|
||||||
data-testid={`callback-url-${i + 1}`}
|
data-testid={`callback-url-${i + 1}`}
|
||||||
type="url"
|
type="text"
|
||||||
|
inputmode="url"
|
||||||
|
autocomplete="url"
|
||||||
bind:value={callbackURLs[i]}
|
bind:value={callbackURLs[i]}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import FormInput from '$lib/components/form/form-input.svelte';
|
import FormInput from '$lib/components/form/form-input.svelte';
|
||||||
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
|
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Toggle } from '$lib/components/ui/toggle';
|
import { Toggle } from '$lib/components/ui/toggle';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
@@ -40,9 +41,9 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
firstName: z.string().min(1).max(50),
|
firstName: z.string().max(50),
|
||||||
lastName: emptyToUndefined(z.string().max(50).optional()),
|
lastName: emptyToUndefined(z.string().max(50).optional()),
|
||||||
displayName: z.string().min(1).max(100),
|
displayName: z.string().max(100),
|
||||||
username: usernameSchema,
|
username: usernameSchema,
|
||||||
email: get(appConfigStore).requireUserEmail
|
email: get(appConfigStore).requireUserEmail
|
||||||
? z.email()
|
? z.email()
|
||||||
@@ -67,7 +68,7 @@
|
|||||||
if (!hasManualDisplayNameEdit) {
|
if (!hasManualDisplayNameEdit) {
|
||||||
$inputs.displayName.value = `${$inputs.firstName.value}${
|
$inputs.displayName.value = `${$inputs.firstName.value}${
|
||||||
$inputs.lastName?.value ? ' ' + $inputs.lastName.value : ''
|
$inputs.lastName?.value ? ' ' + $inputs.lastName.value : ''
|
||||||
}`;
|
}`.trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -75,6 +76,38 @@
|
|||||||
<form onsubmit={preventDefault(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.username()} bind:input={$inputs.username} />
|
||||||
|
<FormInput label={m.email()} bind:input={$inputs.email} labelFor="email">
|
||||||
|
<div class="flex items-end">
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
class="rounded-r-none border-r-0"
|
||||||
|
aria-invalid={!!$inputs.email.error}
|
||||||
|
bind:value={$inputs.email.value}
|
||||||
|
/>
|
||||||
|
<Tooltip.Provider>
|
||||||
|
{@const label = $inputs.emailVerified.value
|
||||||
|
? m.mark_as_unverified()
|
||||||
|
: m.mark_as_verified()}
|
||||||
|
<Tooltip.Root>
|
||||||
|
<Tooltip.Trigger>
|
||||||
|
<Toggle
|
||||||
|
bind:pressed={$inputs.emailVerified.value}
|
||||||
|
aria-label={label}
|
||||||
|
class="h-9 border-input bg-yellow-100 dark:bg-yellow-950 data-[state=on]:bg-green-100 dark:data-[state=on]:bg-green-950 rounded-l-none border px-2 py-1 shadow-xs flex items-center hover:data-[state=on]:bg-accent"
|
||||||
|
>
|
||||||
|
{#if $inputs.emailVerified.value}
|
||||||
|
<LucideMailCheck class="text-green-500 dark:text-green-600 size-5" />
|
||||||
|
{:else}
|
||||||
|
<LucideMailWarning class="text-yellow-500 dark:text-yellow-600 size-5" />
|
||||||
|
{/if}
|
||||||
|
</Toggle>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content>{label}</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
</div>
|
||||||
|
</FormInput>
|
||||||
<FormInput label={m.first_name()} oninput={onNameInput} bind:input={$inputs.firstName} />
|
<FormInput label={m.first_name()} oninput={onNameInput} bind:input={$inputs.firstName} />
|
||||||
<FormInput label={m.last_name()} oninput={onNameInput} bind:input={$inputs.lastName} />
|
<FormInput label={m.last_name()} oninput={onNameInput} bind:input={$inputs.lastName} />
|
||||||
<FormInput
|
<FormInput
|
||||||
@@ -82,35 +115,6 @@
|
|||||||
oninput={() => (hasManualDisplayNameEdit = true)}
|
oninput={() => (hasManualDisplayNameEdit = true)}
|
||||||
bind:input={$inputs.displayName}
|
bind:input={$inputs.displayName}
|
||||||
/>
|
/>
|
||||||
<FormInput label={m.username()} bind:input={$inputs.username} />
|
|
||||||
<div class="flex items-end">
|
|
||||||
<FormInput
|
|
||||||
inputClass="rounded-r-none border-r-0"
|
|
||||||
label={m.email()}
|
|
||||||
bind:input={$inputs.email}
|
|
||||||
/>
|
|
||||||
<Tooltip.Provider>
|
|
||||||
{@const label = $inputs.emailVerified.value
|
|
||||||
? m.mark_as_unverified()
|
|
||||||
: m.mark_as_verified()}
|
|
||||||
<Tooltip.Root>
|
|
||||||
<Tooltip.Trigger>
|
|
||||||
<Toggle
|
|
||||||
bind:pressed={$inputs.emailVerified.value}
|
|
||||||
aria-label={label}
|
|
||||||
class="h-9 border-input bg-yellow-100 dark:bg-yellow-950 data-[state=on]:bg-green-100 dark:data-[state=on]:bg-green-950 rounded-l-none border px-2 py-1 shadow-xs flex items-center hover:data-[state=on]:bg-accent"
|
|
||||||
>
|
|
||||||
{#if $inputs.emailVerified.value}
|
|
||||||
<LucideMailCheck class="text-green-500 dark:text-green-600 size-5" />
|
|
||||||
{:else}
|
|
||||||
<LucideMailWarning class="text-yellow-500 dark:text-yellow-600 size-5" />
|
|
||||||
{/if}
|
|
||||||
</Toggle>
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Content>{label}</Tooltip.Content>
|
|
||||||
</Tooltip.Root>
|
|
||||||
</Tooltip.Provider>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
|
<div class="mt-5 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
|
||||||
<SwitchWithLabel
|
<SwitchWithLabel
|
||||||
|
|||||||
@@ -2,28 +2,47 @@ import { paraglideVitePlugin } from '@inlang/paraglide-js';
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
import viteCompression from 'vite-plugin-compression';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig((mode) => {
|
||||||
plugins: [
|
return {
|
||||||
sveltekit(),
|
plugins: [
|
||||||
tailwindcss(),
|
sveltekit(),
|
||||||
paraglideVitePlugin({
|
tailwindcss(),
|
||||||
project: './project.inlang',
|
paraglideVitePlugin({
|
||||||
outdir: './src/lib/paraglide',
|
project: './project.inlang',
|
||||||
cookieName: 'locale',
|
outdir: './src/lib/paraglide',
|
||||||
strategy: ['cookie', 'preferredLanguage', 'baseLocale']
|
cookieName: 'locale',
|
||||||
})
|
strategy: ['cookie', 'preferredLanguage', 'baseLocale']
|
||||||
],
|
}),
|
||||||
|
|
||||||
server: {
|
// Create gzip-compressed files
|
||||||
host: process.env.HOST,
|
viteCompression({
|
||||||
proxy: {
|
disable: mode.isPreview,
|
||||||
'/api': {
|
algorithm: 'gzip',
|
||||||
target: process.env.DEVELOPMENT_BACKEND_URL || 'http://localhost:1411'
|
ext: '.gz',
|
||||||
},
|
filter: /\.(js|mjs|json|css)$/i
|
||||||
'/.well-known': {
|
}),
|
||||||
target: process.env.DEVELOPMENT_BACKEND_URL || 'http://localhost:1411'
|
|
||||||
|
// Create brotli-compressed files
|
||||||
|
viteCompression({
|
||||||
|
disable: mode.isPreview,
|
||||||
|
algorithm: 'brotliCompress',
|
||||||
|
ext: '.br',
|
||||||
|
filter: /\.(js|mjs|json|css)$/i
|
||||||
|
})
|
||||||
|
],
|
||||||
|
|
||||||
|
server: {
|
||||||
|
host: process.env.HOST,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: process.env.DEVELOPMENT_BACKEND_URL || 'http://localhost:1411'
|
||||||
|
},
|
||||||
|
'/.well-known': {
|
||||||
|
target: process.env.DEVELOPMENT_BACKEND_URL || 'http://localhost:1411'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user