Compare commits

...

40 Commits

Author SHA1 Message Date
Elias Schneider
8973e93cb6 release: 1.11.1 2025-09-18 22:33:22 +02:00
Elias Schneider
8c9cac2655 chore(translations): update translations via Crowdin (#957) 2025-09-18 22:26:38 +02:00
Elias Schneider
ed8547ccc1 release: 1.11.0 2025-09-18 22:16:32 +02:00
Elias Schneider
e7e53a8b8c fix: my apps card shouldn't take full width if only one item exists 2025-09-18 21:55:43 +02:00
Elias Schneider
02249491f8 feat: allow uppercase usernames (#958) 2025-09-17 14:43:12 -05:00
Elias Schneider
cf0892922b chore: include version in changelog 2025-09-17 18:00:04 +02:00
Elias Schneider
99f31a7c26 fix: make environment variables case insensitive where necessary (#954)
fix #935
2025-09-17 08:21:54 -07:00
Kyle Mendell
68373604dd feat: add user display name field (#898)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-09-17 17:18:27 +02:00
Elias Schneider
2d6d5df0e7 feat: add support for LOG_LEVEL env variable (#942) 2025-09-14 08:26:21 -07:00
Alessandro (Ale) Segala
a897b31166 chore: minify background image (#933)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-09-14 08:24:28 -07:00
dependabot[bot]
fb92906c3a chore(deps): bump axios from 1.11.0 to 1.12.0 in the npm_and_yarn group across 1 directory (#943)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-13 12:20:18 -05:00
Alessandro (Ale) Segala
c018f29ad7 fix: key-rotate doesn't work with database storage (#940) 2025-09-12 20:04:45 -05:00
Elias Schneider
5367463239 feat: add PWA support (#938) 2025-09-12 10:17:35 -05:00
Elias Schneider
6c9147483c fix: add validation for callback URLs (#929) 2025-09-10 10:14:54 -07:00
Elias Schneider
d123d7f335 chore(translations): update translations via Crowdin (#931) 2025-09-10 07:57:58 -05:00
dependabot[bot]
da8ca08c36 chore(deps-dev): bump vite from 7.0.6 to 7.0.7 in the npm_and_yarn group across 1 directory (#932)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-09 17:32:14 -05:00
Alessandro (Ale) Segala
307caaa3ef feat: return new id_token when using refresh token (#925) 2025-09-09 11:31:50 +02:00
Elias Schneider
6c696b46c8 fix: list items on previous page get unselected if other items selected on next page 2025-09-09 10:02:59 +02:00
Alessandro (Ale) Segala
42155238b7 fix: ensure users imported from LDAP have fields validated (#923) 2025-09-09 09:31:49 +02:00
Elias Schneider
92edc26a30 chore(translations): update translations via Crowdin (#924) 2025-09-08 08:12:21 -05:00
github-actions[bot]
e36499c483 chore: update AAGUIDs (#926)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-09-08 00:14:56 -05:00
Elias Schneider
6215e1ac01 feat: add CSP header (#908)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2025-09-07 11:45:06 -07:00
Elias Schneider
74b39e16f9 chore(translations): update translations via Crowdin (#915) 2025-09-07 20:31:30 +02:00
Elias Schneider
a1d8538c64 feat: add info box to app settings if UI config is disabled 2025-09-07 19:49:07 +02:00
Elias Schneider
1d7cbc2a4e fix: disable sign up options in UI if UI_CONFIG_DISABLED 2025-09-07 19:42:20 +02:00
Kyle Mendell
954fb4f0c8 chore(translations): add Swedish files 2025-09-05 19:57:54 -05:00
Savely Krasovsky
901333f7e4 feat: client_credentials flow support (#901) 2025-09-02 18:33:01 -05:00
Elias Schneider
0b381467ca chore(translations): update translations via Crowdin (#904) 2025-09-02 09:57:31 -05:00
github-actions[bot]
6188dc6fb7 chore: update AAGUIDs (#903)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-08-31 19:40:23 -05:00
Kyle Mendell
802754c24c refactor: use react email for email templates (#734)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-31 16:54:13 +00:00
Elias Schneider
6c843228eb chore(translations): update translations via Crowdin (#893) 2025-08-30 13:20:35 -05:00
Stephan H.
a3979f63e0 feat: add custom base url (#858)
Co-authored-by: Stephan Höhn <me@steph.ovh>
Co-authored-by: Kyle Mendell <ksm@ofkm.us>
2025-08-30 13:13:57 -05:00
Elias Schneider
52c560c30d chore(translations): update translations via Crowdin (#887)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-08-27 16:44:26 -05:00
Kyle Mendell
e88be7e61a fix: update localized name and description of ldap group name attribute (#892) 2025-08-27 15:52:50 -05:00
Kyle Mendell
a4e965434f release: 1.10.0 2025-08-27 15:24:57 -05:00
Kyle Mendell
096d214a88 feat: redesigned sidebar with administrative dropdown (#881)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-27 16:39:22 +00:00
Savely Krasovsky
afb7fc32e7 chore(translations): add missing translations (#884) 2025-08-27 18:13:35 +02:00
Elias Schneider
641bbc9351 fix: apps showed multiple times if user is in multiple groups 2025-08-27 17:53:21 +02:00
Kyle Mendell
136c6082f6 chore(deps): bump sveltekit to 2.36.3 and devalue to 5.3.2 (#889) 2025-08-26 18:59:35 -05:00
github-actions[bot]
b9a20d2923 chore: update AAGUIDs (#885)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-08-25 08:13:32 +02:00
141 changed files with 8148 additions and 1178 deletions

View File

@@ -23,8 +23,6 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4

View File

@@ -3,15 +3,15 @@ on:
push:
branches: [main]
paths-ignore:
- "docs/**"
- "**.md"
- ".github/**"
- 'docs/**'
- '**.md'
- '.github/**'
pull_request:
branches: [main]
paths-ignore:
- "docs/**"
- "**.md"
- ".github/**"
- 'docs/**'
- '**.md'
- '.github/**'
jobs:
build:
@@ -61,13 +61,11 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml
- name: Cache Playwright Browsers

View File

@@ -18,8 +18,6 @@ jobs:
uses: actions/checkout@v3
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@@ -71,6 +69,7 @@ jobs:
run: pnpm --filter pocket-id-frontend install --frozen-lockfile
- name: Build frontend
run: pnpm --filter pocket-id-frontend build
- name: Build binaries
run: sh scripts/development/build-binaries.sh
- name: Build and push container image

View File

@@ -38,8 +38,6 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4

View File

@@ -1 +1 @@
1.9.1
1.11.1

View File

@@ -1,3 +1,45 @@
## [1.11.1](https://github.com/pocket-id/pocket-id/compare/v1.10.0...v1.11.1) (2025-09-18)
### Bug Fixes
- add missing translations ([8c9cac2](https://github.com/pocket-id/pocket-id/commit/8c9cac2655ddbe4872234a1b55fdd51d2f3ac31c))
## [1.11.0](https://github.com/pocket-id/pocket-id/compare/v1.10.0...v1.11.0) (2025-09-18)
### Features
* add CSP header ([#908](https://github.com/pocket-id/pocket-id/issues/908)) ([6215e1a](https://github.com/pocket-id/pocket-id/commit/6215e1ac01c03866f8b2e89ac084ddd6a3c3ac9e))
* add custom base url ([#858](https://github.com/pocket-id/pocket-id/issues/858)) ([a3979f6](https://github.com/pocket-id/pocket-id/commit/a3979f63e07d418ee9eb1cb1abc37aede5799fc8))
* add info box to app settings if UI config is disabled ([a1d8538](https://github.com/pocket-id/pocket-id/commit/a1d8538c64beb4d7e8559934985772fba27623ca))
* add PWA support ([#938](https://github.com/pocket-id/pocket-id/issues/938)) ([5367463](https://github.com/pocket-id/pocket-id/commit/5367463239b354640fd65390bc409e4a0ac13fd1))
* add support for `LOG_LEVEL` env variable ([#942](https://github.com/pocket-id/pocket-id/issues/942)) ([2d6d5df](https://github.com/pocket-id/pocket-id/commit/2d6d5df0e7f104a148fb4eeac89a2fbb7db8047a))
* add user display name field ([#898](https://github.com/pocket-id/pocket-id/issues/898)) ([6837360](https://github.com/pocket-id/pocket-id/commit/68373604dd30065947226922233bc1e19e778b01))
* allow uppercase usernames ([#958](https://github.com/pocket-id/pocket-id/issues/958)) ([0224949](https://github.com/pocket-id/pocket-id/commit/02249491f86c289adf596d9d9922dfa04779edee))
* client_credentials flow support ([#901](https://github.com/pocket-id/pocket-id/issues/901)) ([901333f](https://github.com/pocket-id/pocket-id/commit/901333f7e43b4e925ed6dfd890dee2caa1947934))
* return new id_token when using refresh token ([#925](https://github.com/pocket-id/pocket-id/issues/925)) ([307caaa](https://github.com/pocket-id/pocket-id/commit/307caaa3efbc966341b95ee4b5ff18c81ed98e54))
### Bug Fixes
* add validation for callback URLs ([#929](https://github.com/pocket-id/pocket-id/issues/929)) ([6c91474](https://github.com/pocket-id/pocket-id/commit/6c9147483c0a370e2b5011d13898279d2acc445d))
* disable sign up options in UI if `UI_CONFIG_DISABLED` ([1d7cbc2](https://github.com/pocket-id/pocket-id/commit/1d7cbc2a4ecf352d46087f30b477f6bbaa23adf5))
* ensure users imported from LDAP have fields validated ([#923](https://github.com/pocket-id/pocket-id/issues/923)) ([4215523](https://github.com/pocket-id/pocket-id/commit/42155238b750b015b0547294f397e1e285594e3e))
* key-rotate doesn't work with database storage ([#940](https://github.com/pocket-id/pocket-id/issues/940)) ([c018f29](https://github.com/pocket-id/pocket-id/commit/c018f29ad7c61a3ef1b235b0d404a3a2024a26ca))
* list items on previous page get unselected if other items selected on next page ([6c696b4](https://github.com/pocket-id/pocket-id/commit/6c696b46c8b60b3dc4af35c9c6cf1b8e1322f4cd))
* make environment variables case insensitive where necessary ([#954](https://github.com/pocket-id/pocket-id/issues/954)) ([99f31a7](https://github.com/pocket-id/pocket-id/commit/99f31a7c26c63dec76682ddf450d88e6ee40876f)), closes [#935](https://github.com/pocket-id/pocket-id/issues/935)
* my apps card shouldn't take full width if only one item exists ([e7e53a8](https://github.com/pocket-id/pocket-id/commit/e7e53a8b8c87bee922167d24556aef3ea219b1a2))
* update localized name and description of ldap group name attribute ([#892](https://github.com/pocket-id/pocket-id/issues/892)) ([e88be7e](https://github.com/pocket-id/pocket-id/commit/e88be7e61a8aafabcae70adf9265023c50626705))
## [](https://github.com/pocket-id/pocket-id/compare/v1.9.1...v) (2025-08-27)
### Features
* redesigned sidebar with administrative dropdown ([#881](https://github.com/pocket-id/pocket-id/issues/881)) ([096d214](https://github.com/pocket-id/pocket-id/commit/096d214a88808848dae726b0ef4c9a9987185836))
### Bug Fixes
* apps showed multiple times if user is in multiple groups ([641bbc9](https://github.com/pocket-id/pocket-id/commit/641bbc935191bad8afbfec90943fc3e9de7a0cb6))
## [](https://github.com/pocket-id/pocket-id/compare/v1.9.0...v) (2025-08-24)

View File

@@ -61,4 +61,4 @@ formatters:
paths:
- third_party$
- builtin$
- examples$
- examples$

View File

@@ -3,8 +3,10 @@
package frontend
import (
"bytes"
"embed"
"fmt"
"io"
"io/fs"
"net/http"
"os"
@@ -12,11 +14,55 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
)
//go:embed all:dist/*
var frontendFS embed.FS
// This function, created by the init() method, writes to "w" the index.html page, populating the nonce
var writeIndexFn func(w io.Writer, nonce string) error
func init() {
const scriptTag = "<script>"
// Read the index.html from the bundle
index, iErr := fs.ReadFile(frontendFS, "dist/index.html")
if iErr != nil {
panic(fmt.Errorf("failed to read index.html: %w", iErr))
}
// Get the position of the first <script> tag
idx := bytes.Index(index, []byte(scriptTag))
// Create writeIndexFn, which adds the CSP tag to the script tag if needed
writeIndexFn = func(w io.Writer, nonce string) (err error) {
// If there's no nonce, write the index as-is
if nonce == "" {
_, err = w.Write(index)
return err
}
// We have a nonce, so first write the index until the <script> tag
// Then we write the modified script tag
// Finally, the rest of the index
_, err = w.Write(index[0:idx])
if err != nil {
return err
}
_, err = w.Write([]byte(`<script nonce="` + nonce + `">`))
if err != nil {
return err
}
_, err = w.Write(index[(idx + len(scriptTag)):])
if err != nil {
return err
}
return nil
}
}
func RegisterFrontend(router *gin.Engine) error {
distFS, err := fs.Sub(frontendFS, "dist")
if err != nil {
@@ -27,13 +73,39 @@ func RegisterFrontend(router *gin.Engine) error {
fileServer := NewFileServerWithCaching(http.FS(distFS), int(cacheMaxAge.Seconds()))
router.NoRoute(func(c *gin.Context) {
// Try to serve the requested file
path := strings.TrimPrefix(c.Request.URL.Path, "/")
if _, err := fs.Stat(distFS, path); os.IsNotExist(err) {
// File doesn't exist, serve index.html instead
c.Request.URL.Path = "/"
if strings.HasPrefix(path, "api/") {
c.JSON(http.StatusNotFound, gin.H{"error": "API endpoint not found"})
return
}
// If path is / or does not exist, serve index.html
if path == "" {
path = "index.html"
} else if _, err := fs.Stat(distFS, path); os.IsNotExist(err) {
path = "index.html"
}
if path == "index.html" {
nonce := middleware.GetCSPNonce(c)
// Do not cache the HTML shell, as it embeds a per-request nonce
c.Header("Content-Type", "text/html; charset=utf-8")
c.Header("Cache-Control", "no-store")
c.Status(http.StatusOK)
err = writeIndexFn(c.Writer, nonce)
if err != nil {
_ = c.Error(fmt.Errorf("failed to write index.html file: %w", err))
return
}
return
}
// Serve other static assets with caching
c.Request.URL.Path = "/" + path
fileServer.ServeHTTP(c.Writer, c.Request)
})

View File

@@ -10,6 +10,7 @@ require (
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
github.com/emersion/go-smtp v0.21.3
github.com/fxamacker/cbor/v2 v2.9.0
github.com/gin-contrib/slog v1.1.0
github.com/gin-gonic/gin v1.10.1
github.com/glebarez/go-sqlite v1.22.0
github.com/glebarez/sqlite v1.11.0
@@ -29,7 +30,6 @@ require (
github.com/mileusna/useragent v1.3.5
github.com/orandin/slog-gorm v1.4.0
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8
github.com/samber/slog-gin v1.15.1
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0
@@ -45,6 +45,7 @@ require (
go.opentelemetry.io/otel/trace v1.37.0
golang.org/x/crypto v0.41.0
golang.org/x/image v0.30.0
golang.org/x/sync v0.16.0
golang.org/x/text v0.28.0
golang.org/x/time v0.12.0
gorm.io/driver/postgres v1.6.0
@@ -135,7 +136,6 @@ require (
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/oauth2 v0.27.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect

View File

@@ -56,6 +56,8 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/slog v1.1.0 h1:K9MVNrETT6r/C3u2Aheer/gxwVeVqrGL0hXlsmv3fm4=
github.com/gin-contrib/slog v1.1.0/go.mod h1:PvNXQVXcVOAaaiJR84LV1/xlQHIaXi9ygEXyBkmjdkY=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
@@ -241,8 +243,6 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/slog-gin v1.15.1 h1:jsnfr+S5HQPlz9pFPA3tOmKW7wN/znyZiE6hncucrTM=
github.com/samber/slog-gin v1.15.1/go.mod h1:mPAEinK/g2jPLauuWO11m3Q0Ca7aG4k9XjXjXY8IhMQ=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=

View File

@@ -1,9 +1,13 @@
package bootstrap
import (
"bytes"
"encoding/hex"
"fmt"
"io/fs"
"log/slog"
"os"
"path"
"path/filepath"
"strings"
"github.com/pocket-id/pocket-id/backend/internal/common"
@@ -13,6 +17,15 @@ import (
// initApplicationImages copies the images from the images directory to the application-images directory
func initApplicationImages() error {
// Images that are built into the Pocket ID binary
builtInImageHashes := getBuiltInImageHashes()
// Previous versions of images
// If these are found, they are deleted
legacyImageHashes := imageHashMap{
"background.jpg": mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"),
}
dirPath := common.EnvConfig.UploadPath + "/application-images"
sourceFiles, err := resources.FS.ReadDir("images")
@@ -24,15 +37,48 @@ func initApplicationImages() error {
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to read directory: %w", err)
}
destinationFilesMap := make(map[string]bool, len(destinationFiles))
for _, f := range destinationFiles {
name := f.Name()
destFilePath := filepath.Join(dirPath, name)
h, err := utils.CreateSha256FileHash(destFilePath)
if err != nil {
return fmt.Errorf("failed to get hash for file '%s': %w", name, err)
}
// Check if the file is a legacy one - if so, delete it
if legacyImageHashes.Contains(h) {
slog.Info("Found legacy application image that will be removed", slog.String("name", name))
err = os.Remove(destFilePath)
if err != nil {
return fmt.Errorf("failed to remove legacy file '%s': %w", name, err)
}
continue
}
// Check if the file is a built-in one and save it in the map
destinationFilesMap[getImageNameWithoutExtension(name)] = builtInImageHashes.Contains(h)
}
// Copy images from the images directory to the application-images directory if they don't already exist
for _, sourceFile := range sourceFiles {
if sourceFile.IsDir() || imageAlreadyExists(sourceFile.Name(), destinationFiles) {
// Skip if it's a directory
if sourceFile.IsDir() {
continue
}
srcFilePath := path.Join("images", sourceFile.Name())
destFilePath := path.Join(dirPath, sourceFile.Name())
name := sourceFile.Name()
srcFilePath := filepath.Join("images", name)
destFilePath := filepath.Join(dirPath, name)
// Skip if there's already an image at the path
// We do not check the extension because users could have uploaded a different one
if imageAlreadyExists(sourceFile, destinationFilesMap) {
continue
}
slog.Info("Writing new application image", slog.String("name", name))
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
if err != nil {
return fmt.Errorf("failed to copy file: %w", err)
@@ -42,25 +88,49 @@ func initApplicationImages() error {
return nil
}
func imageAlreadyExists(fileName string, destinationFiles []os.DirEntry) bool {
for _, destinationFile := range destinationFiles {
sourceFileWithoutExtension := getImageNameWithoutExtension(fileName)
destinationFileWithoutExtension := getImageNameWithoutExtension(destinationFile.Name())
func getBuiltInImageHashes() imageHashMap {
return imageHashMap{
"background.webp": mustDecodeHex("3fc436a66d6b872b01d96a4e75046c46b5c3e2daccd51e98ecdf98fd445599ab"),
"favicon.ico": mustDecodeHex("70f9c4b6bd4781ade5fc96958b1267511751e91957f83c2354fb880b35ec890a"),
"logo.svg": mustDecodeHex("f1e60707df9784152ce0847e3eb59cb68b9015f918ff160376c27ebff1eda796"),
"logoDark.svg": mustDecodeHex("0421a8d93714bacf54c78430f1db378fd0d29565f6de59b6a89090d44a82eb16"),
"logoLight.svg": mustDecodeHex("6d42c88cf6668f7e57c4f2a505e71ecc8a1e0a27534632aa6adec87b812d0bb0"),
}
}
if sourceFileWithoutExtension == destinationFileWithoutExtension {
type imageHashMap map[string][]byte
func (m imageHashMap) Contains(target []byte) bool {
if len(target) == 0 {
return false
}
for _, h := range m {
if bytes.Equal(h, target) {
return true
}
}
return false
}
func imageAlreadyExists(sourceFile fs.DirEntry, destinationFiles map[string]bool) bool {
sourceFileWithoutExtension := getImageNameWithoutExtension(sourceFile.Name())
_, ok := destinationFiles[sourceFileWithoutExtension]
return ok
}
func getImageNameWithoutExtension(fileName string) string {
idx := strings.LastIndexByte(fileName, '.')
if idx < 1 {
// No dot found, or fileName starts with a dot
return fileName
}
return fileName[:idx]
}
func mustDecodeHex(str string) []byte {
b, err := hex.DecodeString(str)
if err != nil {
panic(err)
}
return b
}

View File

@@ -0,0 +1,61 @@
package bootstrap
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
func TestGetBuiltInImageData(t *testing.T) {
// Get the built-in image data map
builtInImages := getBuiltInImageHashes()
// Read the actual images directory from disk
imagesDir := filepath.Join("..", "..", "resources", "images")
actualFiles, err := os.ReadDir(imagesDir)
require.NoError(t, err, "Failed to read images directory")
// Create a map of actual files for comparison
actualFilesMap := make(map[string]struct{})
// Validate each actual file exists in the built-in data with correct hash
for _, file := range actualFiles {
fileName := file.Name()
if file.IsDir() || strings.HasPrefix(fileName, ".") {
continue
}
actualFilesMap[fileName] = struct{}{}
// Check if the file exists in the built-in data
builtInHash, exists := builtInImages[fileName]
assert.True(t, exists, "File %s exists in images directory but not in getBuiltInImageData map", fileName)
if !exists {
continue
}
filePath := filepath.Join(imagesDir, fileName)
// Validate SHA256 hash
actualHash, err := utils.CreateSha256FileHash(filePath)
require.NoError(t, err, "Failed to compute hash for %s", fileName)
assert.Equal(t, actualHash, builtInHash, "SHA256 hash mismatch for file %s", fileName)
}
// Ensure the built-in data doesn't have extra files that don't exist in the directory
for fileName := range builtInImages {
_, exists := actualFilesMap[fileName]
assert.True(t, exists, "File %s exists in getBuiltInImageData map but not in images directory", fileName)
}
// Ensure we have at least some files (sanity check)
assert.NotEmpty(t, actualFilesMap, "Images directory should contain at least one file")
assert.Len(t, actualFilesMap, len(builtInImages), "Number of files in directory should match number in built-in data map")
}

View File

@@ -422,17 +422,18 @@ func getGormLogger() gormLogger.Interface {
slogGorm.WithErrorField("error"),
)
if common.EnvConfig.AppEnv == "production" {
loggerOpts = append(loggerOpts,
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelWarn),
slogGorm.WithIgnoreTrace(),
)
} else {
if common.EnvConfig.LogLevel == "debug" {
loggerOpts = append(loggerOpts,
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelDebug),
slogGorm.WithRecordNotFoundError(),
slogGorm.WithTraceAll(),
)
} else {
loggerOpts = append(loggerOpts,
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelWarn),
slogGorm.WithIgnoreTrace(),
)
}
return slogGorm.New(loggerOpts...)

View File

@@ -8,6 +8,8 @@ import (
"os"
"time"
sloggin "github.com/gin-contrib/slog"
"github.com/lmittmann/tint"
"github.com/mattn/go-isatty"
"go.opentelemetry.io/contrib/bridges/otelslog"
@@ -89,28 +91,19 @@ func initOtelLogging(ctx context.Context, resource *resource.Resource) error {
return fmt.Errorf("failed to initialize OpenTelemetry log exporter: %w", err)
}
level := slog.LevelDebug
if common.EnvConfig.AppEnv == "production" {
level = slog.LevelInfo
}
level, _ := sloggin.ParseLevel(common.EnvConfig.LogLevel)
// Create the handler
var handler slog.Handler
switch {
case common.EnvConfig.LogJSON:
// Log as JSON if configured
if common.EnvConfig.LogJSON {
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
})
case isatty.IsTerminal(os.Stdout.Fd()):
// Enable colors if we have a TTY
} else {
handler = tint.NewHandler(os.Stdout, &tint.Options{
TimeFormat: time.StampMilli,
TimeFormat: time.Stamp,
Level: level,
})
default:
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
NoColor: !isatty.IsTerminal(os.Stdout.Fd()),
})
}

View File

@@ -12,8 +12,8 @@ import (
"strings"
"time"
sloggin "github.com/gin-contrib/slog"
"github.com/gin-gonic/gin"
sloggin "github.com/samber/slog-gin"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
"golang.org/x/time/rate"
"gorm.io/gorm"
@@ -49,30 +49,8 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
gin.SetMode(gin.TestMode)
}
// do not log these URLs
loggerSkipPathsPrefix := []string{
"GET /application-configuration/logo",
"GET /application-configuration/background-image",
"GET /application-configuration/favicon",
"GET /_app",
"GET /fonts",
"GET /healthz",
"HEAD /healthz",
}
r := gin.New()
r.Use(sloggin.NewWithConfig(slog.Default(), sloggin.Config{
Filters: []sloggin.Filter{
func(c *gin.Context) bool {
for _, prefix := range loggerSkipPathsPrefix {
if strings.HasPrefix(c.Request.Method+" "+c.Request.URL.String(), prefix) {
return false
}
}
return true
},
},
}))
initLogger(r)
if !common.EnvConfig.TrustProxy {
_ = r.SetTrustedProxies(nil)
@@ -86,6 +64,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
// Setup global middleware
r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewCspMiddleware().Add())
r.Use(middleware.NewErrorHandlerMiddleware().Add())
err := frontend.RegisterFrontend(r)
@@ -109,6 +88,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
controller.NewVersionController(apiGroup, svc.versionService)
// Add test controller in non-production environments
if common.EnvConfig.AppEnv != "production" {
@@ -198,3 +178,29 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
return runFn, nil
}
func initLogger(r *gin.Engine) {
loggerSkipPathsPrefix := []string{
"GET /api/application-configuration/logo",
"GET /api/application-configuration/background-image",
"GET /api/application-configuration/favicon",
"GET /_app",
"GET /fonts",
"GET /healthz",
"HEAD /healthz",
}
r.Use(sloggin.SetLogger(
sloggin.WithLogger(func(_ *gin.Context, _ *slog.Logger) *slog.Logger {
return slog.Default()
}),
sloggin.WithSkipper(func(c *gin.Context) bool {
for _, prefix := range loggerSkipPathsPrefix {
if strings.HasPrefix(c.Request.Method+" "+c.Request.URL.String(), prefix) {
return true
}
}
return false
}),
))
}

View File

@@ -23,6 +23,7 @@ type services struct {
userGroupService *service.UserGroupService
ldapService *service.LdapService
apiKeyService *service.ApiKeyService
versionService *service.VersionService
}
// Initializes all services
@@ -62,5 +63,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (sv
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
svc.versionService = service.NewVersionService(httpClient)
return svc, nil
}

View File

@@ -10,6 +10,7 @@ import (
"strings"
"github.com/caarlos0/env/v11"
sloggin "github.com/gin-contrib/slog"
_ "github.com/joho/godotenv/autoload"
)
@@ -27,19 +28,21 @@ const (
DbProviderPostgres DbProvider = "postgres"
MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"
defaultSqliteConnString string = "data/pocket-id.db"
AppUrl string = "http://localhost:1411"
)
type EnvConfigSchema struct {
AppEnv string `env:"APP_ENV"`
AppURL string `env:"APP_URL"`
DbProvider DbProvider `env:"DB_PROVIDER"`
AppEnv string `env:"APP_ENV" options:"toLower"`
LogLevel string `env:"LOG_LEVEL" options:"toLower"`
AppURL string `env:"APP_URL" options:"toLower"`
DbProvider DbProvider `env:"DB_PROVIDER" options:"toLower"`
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
UploadPath string `env:"UPLOAD_PATH"`
KeysPath string `env:"KEYS_PATH"`
KeysStorage string `env:"KEYS_STORAGE"`
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
Port string `env:"PORT"`
Host string `env:"HOST"`
Host string `env:"HOST" options:"toLower"`
UnixSocket string `env:"UNIX_SOCKET"`
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
@@ -53,6 +56,7 @@ type EnvConfigSchema struct {
TrustProxy bool `env:"TRUST_PROXY"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
InternalAppURL string `env:"INTERNAL_APP_URL"`
}
var EnvConfig = defaultConfig()
@@ -68,13 +72,14 @@ func init() {
func defaultConfig() EnvConfigSchema {
return EnvConfigSchema{
AppEnv: "production",
LogLevel: "info",
DbProvider: "sqlite",
DbConnectionString: "",
UploadPath: "data/uploads",
KeysPath: "data/keys",
KeysStorage: "", // "database" or "file"
EncryptionKey: nil,
AppURL: "http://localhost:1411",
AppURL: AppUrl,
Port: "1411",
Host: "0.0.0.0",
UnixSocket: "",
@@ -89,6 +94,7 @@ func defaultConfig() EnvConfigSchema {
TrustProxy: false,
AnalyticsDisabled: false,
AllowDowngrade: false,
InternalAppURL: "",
}
}
@@ -106,26 +112,40 @@ func parseEnvConfig() error {
return fmt.Errorf("error parsing env config: %w", err)
}
err = resolveFileBasedEnvVariables(&EnvConfig)
err = prepareEnvConfig(&EnvConfig)
if err != nil {
return fmt.Errorf("error preparing env config: %w", err)
}
err = validateEnvConfig(&EnvConfig)
if err != nil {
return err
}
// Validate the environment variables
switch EnvConfig.DbProvider {
return nil
}
// validateEnvConfig checks the EnvConfig for required fields and valid values
func validateEnvConfig(config *EnvConfigSchema) error {
if _, err := sloggin.ParseLevel(config.LogLevel); err != nil {
return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'")
}
switch config.DbProvider {
case DbProviderSqlite:
if EnvConfig.DbConnectionString == "" {
EnvConfig.DbConnectionString = defaultSqliteConnString
if config.DbConnectionString == "" {
config.DbConnectionString = defaultSqliteConnString
}
case DbProviderPostgres:
if EnvConfig.DbConnectionString == "" {
if config.DbConnectionString == "" {
return errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
}
default:
return errors.New("invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
}
parsedAppUrl, err := url.Parse(EnvConfig.AppURL)
parsedAppUrl, err := url.Parse(config.AppURL)
if err != nil {
return errors.New("APP_URL is not a valid URL")
}
@@ -133,25 +153,39 @@ func parseEnvConfig() error {
return errors.New("APP_URL must not contain a path")
}
switch EnvConfig.KeysStorage {
// Derive INTERNAL_APP_URL from APP_URL if not set; validate only when provided
if config.InternalAppURL == "" {
config.InternalAppURL = config.AppURL
} else {
parsedInternalAppUrl, err := url.Parse(config.InternalAppURL)
if err != nil {
return errors.New("INTERNAL_APP_URL is not a valid URL")
}
if parsedInternalAppUrl.Path != "" {
return errors.New("INTERNAL_APP_URL must not contain a path")
}
}
switch config.KeysStorage {
// KeysStorage defaults to "file" if empty
case "":
EnvConfig.KeysStorage = "file"
config.KeysStorage = "file"
case "database":
if EnvConfig.EncryptionKey == nil {
if config.EncryptionKey == nil {
return errors.New("ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database")
}
case "file":
// All good, these are valid values
default:
return fmt.Errorf("invalid value for KEYS_STORAGE: %s", EnvConfig.KeysStorage)
return fmt.Errorf("invalid value for KEYS_STORAGE: %s", config.KeysStorage)
}
return nil
}
// resolveFileBasedEnvVariables uses reflection to automatically resolve file-based secrets
func resolveFileBasedEnvVariables(config *EnvConfigSchema) error {
// prepareEnvConfig processes special options for EnvConfig fields
func prepareEnvConfig(config *EnvConfigSchema) error {
val := reflect.ValueOf(config).Elem()
typ := val.Type()
@@ -159,48 +193,65 @@ func resolveFileBasedEnvVariables(config *EnvConfigSchema) error {
field := val.Field(i)
fieldType := typ.Field(i)
// Only process string and []byte fields
isString := field.Kind() == reflect.String
isByteSlice := field.Kind() == reflect.Slice && field.Type().Elem().Kind() == reflect.Uint8
if !isString && !isByteSlice {
continue
}
// Only process fields with the "options" tag set to "file"
optionsTag := fieldType.Tag.Get("options")
if optionsTag != "file" {
continue
}
options := strings.Split(optionsTag, ",")
// Only process fields with the "env" tag
envTag := fieldType.Tag.Get("env")
if envTag == "" {
continue
}
envVarName := envTag
if commaIndex := len(envTag); commaIndex > 0 {
envVarName = envTag[:commaIndex]
}
// If the file environment variable is not set, skip
envVarFileName := envVarName + "_FILE"
envVarFileValue := os.Getenv(envVarFileName)
if envVarFileValue == "" {
continue
}
fileContent, err := os.ReadFile(envVarFileValue)
if err != nil {
return fmt.Errorf("failed to read file for env var %s: %w", envVarFileName, err)
}
if isString {
field.SetString(strings.TrimSpace(string(fileContent)))
} else {
field.SetBytes(fileContent)
for _, option := range options {
switch option {
case "toLower":
if field.Kind() == reflect.String {
field.SetString(strings.ToLower(field.String()))
}
case "file":
err := resolveFileBasedEnvVariable(field, fieldType)
if err != nil {
return err
}
}
}
}
return nil
}
// resolveFileBasedEnvVariable checks if an environment variable with the suffix "_FILE" is set,
// reads the content of the file specified by that variable, and sets the corresponding field's value.
func resolveFileBasedEnvVariable(field reflect.Value, fieldType reflect.StructField) error {
// Only process string and []byte fields
isString := field.Kind() == reflect.String
isByteSlice := field.Kind() == reflect.Slice && field.Type().Elem().Kind() == reflect.Uint8
if !isString && !isByteSlice {
return nil
}
// Only process fields with the "env" tag
envTag := fieldType.Tag.Get("env")
if envTag == "" {
return nil
}
envVarName := envTag
if commaIndex := len(envTag); commaIndex > 0 {
envVarName = envTag[:commaIndex]
}
// If the file environment variable is not set, skip
envVarFileName := envVarName + "_FILE"
envVarFileValue := os.Getenv(envVarFileName)
if envVarFileValue == "" {
return nil
}
fileContent, err := os.ReadFile(envVarFileValue)
if err != nil {
return fmt.Errorf("failed to read file for env var %s: %w", envVarFileName, err)
}
if isString {
field.SetString(strings.TrimSpace(string(fileContent)))
} else {
field.SetBytes(fileContent)
}
return nil
}

View File

@@ -17,18 +17,19 @@ func TestParseEnvConfig(t *testing.T) {
t.Run("should parse valid SQLite config correctly", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_PROVIDER", "SQLITE") // should be lowercased automatically
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("APP_URL", "HTTP://LOCALHOST:3000")
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, DbProviderSqlite, EnvConfig.DbProvider)
assert.Equal(t, "http://localhost:3000", EnvConfig.AppURL)
})
t.Run("should parse valid Postgres config correctly", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "postgres")
t.Setenv("DB_PROVIDER", "POSTGRES")
t.Setenv("DB_CONNECTION_STRING", "postgres://user:pass@localhost/db")
t.Setenv("APP_URL", "https://example.com")
@@ -51,7 +52,6 @@ func TestParseEnvConfig(t *testing.T) {
t.Run("should set default SQLite connection string when DB_CONNECTION_STRING is empty", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "") // Explicitly empty
t.Setenv("APP_URL", "http://localhost:3000")
err := parseEnvConfig()
@@ -91,6 +91,28 @@ func TestParseEnvConfig(t *testing.T) {
assert.ErrorContains(t, err, "APP_URL must not contain a path")
})
t.Run("should fail with invalid INTERNAL_APP_URL", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("INTERNAL_APP_URL", "€://not-a-valid-url")
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "INTERNAL_APP_URL is not a valid URL")
})
t.Run("should fail when INTERNAL_APP_URL contains path", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("INTERNAL_APP_URL", "http://localhost:3000/path")
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "INTERNAL_APP_URL must not contain a path")
})
t.Run("should default KEYS_STORAGE to 'file' when empty", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
@@ -170,25 +192,25 @@ func TestParseEnvConfig(t *testing.T) {
t.Setenv("DB_PROVIDER", "postgres")
t.Setenv("DB_CONNECTION_STRING", "postgres://test")
t.Setenv("APP_URL", "https://prod.example.com")
t.Setenv("APP_ENV", "staging")
t.Setenv("APP_ENV", "STAGING")
t.Setenv("UPLOAD_PATH", "/custom/uploads")
t.Setenv("KEYS_PATH", "/custom/keys")
t.Setenv("PORT", "8080")
t.Setenv("HOST", "127.0.0.1")
t.Setenv("HOST", "LOCALHOST")
t.Setenv("UNIX_SOCKET", "/tmp/app.sock")
t.Setenv("MAXMIND_LICENSE_KEY", "test-license")
t.Setenv("GEOLITE_DB_PATH", "/custom/geolite.mmdb")
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, "staging", EnvConfig.AppEnv)
assert.Equal(t, "staging", EnvConfig.AppEnv) // lowercased
assert.Equal(t, "/custom/uploads", EnvConfig.UploadPath)
assert.Equal(t, "8080", EnvConfig.Port)
assert.Equal(t, "127.0.0.1", EnvConfig.Host)
assert.Equal(t, "localhost", EnvConfig.Host) // lowercased
})
}
func TestResolveFileBasedEnvVariables(t *testing.T) {
func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
// Create temporary directory for test files
tempDir := t.TempDir()
@@ -203,103 +225,34 @@ func TestResolveFileBasedEnvVariables(t *testing.T) {
err = os.WriteFile(dbConnFile, []byte(dbConnContent), 0600)
require.NoError(t, err)
// Create a binary file for testing binary data handling
binaryKeyFile := tempDir + "/binary_key.bin"
binaryKeyContent := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10}
binaryKeyContent := []byte{0x01, 0x02, 0x03, 0x04}
err = os.WriteFile(binaryKeyFile, binaryKeyContent, 0600)
require.NoError(t, err)
t.Run("should read file content for fields with options:file tag", func(t *testing.T) {
t.Run("should process toLower and file options", func(t *testing.T) {
config := defaultConfig()
config.AppEnv = "STAGING"
config.Host = "LOCALHOST"
// Set environment variables pointing to files
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
err := resolveFileBasedEnvVariables(&config)
err := prepareEnvConfig(&config)
require.NoError(t, err)
// Verify file contents were read correctly
assert.Equal(t, "staging", config.AppEnv)
assert.Equal(t, "localhost", config.Host)
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, dbConnContent, config.DbConnectionString)
})
t.Run("should skip fields without options:file tag", func(t *testing.T) {
config := defaultConfig()
originalAppURL := config.AppURL
// Set a file for a field that doesn't have options:file tag
t.Setenv("APP_URL_FILE", "/tmp/nonexistent.txt")
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// AppURL should remain unchanged
assert.Equal(t, originalAppURL, config.AppURL)
})
t.Run("should skip non-string fields", func(t *testing.T) {
// This test verifies that non-string fields are skipped
// We test this indirectly by ensuring the function doesn't error
// when processing the actual EnvConfigSchema which has bool fields
config := defaultConfig()
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
})
t.Run("should skip when _FILE environment variable is not set", func(t *testing.T) {
config := defaultConfig()
originalEncryptionKey := config.EncryptionKey
// Don't set ENCRYPTION_KEY_FILE environment variable
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// EncryptionKey should remain unchanged
assert.Equal(t, originalEncryptionKey, config.EncryptionKey)
})
t.Run("should handle multiple file-based variables simultaneously", func(t *testing.T) {
config := defaultConfig()
// Set multiple file environment variables
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// All should be resolved correctly
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, dbConnContent, config.DbConnectionString)
})
t.Run("should handle mixed file and non-file environment variables", func(t *testing.T) {
config := defaultConfig()
// Set both file and non-file environment variables
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// File-based should be resolved, others should remain as set by env parser
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, "http://localhost:1411", config.AppURL)
})
t.Run("should handle binary data correctly", func(t *testing.T) {
config := defaultConfig()
// Set environment variable pointing to binary file
t.Setenv("ENCRYPTION_KEY_FILE", binaryKeyFile)
err := resolveFileBasedEnvVariables(&config)
err := prepareEnvConfig(&config)
require.NoError(t, err)
// Verify binary data was read correctly without corruption
assert.Equal(t, binaryKeyContent, config.EncryptionKey)
})
}

View File

@@ -828,7 +828,7 @@ func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
return
}
preview, err := oc.oidcService.GetClientPreview(c.Request.Context(), clientID, userID, scopes)
preview, err := oc.oidcService.GetClientPreview(c.Request.Context(), clientID, userID, strings.Split(scopes, " "))
if err != nil {
_ = c.Error(err)
return

View File

@@ -0,0 +1,40 @@
package controller
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
// NewVersionController registers version-related routes.
func NewVersionController(group *gin.RouterGroup, versionService *service.VersionService) {
vc := &VersionController{versionService: versionService}
group.GET("/version/latest", vc.getLatestVersionHandler)
}
type VersionController struct {
versionService *service.VersionService
}
// getLatestVersionHandler godoc
// @Summary Get latest available version of Pocket ID
// @Tags Version
// @Produce json
// @Success 200 {object} map[string]string "Latest version information"
// @Router /api/version/latest [get]
func (vc *VersionController) getLatestVersionHandler(c *gin.Context) {
tag, err := vc.versionService.GetLatestVersion(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
}
utils.SetCacheControlHeader(c, 5*time.Minute, 15*time.Minute)
c.JSON(http.StatusOK, gin.H{
"latestVersion": tag,
})
}

View File

@@ -67,6 +67,9 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
appUrl := common.EnvConfig.AppURL
internalAppUrl := common.EnvConfig.InternalAppURL
alg, err := wkc.jwtService.GetKeyAlg()
if err != nil {
return nil, fmt.Errorf("failed to get key algorithm: %w", err)
@@ -74,13 +77,13 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
config := map[string]any{
"issuer": appUrl,
"authorization_endpoint": appUrl + "/authorize",
"token_endpoint": appUrl + "/api/oidc/token",
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
"token_endpoint": internalAppUrl + "/api/oidc/token",
"userinfo_endpoint": internalAppUrl + "/api/oidc/userinfo",
"end_session_endpoint": appUrl + "/api/oidc/end-session",
"introspection_endpoint": appUrl + "/api/oidc/introspect",
"introspection_endpoint": internalAppUrl + "/api/oidc/introspect",
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
"jwks_uri": appUrl + "/.well-known/jwks.json",
"grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode},
"jwks_uri": internalAppUrl + "/.well-known/jwks.json",
"grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode, service.GrantTypeClientCredentials},
"scopes_supported": []string{"openid", "profile", "email", "groups"},
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
"response_types_supported": []string{"code", "id_token"},

View File

@@ -41,6 +41,7 @@ type AppConfigUpdateDto struct {
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
LdapAttributeUserDisplayName string `json:"ldapAttributeUserDisplayName"`
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"`
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`

View File

@@ -31,8 +31,8 @@ type OidcClientWithAllowedGroupsCountDto struct {
type OidcClientUpdateDto struct {
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
RequiresReauthentication bool `json:"requiresReauthentication"`
@@ -87,6 +87,7 @@ type OidcCreateTokensDto struct {
RefreshToken string `form:"refresh_token"`
ClientAssertion string `form:"client_assertion"`
ClientAssertionType string `form:"client_assertion_type"`
Resource string `form:"resource"`
}
type OidcIntrospectDto struct {

View File

@@ -1,6 +1,9 @@
package dto
import (
"errors"
"github.com/gin-gonic/gin/binding"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
@@ -9,7 +12,8 @@ type UserDto struct {
Username string `json:"username"`
Email string `json:"email" `
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
LastName *string `json:"lastName"`
DisplayName string `json:"displayName"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
CustomClaims []CustomClaimDto `json:"customClaims"`
@@ -19,14 +23,26 @@ type UserDto struct {
}
type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Email string `json:"email" binding:"required,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
Disabled bool `json:"disabled"`
LdapID string `json:"-"`
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Email string `json:"email" binding:"required,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
DisplayName string `json:"displayName" binding:"required,max=100" unorm:"nfc"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
Disabled bool `json:"disabled"`
LdapID string `json:"-"`
}
func (u UserCreateDto) Validate() error {
e, ok := binding.Validator.Engine().(interface {
Struct(s any) error
})
if !ok {
return errors.New("validator does not implement the expected interface")
}
return e.Struct(u)
}
type OneTimeAccessTokenCreateDto struct {

View File

@@ -0,0 +1,104 @@
package dto
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestUserCreateDto_Validate(t *testing.T) {
testCases := []struct {
name string
input UserCreateDto
wantErr string
}{
{
name: "valid input",
input: UserCreateDto{
Username: "testuser",
Email: "test@example.com",
FirstName: "John",
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "",
},
{
name: "missing username",
input: UserCreateDto{
Email: "test@example.com",
FirstName: "John",
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'Username' failed on the 'required' tag",
},
{
name: "missing display name",
input: UserCreateDto{
Email: "test@example.com",
FirstName: "John",
LastName: "Doe",
},
wantErr: "Field validation for 'DisplayName' failed on the 'required' tag",
},
{
name: "username contains invalid characters",
input: UserCreateDto{
Username: "test/ser",
Email: "test@example.com",
FirstName: "John",
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'Username' failed on the 'username' tag",
},
{
name: "invalid email",
input: UserCreateDto{
Username: "testuser",
Email: "not-an-email",
FirstName: "John",
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'Email' failed on the 'email' tag",
},
{
name: "first name too short",
input: UserCreateDto{
Username: "testuser",
Email: "test@example.com",
FirstName: "",
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'FirstName' failed on the 'required' tag",
},
{
name: "last name too long",
input: UserCreateDto{
Username: "testuser",
Email: "test@example.com",
FirstName: "John",
LastName: "abcdfghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'LastName' failed on the 'max' tag",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := tc.input.Validate()
if tc.wantErr == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
require.ErrorContains(t, err, tc.wantErr)
})
}
}

View File

@@ -1,6 +1,9 @@
package dto
import (
"errors"
"github.com/gin-gonic/gin/binding"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
@@ -39,6 +42,17 @@ type UserGroupCreateDto struct {
LdapID string `json:"-"`
}
func (g UserGroupCreateDto) Validate() error {
e, ok := binding.Validator.Engine().(interface {
Struct(s any) error
})
if !ok {
return errors.New("validator does not implement the expected interface")
}
return e.Struct(g)
}
type UserGroupUpdateUsersDto struct {
UserIDs []string `json:"userIds" binding:"required"`
}

View File

@@ -1,7 +1,9 @@
package dto
import (
"net/url"
"regexp"
"strings"
"time"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -10,43 +12,76 @@ import (
"github.com/go-playground/validator/v10"
)
// [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 username must end with an 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._-]+$")
func init() {
v := binding.Validator.Engine().(*validator.Validate)
// [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 username must end with an 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._-]+$")
// Maximum allowed value for TTLs
const maxTTL = 31 * 24 * time.Hour
// Errors here are development-time ones
err := v.RegisterValidation("username", func(fl validator.FieldLevel) bool {
return validateUsernameRegex.MatchString(fl.Field().String())
})
if err != nil {
if err := v.RegisterValidation("username", func(fl validator.FieldLevel) bool {
return ValidateUsername(fl.Field().String())
}); err != nil {
panic("Failed to register custom validation for username: " + err.Error())
}
err = v.RegisterValidation("client_id", func(fl validator.FieldLevel) bool {
return validateClientIDRegex.MatchString(fl.Field().String())
})
if err != nil {
if err := v.RegisterValidation("client_id", func(fl validator.FieldLevel) bool {
return ValidateClientID(fl.Field().String())
}); err != nil {
panic("Failed to register custom validation for client_id: " + err.Error())
}
err = v.RegisterValidation("ttl", func(fl validator.FieldLevel) bool {
if err := v.RegisterValidation("ttl", func(fl validator.FieldLevel) bool {
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
if !ok {
return false
}
// Allow zero, which means the field wasn't set
return ttl.Duration == 0 || ttl.Duration > time.Second && ttl.Duration <= maxTTL
})
if err != nil {
return ttl.Duration == 0 || (ttl.Duration > time.Second && ttl.Duration <= maxTTL)
}); err != nil {
panic("Failed to register custom validation for ttl: " + err.Error())
}
if err := v.RegisterValidation("callback_url", func(fl validator.FieldLevel) bool {
return ValidateCallbackURL(fl.Field().String())
}); err != nil {
panic("Failed to register custom validation for callback_url: " + err.Error())
}
}
// ValidateUsername validates username inputs
func ValidateUsername(username string) bool {
return validateUsernameRegex.MatchString(username)
}
// ValidateClientID validates client ID inputs
func ValidateClientID(clientID string) bool {
return validateClientIDRegex.MatchString(clientID)
}
// ValidateCallbackURL validates callback URLs with support for wildcards
func ValidateCallbackURL(raw string) bool {
if raw == "*" {
return true
}
// Replace all '*' with 'x' to check if the rest is still a valid URI
test := strings.ReplaceAll(raw, "*", "x")
u, err := url.Parse(test)
if err != nil {
return false
}
if !u.IsAbs() {
return false
}
return true
}

View File

@@ -0,0 +1,58 @@
package dto
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestValidateUsername(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"valid simple", "user123", true},
{"valid with dot", "user.name", true},
{"valid with underscore", "user_name", true},
{"valid with hyphen", "user-name", true},
{"valid with at", "user@name", true},
{"starts with symbol", ".username", false},
{"ends with non-alphanumeric", "username-", false},
{"contains space", "user name", false},
{"empty", "", false},
{"only special chars", "-._@", false},
{"valid long", "a1234567890_b.c-d@e", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, ValidateUsername(tt.input))
})
}
}
func TestValidateClientID(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"valid simple", "client123", true},
{"valid with dot", "client.id", true},
{"valid with underscore", "client_id", true},
{"valid with hyphen", "client-id", true},
{"valid with all", "client.id-123_abc", true},
{"contains space", "client id", false},
{"contains at", "client@id", false},
{"empty", "", false},
{"only special chars", "-._", true},
{"invalid char", "client!id", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, ValidateClientID(tt.input))
})
}
}

View File

@@ -0,0 +1,53 @@
package middleware
import (
"crypto/rand"
"encoding/base64"
"github.com/gin-gonic/gin"
)
// CspMiddleware sets a Content Security Policy header and, when possible,
// includes a per-request nonce for inline scripts.
type CspMiddleware struct{}
func NewCspMiddleware() *CspMiddleware { return &CspMiddleware{} }
// GetCSPNonce returns the CSP nonce generated for this request, if any.
func GetCSPNonce(c *gin.Context) string {
if v, ok := c.Get("csp_nonce"); ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
func (m *CspMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
// Generate a random base64 nonce for this request
nonce := generateNonce()
c.Set("csp_nonce", nonce)
csp := "default-src 'self'; " +
"base-uri 'self'; " +
"object-src 'none'; " +
"frame-ancestors 'none'; " +
"form-action 'self'; " +
"img-src 'self' data: blob:; " +
"font-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"script-src 'self' 'nonce-" + nonce + "'"
c.Writer.Header().Set("Content-Security-Policy", csp)
c.Next()
}
}
func generateNonce() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "" // if generation fails, return empty; policy will omit nonce
}
return base64.RawURLEncoding.EncodeToString(b)
}

View File

@@ -77,7 +77,7 @@ func handleValidationError(validationErrors validator.ValidationErrors) string {
case "email":
errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName)
case "username":
errorMessage = fmt.Sprintf("%s must only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
errorMessage = fmt.Sprintf("%s must only contain letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
case "url":
errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName)
case "min":

View File

@@ -74,6 +74,7 @@ type AppConfig struct {
LdapAttributeUserEmail AppConfigVariable `key:"ldapAttributeUserEmail"`
LdapAttributeUserFirstName AppConfigVariable `key:"ldapAttributeUserFirstName"`
LdapAttributeUserLastName AppConfigVariable `key:"ldapAttributeUserLastName"`
LdapAttributeUserDisplayName AppConfigVariable `key:"ldapAttributeUserDisplayName"`
LdapAttributeUserProfilePicture AppConfigVariable `key:"ldapAttributeUserProfilePicture"`
LdapAttributeGroupMember AppConfigVariable `key:"ldapAttributeGroupMember"`
LdapAttributeGroupUniqueIdentifier AppConfigVariable `key:"ldapAttributeGroupUniqueIdentifier"`

View File

@@ -4,6 +4,7 @@ import (
"database/sql/driver"
"encoding/json"
"fmt"
"strings"
"gorm.io/gorm"
@@ -21,6 +22,14 @@ type UserAuthorizedOidcClient struct {
Client OidcClient
}
func (c UserAuthorizedOidcClient) Scopes() []string {
if len(c.Scope) == 0 {
return []string{}
}
return strings.Split(c.Scope, " ")
}
type OidcAuthorizationCode struct {
Base
@@ -72,6 +81,14 @@ type OidcRefreshToken struct {
Client OidcClient
}
func (c OidcRefreshToken) Scopes() []string {
if len(c.Scope) == 0 {
return []string{}
}
return strings.Split(c.Scope, " ")
}
func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
// Compute HasLogo field
c.HasLogo = c.ImageType != nil && *c.ImageType != ""

View File

@@ -13,14 +13,15 @@ import (
type User struct {
Base
Username string `sortable:"true"`
Email string `sortable:"true"`
FirstName string `sortable:"true"`
LastName string `sortable:"true"`
IsAdmin bool `sortable:"true"`
Locale *string
LdapID *string
Disabled bool `sortable:"true"`
Username string `sortable:"true"`
Email string `sortable:"true"`
FirstName string `sortable:"true"`
LastName string `sortable:"true"`
DisplayName string `sortable:"true"`
IsAdmin bool `sortable:"true"`
Locale *string
LdapID *string
Disabled bool `sortable:"true"`
CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
@@ -31,7 +32,12 @@ func (u User) WebAuthnID() []byte { return []byte(u.ID) }
func (u User) WebAuthnName() string { return u.Username }
func (u User) WebAuthnDisplayName() string { return u.FirstName + " " + u.LastName }
func (u User) WebAuthnDisplayName() string {
if u.DisplayName != "" {
return u.DisplayName
}
return u.FirstName + " " + u.LastName
}
func (u User) WebAuthnIcon() string { return "" }
@@ -66,7 +72,9 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
return descriptors
}
func (u User) FullName() string { return u.FirstName + " " + u.LastName }
func (u User) FullName() string {
return u.FirstName + " " + u.LastName
}
func (u User) Initials() string {
first := utils.GetFirstCharacter(u.FirstName)

View File

@@ -70,7 +70,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"},
AccentColor: model.AppConfigVariable{Value: "default"},
// Internal
BackgroundImageType: model.AppConfigVariable{Value: "jpg"},
BackgroundImageType: model.AppConfigVariable{Value: "webp"},
LogoLightImageType: model.AppConfigVariable{Value: "svg"},
LogoDarkImageType: model.AppConfigVariable{Value: "svg"},
InstanceID: model.AppConfigVariable{Value: ""},
@@ -100,6 +100,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
LdapAttributeUserEmail: model.AppConfigVariable{},
LdapAttributeUserFirstName: model.AppConfigVariable{},
LdapAttributeUserLastName: model.AppConfigVariable{},
LdapAttributeUserDisplayName: model.AppConfigVariable{Value: "cn"},
LdapAttributeUserProfilePicture: model.AppConfigVariable{},
LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"},
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{},

View File

@@ -25,6 +25,7 @@ func isReservedClaim(key string) bool {
"name",
"email",
"preferred_username",
"display_name",
"groups",
TokenTypeClaim,
"sub",

View File

@@ -78,21 +78,23 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Base: model.Base{
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
},
Username: "tim",
Email: "tim.cook@test.com",
FirstName: "Tim",
LastName: "Cook",
IsAdmin: true,
Username: "tim",
Email: "tim.cook@test.com",
FirstName: "Tim",
LastName: "Cook",
DisplayName: "Tim Cook",
IsAdmin: true,
},
{
Base: model.Base{
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
},
Username: "craig",
Email: "craig.federighi@test.com",
FirstName: "Craig",
LastName: "Federighi",
IsAdmin: false,
Username: "craig",
Email: "craig.federighi@test.com",
FirstName: "Craig",
LastName: "Federighi",
DisplayName: "Craig Federighi",
IsAdmin: false,
},
}
for _, user := range users {

View File

@@ -262,7 +262,7 @@ func prepareBody[V any](srv *EmailService, template email.Template[V], data *ema
// prepare text part
var textHeader = textproto.MIMEHeader{}
textHeader.Add("Content-Type", "text/plain;\n charset=UTF-8")
textHeader.Add("Content-Type", "text/plain; charset=UTF-8")
textHeader.Add("Content-Transfer-Encoding", "quoted-printable")
textPart, err := mpart.CreatePart(textHeader)
if err != nil {
@@ -274,18 +274,17 @@ func prepareBody[V any](srv *EmailService, template email.Template[V], data *ema
if err != nil {
return "", "", fmt.Errorf("execute text template: %w", err)
}
textQp.Close()
// prepare html part
var htmlHeader = textproto.MIMEHeader{}
htmlHeader.Add("Content-Type", "text/html;\n charset=UTF-8")
htmlHeader.Add("Content-Transfer-Encoding", "quoted-printable")
htmlHeader.Add("Content-Type", "text/html; charset=UTF-8")
htmlHeader.Add("Content-Transfer-Encoding", "8bit")
htmlPart, err := mpart.CreatePart(htmlHeader)
if err != nil {
return "", "", fmt.Errorf("create html part: %w", err)
}
htmlQp := quotedprintable.NewWriter(htmlPart)
err = email.GetTemplate(srv.htmlTemplates, template).ExecuteTemplate(htmlQp, "root", data)
err = email.GetTemplate(srv.htmlTemplates, template).ExecuteTemplate(htmlPart, "root", data)
if err != nil {
return "", "", fmt.Errorf("execute html template: %w", err)
}

View File

@@ -179,10 +179,12 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
}
}
username = norm.NFC.String(username)
var databaseUser model.User
err = tx.
WithContext(ctx).
Where("username = ? AND ldap_id IS NOT NULL", norm.NFC.String(username)).
Where("username = ? AND ldap_id IS NOT NULL", username).
First(&databaseUser).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -202,6 +204,12 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
}
dto.Normalize(syncGroup)
err = syncGroup.Validate()
if err != nil {
slog.WarnContext(ctx, "LDAP user group object is not valid", slog.Any("error", err))
continue
}
if databaseGroup.ID == "" {
newGroup, err := s.groupService.createInternal(ctx, syncGroup, tx)
if err != nil {
@@ -270,6 +278,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
dbConfig.LdapAttributeUserFirstName.Value,
dbConfig.LdapAttributeUserLastName.Value,
dbConfig.LdapAttributeUserProfilePicture.Value,
dbConfig.LdapAttributeUserDisplayName.Value,
}
// Filters must start and finish with ()!
@@ -338,15 +347,22 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
}
newUser := dto.UserCreateDto{
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
Email: value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value),
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
IsAdmin: isAdmin,
LdapID: ldapId,
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
Email: value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value),
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
IsAdmin: isAdmin,
LdapID: ldapId,
}
dto.Normalize(newUser)
err = newUser.Validate()
if err != nil {
slog.WarnContext(ctx, "LDAP user object is not valid", slog.Any("error", err))
continue
}
if databaseUser.ID == "" {
_, err = s.userService.createUserInternal(ctx, newUser, true, tx)
if errors.Is(err, &common.AlreadyInUseError{}) {

View File

@@ -37,9 +37,11 @@ const (
GrantTypeAuthorizationCode = "authorization_code"
GrantTypeRefreshToken = "refresh_token"
GrantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code"
GrantTypeClientCredentials = "client_credentials"
ClientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" //nolint:gosec
AccessTokenDuration = time.Hour
RefreshTokenDuration = 30 * 24 * time.Hour // 30 days
DeviceCodeDuration = 15 * time.Minute
)
@@ -247,6 +249,8 @@ func (s *OidcService) CreateTokens(ctx context.Context, input dto.OidcCreateToke
return s.createTokenFromRefreshToken(ctx, input)
case GrantTypeDeviceCode:
return s.createTokenFromDeviceCode(ctx, input)
case GrantTypeClientCredentials:
return s.createTokenFromClientCredentials(ctx, input)
default:
return CreatedTokens{}, &common.OidcGrantTypeNotSupportedError{}
}
@@ -329,7 +333,35 @@ func (s *OidcService) createTokenFromDeviceCode(ctx context.Context, input dto.O
IdToken: idToken,
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: time.Hour,
ExpiresIn: AccessTokenDuration,
}, nil
}
func (s *OidcService) createTokenFromClientCredentials(ctx context.Context, input dto.OidcCreateTokensDto) (CreatedTokens, error) {
client, err := s.verifyClientCredentialsInternal(ctx, s.db, clientAuthCredentialsFromCreateTokensDto(&input), false)
if err != nil {
return CreatedTokens{}, err
}
// GenerateOAuthAccessToken uses user.ID as a "sub" claim. Prefix is used to take those security considerations
// into account: https://datatracker.ietf.org/doc/html/rfc9068#name-security-considerations
dummyUser := model.User{
Base: model.Base{ID: "client-" + client.ID},
}
audClaim := client.ID
if input.Resource != "" {
audClaim = input.Resource
}
accessToken, err := s.jwtService.GenerateOAuthAccessToken(dummyUser, audClaim)
if err != nil {
return CreatedTokens{}, err
}
return CreatedTokens{
AccessToken: accessToken,
ExpiresIn: AccessTokenDuration,
}, nil
}
@@ -403,7 +435,7 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu
IdToken: idToken,
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: time.Hour,
ExpiresIn: AccessTokenDuration,
}, nil
}
@@ -447,10 +479,9 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
).
First(&storedRefreshToken).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
} else if err != nil {
return CreatedTokens{}, err
}
@@ -465,6 +496,19 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
return CreatedTokens{}, err
}
// Load the profile, which we need for the ID token
userClaims, err := s.getUserClaims(ctx, &storedRefreshToken.User, storedRefreshToken.Scopes(), tx)
if err != nil {
return CreatedTokens{}, err
}
// Generate a new ID token
// There's no nonce here because we don't have one with the refresh token, but that's not required
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, "")
if err != nil {
return CreatedTokens{}, err
}
// Generate a new refresh token and invalidate the old one
newRefreshToken, err := s.createRefreshToken(ctx, input.ClientID, storedRefreshToken.UserID, storedRefreshToken.Scope, tx)
if err != nil {
@@ -488,7 +532,8 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
return CreatedTokens{
AccessToken: accessToken,
RefreshToken: newRefreshToken,
ExpiresIn: time.Hour,
IdToken: idToken,
ExpiresIn: AccessTokenDuration,
}, nil
}
@@ -1383,14 +1428,18 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
// If user has no groups, only return clients with no allowed user groups
if len(userGroupIDs) == 0 {
query = query.
Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id").
Where("oidc_clients_allowed_user_groups.oidc_client_id IS NULL")
query = query.Where(`NOT EXISTS (
SELECT 1 FROM oidc_clients_allowed_user_groups
WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id)`)
} else {
// Return clients with no allowed user groups OR clients where user is in allowed groups
query = query.
Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id").
Where("oidc_clients_allowed_user_groups.oidc_client_id IS NULL OR oidc_clients_allowed_user_groups.user_group_id IN (?)", userGroupIDs)
query = query.Where(`
NOT EXISTS (
SELECT 1 FROM oidc_clients_allowed_user_groups
WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id
) OR EXISTS (
SELECT 1 FROM oidc_clients_allowed_user_groups
WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id
AND oidc_clients_allowed_user_groups.user_group_id IN (?))`, userGroupIDs)
}
var clients []model.OidcClient
@@ -1690,7 +1739,7 @@ func (s *OidcService) extractClientIDFromAssertion(assertion string) (string, er
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()
defer func() {
tx.Rollback()
@@ -1715,14 +1764,7 @@ func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, use
return nil, &common.OidcAccessDeniedError{}
}
dummyAuthorizedClient := model.UserAuthorizedOidcClient{
UserID: userID,
ClientID: clientID,
Scope: scopes,
User: user,
}
userClaims, err := s.getUserClaimsFromAuthorizedClient(ctx, &dummyAuthorizedClient, tx)
userClaims, err := s.getUserClaims(ctx, &user, scopes, tx)
if err != nil {
return nil, err
}
@@ -1775,14 +1817,10 @@ func (s *OidcService) getUserClaimsForClientInternal(ctx context.Context, userID
return nil, err
}
return s.getUserClaimsFromAuthorizedClient(ctx, &authorizedOidcClient, tx)
return s.getUserClaims(ctx, &authorizedOidcClient.User, authorizedOidcClient.Scopes(), tx)
}
func (s *OidcService) getUserClaimsFromAuthorizedClient(ctx context.Context, authorizedClient *model.UserAuthorizedOidcClient, tx *gorm.DB) (map[string]any, error) {
user := authorizedClient.User
scopes := strings.Split(authorizedClient.Scope, " ")
func (s *OidcService) getUserClaims(ctx context.Context, user *model.User, scopes []string, tx *gorm.DB) (map[string]any, error) {
claims := make(map[string]any, 10)
claims["sub"] = user.ID
@@ -1800,13 +1838,6 @@ func (s *OidcService) getUserClaimsFromAuthorizedClient(ctx context.Context, aut
}
if slices.Contains(scopes, "profile") {
// Add profile claims
claims["given_name"] = user.FirstName
claims["family_name"] = user.LastName
claims["name"] = user.FullName()
claims["preferred_username"] = user.Username
claims["picture"] = common.EnvConfig.AppURL + "/api/users/" + user.ID + "/profile-picture.png"
// Add custom claims
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, tx)
if err != nil {
@@ -1825,6 +1856,15 @@ func (s *OidcService) getUserClaimsFromAuthorizedClient(ctx context.Context, aut
claims[customClaim.Key] = customClaim.Value
}
}
// Add profile claims
claims["given_name"] = user.FirstName
claims["family_name"] = user.LastName
claims["name"] = user.FullName()
claims["display_name"] = user.DisplayName
claims["preferred_username"] = user.Username
claims["picture"] = common.EnvConfig.AppURL + "/api/users/" + user.ID + "/profile-picture.png"
}
if slices.Contains(scopes, "email") {

View File

@@ -18,6 +18,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
@@ -148,6 +149,13 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
privateJWKDefaults, jwkSetJSONDefaults := generateTestECDSAKey(t)
require.NoError(t, err)
// Create a mock config and JwtService to test complete a token creation process
mockConfig := NewTestAppConfigService(&model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
})
mockJwtService, err := NewJwtService(db, mockConfig)
require.NoError(t, err)
// Create a mock HTTP client with custom transport to return the JWKS
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
@@ -162,8 +170,10 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
// Init the OidcService
s := &OidcService{
db: db,
httpClient: httpClient,
db: db,
jwtService: mockJwtService,
appConfigService: mockConfig,
httpClient: httpClient,
}
s.jwkCache, err = s.getJWKCache(t.Context())
require.NoError(t, err)
@@ -384,4 +394,119 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
assert.Equal(t, federatedClient.ID, client.ID)
})
})
t.Run("Complete token creation flow", func(t *testing.T) {
t.Run("Client Credentials flow", func(t *testing.T) {
t.Run("Succeeds with valid secret", func(t *testing.T) {
// Generate a token
input := dto.OidcCreateTokensDto{
ClientID: confidentialClient.ID,
ClientSecret: confidentialSecret,
}
token, err := s.createTokenFromClientCredentials(t.Context(), input)
require.NoError(t, err)
require.NotNil(t, token)
// Verify the token
claims, err := s.jwtService.VerifyOAuthAccessToken(token.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-"+confidentialClient.ID, subject, "Token subject should match confidential client ID with prefix")
audience, ok := claims.Audience()
_ = assert.True(t, ok, "Audience not found in token") &&
assert.Equal(t, []string{confidentialClient.ID}, audience, "Audience should contain confidential client ID")
})
t.Run("Fails with invalid secret", func(t *testing.T) {
input := dto.OidcCreateTokensDto{
ClientID: confidentialClient.ID,
ClientSecret: "invalid-secret",
}
_, err := s.createTokenFromClientCredentials(t.Context(), input)
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcClientSecretInvalidError{})
})
t.Run("Fails without client secret for public clients", func(t *testing.T) {
input := dto.OidcCreateTokensDto{
ClientID: publicClient.ID,
}
_, err := s.createTokenFromClientCredentials(t.Context(), input)
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcMissingClientCredentialsError{})
})
t.Run("Succeeds with valid assertion", func(t *testing.T) {
// Create JWT for federated identity
token, err := jwt.NewBuilder().
Issuer(federatedClientIssuer).
Audience([]string{federatedClientAudience}).
Subject(federatedClient.ID).
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{
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("Fails with invalid assertion", func(t *testing.T) {
input := dto.OidcCreateTokensDto{
ClientAssertion: "invalid.jwt.token",
ClientAssertionType: ClientAssertionTypeJWTBearer,
}
_, err := s.createTokenFromClientCredentials(t.Context(), input)
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{})
})
t.Run("Succeeds with custom resource", func(t *testing.T) {
// Generate a token
input := dto.OidcCreateTokensDto{
ClientID: confidentialClient.ID,
ClientSecret: confidentialSecret,
Resource: "https://example.com/",
}
token, err := s.createTokenFromClientCredentials(t.Context(), input)
require.NoError(t, err)
require.NotNil(t, token)
// Verify the token
claims, err := s.jwtService.VerifyOAuthAccessToken(token.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-"+confidentialClient.ID, subject, "Token subject should match confidential client ID with prefix")
audience, ok := claims.Audience()
_ = assert.True(t, ok, "Audience not found in token") &&
assert.Equal(t, []string{input.Resource}, audience, "Audience should contain the resource provided in request")
})
})
})
}

View File

@@ -245,12 +245,13 @@ func (s *UserService) CreateUser(ctx context.Context, input dto.UserCreateDto) (
func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) {
user := model.User{
FirstName: input.FirstName,
LastName: input.LastName,
Email: input.Email,
Username: input.Username,
IsAdmin: input.IsAdmin,
Locale: input.Locale,
FirstName: input.FirstName,
LastName: input.LastName,
DisplayName: input.DisplayName,
Email: input.Email,
Username: input.Username,
IsAdmin: input.IsAdmin,
Locale: input.Locale,
}
if input.LdapID != "" {
user.LdapID = &input.LdapID
@@ -362,6 +363,7 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
// Full update: Allow updating all personal fields
user.FirstName = updatedUser.FirstName
user.LastName = updatedUser.LastName
user.DisplayName = updatedUser.DisplayName
user.Email = updatedUser.Email
user.Username = updatedUser.Username
user.Locale = updatedUser.Locale
@@ -600,11 +602,12 @@ func (s *UserService) SignUpInitialAdmin(ctx context.Context, signUpData dto.Sig
}
userToCreate := dto.UserCreateDto{
FirstName: signUpData.FirstName,
LastName: signUpData.LastName,
Username: signUpData.Username,
Email: signUpData.Email,
IsAdmin: true,
FirstName: signUpData.FirstName,
LastName: signUpData.LastName,
DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
Username: signUpData.Username,
Email: signUpData.Email,
IsAdmin: true,
}
user, err := s.createUserInternal(ctx, userToCreate, false, tx)
@@ -736,10 +739,11 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd
}
userToCreate := dto.UserCreateDto{
Username: signupData.Username,
Email: signupData.Email,
FirstName: signupData.FirstName,
LastName: signupData.LastName,
Username: signupData.Username,
Email: signupData.Email,
FirstName: signupData.FirstName,
LastName: signupData.LastName,
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
}
user, err := s.createUserInternal(ctx, userToCreate, false, tx)

View File

@@ -0,0 +1,74 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
const (
versionTTL = 15 * time.Minute
versionCheckURL = "https://api.github.com/repos/pocket-id/pocket-id/releases/latest"
)
type VersionService struct {
httpClient *http.Client
cache *utils.Cache[string]
}
func NewVersionService(httpClient *http.Client) *VersionService {
return &VersionService{
httpClient: httpClient,
cache: utils.New[string](versionTTL),
}
}
func (s *VersionService) GetLatestVersion(ctx context.Context) (string, error) {
version, err := s.cache.GetOrFetch(ctx, func(ctx context.Context) (string, error) {
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, versionCheckURL, nil)
if err != nil {
return "", fmt.Errorf("create GitHub request: %w", err)
}
resp, err := s.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("get latest tag: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
}
var payload struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return "", fmt.Errorf("decode payload: %w", err)
}
if payload.TagName == "" {
return "", fmt.Errorf("GitHub API returned empty tag name")
}
return strings.TrimPrefix(payload.TagName, "v"), nil
})
var staleErr *utils.ErrStale
if errors.As(err, &staleErr) {
slog.Warn("Failed to fetch latest version, returning stale cache", "error", staleErr.Err)
return version, nil
}
return version, err
}

View File

@@ -0,0 +1,78 @@
package utils
import (
"context"
"sync/atomic"
"time"
"golang.org/x/sync/singleflight"
)
type CacheEntry[T any] struct {
Value T
FetchedAt time.Time
}
type ErrStale struct {
Err error
}
func (e *ErrStale) Error() string { return "returned stale cache: " + e.Err.Error() }
func (e *ErrStale) Unwrap() error { return e.Err }
type Cache[T any] struct {
ttl time.Duration
entry atomic.Pointer[CacheEntry[T]]
sf singleflight.Group
}
func New[T any](ttl time.Duration) *Cache[T] {
return &Cache[T]{ttl: ttl}
}
// Get returns the cached value if it's still fresh.
func (c *Cache[T]) Get() (T, bool) {
entry := c.entry.Load()
if entry == nil {
var zero T
return zero, false
}
if time.Since(entry.FetchedAt) < c.ttl {
return entry.Value, true
}
var zero T
return zero, false
}
// GetOrFetch returns the cached value if it's still fresh, otherwise calls fetch to get a new value.
func (c *Cache[T]) GetOrFetch(ctx context.Context, fetch func(context.Context) (T, error)) (T, error) {
// If fresh, serve immediately
if v, ok := c.Get(); ok {
return v, nil
}
// Fetch with singleflight to prevent multiple concurrent fetches
vAny, err, _ := c.sf.Do("singleton", func() (any, error) {
if v2, ok := c.Get(); ok {
return v2, nil
}
val, fetchErr := fetch(ctx)
if fetchErr != nil {
return nil, fetchErr
}
c.entry.Store(&CacheEntry[T]{Value: val, FetchedAt: time.Now()})
return val, nil
})
if err == nil {
return vAny.(T), nil
}
// Fetch failed. Return stale if possible.
if e := c.entry.Load(); e != nil {
return e.Value, &ErrStale{Err: err}
}
var zero T
return zero, err
}

View File

@@ -3,8 +3,7 @@ package email
import (
"fmt"
htemplate "html/template"
"io/fs"
"path"
"path/filepath"
ttemplate "text/template"
"github.com/pocket-id/pocket-id/backend/resources"
@@ -27,71 +26,35 @@ func GetTemplate[U any, V any](templateMap TemplateMap[U], template Template[V])
return templateMap[template.Path]
}
type cloneable[V pareseable[V]] interface {
Clone() (V, error)
}
type pareseable[V any] interface {
ParseFS(fs.FS, ...string) (V, error)
}
func prepareTemplate[V pareseable[V]](templateFS fs.FS, template string, rootTemplate cloneable[V], suffix string) (V, error) {
tmpl, err := rootTemplate.Clone()
if err != nil {
return *new(V), fmt.Errorf("clone root template: %w", err)
}
filename := fmt.Sprintf("%s%s", template, suffix)
templatePath := path.Join("email-templates", filename)
_, err = tmpl.ParseFS(templateFS, templatePath)
if err != nil {
return *new(V), fmt.Errorf("parsing template '%s': %w", template, err)
}
return tmpl, nil
}
func PrepareTextTemplates(templates []string) (map[string]*ttemplate.Template, error) {
components := path.Join("email-templates", "components", "*_text.tmpl")
rootTmpl, err := ttemplate.ParseFS(resources.FS, components)
if err != nil {
return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err)
}
textTemplates := make(map[string]*ttemplate.Template, len(templates))
for _, tmpl := range templates {
rootTmplClone, err := rootTmpl.Clone()
filename := tmpl + "_text.tmpl"
templatePath := filepath.Join("email-templates", filename)
parsedTemplate, err := ttemplate.ParseFS(resources.FS, templatePath)
if err != nil {
return nil, fmt.Errorf("clone root template: %w", err)
return nil, fmt.Errorf("parsing template '%s': %w", tmpl, err)
}
textTemplates[tmpl], err = prepareTemplate[*ttemplate.Template](resources.FS, tmpl, rootTmplClone, "_text.tmpl")
if err != nil {
return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
}
textTemplates[tmpl] = parsedTemplate
}
return textTemplates, nil
}
func PrepareHTMLTemplates(templates []string) (map[string]*htemplate.Template, error) {
components := path.Join("email-templates", "components", "*_html.tmpl")
rootTmpl, err := htemplate.ParseFS(resources.FS, components)
if err != nil {
return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err)
}
htmlTemplates := make(map[string]*htemplate.Template, len(templates))
for _, tmpl := range templates {
rootTmplClone, err := rootTmpl.Clone()
filename := tmpl + "_html.tmpl"
templatePath := filepath.Join("email-templates", filename)
parsedTemplate, err := htemplate.ParseFS(resources.FS, templatePath)
if err != nil {
return nil, fmt.Errorf("clone root template: %w", err)
return nil, fmt.Errorf("parsing template '%s': %w", tmpl, err)
}
htmlTemplates[tmpl], err = prepareTemplate[*htemplate.Template](resources.FS, tmpl, rootTmplClone, "_html.tmpl")
if err != nil {
return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
}
htmlTemplates[tmpl] = parsedTemplate
}
return htmlTemplates, nil

View File

@@ -2,6 +2,7 @@ package utils
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
@@ -35,6 +36,12 @@ func GetImageMimeType(ext string) string {
return "image/x-icon"
case "gif":
return "image/gif"
case "webp":
return "image/webp"
case "avif":
return "image/avif"
case "heic":
return "image/heic"
default:
return ""
}
@@ -43,29 +50,45 @@ func GetImageMimeType(ext string) string {
func CopyEmbeddedFileToDisk(srcFilePath, destFilePath string) error {
srcFile, err := resources.FS.Open(srcFilePath)
if err != nil {
return err
return fmt.Errorf("failed to open embedded file: %w", err)
}
defer srcFile.Close()
err = os.MkdirAll(filepath.Dir(destFilePath), os.ModePerm)
if err != nil {
return err
return fmt.Errorf("failed to create destination directory: %w", err)
}
destFile, err := os.Create(destFilePath)
if err != nil {
return err
return fmt.Errorf("failed to open destination file: %w", err)
}
defer destFile.Close()
_, err = io.Copy(destFile, srcFile)
if err != nil {
return err
return fmt.Errorf("failed to write to destination file: %w", err)
}
return nil
}
func EmbeddedFileSha256(filePath string) ([]byte, error) {
f, err := resources.FS.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open embedded file: %w", err)
}
defer f.Close()
h := sha256.New()
_, err = io.Copy(h, f)
if err != nil {
return nil, fmt.Errorf("failed to read embedded file: %w", err)
}
return h.Sum(nil), nil
}
func SaveFile(file *multipart.FileHeader, dst string) error {
src, err := file.Open()
if err != nil {

View File

@@ -3,9 +3,28 @@ package utils
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
)
func CreateSha256Hash(input string) string {
hash := sha256.Sum256([]byte(input))
return hex.EncodeToString(hash[:])
}
func CreateSha256FileHash(filePath string) ([]byte, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
defer f.Close()
h := sha256.New()
_, err = io.Copy(h, f)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
return h.Sum(nil), nil
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/lestrrat-go/jwx/v3/jwk"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/pocket-id/pocket-id/backend/internal/model"
cryptoutils "github.com/pocket-id/pocket-id/backend/internal/utils/crypto"
@@ -95,7 +96,14 @@ func (f *KeyProviderDatabase) SaveKey(key jwk.Key) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err = f.db.WithContext(ctx).Create(&row).Error
err = f.db.
WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).
Create(&row).
Error
if err != nil {
// There's one scenario where if Pocket ID is started fresh with more than 1 replica, they both could be trying to create the private key in the database at the same time
// In this case, only one of the replicas will succeed; the other one(s) will return an error here, which will cascade down and cause the replica(s) to crash and be restarted (at that point they'll load the then-existing key from the database)

View File

@@ -3,3 +3,11 @@ package utils
func Ptr[T any](v T) *T {
return &v
}
func PtrValueOrZero[T any](ptr *T) T {
if ptr == nil {
var zero T
return zero
}
return *ptr
}

File diff suppressed because one or more lines are too long

View File

@@ -1,17 +1,3 @@
{{ define "base" }}
<div class="header">
<div class="logo">
<img src="{{ .LogoURL }}" alt="{{ .AppName }}" width="32" height="32" style="width: 32px; height: 32px; max-width: 32px;"/>
<h1>{{ .AppName }}</h1>
</div>
<div class="warning">Warning</div>
</div>
<div class="content">
<h2>API Key Expiring Soon</h2>
<p>
Hello {{ .Data.Name }},<br/><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>.<br/><br/>
Please generate a new API key if you need continued access.
</p>
</div>
{{ 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="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="width:210px;margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column">
<img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle;margin-right:8px" 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><!--7--><!--/$--></body></html>{{end}}

View File

@@ -1,10 +1,12 @@
{{ define "base" -}}
API Key Expiring Soon
====================
{{define "root"}}{{.AppName}}
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" }}.
API KEY EXPIRING SOON
Please generate a new API key if you need continued access.
{{ end -}}
Warning
Hello {{.Data.Name}},
This is a reminder that your API key {{.Data.APIKeyName}} will expire on
{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.
Please generate a new API key if you need continued access.{{end}}

View File

@@ -1,14 +0,0 @@
{{ define "root" }}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{ template "style" . }}
</head>
<body>
<div class="container">
{{ template "base" . }}
</div>
</body>
</html>
{{ end }}

View File

@@ -1,7 +0,0 @@
{{- define "root" -}}
{{- template "base" . -}}
{{- end }}
--
This is automatically sent email from {{.AppName}}.

View File

@@ -1,92 +0,0 @@
{{ define "style" }}
<style>
/* Reset styles for email clients */
body, table, td, p, a {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font-family: Arial, sans-serif;
line-height: 1.5;
}
body {
background-color: #f0f0f0;
color: #333;
}
.container {
width: 100%;
max-width: 600px;
margin: 40px auto;
background-color: #fff;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 32px;
}
.header {
display: flex;
margin-bottom: 24px;
}
.header .logo img {
width: 32px;
height: 32px;
vertical-align: middle;
}
.header h1 {
font-size: 1.5rem;
font-weight: bold;
display: inline-block;
vertical-align: middle;
margin-left: 8px;
}
.warning {
background-color: #ffd966;
color: #7f6000;
padding: 4px 12px;
border-radius: 50px;
font-size: 0.875rem;
margin: auto 0 auto auto;
}
.content {
background-color: #fafafa;
padding: 24px;
border-radius: 10px;
}
.content h2 {
font-size: 1.25rem;
font-weight: bold;
margin-bottom: 16px;
}
.grid {
width: 100%;
margin-bottom: 16px;
}
.grid td {
width: 50%;
padding-bottom: 8px;
vertical-align: top;
}
.label {
color: #888;
font-size: 0.875rem;
}
.message {
font-size: 1rem;
line-height: 1.5;
margin-top: 16px;
}
.button {
background-color: #000000;
color: #ffffff;
padding: 0.7rem 1.5rem;
text-decoration: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 500;
display: inline-block;
margin-top: 24px;
}
.button-container {
text-align: center;
}
</style>
{{ end }}

View File

@@ -1,40 +1,5 @@
{{ define "base" }}
<div class="header">
<div class="logo">
<img src="{{ .LogoURL }}" alt="{{ .AppName }}" width="32" height="32" style="width: 32px; height: 32px; max-width: 32px;"/>
<h1>{{ .AppName }}</h1>
</div>
<div class="warning">Warning</div>
</div>
<div class="content">
<h2>New Sign-In Detected</h2>
<table class="grid">
<tr>
{{ if and .Data.City .Data.Country }}
<td>
<p class="label">Approximate Location</p>
<p>{{ .Data.City }}, {{ .Data.Country }}</p>
</td>
{{ end }}
<td>
<p class="label">IP Address</p>
<p>{{ .Data.IPAddress }}</p>
</td>
</tr>
<tr>
<td>
<p class="label">Device</p>
<p>{{ .Data.Device }}</p>
</td>
<td>
<p class="label">Sign-In Time</p>
<p>{{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC" }}</p>
</td>
</tr>
</table>
<p class="message">
This sign-in was detected from a new device or location. If you recognize this activity, you can
safely ignore this message. If not, please review your account and security settings.
</p>
</div>
{{ 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="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="width:210px;margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column">
<img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle;margin-right:8px" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">New Sign-In Detected</h1></td><td align="right" data-id="__react-email-column">
<p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Your <!-- -->{{.AppName}}<!-- --> account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.</p><h4 style="font-size:1rem;font-weight:bold;margin:30px 0 10px 0">Details</h4><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Approximate Location</p>
<p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.City}}<!-- -->, <!-- -->{{.Data.Country}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">IP Address</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.IPAddress}}</p></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-top:10px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Device</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">
{{.Data.Device}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Sign-In Time</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}</p></td></tr></tbody></table></div></td></tr></tbody></table><!--7--><!--/$--></body></html>{{end}}

View File

@@ -1,15 +1,27 @@
{{ define "base" -}}
New Sign-In Detected
====================
{{define "root"}}{{.AppName}}
{{ if and .Data.City .Data.Country }}
Approximate Location: {{ .Data.City }}, {{ .Data.Country }}
{{ end }}
IP Address: {{ .Data.IPAddress }}
Device: {{ .Data.Device }}
Time: {{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC"}}
This sign-in was detected from a new device or location. If you recognize
this activity, you can safely ignore this message. If not, please review
your account and security settings.
{{ end -}}
NEW SIGN-IN DETECTED
Warning
Your {{.AppName}} account was recently accessed from a new IP address or
browser. If you recognize this activity, no further action is required.
DETAILS
Approximate Location
{{.Data.City}}, {{.Data.Country}}
IP Address
{{.Data.IPAddress}}
Device
{{.Data.Device}}
Sign-In Time
{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}{{end}}

View File

@@ -1,17 +1,4 @@
{{ define "base" }}
<div class="header">
<div class="logo">
<img src="{{ .LogoURL }}" alt="{{ .AppName }}" width="32" height="32" style="width: 32px; height: 32px; max-width: 32px;"/>
<h1>{{ .AppName }}</h1>
</div>
</div>
<div class="content">
<h2>Login Code</h2>
<p class="message">
Click the button below to sign in to {{ .AppName }} with a login code.</br>Or visit <a href="{{ .Data.LoginLink }}">{{ .Data.LoginLink }}</a> and enter the code <strong>{{ .Data.Code }}</strong>.</br></br>This code expires in {{.Data.ExpirationString}}.
</p>
<div class="button-container">
<a class="button" href="{{ .Data.LoginLinkWithCode }}" class="button">Sign In</a>
</div>
</div>
{{ 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="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="width:210px;margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column">
<img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle;margin-right:8px" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Your Login Code</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">
Click the button below to sign in to <!-- -->{{.AppName}}<!-- --> with a login code.<br/>Or visit<!-- --> <a href="{{.Data.LoginLink}}" style="color:#000;text-decoration-line:none;text-decoration:underline;font-family:Arial, sans-serif" target="_blank">{{.Data.LoginLink}}</a> <!-- -->and enter the code <strong>{{.Data.Code}}</strong>.<br/><br/>This code expires in <!-- -->{{.Data.ExpirationString}}<!-- -->.</p><div style="text-align:center"><a href="{{.Data.LoginLinkWithCode}}" style="line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;background-color:#000000;color:#ffffff;padding:12px 24px;border-radius:4px;font-size:15px;font-weight:500;cursor:pointer;margin-top:10px;padding-top:12px;padding-right:24px;padding-bottom:12px;padding-left:24px" target="_blank"><span><!--[if mso]><i style="mso-font-width:400%;mso-text-raise:18" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span>
<span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Sign In</span><span><!--[if mso]><i style="mso-font-width:400%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span></a></div></div></td></tr></tbody></table><!--7--><!--/$--></body></html>{{end}}

View File

@@ -1,10 +1,12 @@
{{ define "base" -}}
Login Code
====================
{{define "root"}}{{.AppName}}
Click the link below to sign in to {{ .AppName }} with a login code. This code expires in {{.Data.ExpirationString}}.
{{ .Data.LoginLinkWithCode }}
YOUR LOGIN CODE
Or visit {{ .Data.LoginLink }} and enter the the code "{{ .Data.Code }}".
{{ end -}}
Click the button below to sign in to {{.AppName}} with a login code.
Or visit {{.Data.LoginLink}} {{.Data.LoginLink}} and enter the code
{{.Data.Code}}.
This code expires in {{.Data.ExpirationString}}.
Sign In {{.Data.LoginLinkWithCode}}{{end}}

View File

@@ -1,11 +1,3 @@
{{ define "base" -}}
<div class="header">
<div class="logo">
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
<h1>{{ .AppName }}</h1>
</div>
</div>
<div class="content">
<p>This is a test email.</p>
</div>
{{ 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="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="width:210px;margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column">
<img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle;margin-right:8px" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Test Email</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">
Your email setup is working correctly!</p></div></td></tr></tbody></table><!--7--><!--/$--></body></html>{{end}}

View File

@@ -1,3 +1,6 @@
{{ define "base" -}}
This is a test email.
{{ end -}}
{{define "root"}}{{.AppName}}
TEST EMAIL
Your email setup is working correctly!{{end}}

View File

@@ -4,5 +4,5 @@ import "embed"
// Embedded file systems for the project
//go:embed email-templates images migrations fonts aaguids.json
//go:embed email-templates/*.tmpl images migrations fonts aaguids.json
var FS embed.FS

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

View File

@@ -0,0 +1,3 @@
ALTER TABLE users DROP COLUMN display_name;
ALTER TABLE users ALTER COLUMN username TYPE TEXT;

View File

@@ -0,0 +1,6 @@
ALTER TABLE users ADD COLUMN display_name TEXT;
UPDATE users SET display_name = trim(coalesce(first_name,'') || ' ' || coalesce(last_name,''));
ALTER TABLE users ALTER COLUMN display_name SET NOT NULL;
CREATE EXTENSION IF NOT EXISTS citext;
ALTER TABLE users ALTER COLUMN username TYPE CITEXT COLLATE "C";

View File

@@ -0,0 +1,3 @@
BEGIN;
ALTER TABLE users DROP COLUMN display_name;
COMMIT;

View File

@@ -0,0 +1,42 @@
PRAGMA foreign_keys = OFF;
BEGIN;
CREATE TABLE users_new
(
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME,
username TEXT NOT NULL COLLATE NOCASE UNIQUE,
email TEXT NOT NULL UNIQUE,
first_name TEXT,
last_name TEXT NOT NULL,
display_name TEXT NOT NULL,
is_admin NUMERIC NOT NULL DEFAULT FALSE,
ldap_id TEXT,
locale TEXT,
disabled NUMERIC NOT NULL DEFAULT FALSE
);
INSERT INTO users_new (id, created_at, username, email, first_name, last_name, display_name, is_admin, ldap_id, locale,
disabled)
SELECT id,
created_at,
username,
email,
first_name,
COALESCE(last_name, ''),
TRIM(COALESCE(first_name, '') || ' ' || COALESCE(last_name, '')),
is_admin,
ldap_id,
locale,
disabled
FROM users;
DROP TABLE users;
ALTER TABLE users_new
RENAME TO users;
CREATE UNIQUE INDEX users_ldap_id ON users (ldap_id);
COMMIT;
PRAGMA foreign_keys = ON;

115
email-templates/build.ts Normal file
View File

@@ -0,0 +1,115 @@
import { render } from "@react-email/components";
import * as fs from "node:fs";
import * as path from "node:path";
const outputDir = "../backend/resources/email-templates";
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
function getTemplateName(filename: string): string {
return filename.replace(".tsx", "");
}
/**
* Tag-aware wrapping:
* - Prefer breaking immediately after the last '>' within maxLen.
* - Never break at spaces.
* - If no '>' exists in the window, hard-break at maxLen.
*/
function tagAwareWrap(input: string, maxLen: number): string {
const out: string[] = [];
for (const originalLine of input.split(/\r?\n/)) {
let line = originalLine;
while (line.length > maxLen) {
let breakPos = line.lastIndexOf(">", maxLen);
// If '>' happens to be exactly at maxLen, break after it
if (breakPos === maxLen) breakPos = maxLen;
// If we found a '>' before the limit, break right after it
if (breakPos > -1 && breakPos < maxLen) {
out.push(line.slice(0, breakPos + 1));
line = line.slice(breakPos + 1);
continue;
}
// No suitable tag end found—hard break
out.push(line.slice(0, maxLen));
line = line.slice(maxLen);
}
out.push(line);
}
return out.join("\n");
}
async function buildTemplateFile(
Component: any,
templateName: string,
isPlainText: boolean
) {
const rendered = await render(Component(Component.TemplateProps), {
plainText: isPlainText,
});
// Normalize quotes
const normalized = rendered.replace(/&quot;/g, '"');
// Enforce line length: prefer tag boundaries, never spaces
const maxLen = isPlainText ? 78 : 998; // RFC-safe
const safe = tagAwareWrap(normalized, maxLen);
const goTemplate = `{{define "root"}}${safe}{{end}}`;
const suffix = isPlainText ? "_text.tmpl" : "_html.tmpl";
const templatePath = path.join(outputDir, `${templateName}${suffix}`);
fs.writeFileSync(templatePath, goTemplate);
}
async function discoverAndBuildTemplates() {
console.log("Discovering and building email templates...");
const emailsDir = "./emails";
const files = fs.readdirSync(emailsDir);
for (const file of files) {
if (!file.endsWith(".tsx")) continue;
const templateName = getTemplateName(file);
const modulePath = `./${emailsDir}/${file}`;
console.log(`Building ${templateName}...`);
try {
const module = await import(modulePath);
const Component = module.default || module[Object.keys(module)[0]];
if (!Component) {
console.error(`✗ No component found in ${file}`);
continue;
}
if (!Component.TemplateProps) {
console.error(`✗ No TemplateProps found in ${file}`);
continue;
}
await buildTemplateFile(Component, templateName, false); // HTML
await buildTemplateFile(Component, templateName, true); // Text
console.log(`✓ Built ${templateName}`);
} catch (error) {
console.error(`✗ Error building ${templateName}:`, error);
}
}
}
async function main() {
await discoverAndBuildTemplates();
console.log("All templates built successfully!");
}
main().catch(console.error);

View File

@@ -0,0 +1,87 @@
import {
Body,
Column,
Container,
Head,
Html,
Img,
Row,
Section,
Text,
} from "@react-email/components";
interface BaseTemplateProps {
logoURL?: string;
appName: string;
children: React.ReactNode;
}
export const BaseTemplate = ({
logoURL,
appName,
children,
}: BaseTemplateProps) => {
const finalLogoURL =
logoURL ||
"https://private-user-images.githubusercontent.com/58886915/359183039-4ceb2708-9f29-4694-b797-be833efce17d.png?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NTY0NTk5MzksIm5iZiI6MTc1NjQ1OTYzOSwicGF0aCI6Ii81ODg4NjkxNS8zNTkxODMwMzktNGNlYjI3MDgtOWYyOS00Njk0LWI3OTctYmU4MzNlZmNlMTdkLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTA4MjklMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwODI5VDA5MjcxOVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWM4ZWI5NzlkMDA5NDNmZGU5MjQwMGE1YjA0NWZiNzEzM2E0MzAzOTFmOWRmNDUzNmJmNjQwZTMxNGIzZmMyYmQmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.YdfLv1tD5KYnRZPSA3QlR1SsvScpP0rt-J3YD6ZHsCk";
return (
<Html>
<Head />
<Body style={mainStyle}>
<Container style={{ width: "500px", margin: "0 auto" }}>
<Section>
<Row
align="left"
style={{
width: "210px",
marginBottom: "16px",
}}
>
<Column>
<Img
src={finalLogoURL}
width="32"
height="32"
alt={appName}
style={logoStyle}
/>
</Column>
<Column>
<Text style={titleStyle}>{appName}</Text>
</Column>
</Row>
</Section>
<div style={content}>{children}</div>
</Container>
</Body>
</Html>
);
};
const mainStyle = {
padding: "50px",
backgroundColor: "#FBFBFB",
fontFamily: "Arial, sans-serif",
};
const logoStyle = {
width: "32px",
height: "32px",
verticalAlign: "middle",
marginRight: "8px",
};
const titleStyle = {
fontSize: "23px",
fontWeight: "bold",
margin: "0",
padding: "0",
};
const content = {
backgroundColor: "white",
padding: "24px",
borderRadius: "10px",
boxShadow: "0 1px 4px 0px rgba(0, 0, 0, 0.1)",
};

View File

@@ -0,0 +1,33 @@
import { Button as EmailButton } from "@react-email/components";
interface ButtonProps {
href: string;
children: React.ReactNode;
style?: React.CSSProperties;
}
export const Button = ({ href, children, style = {} }: ButtonProps) => {
const buttonStyle = {
backgroundColor: "#000000",
color: "#ffffff",
padding: "12px 24px",
borderRadius: "4px",
fontSize: "15px",
fontWeight: "500",
cursor: "pointer",
marginTop: "10px",
...style,
};
return (
<div style={buttonContainer}>
<EmailButton style={buttonStyle} href={href}>
{children}
</EmailButton>
</div>
);
};
const buttonContainer = {
textAlign: "center" as const,
};

View File

@@ -0,0 +1,38 @@
import { Column, Heading, Row, Text } from "@react-email/components";
export default function CardHeader({
title,
warning,
}: {
title: string;
warning?: boolean;
}) {
return (
<Row>
<Column>
<Heading as="h1" style={titleStyle}>
{title}
</Heading>
</Column>
<Column align="right">
{warning && <Text style={warningStyle}>Warning</Text>}
</Column>
</Row>
);
}
const titleStyle = {
fontSize: "20px",
fontWeight: "bold" as const,
margin: 0,
};
const warningStyle = {
backgroundColor: "#ffd966",
color: "#7f6000",
padding: "1px 12px",
borderRadius: "50px",
fontSize: "12px",
display: "inline-block",
margin: 0,
};

View File

@@ -0,0 +1,55 @@
import { Text } from "@react-email/components";
import { BaseTemplate } from "../components/base-template";
import CardHeader from "../components/card-header";
import { sharedPreviewProps, sharedTemplateProps } from "../props";
interface ApiKeyExpiringData {
name: string;
apiKeyName: string;
expiresAt: string;
}
interface ApiKeyExpiringEmailProps {
logoURL: string;
appName: string;
data: ApiKeyExpiringData;
}
export const ApiKeyExpiringEmail = ({
logoURL,
appName,
data,
}: ApiKeyExpiringEmailProps) => (
<BaseTemplate logoURL={logoURL} appName={appName}>
<CardHeader title="API Key Expiring Soon" warning />
<Text>
Hello {data.name}, <br />
This is a reminder that your API key <strong>
{data.apiKeyName}
</strong>{" "}
will expire on <strong>{data.expiresAt}</strong>.
</Text>
<Text>Please generate a new API key if you need continued access.</Text>
</BaseTemplate>
);
export default ApiKeyExpiringEmail;
ApiKeyExpiringEmail.TemplateProps = {
...sharedTemplateProps,
data: {
name: "{{.Data.Name}}",
apiKeyName: "{{.Data.APIKeyName}}",
expiresAt: '{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}',
},
};
ApiKeyExpiringEmail.PreviewProps = {
...sharedPreviewProps,
data: {
name: "Elias Schneider",
apiKeyName: "My API Key",
expiresAt: "September 30, 2024",
},
};

View File

@@ -0,0 +1,104 @@
import { Column, Heading, Row, Text } from "@react-email/components";
import { BaseTemplate } from "../components/base-template";
import CardHeader from "../components/card-header";
import { sharedPreviewProps, sharedTemplateProps } from "../props";
interface SignInData {
city?: string;
country?: string;
ipAddress: string;
device: string;
dateTime: string;
}
interface NewSignInEmailProps {
logoURL: string;
appName: string;
data: SignInData;
}
export const NewSignInEmail = ({
logoURL,
appName,
data,
}: NewSignInEmailProps) => (
<BaseTemplate logoURL={logoURL} appName={appName}>
<CardHeader title="New Sign-In Detected" warning />
<Text>
Your {appName} account was recently accessed from a new IP address or
browser. If you recognize this activity, no further action is required.
</Text>
<Heading
style={{
fontSize: "1rem",
fontWeight: "bold",
margin: "30px 0 10px 0",
}}
as="h4"
>
Details
</Heading>
<Row>
<Column style={detailsBoxStyle}>
<Text style={detailsLabelStyle}>Approximate Location</Text>
<Text style={detailsBoxValueStyle}>
{data.city}, {data.country}
</Text>
</Column>
<Column style={detailsBoxStyle}>
<Text style={detailsLabelStyle}>IP Address</Text>
<Text style={detailsBoxValueStyle}>{data.ipAddress}</Text>
</Column>
</Row>
<Row style={{ marginTop: "10px" }}>
<Column style={detailsBoxStyle}>
<Text style={detailsLabelStyle}>Device</Text>
<Text style={detailsBoxValueStyle}>{data.device}</Text>
</Column>
<Column style={detailsBoxStyle}>
<Text style={detailsLabelStyle}>Sign-In Time</Text>
<Text style={detailsBoxValueStyle}>{data.dateTime}</Text>
</Column>
</Row>
</BaseTemplate>
);
export default NewSignInEmail;
const detailsBoxStyle = {
width: "225px",
};
const detailsLabelStyle = {
margin: 0,
fontSize: "12px",
color: "gray",
};
const detailsBoxValueStyle = {
margin: 0,
};
NewSignInEmail.TemplateProps = {
...sharedTemplateProps,
data: {
city: "{{.Data.City}}",
country: "{{.Data.Country}}",
ipAddress: "{{.Data.IPAddress}}",
device: "{{.Data.Device}}",
dateTime: '{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}',
},
};
NewSignInEmail.PreviewProps = {
...sharedPreviewProps,
data: {
city: "San Francisco",
country: "USA",
ipAddress: "127.0.0.1",
device: "Chrome on macOS",
dateTime: "2024-01-01 12:00 PM UTC",
},
};

View File

@@ -0,0 +1,71 @@
import { Link, Text } from "@react-email/components";
import { BaseTemplate } from "../components/base-template";
import { Button } from "../components/button";
import CardHeader from "../components/card-header";
import { sharedPreviewProps, sharedTemplateProps } from "../props";
interface OneTimeAccessData {
code: string;
loginLink: string;
buttonCodeLink: string;
expirationString: string;
}
interface OneTimeAccessEmailProps {
logoURL: string;
appName: string;
data: OneTimeAccessData;
}
export const OneTimeAccessEmail = ({
logoURL,
appName,
data,
}: OneTimeAccessEmailProps) => (
<BaseTemplate logoURL={logoURL} appName={appName}>
<CardHeader title="Your Login Code" />
<Text>
Click the button below to sign in to {appName} with a login code.
<br />
Or visit{" "}
<Link href={data.loginLink} style={linkStyle}>
{data.loginLink}
</Link>{" "}
and enter the code <strong>{data.code}</strong>.
<br />
<br />
This code expires in {data.expirationString}.
</Text>
<Button href={data.buttonCodeLink}>Sign In</Button>
</BaseTemplate>
);
export default OneTimeAccessEmail;
const linkStyle = {
color: "#000",
textDecoration: "underline",
fontFamily: "Arial, sans-serif",
};
OneTimeAccessEmail.TemplateProps = {
...sharedTemplateProps,
data: {
code: "{{.Data.Code}}",
loginLink: "{{.Data.LoginLink}}",
buttonCodeLink: "{{.Data.LoginLinkWithCode}}",
expirationString: "{{.Data.ExpirationString}}",
},
};
OneTimeAccessEmail.PreviewProps = {
...sharedPreviewProps,
data: {
code: "123456",
loginLink: "https://example.com/login",
buttonCodeLink: "https://example.com/login?code=123456",
expirationString: "15 minutes",
},
};

View File

@@ -0,0 +1,26 @@
import { Text } from "@react-email/components";
import { BaseTemplate } from "../components/base-template";
import CardHeader from "../components/card-header";
import { sharedPreviewProps, sharedTemplateProps } from "../props";
interface TestEmailProps {
logoURL: string;
appName: string;
}
export const TestEmail = ({ logoURL, appName }: TestEmailProps) => (
<BaseTemplate logoURL={logoURL} appName={appName}>
<CardHeader title="Test Email" />
<Text>Your email setup is working correctly!</Text>
</BaseTemplate>
);
export default TestEmail;
TestEmail.TemplateProps = {
...sharedTemplateProps,
};
TestEmail.PreviewProps = {
...sharedPreviewProps,
};

View File

@@ -0,0 +1,25 @@
{
"name": "pocketid-email-templates",
"version": "1.0.0",
"scripts": {
"preinstall": "npx only-allow pnpm",
"build": "tsx build.ts",
"build:watch": "tsx watch build.ts",
"dev": "email dev --port 3030",
"export": "email export"
},
"dependencies": {
"@react-email/components": "0.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@react-email/preview-server": "4.2.8",
"@types/node": "^24.0.10",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"react-email": "4.2.8",
"tsx": "^4.0.0"
}
}

9
email-templates/props.ts Normal file
View File

@@ -0,0 +1,9 @@
export const sharedPreviewProps = {
logoURL: "https://pocket-id.org/img/logo.png",
appName: "Pocket ID",
};
export const sharedTemplateProps = {
logoURL: "{{.LogoURL}}",
appName: "{{.AppName}}",
};

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["node"]
},
"include": [
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules",
"dist"
]
}

View File

@@ -17,6 +17,7 @@
"image_should_be_in_format": "Obrázek by měl být ve formátu PNG nebo JPEG.",
"items_per_page": "Položek na stránku",
"no_items_found": "Nenalezeny žádné položky",
"select_items": "Vyberte položky...",
"search": "Hledat...",
"expand_card": "Rozbalit kartu",
"copied": "Zkopírováno",
@@ -119,6 +120,8 @@
"username": "Uživatelské jméno",
"save": "Uložit",
"username_can_only_contain": "Uživatelské jméno může obsahovat pouze malá písmena, číslice, podtržítka, tečky, pomlčky a symbol '@'",
"username_must_start_with": "Uživatelské jméno musí začínat alfanumerickým znakem.",
"username_must_end_with": "Uživatelské jméno musí končit alfanumerickým znakem.",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Přihlaste se pomocí následujícího kódu. Platnost kódu vyprší za 15 minut.",
"or_visit": "nebo navštívit",
"added_on": "Přidáno",
@@ -214,7 +217,7 @@
"group_members_attribute": "Atribut členů skupiny",
"the_attribute_to_use_for_querying_members_of_a_group": "Atribut použitý pro dotazování členů skupiny.",
"group_unique_identifier_attribute": "Atribut unikátního identifikátoru skupiny",
"group_name_attribute": "Atribut názvu skupiny",
"group_rdn_attribute": "Atribut skupiny RDN (v DN)",
"admin_group_name": "Název skupiny administrátorů",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Členové této skupiny budou mít práva administrátora v Pocket ID.",
"disable": "Zakázat",
@@ -440,5 +443,12 @@
"last_signed_in_ago": "Naposledy přihlášen {time} před",
"invalid_client_id": "ID klienta může obsahovat pouze písmena, číslice, podtržítka a pomlčky.",
"custom_client_id_description": "Nastavte vlastní ID klienta, pokud to vyžaduje vaše aplikace. V opačném případě pole nechte prázdné, aby bylo vygenerováno náhodné ID.",
"generated": "Vygenerováno"
"generated": "Vygenerováno",
"administration": "Správa",
"group_rdn_attribute_description": "Atribut použitý v rozlišovacím jménu (DN) skupiny.",
"display_name_attribute": "Atribut zobrazovaného jména",
"display_name": "Zobrazované jméno",
"configure_application_images": "Konfigurace obrazů aplikací",
"ui_config_disabled_info_title": "Konfigurace uživatelského rozhraní je deaktivována",
"ui_config_disabled_info_description": "Konfigurace uživatelského rozhraní je deaktivována, protože nastavení konfigurace aplikace se spravuje prostřednictvím proměnných prostředí. Některá nastavení nemusí být editovatelná."
}

View File

@@ -17,6 +17,7 @@
"image_should_be_in_format": "Billedet skal være i PNG eller JPEG-format.",
"items_per_page": "Emner pr. side",
"no_items_found": "Ingen emner fundet",
"select_items": "Vælg emner...",
"search": "Søg...",
"expand_card": "Udvid kortet",
"copied": "Kopieret",
@@ -119,6 +120,8 @@
"username": "Brugernavn",
"save": "Gem",
"username_can_only_contain": "Brugernavn må kun indeholde små bogstaver, tal, understregninger (_), punktummer (.), bindestreger (-) og @-tegn",
"username_must_start_with": "Brugernavnet skal begynde med et alfanumerisk tegn",
"username_must_end_with": "Brugernavnet skal slutte med et alfanumerisk tegn",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Log ind med nedenstående kode. Koden udløber om 15 minutter.",
"or_visit": "eller besøg",
"added_on": "Tilføjet den",
@@ -214,7 +217,7 @@
"group_members_attribute": "Gruppemedlems-attribut",
"the_attribute_to_use_for_querying_members_of_a_group": "Attributten der bruges til at hente gruppemedlemmer.",
"group_unique_identifier_attribute": "Unik gruppe-ID-attribut",
"group_name_attribute": "Gruppenavns-attribut",
"group_rdn_attribute": "Gruppe RDN-attribut (i DN)",
"admin_group_name": "Administratorgruppe-navn",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Medlemmer af denne gruppe får administratorrettigheder i Pocket ID.",
"disable": "Deaktivér",
@@ -440,5 +443,12 @@
"last_signed_in_ago": "Sidst logget ind {time} siden",
"invalid_client_id": "Kunde-ID må kun indeholde bogstaver, tal, understregninger og bindestreger.",
"custom_client_id_description": "Indstil et brugerdefineret klient-id, hvis dette kræves af din applikation. Ellers skal du lade feltet være tomt for at generere et tilfældigt id.",
"generated": "Genereret"
"generated": "Genereret",
"administration": "Administration",
"group_rdn_attribute_description": "Den attribut, der bruges i gruppernes skelnenavn (DN).",
"display_name_attribute": "Visningsnavn-attribut",
"display_name": "Visningsnavn",
"configure_application_images": "Konfigurer applikationsbilleder",
"ui_config_disabled_info_title": "UI-konfiguration deaktiveret",
"ui_config_disabled_info_description": "UI-konfigurationen er deaktiveret, fordi applikationskonfigurationsindstillingerne administreres via miljøvariabler. Nogle indstillinger kan muligvis ikke redigeres."
}

View File

@@ -17,6 +17,7 @@
"image_should_be_in_format": "Das Bild sollte im PNG- oder JPEG-Format vorliegen.",
"items_per_page": "Einträge pro Seite",
"no_items_found": "Keine Einträge gefunden",
"select_items": "Wähle Artikel aus...",
"search": "Suchen...",
"expand_card": "Karte erweitern",
"copied": "Kopiert",
@@ -62,10 +63,10 @@
"try_again": "Erneut versuchen",
"client_logo": "Client-Logo",
"sign_out": "Abmelden",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Möchtest du dich mit deinem Konto <b>{username}</b> von Pocket ID abmelden?",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Möchtest du dich mit deinem Konto <b>{username}</b> von {appName} abmelden?",
"sign_in_to_appname": "Bei {appName} anmelden",
"please_try_to_sign_in_again": "Bitte versuche dich erneut anzumelden.",
"authenticate_with_passkey_to_access_account": "Melde dich mit deinem Passwort an, um auf dein Konto zuzugreifen.",
"authenticate_with_passkey_to_access_account": "Melde dich mit deinem Passkey an, um auf dein Konto zuzugreifen.",
"authenticate": "Authentifizieren",
"please_try_again": "Bitte versuche es noch einmal.",
"continue": "Fortsetzen",
@@ -88,7 +89,7 @@
"users": "Benutzer",
"user_groups": "Benutzergruppen",
"oidc_clients": "OIDC Clients",
"api_keys": "API Keys",
"api_keys": "API-Schlüssel",
"application_configuration": "Anwendungskonfiguration",
"settings": "Einstellungen",
"update_pocket_id": "Pocket ID aktualisieren",
@@ -105,7 +106,7 @@
"profile_picture_updated_successfully": "Profilbild erfolgreich aktualisiert. Die Aktualisierung kann einige Minuten dauern.",
"account_settings": "Konto Einstellungen",
"passkey_missing": "Passkey fehlt",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Bitte füge einen Hauptschlüssel hinzu, um zu verhindern, dass du den Zugriff auf dein Konto verlierst.",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Bitte füge einen Passkey hinzu, um zu verhindern, dass du den Zugriff auf dein Konto verlierst.",
"single_passkey_configured": "Nur ein Passkey hinterlegt",
"it_is_recommended_to_add_more_than_one_passkey": "Es wird empfohlen, mehr als einen Passkey zu hinterlegen, um den Zugriff auf das Konto nicht zu verlieren.",
"account_details": "Kontodetails",
@@ -119,36 +120,38 @@
"username": "Benutzername",
"save": "Speichern",
"username_can_only_contain": "Der Benutzername darf nur Kleinbuchstaben, Ziffern, Unterstriche, Punkte, Bindestriche und das Symbol „@“ enthalten",
"username_must_start_with": "Der Benutzername muss mit einem Buchstaben oder einer Zahl anfangen.",
"username_must_end_with": "Der Benutzername muss mit einem Buchstaben oder einer Zahl enden.",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Melde dich mit dem folgenden Code an. Der Code läuft in 15 Minuten ab.",
"or_visit": "oder besuche",
"added_on": "Hinzugefügt am",
"rename": "Umbenennen",
"delete": "Löschen",
"are_you_sure_you_want_to_delete_this_passkey": "Möchtest du diesen Hauptschlüssel wirklich löschen?",
"are_you_sure_you_want_to_delete_this_passkey": "Möchtest du diesen Passkey wirklich löschen?",
"passkey_deleted_successfully": "Passkey erfolgreich gelöscht",
"delete_passkey_name": "Lösche {passkeyName}",
"passkey_name_updated_successfully": "Passkey Name erfolgreich aktualisiert",
"name_passkey": "Passkey Name",
"delete_passkey_name": "{passkeyName} löschen",
"passkey_name_updated_successfully": "Passkey-Name erfolgreich aktualisiert",
"name_passkey": "Passkey benennen",
"name_your_passkey_to_easily_identify_it_later": "Benenne deinen Passkey, um ihn später leicht identifizieren zu können.",
"create_api_key": "API Key erstellen",
"create_api_key": "API-Schlüssel erstellen",
"add_a_new_api_key_for_programmatic_access": "Füge einen neuen API-Schlüssel für programmatischen Zugriff hinzu.",
"add_api_key": "API Key hinzufügen",
"manage_api_keys": "API Keys verwalten",
"api_key_created": "API Key erstellt",
"add_api_key": "API-Schlüssel hinzufügen",
"manage_api_keys": "API-Schlüssel verwalten",
"api_key_created": "API-Schlüssel erstellt",
"for_security_reasons_this_key_will_only_be_shown_once": "Aus Sicherheitsgründen wird dieser Schlüssel nur einmal angezeigt. Bitte speichere ihn sicher.",
"description": "Beschreibung",
"api_key": "API Key",
"api_key": "API-Schlüssel",
"close": "Schließen",
"name_to_identify_this_api_key": "Name zum identifizieren des API Keys.",
"name_to_identify_this_api_key": "Name zum Identifizieren des API-Schlüssels.",
"expires_at": "Ablaufdatum",
"when_this_api_key_will_expire": "Wann der API Key ablaufen wird.",
"when_this_api_key_will_expire": "Wann der API-Schlüssel ablaufen wird.",
"optional_description_to_help_identify_this_keys_purpose": "Optionale Beschreibung, um den Zweck dieses Schlüssels zu identifizieren.",
"expiration_date_must_be_in_the_future": "Ablaufdatum muss in der Zukunft liegen",
"revoke_api_key": "API Key widerrufen",
"revoke_api_key": "API-Schlüssel widerrufen",
"never": "Nie",
"revoke": "Widerrufen",
"api_key_revoked_successfully": "API Key erfolgreich widerrufen",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Bist du sicher, dass du den API Schlüssel \"{apiKeyName}\" widerrufen willst? Das wird jegliche Integrationen, die diesen Schlüssel verwenden, brechen.",
"api_key_revoked_successfully": "API-Schlüssel erfolgreich widerrufen",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Bist du sicher, dass du den API-Schlüssel \"{apiKeyName}\" widerrufen willst? Das wird jegliche Integrationen, die diesen Schlüssel verwenden, brechen.",
"last_used": "Letzte Verwendung",
"actions": "Aktionen",
"images_updated_successfully": "Bild erfolgreich aktualisiert",
@@ -214,7 +217,7 @@
"group_members_attribute": "Gruppenmitglieder Attribut",
"the_attribute_to_use_for_querying_members_of_a_group": "Das zu verwendende Attribut zur Abfrage von Mitgliedern einer Gruppe.",
"group_unique_identifier_attribute": "Eindeutiges Gruppenkennungs-Attribut",
"group_name_attribute": "Gruppennamen Attribut",
"group_rdn_attribute": "Gruppen-RDN-Attribut (im DN)",
"admin_group_name": "Name der Admingruppe",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Mitglieder dieser Gruppe werden Admin-Privilegien in Pocket ID haben.",
"disable": "Deaktivieren",
@@ -226,7 +229,7 @@
"add_user": "Benutzer hinzufügen",
"manage_users": "Benutzer verwalten",
"admin_privileges": "Administratorrechte",
"admins_have_full_access_to_the_admin_panel": "Admins haben vollen Zugriff auf das Admin Panel.",
"admins_have_full_access_to_the_admin_panel": "Admins haben vollen Zugriff auf das Admin-Panel.",
"delete_firstname_lastname": "{firstName} {lastName} löschen",
"are_you_sure_you_want_to_delete_this_user": "Bist du sicher, dass du diesen Benutzer löschen willst?",
"user_deleted_successfully": "Benutzer erfolgreich gelöscht",
@@ -262,16 +265,16 @@
"user_group_details_name": "Benutzergruppendetails {name}",
"assign_users_to_this_group": "Benutzer dieser Gruppe zuweisen.",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Benutzerdefinierte Claims sind Schlüssel-Wert-Paare, die verwendet werden können, um zusätzliche Informationen über einen Benutzer zu speichern. Diese Claims werden im ID-Token aufgenommen, wenn der Scope \"profile\" angefordert wird. Benutzerdefinierte Claims werden priorisiert, wenn Konflikte auftreten.",
"oidc_client_created_successfully": "OIDC Client erfolgreich erstellt",
"create_oidc_client": "OIDC Client erstellen",
"add_a_new_oidc_client_to_appname": "Einen neuen OIDC Client zu {appName} hinzufügen.",
"add_oidc_client": "OIDC Client hinzufügen",
"manage_oidc_clients": "OIDC Clients verwalten",
"oidc_client_created_successfully": "OIDC-Client erfolgreich erstellt",
"create_oidc_client": "OIDC-Client erstellen",
"add_a_new_oidc_client_to_appname": "Einen neuen OIDC-Client zu {appName} hinzufügen.",
"add_oidc_client": "OIDC-Client hinzufügen",
"manage_oidc_clients": "OIDC-Clients verwalten",
"one_time_link": "Einmallink",
"use_this_link_to_sign_in_once": "Benutze diesen Link, um dich einmal anzumelden. Dieser wird für Benutzer benötigt, die noch keinen Passkey hinzugefügt haben oder diesen verloren haben.",
"add": "Hinzufügen",
"callback_urls": "Callback URLs",
"logout_callback_urls": "Abmelde Callback URLs",
"callback_urls": "Callback-URLs",
"logout_callback_urls": "Abmelde-Callback-URLs",
"public_client": "Öffentlicher Client",
"public_clients_description": "Öffentliche Clients haben kein Client-Geheimnis und verwenden stattdessen PKCE. Aktiviere dies, wenn dein Client eine SPA oder mobile App ist.",
"pkce": "PKCE",
@@ -282,24 +285,24 @@
"change_logo": "Logo ändern",
"upload_logo": "Logo hochladen",
"remove_logo": "Logo entfernen",
"are_you_sure_you_want_to_delete_this_oidc_client": "Bist du sicher, dass du diesen OIDC Client löschen willst?",
"oidc_client_deleted_successfully": "OIDC Client erfolgreich gelöscht",
"are_you_sure_you_want_to_delete_this_oidc_client": "Bist du sicher, dass du diesen OIDC-Client löschen willst?",
"oidc_client_deleted_successfully": "OIDC-Client erfolgreich gelöscht",
"authorization_url": "Autorisierungs-URL",
"oidc_discovery_url": "OIDC Discovery URL",
"oidc_discovery_url": "OIDC-Discovery-URL",
"token_url": "Token URL",
"userinfo_url": "Benutzerinfo URL",
"logout_url": "Abmelde URL",
"userinfo_url": "Benutzerinfo-URL",
"logout_url": "Abmelde-URL",
"certificate_url": "Zertifikats-URL",
"enabled": "Aktiviert",
"disabled": "Deaktiviert",
"oidc_client_updated_successfully": "OIDC Client erfolgreich aktualisiert",
"oidc_client_updated_successfully": "OIDC-Client erfolgreich aktualisiert",
"create_new_client_secret": "Neues Client-Geheimnis erstellen",
"are_you_sure_you_want_to_create_a_new_client_secret": "Bist du sicher, dass du ein neues Client-Geheimnis erstellen möchtest? Das alte Client-Geheimnis wird dadurch ungültig.",
"generate": "Generieren",
"new_client_secret_created_successfully": "Neues Client-Geheimnis erfolgreich erstellt",
"allowed_user_groups_updated_successfully": "Erlaubte Benutzergruppen erfolgreich aktualisiert",
"oidc_client_name": "OIDC Client {name}",
"client_id": "Client ID",
"oidc_client_name": "OIDC-Client {name}",
"client_id": "Client-ID",
"client_secret": "Client-Geheimnis",
"show_more_details": "Mehr Details anzeigen",
"allowed_user_groups": "Erlaubte Benutzergruppen",
@@ -343,8 +346,8 @@
"show_code": "Code anzeigen",
"callback_url_description": "URL(s) die von deinem Client bereitgestellt werden. Automatische Ergänzung bei leerem Feld. Wildcards (*) werden unterstützt, sollten für bessere Sicherheit jedoch vermieden werden.",
"logout_callback_url_description": "URL(s) die von deinem Client für die Abmeldung bereitgestellt werden. Wildcards (*) werden unterstützt, sollten für bessere Sicherheit jedoch vermieden werden.",
"api_key_expiration": "API Key Ablauf",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Sende eine E-Mail an den Benutzer, wenn sein API Key ablaufen wird.",
"api_key_expiration": "API-Schlüssel-Ablauf",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Sende eine E-Mail an den Benutzer, wenn sein API-Schlüssel ablaufen wird.",
"authorize_device": "Gerät autorisieren",
"the_device_has_been_authorized": "Das Gerät wurde autorisiert.",
"enter_code_displayed_in_previous_step": "Gib den Code ein, der im vorherigen Schritt angezeigt wurde.",
@@ -399,7 +402,7 @@
"signup_to_appname": "Melde dich bei „ {appName}“ an",
"create_your_account_to_get_started": "Erstell dein Konto, um loszulegen.",
"initial_account_creation_description": "Erstell dein Konto, um loszulegen. Du kannst später einen Passkey einrichten.",
"setup_your_passkey": "Passwort einrichten",
"setup_your_passkey": "Passkey einrichten",
"create_a_passkey_to_securely_access_your_account": "Erstell einen Passkey, um sicher auf dein Konto zuzugreifen. Das wird deine Hauptmethode zum Anmelden sein.",
"skip_for_now": "Jetzt überspringen",
"account_created": "Konto erstellt",
@@ -425,7 +428,7 @@
"signup_open": "Anmeldung offen",
"signup_open_description": "Jeder kann ohne Einschränkungen ein neues Konto erstellen.",
"of": "von",
"skip_passkey_setup": "Passwort-Einrichtung überspringen",
"skip_passkey_setup": "Passkey-Einrichtung überspringen",
"skip_passkey_setup_description": "Es wird dringend empfohlen, einen Passkey einzurichten, da du sonst nach Ablauf der Sitzung aus deinem Konto ausgesperrt wirst.",
"my_apps": "Meine Apps",
"no_apps_available": "Keine Apps verfügbar",
@@ -440,5 +443,12 @@
"last_signed_in_ago": "Zuletzt angemeldet vor {time} Stunden",
"invalid_client_id": "Die Kunden-ID darf nur Buchstaben, Zahlen, Unterstriche und Bindestriche haben.",
"custom_client_id_description": "Gib eine eigene Client-ID ein, wenn deine App das braucht. Ansonsten lass das Feld leer, damit eine zufällige ID generiert wird.",
"generated": "Generiert"
"generated": "Generiert",
"administration": "Verwaltung",
"group_rdn_attribute_description": "Das Attribut, das im Distinguished Name (DN) der Gruppen benutzt wird.",
"display_name_attribute": "Anzeigename-Attribut",
"display_name": "Anzeigename",
"configure_application_images": "Anwendungsimages einrichten",
"ui_config_disabled_info_title": "UI-Konfiguration deaktiviert",
"ui_config_disabled_info_description": "Die UI-Konfiguration ist deaktiviert, weil die Anwendungseinstellungen über Umgebungsvariablen verwaltet werden. Manche Einstellungen können vielleicht nicht geändert werden."
}

View File

@@ -17,6 +17,7 @@
"image_should_be_in_format": "The image should be in PNG or JPEG format.",
"items_per_page": "Items per page",
"no_items_found": "No items found",
"select_items": "Select items...",
"search": "Search...",
"expand_card": "Expand card",
"copied": "Copied",
@@ -119,6 +120,8 @@
"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",
@@ -214,7 +217,7 @@
"group_members_attribute": "Group Members Attribute",
"the_attribute_to_use_for_querying_members_of_a_group": "The attribute to use for querying members of a group.",
"group_unique_identifier_attribute": "Group Unique Identifier Attribute",
"group_name_attribute": "Group Name Attribute",
"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",
@@ -440,5 +443,12 @@
"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"
"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."
}

View File

@@ -17,6 +17,7 @@
"image_should_be_in_format": "La imagen debe ser en formato PNG o JPEG.",
"items_per_page": "Elementos por página",
"no_items_found": "No se encontraron elementos",
"select_items": "Seleccionar elementos...",
"search": "Buscar...",
"expand_card": "Ampliar tarjeta",
"copied": "Copiado",
@@ -119,6 +120,8 @@
"username": "Nombre de usuario",
"save": "Guardar",
"username_can_only_contain": "El nombre de usuario solo puede contener letras minúsculas, números, guiones bajos, puntos, guiones y símbolos '@'",
"username_must_start_with": "El nombre de usuario debe comenzar con un carácter alfanumérico.",
"username_must_end_with": "El nombre de usuario debe terminar con un carácter alfanumérico.",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Inicia sesión usando el siguiente código. El código caducará en 15 minutos.",
"or_visit": "o visita",
"added_on": "Añadido el",
@@ -214,7 +217,7 @@
"group_members_attribute": "Atributo de los miembros del grupo",
"the_attribute_to_use_for_querying_members_of_a_group": "El atributo que se utilizará para consultar los miembros de un grupo.",
"group_unique_identifier_attribute": "Atributo identificador único de grupo",
"group_name_attribute": "Atributo de nombre de grupo",
"group_rdn_attribute": "Atributo RDN de grupo (en DN)",
"admin_group_name": "Nombre del grupo de administración",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Los miembros de este grupo tendrán privilegios de administrador en Pocket ID.",
"disable": "Desactivar",
@@ -440,5 +443,12 @@
"last_signed_in_ago": "Último inicio de sesión en {time} hace",
"invalid_client_id": "El ID de cliente solo puede contener letras, números, guiones bajos y guiones.",
"custom_client_id_description": "Establece un ID de cliente personalizado si tu aplicación lo requiere. De lo contrario, déjalo en blanco para generar uno aleatorio.",
"generated": "Generado"
"generated": "Generado",
"administration": "Administración",
"group_rdn_attribute_description": "El atributo utilizado en el nombre distintivo (DN) de los grupos.",
"display_name_attribute": "Atributo de nombre para mostrar",
"display_name": "Nombre para mostrar",
"configure_application_images": "Configurar imágenes de aplicaciones",
"ui_config_disabled_info_title": "Configuración de la interfaz de usuario desactivada",
"ui_config_disabled_info_description": "La configuración de la interfaz de usuario está desactivada porque los ajustes de configuración de la aplicación se gestionan a través de variables de entorno. Es posible que algunos ajustes no se puedan editar."
}

View File

@@ -17,6 +17,7 @@
"image_should_be_in_format": "L'image doit être au format PNG ou JPEG.",
"items_per_page": "Éléments par page",
"no_items_found": "Aucune donnée trouvée",
"select_items": "Choisis des trucs...",
"search": "Rechercher...",
"expand_card": "Carte d'expansion",
"copied": "Copié",
@@ -119,6 +120,8 @@
"username": "Nom d'utilisateur",
"save": "Enregistrer",
"username_can_only_contain": "Le nom d'utilisateur ne peut contenir que des lettres minuscules, des chiffres, des tirets, des tirets bas et le symbole '@'",
"username_must_start_with": "Le nom d'utilisateur doit commencer par un caractère alphanumérique.",
"username_must_end_with": "Le nom d'utilisateur doit finir par un caractère alphanumérique.",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Connectez-vous avec le code suivant. Le code expirera dans 15 minutes.",
"or_visit": "ou visiter",
"added_on": "Ajoutée le",
@@ -214,7 +217,7 @@
"group_members_attribute": "Attribut des membres du groupe",
"the_attribute_to_use_for_querying_members_of_a_group": "L'attribut à utiliser pour interroger les membres d'un groupe.",
"group_unique_identifier_attribute": "Attribut d'identifiant unique de groupe",
"group_name_attribute": "Attribut de nom de groupe",
"group_rdn_attribute": "Attribut RDN du groupe (dans DN)",
"admin_group_name": "Nom du groupe administrateur",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Les membres de ce groupe auront des privilèges d'administrateur dans Pocket ID.",
"disable": "Désactiver",
@@ -440,5 +443,12 @@
"last_signed_in_ago": "Dernière connexion il y a {time} il y a",
"invalid_client_id": "L'ID client ne peut contenir que des lettres, des chiffres, des traits de soulignement et des tirets.",
"custom_client_id_description": "Définissez un identifiant client personnalisé si votre application l'exige. Sinon, laissez ce champ vide pour qu'un identifiant aléatoire soit généré.",
"generated": "Généré"
"generated": "Généré",
"administration": "Administration",
"group_rdn_attribute_description": "L'attribut utilisé dans le nom distinctif (DN) des groupes.",
"display_name_attribute": "Attribut du nom d'affichage",
"display_name": "Nom d'affichage",
"configure_application_images": "Configurer les images d'application",
"ui_config_disabled_info_title": "Configuration de l'interface utilisateur désactivée",
"ui_config_disabled_info_description": "La configuration de l'interface utilisateur est désactivée parce que les paramètres de configuration de l'application sont gérés par des variables d'environnement. Certains paramètres peuvent ne pas être modifiables."
}

View File

@@ -17,6 +17,7 @@
"image_should_be_in_format": "L'immagine deve essere in formato PNG o JPEG.",
"items_per_page": "Elementi per pagina",
"no_items_found": "Nessun elemento trovato",
"select_items": "Scegli gli articoli...",
"search": "Cerca...",
"expand_card": "Espandi scheda",
"copied": "Copiato",
@@ -119,6 +120,8 @@
"username": "Nome utente",
"save": "Salva",
"username_can_only_contain": "Il nome utente può contenere solo lettere minuscole, numeri, underscore, punti, trattini e simboli '@'",
"username_must_start_with": "Il nome utente deve iniziare con un carattere alfanumerico.",
"username_must_end_with": "Il nome utente deve finire con un carattere alfanumerico",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Accedi utilizzando il seguente codice. Il codice scadrà tra 15 minuti.",
"or_visit": "o visita",
"added_on": "Aggiunto il",
@@ -214,7 +217,7 @@
"group_members_attribute": "Attributo membri del gruppo",
"the_attribute_to_use_for_querying_members_of_a_group": "L'attributo da utilizzare per interrogare i membri di un gruppo.",
"group_unique_identifier_attribute": "Attributo identificativo univoco gruppo",
"group_name_attribute": "Attributo nome gruppo",
"group_rdn_attribute": "Attributo RDN di gruppo (in DN)",
"admin_group_name": "Nome gruppo amministratori",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "I membri di questo gruppo avranno privilegi di amministratore in Pocket ID.",
"disable": "Disabilita",
@@ -440,5 +443,12 @@
"last_signed_in_ago": "Ultimo accesso {time} fa",
"invalid_client_id": "L'ID cliente può contenere solo lettere, numeri, trattini bassi e trattini.",
"custom_client_id_description": "Imposta un ID cliente personalizzato se la tua app lo richiede. Altrimenti, lascia vuoto per generarne uno casuale.",
"generated": "Generato"
"generated": "Generato",
"administration": "Amministrazione",
"group_rdn_attribute_description": "L'attributo usato nel nome distinto (DN) dei gruppi.",
"display_name_attribute": "Attributo del nome visualizzato",
"display_name": "Nome visualizzato",
"configure_application_images": "Configurare le immagini dell'applicazione",
"ui_config_disabled_info_title": "Configurazione dell'interfaccia utente disattivata",
"ui_config_disabled_info_description": "La configurazione dell'interfaccia utente è disattivata perché le impostazioni di configurazione dell'applicazione sono gestite tramite variabili di ambiente. Alcune impostazioni potrebbero non essere modificabili."
}

View File

@@ -14,9 +14,10 @@
"profile_picture": "프로필 사진",
"profile_picture_is_managed_by_ldap_server": "프로필 사진이 LDAP 서버에서 관리되어 여기에서 변경할 수 없습니다.",
"click_profile_picture_to_upload_custom": "프로필 사진을 클릭하여 파일에서 사용자 정의 사진을 업로드하세요.",
"image_should_be_in_format": "이미지는 PNG 또는 JPEG 형식이야 합니다.",
"image_should_be_in_format": "이미지는 PNG 또는 JPEG 형식이야 합니다.",
"items_per_page": "페이지당 항목",
"no_items_found": "항목 없음",
"select_items": "항목을 선택하세요...",
"search": "검색...",
"expand_card": "카드 확장",
"copied": "복사됨",
@@ -84,7 +85,7 @@
"enter_the_code_you_received_to_sign_in": "로그인하기 위해 받은 코드를 입력하세요.",
"code": "코드",
"invalid_redirect_url": "잘못된 리다이렉트 URL",
"audit_log": "감사 로그",
"audit_log": "활동 기록",
"users": "사용자",
"user_groups": "사용자 그룹",
"oidc_clients": "OIDC 클라이언트",
@@ -119,6 +120,8 @@
"username": "사용자 이름",
"save": "저장",
"username_can_only_contain": "사용자 이름은 영어 소문자, 숫자, 밑줄, 점, 하이픈, '@' 기호만 포함할 수 있습니다",
"username_must_start_with": "사용자 이름은 영숫자로 시작해야 합니다",
"username_must_end_with": "사용자 이름은 영숫자로 끝나야 합니다",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "다음 코드를 사용하여 로그인하세요. 이 코드는 15분 후에 만료됩니다.",
"or_visit": "또는",
"added_on": "추가:",
@@ -139,10 +142,10 @@
"description": "설명",
"api_key": "API 키",
"close": "닫기",
"name_to_identify_this_api_key": "API 키를 구분하기 위한 이름.",
"name_to_identify_this_api_key": "API 키의 이름입니다.",
"expires_at": "만료일",
"when_this_api_key_will_expire": "API 키의 만료일.",
"optional_description_to_help_identify_this_keys_purpose": "이 키의 목적을 알기 위한 설명. (선택)",
"when_this_api_key_will_expire": "API 키의 만료일입니다.",
"optional_description_to_help_identify_this_keys_purpose": "이 키의 목적에 대한 설명입니다. (선택)",
"expiration_date_must_be_in_the_future": "만료일은 미래의 날짜여야 합니다",
"revoke_api_key": "API 키 취소",
"never": "없음",
@@ -185,7 +188,7 @@
"application_configuration_updated_successfully": "애플리케이션 구성이 성공적으로 업데이트되었습니다",
"application_name": "애플리케이션 이름",
"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": "셀프 계정 편집 활성화",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "사용자가 자신의 계정 정보를 편집할 수 있습니다.",
"emails_verified": "이메일 인증됨",
@@ -195,9 +198,9 @@
"ldap_sync_finished": "LDAP 동기화 완료",
"client_configuration": "클라이언트 구성",
"ldap_url": "LDAP URL",
"ldap_bind_dn": "LDAP 바인드 DN",
"ldap_bind_password": "LDAP 바인드 비밀번호",
"ldap_base_dn": "LDAP 베이스 DN",
"ldap_bind_dn": "LDAP Bind DN",
"ldap_bind_password": "LDAP Bind Password",
"ldap_base_dn": "LDAP Base DN",
"user_search_filter": "사용자 검색 필터",
"the_search_filter_to_use_to_search_or_sync_users": "사용자 검색 및 동기화를 위한 검색 필터.",
"groups_search_filter": "그룹 검색 필터",
@@ -214,7 +217,7 @@
"group_members_attribute": "그룹 멤버 속성",
"the_attribute_to_use_for_querying_members_of_a_group": "그룹의 멤버를 질의할 때 사용할 속성.",
"group_unique_identifier_attribute": "그룹 고유 식별자 속성",
"group_name_attribute": "그룹 멤버 속성",
"group_rdn_attribute": "그룹 RDN 속성 (DN 내)",
"admin_group_name": "관리자 그룹 이름",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "이 그룹의 멤버들은 Pocket ID에서 관리자 권한을 갖게 됩니다.",
"disable": "비활성화",
@@ -275,7 +278,7 @@
"public_client": "공개 클라이언트",
"public_clients_description": "공개 클라이언트는 클라이언트 시크릿이 없습니다. 이들은 시크릿을 안전하게 보관할 수 없는 모바일, 웹, 네이티브 애플리케이션을 위해 설계되었습니다.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "공개 키 코드 교환 CSRF 및 승인 코드 가로채기 공격을 방지하기 위한 보안 기능입니다.",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "PKCE(공개 키 코드 교환)는 CSRF 및 승인 코드 가로채기 공격을 방지하기 위한 보안 기능입니다.",
"requires_reauthentication": "재인증 요구",
"requires_users_to_authenticate_again_on_each_authorization": "사용자가 이미 로그인한 상태에서도 승인할 때마다 다시 인증을 요구합니다.",
"name_logo": "{name} 로고",
@@ -317,12 +320,12 @@
"select_the_language_you_want_to_use": "사용할 언어를 선택하세요. 일부 텍스트는 자동으로 번역되었을 수 있으며, 정확하지 않을 수 있습니다.",
"contribute_to_translation": "문제를 발견했다면 <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>에서 번역에 기여해 주세요.",
"personal": "개인",
"global": "전",
"global": "전",
"all_users": "모든 사용자",
"all_events": "모든 이벤트",
"all_clients": "모든 클라이언트",
"all_locations": "모든 위치",
"global_audit_log": "전역 감사 로그",
"global_audit_log": "전체 활동 기록",
"see_all_account_activities_from_the_last_3_months": "지난 3개월 동안의 모든 사용자 활동을 확인하세요.",
"token_sign_in": "토큰 로그인",
"client_authorization": "클라이언트 승인",
@@ -341,8 +344,8 @@
"login_code_email_success": "로그인 코드가 사용자에게 전송되었습니다.",
"send_email": "이메일 전송",
"show_code": "코드 표시",
"callback_url_description": "클라이언트가 제공한 URL. 비워둔 경우 자동으로 추가됩니다. 와일드카드(*)도 지원하지만, 보안상의 이유로 사용을 권장하지 않습니다.",
"logout_callback_url_description": "클라이언트가 제공한 로그아웃 URL. 와일드카드(*)도 지원하지만, 보안상의 이유로 사용을 권장하지 않습니다.",
"callback_url_description": "클라이언트가 제공한 URL입니다. 비워둔 경우 자동으로 추가됩니다. 와일드카드(*)도 지원하지만, 보안상의 이유로 사용을 권장하지 않습니다.",
"logout_callback_url_description": "클라이언트가 제공한 로그아웃 URL입니다. 와일드카드(*)도 지원하지만, 보안상의 이유로 사용을 권장하지 않습니다.",
"api_key_expiration": "API 키 만료",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "API 키가 만료되기 전에 사용자에게 이메일을 전송합니다.",
"authorize_device": "기기 승인",
@@ -381,22 +384,22 @@
"custom_accent_color_description": "유효한 CSS 색상 형식(예: hex, rgb, hsl)을 사용하여 맞춤 색상을 입력하세요.",
"color_value": "색상 값",
"apply": "적용",
"signup_token": "계정 생성 토큰",
"create_a_signup_token_to_allow_new_user_registration": "새로운 사용자 등록을 허용하기 위해 계정 생성 토큰을 생성합니다.",
"signup_token": "가입 토큰",
"create_a_signup_token_to_allow_new_user_registration": "새로운 사용자 등록을 허용하기 위해 가입 토큰을 생성합니다.",
"usage_limit": "사용량 제한",
"number_of_times_token_can_be_used": "계정 생성 토큰을 사용할 수 있는 횟수.",
"number_of_times_token_can_be_used": "가입 토큰을 사용할 수 있는 횟수.",
"expires": "만료일",
"signup": "계정 생성",
"signup": "계정 만들기",
"user_creation": "사용자 생성",
"configure_user_creation": "사용자 생성 설정을 관리합니다. 에는 신규 사용자 등록 방법 및 신규 사용자의 기본 권한이 포함됩니다.",
"user_creation_groups_description": "새 사용자가 가입할 때 이 그룹을 자동으로 할당합니다.",
"user_creation_claims_description": "새 사용자가 가입할 때 이 사용자 정의 클레임을 자동으로 할당합니다.",
"user_creation_updated_successfully": "사용자 생성 설정 업데이트가 성공적으로 완료되었습니다.",
"configure_user_creation": "사용자 생성 설정을 관리합니다. 여기에는 신규 사용자 등록 방법 및 신규 사용자의 기본 권한이 포함됩니다.",
"user_creation_groups_description": "새 사용자가 계정을 만들 때 이 그룹을 자동으로 할당합니다.",
"user_creation_claims_description": "새 사용자가 계정을 만들 때 이 사용자 정의 클레임을 자동으로 할당합니다.",
"user_creation_updated_successfully": "사용자 생성 설정 성공적으로 업데이트되었습니다.",
"signup_disabled_description": "계정 생성이 완전히 비활성화됩니다. 새로운 사용자 계정은 관리자만 생성할 수 있습니다.",
"signup_requires_valid_token": "계정을 생성하려면 유효한 계정 생성 토큰이 필요합니다",
"validating_signup_token": "계정 생성 토큰 검증",
"signup_requires_valid_token": "계정을 생성하려면 유효한 가입 토큰이 필요합니다",
"validating_signup_token": "가입 토큰 검증",
"go_to_login": "로그인으로 이동",
"signup_to_appname": "{appName} 계정 생성하기",
"signup_to_appname": "{appName} 계정 만들기",
"create_your_account_to_get_started": "계정을 만들어 시작하세요.",
"initial_account_creation_description": "시작하기 위해 계정을 만드세요. 패스키를 나중에 설정할 수 있습니다.",
"setup_your_passkey": "패스키 설정",
@@ -406,11 +409,11 @@
"enable_user_signups": "계정 생성 활성화",
"enable_user_signups_description": "Pocket ID에서 사용자가 새로운 계정을 생성하는 방법을 결정하세요.",
"user_signups_are_disabled": "계정 생성이 현재 비활성화되었습니다",
"create_signup_token": "계정 생성 토큰 생성",
"view_active_signup_tokens": "활성 계정 생성 토큰 보기",
"manage_signup_tokens": "계정 생성 토큰 관리",
"view_and_manage_active_signup_tokens": "활성 계정 생성 토큰을 조회하고 관리합니다.",
"signup_token_deleted_successfully": "계정 생성 토큰이 성공적으로 삭제되었습니다.",
"create_signup_token": "가입 토큰 생성",
"view_active_signup_tokens": "활성 가입 토큰 보기",
"manage_signup_tokens": "가입 토큰 관리",
"view_and_manage_active_signup_tokens": "활성 가입 토큰을 조회하고 관리합니다.",
"signup_token_deleted_successfully": "가입 토큰이 성공적으로 삭제되었습니다.",
"expired": "만료됨",
"used_up": "사용 완료",
"active": "활성",
@@ -418,10 +421,10 @@
"created": "생성일",
"token": "토큰",
"loading": "불러오는 중",
"delete_signup_token": "계정 생성 토큰 삭제",
"are_you_sure_you_want_to_delete_this_signup_token": "이 계정 생성 토큰을 정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"delete_signup_token": "가입 토큰 삭제",
"are_you_sure_you_want_to_delete_this_signup_token": "이 가입 토큰을 정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"signup_with_token": "토큰으로 계정 생성",
"signup_with_token_description": "사용자는 관리자가 생성한 유효한 계정 생성 토큰을 사용해야 가입할 수 있습니다.",
"signup_with_token_description": "사용자는 관리자가 생성한 유효한 가입 토큰을 사용해야 계정을 생성할 수 있습니다.",
"signup_open": "계정 생성 허용",
"signup_open_description": "누구나 제한 없이 새로운 계정을 생성할 수 있습니다.",
"of": "의",
@@ -438,7 +441,14 @@
"revoke_access_description": "<b>{clientName}</b>의 접근 권한을 취소합니다. <b>{clientName}</b>은 더 이상 계정 정보에 접근할 수 없습니다.",
"revoke_access_successful": "{clientName}의 접근이 성공적으로 취소되었습니다.",
"last_signed_in_ago": "{time} 전에 로그인함",
"invalid_client_id": "고객 ID는 영문자, 숫자, 밑줄, 하이픈만 포함될 수 있습니다.",
"invalid_client_id": "클라이언트 ID는 영문자, 숫자, 밑줄, 하이픈만 포함될 수 있습니다",
"custom_client_id_description": "애플리케이션에서 사용자 정의 클라이언트 ID가 요구되는 경우 설정하세요. 그렇지 않으면 빈 상태로 두어서 무작위로 생성할 수 있습니다.",
"generated": "생성됨"
"generated": "생성됨",
"administration": "관리",
"group_rdn_attribute_description": "그룹의 고유 식별자(DN)에 사용되는 속성.",
"display_name_attribute": "표시 이름 속성",
"display_name": "표시 이름",
"configure_application_images": "애플리케이션 이미지 구성",
"ui_config_disabled_info_title": "UI 구성 비활성화됨",
"ui_config_disabled_info_description": "UI 구성이 비활성화되었습니다. 애플리케이션 구성 설정은 환경 변수를 통해 관리되기 때문입니다. 일부 설정은 편집할 수 없을 수 있습니다."
}

View File

@@ -17,6 +17,7 @@
"image_should_be_in_format": "De afbeelding moet in PNG- of JPEG-formaat zijn.",
"items_per_page": "Aantal per pagina",
"no_items_found": "Geen items gevonden",
"select_items": "Kies items...",
"search": "Zoek...",
"expand_card": "Kaart uitbreiden",
"copied": "Gekopieerd",
@@ -65,18 +66,18 @@
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Wil je je afmelden bij {appName} met het account <b>{username}</b>?",
"sign_in_to_appname": "Meld je aan bij {appName}",
"please_try_to_sign_in_again": "Probeer opnieuw in te loggen.",
"authenticate_with_passkey_to_access_account": "Log in met uw passkey om toegang te krijgen tot uw account.",
"authenticate_with_passkey_to_access_account": "Log in met je passkey om toegang te krijgen tot je account.",
"authenticate": "Authenticeren",
"please_try_again": "Probeer het opnieuw.",
"continue": "Doorgaan",
"alternative_sign_in": "Alternatieve aanmelding",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Als je geen toegang hebt tot je passkey, kun je je op een van de volgende manieren aanmelden.",
"use_your_passkey_instead": "Wil je in plaats daarvan je passkey gebruiken?",
"email_login": "E-mail inloggen",
"email_login": "Inloggen met e-mail",
"enter_a_login_code_to_sign_in": "Voer een inlogcode in om in te loggen.",
"request_a_login_code_via_email": "Vraag een inlogcode aan via e-mail.",
"go_back": "Terug",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Er is een e-mail verzonden naar het opgegeven e-mailadres, indien deze in het systeem voorkomt.",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Er is een e-mail verzonden naar het opgegeven e-mailadres, indien dit in het systeem voorkomt.",
"enter_code": "Voer code in",
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Voer je e-mailadres in om een e-mail met een inlogcode te ontvangen.",
"your_email": "Je e-mail",
@@ -107,7 +108,7 @@
"passkey_missing": "Passkey ontbreekt",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Voeg een passkey toe om te voorkomen dat je de toegang tot je account verliest.",
"single_passkey_configured": "Eén enkele passkey geconfigureerd",
"it_is_recommended_to_add_more_than_one_passkey": "Het is raadzaam om meer dan één passkey toe te voegen om te voorkomen dat je de toegang tot uw account verliest.",
"it_is_recommended_to_add_more_than_one_passkey": "Het is raadzaam om meer dan één passkey toe te voegen om te voorkomen dat je de toegang tot je account verliest.",
"account_details": "Accountgegevens",
"passkeys": "Passkeys",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Beheer de passkeys waarmee je jezelf kunt verifiëren.",
@@ -119,6 +120,8 @@
"username": "Gebruikersnaam",
"save": "Opslaan",
"username_can_only_contain": "Gebruikersnaam mag alleen kleine letters, cijfers, onderstrepingstekens, punten, koppeltekens en '@'-symbolen bevatten",
"username_must_start_with": "Je gebruikersnaam moet beginnen met een letter of cijfer.",
"username_must_end_with": "Je gebruikersnaam moet eindigen met een letter of cijfer.",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Meld je aan met de volgende code. De code verloopt over 15 minuten.",
"or_visit": "of bezoek",
"added_on": "Toegevoegd op",
@@ -148,7 +151,7 @@
"never": "Nooit",
"revoke": "Intrekken",
"api_key_revoked_successfully": "API-sleutel succesvol ingetrokken",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Weet je zeker dat u de API-sleutel \"{apiKeyName}\" wilt intrekken? Hiermee worden alle integraties die deze sleutel gebruiken, verbroken.",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Weet je zeker dat je de API-sleutel \"{apiKeyName}\" wilt intrekken? Hiermee worden alle integraties die deze sleutel gebruiken, verbroken.",
"last_used": "Laatst gebruikt",
"actions": "Acties",
"images_updated_successfully": "Afbeeldingen succesvol bijgewerkt",
@@ -214,7 +217,7 @@
"group_members_attribute": "Groepsleden attribuut",
"the_attribute_to_use_for_querying_members_of_a_group": "Het attribuut dat gebruikt moet worden om leden van een groep te bevragen.",
"group_unique_identifier_attribute": "Uniek groepsidentificatie attribuut",
"group_name_attribute": "Groepsnaam attribuut",
"group_rdn_attribute": "Groeps-RDN-kenmerk (in DN)",
"admin_group_name": "Naam van beheerdersgroep",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Leden van deze groep hebben beheerdersrechten in Pocket ID.",
"disable": "Uitschakelen",
@@ -227,8 +230,8 @@
"manage_users": "Gebruikers beheren",
"admin_privileges": "Beheerdersrechten",
"admins_have_full_access_to_the_admin_panel": "Beheerders hebben volledige toegang tot het beheerderspaneel.",
"delete_firstname_lastname": "Verwijderen {firstName} {lastName}",
"are_you_sure_you_want_to_delete_this_user": "Weet je zeker dat u deze gebruiker wilt verwijderen?",
"delete_firstname_lastname": "{firstName} {lastName} verwijderen",
"are_you_sure_you_want_to_delete_this_user": "Weet je zeker dat je deze gebruiker wilt verwijderen?",
"user_deleted_successfully": "Gebruiker succesvol verwijderd",
"role": "Rol",
"source": "Bron",
@@ -294,13 +297,13 @@
"disabled": "Uitgeschakeld",
"oidc_client_updated_successfully": "OIDC-client succesvol bijgewerkt",
"create_new_client_secret": "Nieuw clientgeheim aanmaken",
"are_you_sure_you_want_to_create_a_new_client_secret": "Weet je zeker dat je een nieuw client secret wilt aanmaken? De oude wordt ongeldig.",
"are_you_sure_you_want_to_create_a_new_client_secret": "Weet je zeker dat je een nieuw clientgeheim wilt aanmaken? Het oude wordt ongeldig.",
"generate": "Genereren",
"new_client_secret_created_successfully": "Nieuw clientgeheim succesvol aangemaakt",
"allowed_user_groups_updated_successfully": "Toegestane gebruikersgroepen succesvol bijgewerkt",
"oidc_client_name": "OIDC-client {name}",
"client_id": "Client ID",
"client_secret": "Client geheim",
"client_id": "Client-ID",
"client_secret": "Clientgeheim",
"show_more_details": "Meer details weergeven",
"allowed_user_groups": "Toegestane gebruikersgroepen",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Voeg gebruikersgroepen toe aan deze client om de toegang tot gebruikers in deze groepen te beperken. Als er geen gebruikersgroepen zijn geselecteerd, hebben alle gebruikers toegang tot deze client.",
@@ -310,12 +313,12 @@
"background_image": "Achtergrondfoto",
"language": "Taal",
"reset_profile_picture_question": "Profielfoto opnieuw instellen?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "Hiermee wordt de geüploade afbeelding verwijderd en wordt de profielfoto teruggezet naar de standaardinstelling. Wilt u doorgaan?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "Hiermee wordt de geüploade afbeelding verwijderd en wordt de profielfoto teruggezet naar de standaardinstelling. Wil je doorgaan?",
"reset": "Opnieuw instellen",
"reset_to_default": "Standaardinstellingen herstellen",
"profile_picture_has_been_reset": "Profielfoto is gereset. Het kan enkele minuten duren voordat de wijzigingen zichtbaar zijn.",
"select_the_language_you_want_to_use": "Kies de taal die u wilt gebruiken. Let op: sommige teksten worden automatisch vertaald en kunnen onnauwkeurig zijn.",
"contribute_to_translation": "Als u een fout vindt, kun je altijd helpen met de vertaling op <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
"select_the_language_you_want_to_use": "Kies de taal die je wilt gebruiken. Let op: sommige teksten worden automatisch vertaald en kunnen onnauwkeurig zijn.",
"contribute_to_translation": "Als je een fout vindt, kun je altijd helpen met de vertaling op <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
"personal": "Persoonlijk",
"global": "Globaal",
"all_users": "Alle gebruikers",
@@ -335,14 +338,14 @@
"user_enabled_successfully": "Gebruiker is succesvol geactiveerd.",
"status": "Status",
"disable_firstname_lastname": "{firstName} {lastName} uitschakelen",
"are_you_sure_you_want_to_disable_this_user": "Weet u zeker dat u deze gebruiker wilt uitschakelen? Deze kan dan niet meer inloggen of diensten gebruiken.",
"are_you_sure_you_want_to_disable_this_user": "Weet je zeker dat je deze gebruiker wilt uitschakelen? Deze kan dan niet meer inloggen of diensten gebruiken.",
"ldap_soft_delete_users": "Voorkom dat in LDAP uitgeschakelde gebruikers toegang krijgen.",
"ldap_soft_delete_users_description": "Als dit is ingeschakeld, worden gebruikers die uit LDAP worden verwijderd, uitgeschakeld in plaats van daadwerkelijk uit het systeem verwijderd.",
"login_code_email_success": "De inlogcode is naar de gebruiker gestuurd.",
"send_email": "Verstuur e-mail",
"show_code": "Toon code",
"callback_url_description": "URL's die de client heeft aangegeven. Als je dit leeg laat, worden ze automatisch toegevoegd. Je kunt jokertekens (*) gebruiken, maar voor de veiligheid kun je dat beter niet doen.",
"logout_callback_url_description": "URL's die uw client heeft aangegeven om uit te loggen. Je kunt jokertekens (*) gebruiken, maar voor de veiligheid kun je dat beter niet doen.",
"logout_callback_url_description": "URL's die je client heeft aangegeven om uit te loggen. Je kunt jokertekens (*) gebruiken, maar voor de veiligheid kun je dat beter niet doen.",
"api_key_expiration": "API-sleutel verloopt",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Stuur een e-mail naar de gebruiker als de geldigheid van hun API-sleutel bijna verloopt.",
"authorize_device": "Apparaat autoriseren",
@@ -350,7 +353,7 @@
"enter_code_displayed_in_previous_step": "Voer de code in die in de vorige stap werd getoond.",
"authorize": "Autoriseren",
"federated_client_credentials": "Federatieve clientreferenties",
"federated_client_credentials_description": "Met federatieve clientreferenties kunt u OIDC-clients verifiëren met JWT-tokens die zijn uitgegeven door andere instanties.",
"federated_client_credentials_description": "Met federatieve clientreferenties kun je OIDC-clients verifiëren met JWT-tokens die zijn uitgegeven door andere instanties.",
"add_federated_client_credential": "Federatieve clientreferenties toevoegen",
"add_another_federated_client_credential": "Voeg nog een federatieve clientreferentie toe",
"oidc_allowed_group_count": "Aantal groepen met toegang",
@@ -393,13 +396,13 @@
"user_creation_claims_description": "Wijs deze aangepaste claims automatisch toe aan nieuwe gebruikers bij aanmelding.",
"user_creation_updated_successfully": "Instellingen voor het aanmaken van gebruikers zijn bijgewerkt.",
"signup_disabled_description": "Gebruikersregistraties zijn helemaal uitgeschakeld. Alleen beheerders kunnen nieuwe gebruikersaccounts aanmaken.",
"signup_requires_valid_token": "U heeft een geldige registratietoken nodig om een account aan te maken.",
"signup_requires_valid_token": "Je hebt een geldige registratietoken nodig om een account aan te maken.",
"validating_signup_token": "Inlogtoken checken",
"go_to_login": "Ga naar inloggen",
"signup_to_appname": "Meld u aan voor {appName}",
"create_your_account_to_get_started": "Om te beginnen moet u een account aanmaken.",
"initial_account_creation_description": "Maak een account aan om te beginnen. U kunt later een wachtwoord instellen.",
"setup_your_passkey": "Stel uw passkey in",
"signup_to_appname": "Meld je aan voor {appName}",
"create_your_account_to_get_started": "Maak je account aan om te beginnen.",
"initial_account_creation_description": "Maak een account aan om te beginnen. Je kunt later een wachtwoord instellen.",
"setup_your_passkey": "Stel je passkey in",
"create_a_passkey_to_securely_access_your_account": "Maak een passkey aan om veilig toegang te krijgen tot je account. Dit is je primaire manier om in te loggen.",
"skip_for_now": "Voor nu even overslaan",
"account_created": "Account aangemaakt",
@@ -418,15 +421,15 @@
"created": "Gemaakt",
"token": "Token",
"loading": "Bezig met laden",
"delete_signup_token": "Registratietoken verwijderen",
"are_you_sure_you_want_to_delete_this_signup_token": "Weet u zeker dat u dit aanmeldingstoken wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"delete_signup_token": "Aanmeldtoken verwijderen",
"are_you_sure_you_want_to_delete_this_signup_token": "Weet je zeker dat je dit aanmeldingstoken wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"signup_with_token": "Aanmelden met token",
"signup_with_token_description": "U kunt zich alleen aanmelden met een geldige aanmeldtoken die door een beheerder is aangemaakt.",
"signup_with_token_description": "Je kunt je alleen aanmelden met een geldige aanmeldtoken die door een beheerder is aangemaakt.",
"signup_open": "Open inschrijving",
"signup_open_description": "Iedereen kan zonder beperkingen een nieuw account aanmaken.",
"of": "van",
"skip_passkey_setup": "Sla de instellingen voor de toegangssleutel over",
"skip_passkey_setup_description": "Het wordt aangeraden om een passkey in te stellen, want zonder dit kunt u niet meer inloggen zodra de sessie afloopt.",
"skip_passkey_setup_description": "Het wordt aangeraden om een passkey in te stellen, want zonder dit kun je niet meer inloggen zodra de sessie afloopt.",
"my_apps": "Mijn apps",
"no_apps_available": "Geen apps beschikbaar",
"contact_your_administrator_for_app_access": "Neem contact op met de beheerder om toegang te krijgen tot applicaties.",
@@ -435,10 +438,17 @@
"client_launch_url_description": "De URL die wordt geopend als iemand de app start vanaf de pagina Mijn apps.",
"client_name_description": "De naam van de client die wordt getoond in de Pocket ID UI.",
"revoke_access": "Toegang intrekken",
"revoke_access_description": "Toegang intrekken tot <b>{clientName}</b>. <b>{clientName}</b> kun je accountgegevens niet meer gebruiken.",
"revoke_access_description": "Toegang intrekken tot <b>{clientName}</b>. <b>{clientName}</b> kan je accountgegevens niet meer gebruiken.",
"revoke_access_successful": "De toegang tot {clientName} is nu succesvol geblokkeerd.",
"last_signed_in_ago": "Laatst ingelogd {time} geleden",
"invalid_client_id": "De klant-ID mag alleen letters, cijfers, onderstrepingstekens en koppeltekens bevatten.",
"custom_client_id_description": "Stel een aangepaste client-ID in als je app dit nodig heeft. Anders laat je het gewoon leeg en wordt er een willekeurige ID gegenereerd.",
"generated": "Gemaakt"
"invalid_client_id": "De Client-ID mag alleen letters, cijfers, onderstrepingstekens en koppeltekens bevatten.",
"custom_client_id_description": "Stel een aangepaste Client-ID in als je app dit nodig heeft. Als je het leeg laat wordt er een willekeurige ID gegenereerd.",
"generated": "Gemaakt",
"administration": "Beheer",
"group_rdn_attribute_description": "Het kenmerk dat wordt gebruikt in de onderscheidende naam (DN) van de groepen.",
"display_name_attribute": "Weergavenaam-attribuut",
"display_name": "Weergavenaam",
"configure_application_images": "Configureer applicatieafbeeldingen",
"ui_config_disabled_info_title": "UI-configuratie uitgeschakeld",
"ui_config_disabled_info_description": "De UI-configuratie is uitgeschakeld omdat de configuratie-instellingen van de app via omgevingsvariabelen worden beheerd. Sommige instellingen kun je misschien niet aanpassen."
}

View File

@@ -17,6 +17,7 @@
"image_should_be_in_format": "Obraz powinien być w formacie PNG lub JPEG.",
"items_per_page": "Elementów na stronę",
"no_items_found": "Nie znaleziono żadnych elementów",
"select_items": "Wybierz elementy...",
"search": "Szukaj...",
"expand_card": "Rozwiń kartę",
"copied": "Skopiowano",
@@ -119,6 +120,8 @@
"username": "Nazwa użytkownika",
"save": "Zapisz",
"username_can_only_contain": "Nazwa użytkownika może zawierać tylko małe litery, cyfry, podkreślenia, kropki, myślniki i symbole '@'",
"username_must_start_with": "Nazwa użytkownika musi zaczynać się od znaku alfanumerycznego.",
"username_must_end_with": "Nazwa użytkownika musi kończyć się znakiem alfanumerycznym.",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Zaloguj się, używając następującego kodu. Kod wygaśnie za 15 minut.",
"or_visit": "lub odwiedź",
"added_on": "Dodano",
@@ -214,7 +217,7 @@
"group_members_attribute": "Atrybut członków grupy",
"the_attribute_to_use_for_querying_members_of_a_group": "Atrybut do użycia w zapytaniach o członków grupy.",
"group_unique_identifier_attribute": "Atrybut unikalnego identyfikatora grupy",
"group_name_attribute": "Atrybut nazwy grupy",
"group_rdn_attribute": "Atrybut grupy RDN (w DN)",
"admin_group_name": "Nazwa grupy administratorów",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Członkowie tej grupy będą mieli uprawnienia administratora w Pocket ID.",
"disable": "Wyłącz",
@@ -440,5 +443,12 @@
"last_signed_in_ago": "Ostatnio zalogowany {time} temu",
"invalid_client_id": "Identyfikator klienta może zawierać wyłącznie litery, cyfry, znaki podkreślenia i łączniki.",
"custom_client_id_description": "Ustaw niestandardowy identyfikator klienta, jeśli jest to wymagane przez twoją aplikację. W przeciwnym razie pozostaw to pole puste, aby wygenerować losowy identyfikator.",
"generated": "Wygenerowano"
"generated": "Wygenerowano",
"administration": "Administracja",
"group_rdn_attribute_description": "Atrybut używany w nazwie wyróżniającej grupy (DN).",
"display_name_attribute": "Atrybut nazwy wyświetlanej",
"display_name": "Wyświetlana nazwa",
"configure_application_images": "Konfigurowanie obrazów aplikacji",
"ui_config_disabled_info_title": "Konfiguracja interfejsu użytkownika wyłączona",
"ui_config_disabled_info_description": "Konfiguracja interfejsu użytkownika jest wyłączona, ponieważ ustawienia konfiguracyjne aplikacji są zarządzane za pomocą zmiennych środowiskowych. Niektóre ustawienia mogą nie być edytowalne."
}

View File

@@ -17,6 +17,7 @@
"image_should_be_in_format": "A imagem deve estar no formato PNG ou JPEG.",
"items_per_page": "Itens por página",
"no_items_found": "Nenhum item encontrado",
"select_items": "Selecione os itens...",
"search": "Pesquisar...",
"expand_card": "Expandir cartão",
"copied": "Copiado",
@@ -119,6 +120,8 @@
"username": "Nome de usuário",
"save": "Salvar",
"username_can_only_contain": "O nome de usuário só pode conter letras minúsculas, números, underscores, pontos, hífens e símbolos '@'",
"username_must_start_with": "O nome de usuário precisa começar com um caractere alfanumérico.",
"username_must_end_with": "O nome de usuário precisa terminar com um caractere alfanumérico.",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Faça o login usando o código a seguir. O código irá expirar em 15 minutos.",
"or_visit": "ou visite",
"added_on": "Adicionado em",
@@ -214,7 +217,7 @@
"group_members_attribute": "Atributo dos membros do grupo",
"the_attribute_to_use_for_querying_members_of_a_group": "O atributo a ser usado para consultar membros de um grupo.",
"group_unique_identifier_attribute": "Atributo identificador exclusivo do grupo",
"group_name_attribute": "Atributo do nome do grupo",
"group_rdn_attribute": "Atributo RDN do grupo (em DN)",
"admin_group_name": "Nome do grupo de administradores",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Os membros desse grupo vão ter privilégios de administrador no Pocket ID.",
"disable": "Desativar",
@@ -440,5 +443,12 @@
"last_signed_in_ago": "Último login em {time} atrás",
"invalid_client_id": "A ID do cliente só pode ter letras, números, sublinhados e hífens.",
"custom_client_id_description": "Defina um ID de cliente personalizado se for necessário para o seu aplicativo. Caso contrário, deixe em branco para gerar um aleatório.",
"generated": "Gerado"
"generated": "Gerado",
"administration": "Administração",
"group_rdn_attribute_description": "O atributo usado no nome distinto (DN) dos grupos.",
"display_name_attribute": "Atributo Nome de exibição",
"display_name": "Nome de exibição",
"configure_application_images": "Configurar imagens de aplicativos",
"ui_config_disabled_info_title": "Configuração da interface do usuário desativada",
"ui_config_disabled_info_description": "A configuração da interface do usuário está desativada porque as configurações do aplicativo são gerenciadas por meio de variáveis de ambiente. Algumas configurações podem não ser editáveis."
}

View File

@@ -17,6 +17,7 @@
"image_should_be_in_format": "Изображение должно быть в формате PNG или JPEG.",
"items_per_page": "Элементов на странице",
"no_items_found": "Элементов не найдено",
"select_items": "Выбрать элементы...",
"search": "Поиск...",
"expand_card": "Развернуть карточку",
"copied": "Скопировано",
@@ -119,6 +120,8 @@
"username": "Имя пользователя",
"save": "Сохранить",
"username_can_only_contain": "Имя пользователя может содержать только строчные буквы, цифры, знак подчеркивания, точки, дефиса и символ '@'",
"username_must_start_with": "Имя пользователя должно начинаться с буквы или цифры",
"username_must_end_with": "Имя пользователя должно заканчиваться буквой или цифрой",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Войдите, используя следующий код. Код истечет через 15 минут.",
"or_visit": "или посетите",
"added_on": "Добавлен",
@@ -214,7 +217,7 @@
"group_members_attribute": "Атрибут членов группы",
"the_attribute_to_use_for_querying_members_of_a_group": "Атрибут, используемый для запроса членов группы.",
"group_unique_identifier_attribute": "Атрибут уникального идентификатора группы",
"group_name_attribute": "Атрибут имени группы",
"group_rdn_attribute": "RDN атрибут группы (в DN)",
"admin_group_name": "Имя группы администраторов",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Члены этой группы будут иметь права администратора в Pocket ID.",
"disable": "Отключить",
@@ -440,5 +443,12 @@
"last_signed_in_ago": "Последний вход {time} назад",
"invalid_client_id": "ID клиента может содержать только буквы, цифры, подчеркивания и дефисы",
"custom_client_id_description": "Установите пользовательский ID клиента, если это нужно для вашего приложения. Если нет, оставьте поле пустым, чтобы он был сгенерирован случайным образом.",
"generated": "Сгенерированный"
"generated": "Сгенерированный",
"administration": "Администрирование",
"group_rdn_attribute_description": "Атрибут, который используется в различающемся имени группы (DN).",
"display_name_attribute": "Атрибут отображаемого имени",
"display_name": "Отображаемое имя",
"configure_application_images": "Настройка изображений приложения",
"ui_config_disabled_info_title": "Конфигурация пользовательского интерфейса отключена",
"ui_config_disabled_info_description": "Конфигурация пользовательского интерфейса отключена, потому что настройки приложения управляются через переменные среды. Некоторые настройки могут быть недоступны для редактирования."
}

454
frontend/messages/sv.json Normal file
View File

@@ -0,0 +1,454 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "Mitt konto",
"logout": "Logga ut",
"confirm": "Bekräfta",
"docs": "Dokumentation",
"key": "Nyckel",
"value": "Värde",
"remove_custom_claim": "Ta bort anpassad claim",
"add_custom_claim": "Lägg till anpassad claim",
"add_another": "Lägg till fler",
"select_a_date": "Välj ett datum",
"select_file": "Välj fil",
"profile_picture": "Profilbild",
"profile_picture_is_managed_by_ldap_server": "Profilbilden hanteras av LDAP-servern och kan inte ändras här.",
"click_profile_picture_to_upload_custom": "Klicka på profilbilden för att ladda upp en anpassad bild från dina filer.",
"image_should_be_in_format": "Bilden ska vara i PNG- eller JPEG-format.",
"items_per_page": "Objekt per sida",
"no_items_found": "Inga objekt hittades",
"select_items": "Välj objekt...",
"search": "Sök...",
"expand_card": "Expandera kort",
"copied": "Kopierad",
"click_to_copy": "Klicka för att kopiera",
"something_went_wrong": "Någonting gick fel",
"go_back_to_home": "Gå tillbaka till startsidan",
"alternative_sign_in_methods": "Alternativa inloggningsmetoder",
"login_background": "Inloggningsbakgrund",
"logo": "Logotyp",
"login_code": "Inloggningskod",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Skapa en inloggningskod som användaren kan använda för att logga in en gång utan passkey.",
"one_hour": "1 timme",
"twelve_hours": "12 timmar",
"one_day": "1 dag",
"one_week": "1 vecka",
"one_month": "1 månad",
"expiration": "Upphör",
"generate_code": "Generera kod",
"name": "Namn",
"browser_unsupported": "Webbläsaren stöds ej",
"this_browser_does_not_support_passkeys": "Denna webbläsare stöder inte passkeys. Använd en alternativ inloggningsmetod.",
"an_unknown_error_occurred": "Ett okänt fel har uppstått",
"authentication_process_was_aborted": "Autentiseringsprocessen avbröts",
"error_occurred_with_authenticator": "Ett fel uppstod med autentiseraren",
"authenticator_does_not_support_discoverable_credentials": "Autentiseraren stöder inte upptäckbara autentiseringsuppgifter",
"authenticator_does_not_support_resident_keys": "Autentiseraren stöder inte lagrade nycklar",
"passkey_was_previously_registered": "Denna passkey har redan registrerats",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Autentiseraren stöder inte någon av de begärda algoritmerna",
"authenticator_timed_out": "Autentiseraren överskred tidsgränsen",
"critical_error_occurred_contact_administrator": "Ett kritiskt fel har inträffat. Kontakta din administratör.",
"sign_in_to": "Logga in på {name}",
"client_not_found": "Klienten hittades inte",
"client_wants_to_access_the_following_information": "<b>{client}</b> vill få åtkomst till följande information:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Vill du logga in på <b>{client}</b> med ditt {appName} -konto?",
"email": "E-post",
"view_your_email_address": "Visa din e-postadress",
"profile": "Profil",
"view_your_profile_information": "Visa din profilinformation",
"groups": "Grupper",
"view_the_groups_you_are_a_member_of": "Visa de grupper du är medlem i",
"cancel": "Avbryt",
"sign_in": "Logga in",
"try_again": "Försök igen",
"client_logo": "Klientens logotyp",
"sign_out": "Logga ut",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Vill du logga ut från {appName} med kontot <b>{username}</b>?",
"sign_in_to_appname": "Logga in på {appName}",
"please_try_to_sign_in_again": "Vänligen försök att logga in igen.",
"authenticate_with_passkey_to_access_account": "Autentisera dig med din passkey för att komma åt ditt konto.",
"authenticate": "Autentisera",
"please_try_again": "Vänligen försök igen.",
"continue": "Fortsätt",
"alternative_sign_in": "Alternativ inloggning",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Om du inte har tillgång till din passkey kan du logga in med någon av följande metoder.",
"use_your_passkey_instead": "Använd din passkey istället?",
"email_login": "E-postinloggning",
"enter_a_login_code_to_sign_in": "Ange en inloggningskod för att logga in.",
"request_a_login_code_via_email": "Begär en inloggningskod via e-post.",
"go_back": "Gå tillbaka",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Om adressen finns i systemet har ett e-postmeddelande skickats dit.",
"enter_code": "Ange kod",
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Ange din e-postadress för att få ett e-postmeddelande med en inloggningskod.",
"your_email": "Din e-post",
"submit": "Skicka",
"enter_the_code_you_received_to_sign_in": "Ange koden du fått för att logga in.",
"code": "Kod",
"invalid_redirect_url": "Ogiltig omdirigerings-URL",
"audit_log": "Granskningslogg",
"users": "Användare",
"user_groups": "Användargrupper",
"oidc_clients": "OIDC-klienter",
"api_keys": "API-nycklar",
"application_configuration": "Applikationskonfiguration",
"settings": "Inställningar",
"update_pocket_id": "Uppdatera Pocket-ID",
"powered_by": "Drivs av",
"see_your_account_activities_from_the_last_3_months": "Se dina kontoaktiviteter från de senaste 3 månaderna.",
"time": "Tid",
"event": "Händelse",
"approximate_location": "Ungefärlig plats",
"ip_address": "IP-adress",
"device": "Enhet",
"client": "Klient",
"unknown": "Okänd",
"account_details_updated_successfully": "Kontouppgifterna har uppdaterats",
"profile_picture_updated_successfully": "Profilbilden har uppdaterats. Det kan ta några minuter innan uppdateringen syns.",
"account_settings": "Kontoinställningar",
"passkey_missing": "Passkey saknas",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Lägg till en passkey för att förhindra att du förlorar åtkomsten till ditt konto.",
"single_passkey_configured": "En passkey konfigurerad",
"it_is_recommended_to_add_more_than_one_passkey": "Det rekommenderas att lägga till fler än en passkey för att undvika att förlora åtkomsten till ditt konto.",
"account_details": "Kontouppgifter",
"passkeys": "Passkeys",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Hantera dina passkeys som du kan använda för att autentisera dig själv.",
"add_passkey": "Lägg till en passkey",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Skapa en engångskod för att logga in från en annan enhet utan en passkey.",
"create": "Skapa",
"first_name": "Förnamn",
"last_name": "Efternamn",
"username": "Användarnamn",
"save": "Spara",
"username_can_only_contain": "Användarnamnet får endast innehålla små bokstäver, siffror, understreck, punkter, bindestreck och '@'-tecken",
"username_must_start_with": "Användarnamnet måste börja med ett alfanumeriskt tecken",
"username_must_end_with": "Användarnamnet måste sluta med ett alfanumeriskt tecken",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Logga in med följande kod. Koden upphör att gälla om 15 minuter.",
"or_visit": "eller besök",
"added_on": "Tillagd den",
"rename": "Byt namn",
"delete": "Ta bort",
"are_you_sure_you_want_to_delete_this_passkey": "Är du säker på att du vill ta bort denna passkey?",
"passkey_deleted_successfully": "Passkey har tagits bort",
"delete_passkey_name": "Ta bort {passkeyName}",
"passkey_name_updated_successfully": "Passkey-namnet har uppdaterats",
"name_passkey": "Namn på Passkey",
"name_your_passkey_to_easily_identify_it_later": "Namnge din passkey för att enkelt kunna identifiera den senare.",
"create_api_key": "Skapa API-nyckel",
"add_a_new_api_key_for_programmatic_access": "Lägg till en ny API-nyckel för programmatisk åtkomst.",
"add_api_key": "Lägg till API-nyckel",
"manage_api_keys": "Hantera API-nycklar",
"api_key_created": "API-nyckel skapad",
"for_security_reasons_this_key_will_only_be_shown_once": "Av säkerhetsskäl visas denna nyckel endast en gång. Förvara den på ett säkert sätt.",
"description": "Beskrivning",
"api_key": "API-nyckel",
"close": "Stäng",
"name_to_identify_this_api_key": "Namn för att identifiera denna API-nyckel.",
"expires_at": "Upphör",
"when_this_api_key_will_expire": "Tidpunkt då denna API-nyckel upphör att gälla.",
"optional_description_to_help_identify_this_keys_purpose": "Valfri beskrivning för att identifiera nyckelns syfte.",
"expiration_date_must_be_in_the_future": "Utgångsdatumet måste ligga i framtiden",
"revoke_api_key": "Återkalla API-nyckel",
"never": "Aldrig",
"revoke": "Återkalla",
"api_key_revoked_successfully": "API-nyckeln återkallades framgångsrikt",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Är du säker på att du vill återkalla API-nyckeln ”{apiKeyName}”? Alla integrationer som använder nyckeln slutar att fungera.",
"last_used": "Senast använd",
"actions": "Åtgärder",
"images_updated_successfully": "Bilderna har uppdaterats",
"general": "Allmänt",
"configure_smtp_to_send_emails": "Aktivera e-postaviseringar för att varna användare när en inloggning upptäcks från en ny enhet eller plats.",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Konfigurera LDAP-inställningar för att synkronisera användare och grupper från en LDAP-server.",
"images": "Bilder",
"update": "Uppdatera",
"email_configuration_updated_successfully": "E-postkonfigurationen har uppdaterats",
"save_changes_question": "Spara ändringarna?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "Du måste spara ändringarna innan du skickar ett testmeddelande. Vill du spara nu?",
"save_and_send": "Spara och skicka",
"test_email_sent_successfully": "Testmeddelandet har skickats till din e-postadress.",
"failed_to_send_test_email": "Det gick inte att skicka testmeddelandet. Kontrollera serverloggarna för mer information.",
"smtp_configuration": "SMTP-konfiguration",
"smtp_host": "SMTP-värd",
"smtp_port": "SMTP-port",
"smtp_user": "SMTP-användare",
"smtp_password": "SMTP-lösenord",
"smtp_from": "SMTP Från",
"smtp_tls_option": "SMTP TLS-alternativ",
"email_tls_option": "E-post TLS-alternativ",
"skip_certificate_verification": "Hoppa över certifikatverifiering",
"this_can_be_useful_for_selfsigned_certificates": "Detta kan vara användbart för självsignerade certifikat.",
"enabled_emails": "Aktiverade e-postadresser",
"email_login_notification": "E-postmeddelande om inloggning",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Skicka ett e-postmeddelande till användaren när de loggar in från en ny enhet.",
"emai_login_code_requested_by_user": "E-postinloggningskod begärd av användaren",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Tillåter användare att kringgå passkeys genom att begära en inloggningskod som skickas till deras e-post. Detta minskar säkerheten avsevärt eftersom alla som har tillgång till användarens e-post kan logga in.",
"email_login_code_from_admin": "E-postinloggningskod från administratören",
"allows_an_admin_to_send_a_login_code_to_the_user": "Tillåter en administratör att skicka en inloggningskod till användaren via e-post.",
"send_test_email": "Skicka testmeddelande",
"application_configuration_updated_successfully": "Applikationskonfigurationen har uppdaterats",
"application_name": "Applikationsnamn",
"session_duration": "Sessionsvaraktighet",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Hur länge en session varar i minuter innan användaren måste logga in igen.",
"enable_self_account_editing": "Aktivera redigering av eget konto",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Om användarna ska kunna redigera sina egna kontouppgifter.",
"emails_verified": "E-postadresser verifierade",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Om användarens e-postadress ska markeras som verifierad för OIDC-klienterna.",
"ldap_configuration_updated_successfully": "LDAP-konfigurationen har uppdaterats",
"ldap_disabled_successfully": "LDAP har inaktiverats",
"ldap_sync_finished": "LDAP-synkronisering slutförd",
"client_configuration": "Klientkonfiguration",
"ldap_url": "LDAP-adress",
"ldap_bind_dn": "LDAP-bas-DN",
"ldap_bind_password": "LDAP-bindlösenord",
"ldap_base_dn": "LDAP-bas-DN",
"user_search_filter": "Filter för användarsökning",
"the_search_filter_to_use_to_search_or_sync_users": "Sökfilter som ska användas för att söka/synkronisera användare.",
"groups_search_filter": "Filter för gruppsökningar",
"the_search_filter_to_use_to_search_or_sync_groups": "Sökfilter som ska användas för att söka/synkronisera grupper.",
"attribute_mapping": "Attributmappning",
"user_unique_identifier_attribute": "Attribut för unik användaridentifierare",
"the_value_of_this_attribute_should_never_change": "Värdet på detta attribut bör aldrig ändras.",
"username_attribute": "Attribut för användarnamn",
"user_mail_attribute": "Attribut för användarens e-post",
"user_first_name_attribute": "Attribut för användarens förnamn",
"user_last_name_attribute": "Attribut för användarens efternamn",
"user_profile_picture_attribute": "Attribut för användarens profilbild",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "Värdet för detta attribut kan antingen vara en URL, en binär eller en base64-kodad bild.",
"group_members_attribute": "Attribut för gruppmedlemmar",
"the_attribute_to_use_for_querying_members_of_a_group": "Attributet som ska användas för att söka efter medlemmar i en grupp.",
"group_unique_identifier_attribute": "Attribut för unik gruppidentifierare",
"group_rdn_attribute": "Grupp-RDN-attribut (i DN)",
"admin_group_name": "Admin-gruppens namn",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Medlemmar i denna grupp kommer att ha administratörsbehörighet i Pocket ID.",
"disable": "Inaktivera",
"sync_now": "Synkronisera nu",
"enable": "Aktivera",
"user_created_successfully": "Användaren har skapats",
"create_user": "Skapa användare",
"add_a_new_user_to_appname": "Lägg till en ny användare i {appName}",
"add_user": "Lägg till användare",
"manage_users": "Hantera användare",
"admin_privileges": "Administratörsbehörigheter",
"admins_have_full_access_to_the_admin_panel": "Administratörer har full åtkomst till administratörspanelen.",
"delete_firstname_lastname": "Ta bort {firstName} {lastName}",
"are_you_sure_you_want_to_delete_this_user": "Är du säker på att du vill ta bort den här användaren?",
"user_deleted_successfully": "Användare borttagen",
"role": "Roll",
"source": "Källa",
"admin": "Administratör",
"user": "Användare",
"local": "Lokal",
"toggle_menu": "Visa/dölj meny",
"edit": "Ändra",
"user_groups_updated_successfully": "Användargrupper har uppdaterats",
"user_updated_successfully": "Användaren har uppdaterats",
"custom_claims_updated_successfully": "Anpassade claims har uppdaterats",
"back": "Tillbaka",
"user_details_firstname_lastname": "Användaruppgifter {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Hantera vilka grupper denna användare tillhör.",
"custom_claims": "Anpassade claims",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Anpassade claims är nyckel-värde-par som kan användas för att lagra ytterligare information om en användare. Dessa claims inkluderas i ID-token om scopet 'profile' begärs.",
"user_group_created_successfully": "Användargruppen har skapats",
"create_user_group": "Skapa användargrupp",
"create_a_new_group_that_can_be_assigned_to_users": "Skapa en ny grupp som kan tilldelas användare.",
"add_group": "Lägg till grupp",
"manage_user_groups": "Hantera användargrupper",
"friendly_name": "Visningsnamn",
"name_that_will_be_displayed_in_the_ui": "Namn som kommer att visas i användargränssnittet",
"name_that_will_be_in_the_groups_claim": "Namn som kommer att finnas i 'groups'-claimen",
"delete_name": "Ta bort {name}",
"are_you_sure_you_want_to_delete_this_user_group": "Är du säker på att du vill ta bort den här användargruppen?",
"user_group_deleted_successfully": "Användargruppen har tagits bort",
"user_count": "Antal användare",
"user_group_updated_successfully": "Användargrupp har uppdaterats",
"users_updated_successfully": "Användare har uppdaterats",
"user_group_details_name": "Användargruppsdetaljer {name}",
"assign_users_to_this_group": "Tilldela användare till denna grupp.",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Anpassade claims är nyckel-värde-par som kan användas för att lagra ytterligare information om en användare. Dessa claims inkluderas i ID-token om scopet 'profile' begärs. Anpassade claims som är definierade på användaren kommer att prioriteras om det uppstår konflikter.",
"oidc_client_created_successfully": "OIDC-klient har skapats",
"create_oidc_client": "Skapa OIDC-klient",
"add_a_new_oidc_client_to_appname": "Lägg till en ny OIDC-klient i {appName}.",
"add_oidc_client": "Lägg till OIDC-klient",
"manage_oidc_clients": "Hantera OIDC-klienter",
"one_time_link": "Engångslänk",
"use_this_link_to_sign_in_once": "Använd denna länk för att logga in en gång. Detta behövs för användare som ännu inte har lagt till en passkey eller har tappat bort den.",
"add": "Lägg till",
"callback_urls": "Callback-URL:er",
"logout_callback_urls": "Logout Callback-URL:er",
"public_client": "Public Client",
"public_clients_description": "Public clients har ingen client secret. De är avsedda för mobil-, webb- och native-appar där hemligheter inte kan lagras säkert.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange är en säkerhetsfunktion som skyddar mot CSRF och avlyssning av authorization codes.",
"requires_reauthentication": "Kräver återautentisering",
"requires_users_to_authenticate_again_on_each_authorization": "Kräver att användare autentiserar sig igen vid varje auktorisering, även om de redan är inloggade",
"name_logo": "{name} -logotyp",
"change_logo": "Ändra logotyp",
"upload_logo": "Ladda upp logotyp",
"remove_logo": "Ta bort logotyp",
"are_you_sure_you_want_to_delete_this_oidc_client": "Är du säker på att du vill ta bort denna OIDC-klient?",
"oidc_client_deleted_successfully": "OIDC-klient har tagits bort",
"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": "Aktiverad",
"disabled": "Inaktiverad",
"oidc_client_updated_successfully": "OIDC-klient har uppdaterats",
"create_new_client_secret": "Skapa ny client secret",
"are_you_sure_you_want_to_create_a_new_client_secret": "Är du säker på att du vill skapa en ny klienthemlighet? Den gamla kommer att ogiltigförklaras.",
"generate": "Generera",
"new_client_secret_created_successfully": "Ny klienthemlighet har skapats",
"allowed_user_groups_updated_successfully": "Tillåtna användargrupper har uppdaterats",
"oidc_client_name": "OIDC-klient {name}",
"client_id": "Client ID",
"client_secret": "Klienthemlighet",
"show_more_details": "Visa fler detaljer",
"allowed_user_groups": "Tillåtna användargrupper",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Lägg till användargrupper i denna klient för att begränsa åtkomsten till användare i dessa grupper. Om inga användargrupper väljs får alla användare åtkomst till denna klient.",
"favicon": "Favicon",
"light_mode_logo": "Logotyp för ljust läge",
"dark_mode_logo": "Logotyp för mörkt läge",
"background_image": "Bakgrundsbild",
"language": "Språk",
"reset_profile_picture_question": "Återställ profilbild?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "Detta kommer att ta bort den uppladdade bilden och återställa profilbilden till standard. Vill du fortsätta?",
"reset": "Återställ",
"reset_to_default": "Återställ standardvärden",
"profile_picture_has_been_reset": "Profilbilden har återställts. Det kan ta några minuter innan uppdateringen syns.",
"select_the_language_you_want_to_use": "Välj det språk du vill använda. Observera att vissa texter kan översättas automatiskt och därför vara felaktiga.",
"contribute_to_translation": "Om du hittar ett problem är du välkommen att bidra till översättningen på <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
"personal": "Personlig",
"global": "Global",
"all_users": "Alla användare",
"all_events": "Alla händelser",
"all_clients": "Alla klienter",
"all_locations": "Alla platser",
"global_audit_log": "Global granskningslogg",
"see_all_account_activities_from_the_last_3_months": "Se all användaraktivitet för de senaste 3 månaderna.",
"token_sign_in": "Token-inloggning",
"client_authorization": "Godkännande av klient",
"new_client_authorization": "Ny klientauktorisation",
"disable_animations": "Stäng av animationer",
"turn_off_ui_animations": "Stäng av animationer i hela användargränssnittet.",
"user_disabled": "Konto inaktiverat",
"disabled_users_cannot_log_in_or_use_services": "Inaktiverade användare kan inte logga in eller använda tjänster.",
"user_disabled_successfully": "Användaren har inaktiverats.",
"user_enabled_successfully": "Användaren har aktiverats.",
"status": "Status",
"disable_firstname_lastname": "Inaktivera {firstName} {lastName}",
"are_you_sure_you_want_to_disable_this_user": "Är du säker på att du vill inaktivera den här användaren? De kommer inte att kunna logga in eller komma åt några tjänster.",
"ldap_soft_delete_users": "Behåll inaktiverade användare från LDAP.",
"ldap_soft_delete_users_description": "När denna funktion är aktiverad kommer användare som tas bort från LDAP att inaktiveras istället för att tas bort från systemet.",
"login_code_email_success": "Inloggningskoden har skickats till användaren.",
"send_email": "Skicka e-postmeddelande",
"show_code": "Visa kod",
"callback_url_description": "URL-adresser som tillhandahålls av din klient. Läggs till automatiskt om fältet lämnas tomt. Jokertecken (*) stöds, men bör undvikas för bättre säkerhet.",
"logout_callback_url_description": "URL-adresser som din klient tillhandahåller för utloggning. Jokertecken (*) stöds, men bör undvikas för bättre säkerhet.",
"api_key_expiration": "API-nyckelns giltighetstid",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Skicka ett e-postmeddelande till användaren när deras API-nyckel håller på att upphöra.",
"authorize_device": "Godkänn enhet",
"the_device_has_been_authorized": "Enheten har godkänts.",
"enter_code_displayed_in_previous_step": "Ange koden som visades i föregående steg.",
"authorize": "Godkänn",
"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.",
"add_federated_client_credential": "Lägg till federerad klientuppgift",
"add_another_federated_client_credential": "Lägg till ytterligare en federerad klientuppgift",
"oidc_allowed_group_count": "Tillåtet antal grupper",
"unrestricted": "Obegränsad",
"show_advanced_options": "Visa avancerade alternativ",
"hide_advanced_options": "Dölj avancerade alternativ",
"oidc_data_preview": "Förhandsgranska OIDC-data",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Förhandsgranska OIDC-data som skulle skickas för olika användare",
"id_token": "ID-token",
"access_token": "Åtkomsttoken",
"userinfo": "Användarinformation",
"id_token_payload": "ID-token-payload",
"access_token_payload": "Åtkomsttoken-payload",
"userinfo_endpoint_response": "Svar från Userinfo-endpointen",
"copy": "Kopiera",
"no_preview_data_available": "Inga förhandsgranskningsdata tillgängliga",
"copy_all": "Kopiera allt",
"preview": "Förhandsgranska",
"preview_for_user": "Förhandsgranskning för {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Förhandsgranska OIDC-data som skulle skickas för denna användare",
"show": "Visa",
"select_an_option": "Välj ett alternativ",
"select_user": "Välj Användare",
"error": "Fel",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Välj en accentfärg för att anpassa utseendet på Pocket ID.",
"accent_color": "Accentfärg",
"custom_accent_color": "Anpassad accentfärg",
"custom_accent_color_description": "Ange en anpassad färg med hjälp av giltiga CSS-färgformat (t.ex. hex, rgb, hsl).",
"color_value": "Färgvärde",
"apply": "Tillämpa",
"signup_token": "Registreringstoken",
"create_a_signup_token_to_allow_new_user_registration": "Skapa en registreringstoken för att tillåta registrering av nya användare.",
"usage_limit": "Begränsning av användning",
"number_of_times_token_can_be_used": "Antal gånger registreringstoken kan användas.",
"expires": "Upphör att gälla",
"signup": "Skapa konto",
"user_creation": "Skapa användare",
"configure_user_creation": "Hantera inställningar för skapande av användare, inklusive registreringsmetoder och standardbehörigheter för nya användare.",
"user_creation_groups_description": "Tilldela dessa grupper automatiskt till nya användare vid registrering.",
"user_creation_claims_description": "Tilldela dessa anpassade claims automatiskt till nya användare vid registrering.",
"user_creation_updated_successfully": "Inställningarna för användarskapande har uppdaterats.",
"signup_disabled_description": "Användarregistreringar är helt inaktiverade. Endast administratörer kan skapa nya användarkonton.",
"signup_requires_valid_token": "En giltig registreringstoken krävs för att skapa ett konto",
"validating_signup_token": "Validering av registreringstoken",
"go_to_login": "Gå till inloggning",
"signup_to_appname": "Registrera dig på {appName}",
"create_your_account_to_get_started": "Skapa ditt konto för att komma igång.",
"initial_account_creation_description": "Vänligen skapa ditt konto för att komma igång. Du kommer att kunna skapa en passkey senare.",
"setup_your_passkey": "Skapa din passkey",
"create_a_passkey_to_securely_access_your_account": "Skapa en passkey för säker åtkomst till ditt konto. Detta kommer att vara ditt primära sätt att logga in.",
"skip_for_now": "Hoppa över för tillfället",
"account_created": "Konto skapad",
"enable_user_signups": "Aktivera användarregistreringar",
"enable_user_signups_description": "Bestäm hur användare kan registrera sig för nya konton i Pocket ID.",
"user_signups_are_disabled": "Användarregistreringar är för närvarande inaktiverade",
"create_signup_token": "Skapa registreringstoken",
"view_active_signup_tokens": "Visa aktiva registreringstokens",
"manage_signup_tokens": "Hantera registreringstokens",
"view_and_manage_active_signup_tokens": "Visa och hantera aktiva registreringstokens.",
"signup_token_deleted_successfully": "Registreringstoken har tagits bort.",
"expired": "Har utgått",
"used_up": "Förbrukad",
"active": "Aktiv",
"usage": "Användning",
"created": "Skapad",
"token": "Token",
"loading": "Laddar",
"delete_signup_token": "Ta bort registreringstoken",
"are_you_sure_you_want_to_delete_this_signup_token": "Är du säker på att du vill ta bort denna registreringstoken? Denna åtgärd kan inte ångras.",
"signup_with_token": "Registrera med token",
"signup_with_token_description": "Användare kan endast registrera sig med hjälp av en giltig registreringstoken som skapats av en administratör.",
"signup_open": "Öppen registrering",
"signup_open_description": "Vem som helst kan skapa ett nytt konto utan begränsningar.",
"of": "av",
"skip_passkey_setup": "Hoppa över inställning av passkey",
"skip_passkey_setup_description": "Det rekommenderas starkt att du ställer in en passkey, eftersom du annars blir utelåst från ditt konto när sessionen löper ut.",
"my_apps": "Mina appar",
"no_apps_available": "Inga appar tillgängliga",
"contact_your_administrator_for_app_access": "Kontakta din administratör för att få åtkomst till applikationer.",
"launch": "Starta",
"client_launch_url": "Klientens start-URL",
"client_launch_url_description": "Den URL som öppnas när en användare startar appen från sidan Mina appar.",
"client_name_description": "Namnet på klienten som visas i Pocket ID-användargränssnittet.",
"revoke_access": "Återkalla åtkomst",
"revoke_access_description": "Återkalla åtkomst till <b>{clientName}</b>. <b>{clientName}</b> kommer inte längre att kunna komma åt din kontoinformation.",
"revoke_access_successful": "Åtkomsten till {clientName} har återkallats.",
"last_signed_in_ago": "Senast inloggad {time} sedan",
"invalid_client_id": "Client ID kan endast innehålla bokstäver, siffror, understreck och bindestreck",
"custom_client_id_description": "Ange ett anpassat Client ID om detta krävs av din applikation. Annars lämnar du fältet tomt för att generera ett slumpmässigt ID.",
"generated": "Genererad",
"administration": "Administration",
"group_rdn_attribute_description": "Attributet som används i gruppernas distinkta namn (DN).",
"display_name_attribute": "Visningsnamnattribut",
"display_name": "Visningsnamn",
"configure_application_images": "Konfigurera applikationsbilder",
"ui_config_disabled_info_title": "UI-konfiguration inaktiverad",
"ui_config_disabled_info_description": "UI-konfigurationen är inaktiverad eftersom applikationens konfigurationsinställningar hanteras via miljövariabler. Vissa inställningar kan inte redigeras."
}

View File

@@ -17,6 +17,7 @@
"image_should_be_in_format": "Зображення повинно бути у форматі PNG або JPEG.",
"items_per_page": "Елементів на сторінці",
"no_items_found": "Нічого не знайдено",
"select_items": "Виберіть елементи...",
"search": "Пошук...",
"expand_card": "Розгорнути картку",
"copied": "Скопійовано",
@@ -119,6 +120,8 @@
"username": "Ім’я користувача",
"save": "Зберегти",
"username_can_only_contain": "Ім’я користувача може містити лише малі літери, цифри, підкреслення, крапки, дефіси та символ '@'",
"username_must_start_with": "Ім'я користувача повинно починатися з буквено-цифрового символу",
"username_must_end_with": "Ім'я користувача повинно закінчуватися буквено-цифровим символом",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Увійдіть, використовуючи наступний код. Код дійсний протягом 15 хвилин.",
"or_visit": "або відвідайте",
"added_on": "Додано",
@@ -214,7 +217,7 @@
"group_members_attribute": "Атрибут \"Учасник груп\"",
"the_attribute_to_use_for_querying_members_of_a_group": "Атрибут, який використовується для запиту учасників групи.",
"group_unique_identifier_attribute": "Атрибут \"Унікальний ідентифікатор групи\"",
"group_name_attribute": "Атрибут \"Назва групи\"",
"group_rdn_attribute": "Атрибут групи RDN (у DN)",
"admin_group_name": "Атрибут \"Назва групи адміністратора\"",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Учасники цієї групи матимуть адміністративні права в Pocket ID.",
"disable": "Вимкнути",
@@ -440,5 +443,12 @@
"last_signed_in_ago": "Останній вхід {time} тому",
"invalid_client_id": "Ідентифікатор клієнта може містити тільки літери, цифри, підкреслення та дефіси.",
"custom_client_id_description": "Встановіть власний ідентифікатор клієнта, якщо це потрібно для вашої програми. В іншому випадку залиште поле порожнім, щоб створити випадковий ідентифікатор.",
"generated": "Створено"
"generated": "Створено",
"administration": "Адміністрація",
"group_rdn_attribute_description": "Атрибут, що використовується в розрізнювальному імені групи (DN).",
"display_name_attribute": "Атрибут імені для відображення",
"display_name": "Ім'я для відображення",
"configure_application_images": "Налаштування зображень додатків",
"ui_config_disabled_info_title": "Конфігурація інтерфейсу користувача вимкнена",
"ui_config_disabled_info_description": "Конфігурація інтерфейсу користувача вимкнена, оскільки налаштування конфігурації програми керуються через змінні середовища. Деякі налаштування можуть бути недоступними для редагування."
}

View File

@@ -17,6 +17,7 @@
"image_should_be_in_format": "Hình ảnh phải ở định dạng PNG hoặc JPEG.",
"items_per_page": "Số kết quả mỗi trang",
"no_items_found": "Không tìm thấy kết quả nào",
"select_items": "Chọn các mục...",
"search": "Tìm kiếm...",
"expand_card": "Mở rộng thẻ",
"copied": "Đã sao chép",
@@ -119,6 +120,8 @@
"username": "Tên đăng nhập",
"save": "Lưu",
"username_can_only_contain": "Tên người dùng chỉ có thể chứa các ký tự chữ thường, số, dấu gạch dưới, dấu chấm, dấu gạch ngang và ký hiệu '@'.",
"username_must_start_with": "Tên người dùng phải bắt đầu bằng một ký tự alphanumeric.",
"username_must_end_with": "Tên người dùng phải kết thúc bằng một ký tự alphanumeric.",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Đăng nhập bằng mã sau. Mã này sẽ hết hạn trong 15 phút.",
"or_visit": "hoặc truy cập",
"added_on": "Đã được thêm vào",
@@ -214,7 +217,7 @@
"group_members_attribute": "Thuộc tính thành viên nhóm",
"the_attribute_to_use_for_querying_members_of_a_group": "Thuộc tính được sử dụng để truy vấn các thành viên của một nhóm.",
"group_unique_identifier_attribute": "Thuộc tính định danh duy nhất của người dùng",
"group_name_attribute": "Thuộc tính tên nhóm",
"group_rdn_attribute": "Thuộc tính RDN của nhóm (trong DN)",
"admin_group_name": "Tên nhóm quản trị",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Các thành viên của nhóm này sẽ có quyền quản trị trong Pocket ID.",
"disable": "Tắt",
@@ -440,5 +443,12 @@
"last_signed_in_ago": "Lần đăng nhập cuối cùng cách đây {time}",
"invalid_client_id": "ID khách hàng chỉ có thể chứa các ký tự chữ cái, số, dấu gạch dưới và dấu gạch ngang.",
"custom_client_id_description": "Đặt ID khách hàng tùy chỉnh nếu ứng dụng của bạn yêu cầu. Nếu không, hãy để trống để hệ thống tự động tạo một ID ngẫu nhiên.",
"generated": "Được tạo ra"
"generated": "Được tạo ra",
"administration": "Quản lý",
"group_rdn_attribute_description": "Thuộc tính được sử dụng trong tên phân biệt (DN) của nhóm.",
"display_name_attribute": "Thuộc tính Tên hiển thị",
"display_name": "Tên hiển thị",
"configure_application_images": "Cấu hình hình ảnh ứng dụng",
"ui_config_disabled_info_title": "Cấu hình giao diện người dùng đã bị vô hiệu hóa",
"ui_config_disabled_info_description": "Cấu hình giao diện người dùng (UI) đã bị vô hiệu hóa vì các thiết lập cấu hình ứng dụng được quản lý thông qua biến môi trường. Một số thiết lập có thể không thể chỉnh sửa."
}

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "账户",
"my_account": "我的账户",
"logout": "登出",
"confirm": "确认",
"docs": "文档",
@@ -10,20 +10,21 @@
"add_custom_claim": "添加自定义声明",
"add_another": "再添加一个",
"select_a_date": "选择日期",
"select_file": "选择上传文件",
"select_file": "选择文件",
"profile_picture": "头像",
"profile_picture_is_managed_by_ldap_server": "头像由 LDAP 服务器管理,无法在此处更改。",
"click_profile_picture_to_upload_custom": "点击头像从文件中上传自定义头像。",
"click_profile_picture_to_upload_custom": "点击头像从文件中上传您的自定义头像。",
"image_should_be_in_format": "图片应为 PNG 或 JPEG 格式。",
"items_per_page": "每页条数",
"no_items_found": "🌱 这里暂时空空如也",
"no_items_found": "这里暂时空空如也",
"select_items": "选择项目……",
"search": "搜索……",
"expand_card": "展开卡片",
"copied": "已复制",
"click_to_copy": "点击复制",
"something_went_wrong": "出了点问题",
"go_back_to_home": "返回首页",
"alternative_sign_in_methods": "替代登录方式",
"alternative_sign_in_methods": "备用登录方式",
"login_background": "登录页背景图",
"logo": "图标",
"login_code": "临时登录码",
@@ -119,6 +120,8 @@
"username": "用户名",
"save": "保存",
"username_can_only_contain": "用户名只能包含小写字母、数字、下划线、点、连字符和 '@' 符号",
"username_must_start_with": "用户名必须以字母数字字符开头",
"username_must_end_with": "用户名必须以字母数字字符结尾",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "使用以下代码登录。该代码将在 15 分钟后失效。",
"or_visit": "或访问",
"added_on": "添加于",
@@ -214,7 +217,7 @@
"group_members_attribute": "群组成员属性",
"the_attribute_to_use_for_querying_members_of_a_group": "用于查询群组成员的属性。",
"group_unique_identifier_attribute": "群组唯一标识属性",
"group_name_attribute": "群组名称属性",
"group_rdn_attribute": "组 RDN 属性(在 DN 中)",
"admin_group_name": "管理员组名称",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "此群组的成员将在 Pocket ID 中拥有管理员权限。",
"disable": "禁用",
@@ -439,6 +442,13 @@
"revoke_access_successful": "对 {clientName} 的访问权限已成功撤销。",
"last_signed_in_ago": "最后一次登录 {time} 前",
"invalid_client_id": "客户 ID 只能包含字母、数字、下划线和连字符。",
"custom_client_id_description": "如果您的应用程序需要自定义客户端 ID请在此处设置。否则请留空以生成一个随机生成的客户端 ID。",
"generated": "生成"
"custom_client_id_description": "此处可根据应用需要设置自定义客户端 ID。留空随机生成。",
"generated": "生成",
"administration": "管理员选项",
"group_rdn_attribute_description": "在组的区分名称DN中使用的属性。",
"display_name_attribute": "显示名称属性",
"display_name": "显示名称",
"configure_application_images": "配置应用程序映像",
"ui_config_disabled_info_title": "用户界面配置已禁用",
"ui_config_disabled_info_description": "用户界面配置已禁用,因为应用程序配置设置通过环境变量进行管理。某些设置可能无法编辑。"
}

View File

@@ -17,6 +17,7 @@
"image_should_be_in_format": "圖片應為 PNG 或 JPEG 格式。",
"items_per_page": "每頁項目數",
"no_items_found": "找不到任何項目",
"select_items": "選擇項目...",
"search": "搜尋...",
"expand_card": "展開卡片",
"copied": "已複製",
@@ -119,6 +120,8 @@
"username": "使用者名稱",
"save": "儲存",
"username_can_only_contain": "使用者名稱僅能包含小寫英文字母、數字、底線_、句點.)、連字號(-)與 @ 符號",
"username_must_start_with": "使用者名稱必須以英數字元開頭",
"username_must_end_with": "使用者名稱必須以英數字元結尾",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "使用以下代碼登入。 這個代碼將於 15 分鐘後到期。",
"or_visit": "或造訪",
"added_on": "新增於",
@@ -214,7 +217,7 @@
"group_members_attribute": "群組成員屬性",
"the_attribute_to_use_for_querying_members_of_a_group": "用於查詢群組成員的屬性。",
"group_unique_identifier_attribute": "群組唯一識別屬性",
"group_name_attribute": "群組名稱屬性",
"group_rdn_attribute": "群組 RDN 屬性 (以 DN 表示)",
"admin_group_name": "管理員群組名稱",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "此群組的成員將擁有 Pocket ID 的管理權限。",
"disable": "停用",
@@ -440,5 +443,12 @@
"last_signed_in_ago": "上次登入 {time} 前",
"invalid_client_id": "用戶端 ID 只能包含字母、數字、底線和連字符",
"custom_client_id_description": "如果您的應用程式需要,請設定自訂用戶端 ID。否則請留空以產生隨機 ID。",
"generated": "產生"
"generated": "產生",
"administration": "行政管理",
"group_rdn_attribute_description": "用於群組區別名稱DN的屬性。",
"display_name_attribute": "顯示名稱屬性",
"display_name": "顯示名稱",
"configure_application_images": "設定應用程式映像檔",
"ui_config_disabled_info_title": "使用者介面設定已停用",
"ui_config_disabled_info_description": "使用者介面設定已停用,因為應用程式的設定參數是透過環境變數進行管理。部分設定可能無法編輯。"
}

View File

@@ -1,6 +1,6 @@
{
"name": "pocket-id-frontend",
"version": "1.9.1",
"version": "1.11.1",
"private": true,
"type": "module",
"scripts": {
@@ -16,11 +16,12 @@
"dependencies": {
"@simplewebauthn/browser": "^13.1.2",
"@tailwindcss/vite": "^4.1.11",
"axios": "^1.11.0",
"axios": "^1.12.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"jose": "^5.10.0",
"qrcode": "^1.5.4",
"runed": "^0.31.1",
"sveltekit-superforms": "^2.27.1",
"tailwind-merge": "^3.3.1",
"zod": "^4.0.9"
@@ -32,7 +33,7 @@
"@internationalized/date": "^3.8.2",
"@lucide/svelte": "^0.525.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.26.0",
"@sveltejs/kit": "^2.36.3",
"@sveltejs/vite-plugin-svelte": "^6.1.0",
"@types/eslint": "^9.6.1",
"@types/node": "^22.16.5",
@@ -57,6 +58,6 @@
"tw-animate-css": "^1.3.5",
"typescript": "^5.8.3",
"typescript-eslint": "^8.38.0",
"vite": "^7.0.6"
"vite": "^7.0.7"
}
}

View File

@@ -14,6 +14,7 @@
"pl",
"pt-BR",
"ru",
"sv",
"uk",
"vi",
"zh-CN",

View File

@@ -5,6 +5,8 @@
<link rel="icon" href="/api/application-configuration/favicon" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<link rel="manifest" href="/app.webmanifest" />
<link rel="apple-touch-icon" href="/img/static-logo-512.png">
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View File

@@ -5,13 +5,13 @@
import * as Select from '$lib/components/ui/select';
import * as Table from '$lib/components/ui/table/index.js';
import Empty from '$lib/icons/empty.svelte';
import { m } from '$lib/paraglide/messages';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import { debounced } from '$lib/utils/debounce-util';
import { cn } from '$lib/utils/style';
import { ChevronDown } from '@lucide/svelte';
import type { Snippet } from 'svelte';
import Button from './ui/button/button.svelte';
import { m } from '$lib/paraglide/messages';
let {
items,
@@ -53,19 +53,22 @@
}, 300);
async function onAllCheck(checked: boolean) {
const pageIds = items.data.map((item) => item.id);
const current = selectedIds ?? [];
if (checked) {
selectedIds = items.data.map((item) => item.id);
selectedIds = Array.from(new Set([...current, ...pageIds]));
} else {
selectedIds = [];
selectedIds = current.filter((id) => !pageIds.includes(id));
}
}
async function onCheck(checked: boolean, id: string) {
if (!selectedIds) return;
const current = selectedIds ?? [];
if (checked) {
selectedIds = [...selectedIds, id];
selectedIds = Array.from(new Set([...current, id]));
} else {
selectedIds = selectedIds.filter((selectedId) => selectedId !== id);
selectedIds = current.filter((selectedId) => selectedId !== id);
}
}

View File

@@ -3,6 +3,7 @@
import { Badge } from '$lib/components/ui/badge';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import {translateAuditLogEvent} from "$lib/utils/audit-log-translator";
import AuditLogService from '$lib/services/audit-log-service';
import type { AuditLog } from '$lib/types/audit-log.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
@@ -18,14 +19,6 @@
} = $props();
const auditLogService = new AuditLogService();
function toFriendlyEventString(event: string) {
const words = event.split('_');
const capitalizedWords = words.map((word) => {
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
});
return capitalizedWords.join(' ');
}
</script>
<AdvancedTable
@@ -58,7 +51,7 @@
</Table.Cell>
{/if}
<Table.Cell>
<Badge class="rounded-full" variant="outline">{toFriendlyEventString(item.event)}</Badge>
<Badge class="rounded-full" variant="outline">{translateAuditLogEvent(item.event)}</Badge>
</Table.Cell>
<Table.Cell
>{item.city && item.country ? `${item.city}, ${item.country}` : m.unknown()}</Table.Cell

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