Compare commits

..

89 Commits

Author SHA1 Message Date
Elias Schneider
14f59ce3f3 release: 1.2.0 2025-06-03 22:33:40 +02:00
Elias Schneider
31ad904367 fix: page scrolls up on form submisssion 2025-06-03 21:12:21 +02:00
Elias Schneider
04fcf1110e fix: improve spacing on auth screens 2025-06-03 21:09:32 +02:00
Elias Schneider
eb9b6433ae chore(translations): update translations via Crowdin (#606) 2025-06-02 15:58:52 +02:00
Elias Schneider
b9489b5e9a fix: whitelist authorization header for CORS 2025-06-02 15:55:29 +02:00
Elias Schneider
bd1c69b7b7 Update Crowdin configuration file 2025-06-02 14:17:21 +02:00
Elias Schneider
23dc235bac Update Crowdin configuration file 2025-06-02 14:13:16 +02:00
Elias Schneider
2440379cd1 fix: fallback to primary language if no translation available for specific country 2025-06-02 14:08:32 +02:00
Elias Schneider
6c00aaa3ef fix: allow users to update their locale even when own account update disabled 2025-06-02 11:35:13 +02:00
Elias Schneider
00259f8819 tests: adapt unit test for new app config default value behavior 2025-06-01 20:54:53 +02:00
Elias Schneider
decf8ec70b fix: clear default app config variables from database 2025-06-01 20:46:44 +02:00
Elias Schneider
c24a5546a5 docs: use https in .env.example 2025-05-31 20:51:55 +02:00
Elias Schneider
312421d777 chore(translations): update translations via Crowdin (#599) 2025-05-31 18:44:34 +02:00
Elias Schneider
c42a29a66c chore(translations): update translations via Crowdin (#593) 2025-05-30 21:56:28 -05:00
Elias Schneider
afc317adf7 chore(translations): update translations via Crowdin (#590) 2025-05-29 23:47:58 +02:00
Alessandro (Ale) Segala
256f74d0a3 fix: don't use TOFU for logout callback URLs (#588) 2025-05-29 22:01:23 +02:00
Kyle Mendell
20d3f780a2 feat: auto detect callback url (#583)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-05-29 17:16:10 +02:00
Alessandro (Ale) Segala
6d6dc6646a fix: run jobs at interval instead of specific time (#585) 2025-05-29 17:15:35 +02:00
Alessandro (Ale) Segala
3d402fc0ca fix: small fixes in analytics_job (#582) 2025-05-28 11:12:44 -05:00
Kyle Mendell
b874681824 fix: show LAN for auditlog location for internal networks 2025-05-28 10:52:40 -05:00
Elias Schneider
97cbdfb1ef chore(translations): update translations via Crowdin (#579) 2025-05-28 10:21:03 -05:00
Elias Schneider
24889f9ebc release: 1.1.0 2025-05-28 11:30:09 +02:00
Elias Schneider
e0ec607198 feat: add daily heartbeat request for counting Pocket ID instances (#578) 2025-05-28 11:19:45 +02:00
Maxim Baz
d29fca155e ci/cd: tag container images with v{major} (#577) 2025-05-27 14:01:49 +02:00
Elias Schneider
e2e26b53b3 chore(translations): update translations via Crowdin (#575) 2025-05-26 11:07:40 +02:00
github-actions[bot]
948efbd9c1 chore: update AAGUIDs (#576)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-05-26 11:06:41 +02:00
Elias Schneider
f03b80f9d7 fix: run user group count inside a transaction 2025-05-25 22:24:28 +02:00
Kyle Mendell
38d7ee4432 feat: show allowed group count on oidc client list (#567)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-05-25 19:22:25 +00:00
Kyle Mendell
f66e8e8b44 fix: use ldapAttributeUserUsername for finding group members (#565)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-05-25 20:37:17 +02:00
Elias Schneider
ee133dbceb chore(translations): update translations via Crowdin (#573) 2025-05-25 20:14:46 +02:00
Elias Schneider
68e4b67bd2 feat: require user verification for passkey sign in 2025-05-25 17:09:05 +02:00
Elias Schneider
553147a1c0 release: 1.0.0 2025-05-25 00:06:40 +02:00
Elias Schneider
31ae8cac96 ci/cd: fix subject digest in container image attestation 2025-05-25 00:06:21 +02:00
Elias Schneider
7691622274 chore: remove default value from TARGETARCH in Dockerfile 2025-05-24 23:49:07 +02:00
Elias Schneider
5d1be367c3 chore(translations): update translations via Crowdin (#561) 2025-05-24 23:24:06 +02:00
Elias Schneider
ed0e566e99 ci/cd: upgrade build-push-action 2025-05-24 23:23:48 +02:00
Elias Schneider
2793eb4ebd chore: add major flag to release script 2025-05-24 23:00:33 +02:00
Elias Schneider
059073d4c2 fix: trim whitespaces from string inputs 2025-05-24 22:55:46 +02:00
Elias Schneider
e19b33fc2e fix: use same color as title for description in alert 2025-05-24 22:55:46 +02:00
Elias Schneider
cbe7aa6eec docs: adapt contribution guide 2025-05-24 22:55:46 +02:00
Elias Schneider
2a457ac8e9 chore: remove unused data.json 2025-05-24 22:55:46 +02:00
Elias Schneider
0d4d5386c7 chore: exclude binary from project root 2025-05-24 22:55:46 +02:00
Elias Schneider
f820fc8301 fix: use pointer cursor for menu items 2025-05-24 22:55:46 +02:00
Elias Schneider
131f470757 fix: show correct app name on sign out page 2025-05-24 22:55:46 +02:00
Elias Schneider
6c35570e78 fix: add back month and year selection for date picker 2025-05-24 22:55:46 +02:00
Elias Schneider
869c4c5871 tests: fix e2e tests after shadcn upgrade 2025-05-24 22:55:46 +02:00
Elias Schneider
a65c0b3da3 chore: add missing types to Playwright tests 2025-05-24 22:55:46 +02:00
Elias Schneider
3042de2ce1 tests: fix lldap setup if data already seeded 2025-05-24 22:55:46 +02:00
Elias Schneider
f57c8d347e fix: remove nested button in user group list 2025-05-24 22:55:46 +02:00
Elias Schneider
5b3ff7b879 tests: fix change locale test 2025-05-24 22:55:46 +02:00
Elias Schneider
9fff6ec3b6 tests: move auth.setup.ts into specs folder 2025-05-24 22:55:46 +02:00
Elias Schneider
ca5e754aea ci/cd: fix .auth path of e2e tests 2025-05-24 22:55:46 +02:00
Elias Schneider
ebcf861aa6 ci/cd: start test containers with Docker Compose 2025-05-24 22:55:46 +02:00
Elias Schneider
966a566ade refactor: move e2e tests to root of repository 2025-05-24 22:55:46 +02:00
Elias Schneider
5fa15f6098 fix: remove curly bracket from user group URL 2025-05-24 22:55:46 +02:00
Kyle Mendell
53f212fd3a tests: wait for network 2025-05-24 22:55:46 +02:00
Kyle Mendell
21cb3310d6 tests: use bits-10 as selector 2025-05-24 22:55:46 +02:00
Kyle Mendell
4dc0b2f37f fix: ldap tests 2025-05-24 22:55:46 +02:00
Elias Schneider
ac6df536ef tests: adapt e2e tests 2025-05-24 22:55:46 +02:00
Elias Schneider
c37386f8b2 feat: improve buttons styling 2025-05-24 22:55:46 +02:00
Elias Schneider
c3a03db8b0 fix: authorize page doesn't load 2025-05-24 22:55:46 +02:00
Kyle Mendell
28c85990ba refactor: migrate shadcn-components to Svelte 5 and TW4 (#551)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-05-24 22:55:46 +02:00
Elias Schneider
05b443d984 chore: add .well-known to development reverse proxy 2025-05-24 22:55:46 +02:00
Kyle Mendell
8326bfd136 chore: add pocket-id to .gitignore 2025-05-24 22:55:46 +02:00
Kyle Mendell
b2e89934de chore: remove pocket-id binary 2025-05-24 22:55:46 +02:00
Kyle Mendell
c726c1621b fix: animation speed set to max of 300ms 2025-05-24 22:55:46 +02:00
Alessandro (Ale) Segala
b71c84c355 refactor: some clean-up in OIDC service and controller (#550) 2025-05-24 22:55:46 +02:00
Alessandro (Ale) Segala
3896b7bb3b chore: address linter's complaint in 1.0 branch (#546) 2025-05-24 22:55:46 +02:00
Alessandro (Ale) Segala
cb2a9f9f7d refactor: replace create-one-time-access-token script with in-app functionality (#540) 2025-05-24 22:55:46 +02:00
Alessandro (Ale) Segala
35b227cd17 ci/cd: update release pipelines (#541) 2025-05-24 22:55:46 +02:00
Elias Schneider
f8a7467ec0 refactor!: serve the static frontend trough the backend (#520)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2025-05-24 22:55:46 +02:00
Elias Schneider
bf710aec56 fix: custom logo not correctly loaded if UI configuration is disabled 2025-05-22 19:07:34 +02:00
Elias Schneider
005702e5b6 chore(translations): update translations via Crowdin (#556) 2025-05-21 15:15:21 +02:00
Patryk Mikołajczyk
66d47bf933 chore(translations): add Polish translations (#554)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-05-21 15:13:07 +02:00
github-actions[bot]
d6104bbb35 chore: update AAGUIDs (#547)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-05-18 21:23:43 -05:00
Jake Howard
a0e22036c8 refactor: update options API for simplewebauthn (#543) 2025-05-18 13:22:04 +02:00
Alessandro (Ale) Segala
21bf49c061 refactor: flaky unit test in db_bootstrap_test (#532) 2025-05-14 10:54:03 -05:00
Alessandro (Ale) Segala
a408ef797b refactor: switch SQLite driver to pure-Go implementation (#530) 2025-05-14 09:29:04 +02:00
Kyle Mendell
f1154257c5 refactor!: remove old DB env variables, and jwk migrations logic (#529) 2025-05-13 23:05:54 +02:00
github-actions[bot]
0ca78bef8d chore: update AAGUIDs (#523)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-05-12 07:05:16 +02:00
Elias Schneider
dc5968cd30 release: 0.53.0 2025-05-08 21:56:49 +02:00
Elias Schneider
63a0c08696 fix: handle CORS correctly for endpoints that SPAs need (#513) 2025-05-08 21:56:17 +02:00
Elias Schneider
6c415e7769 chore(translations): update translations via Crowdin (#517) 2025-05-08 20:48:45 +02:00
Elias Schneider
90bdd29fb6 ci/cd: add explicit permissions to actions 2025-05-07 16:48:18 +02:00
Elias Schneider
e0db4695ac refactor: run formatter 2025-05-07 16:43:24 +02:00
Elias Schneider
de648dd6da ci/cd: remove wait for LDAP sync 2025-05-07 16:40:10 +02:00
Kyle Mendell
73c82ae43a tests: add e2e LDAP tests (#466)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-05-07 14:38:02 +00:00
Elias Schneider
ba256c76bc refactor: organize imports 2025-05-07 09:58:38 +02:00
Elias Schneider
5e2e947fe0 feat: add support for TZ environment variable 2025-05-07 09:55:30 +02:00
387 changed files with 8137 additions and 6415 deletions

View File

@@ -1,32 +1,21 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
{ {
"name": "pocket-id", "name": "pocket-id",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm", "image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
"features": { "features": {
"ghcr.io/devcontainers/features/go:1": {}, "ghcr.io/devcontainers/features/go:1": {}
"ghcr.io/devcontainers-extra/features/caddy:1": {}
}, },
"customizations": { "customizations": {
"vscode": { "vscode": {
"extensions": [ "extensions": [
"golang.go", "golang.go",
"svelte.svelte-vscode" "svelte.svelte-vscode",
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode"
] ]
} }
}, },
// Use 'postCreateCommand' to run commands after the container is created. "containerEnv": {
// Install npm dependencies for the frontend. "HOST": "0.0.0.0"
"postCreateCommand": "npm install --prefix frontend" },
"postCreateCommand": "npm install --prefix frontend && cd backend && go mod download"
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
} }

View File

@@ -6,6 +6,9 @@ node_modules
/frontend/.svelte-kit /frontend/.svelte-kit
/frontend/build /frontend/build
/backend/bin /backend/bin
/backend/frontend/dist
/tests/.auth
/tests/.report
# Env # Env
@@ -16,3 +19,4 @@ node_modules
# Application specific # Application specific
data data
/scripts/development /scripts/development
/backend/GeoLite2-City.mmdb

View File

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

View File

@@ -35,5 +35,6 @@ jobs:
uses: golangci/golangci-lint-action@dec74fa03096ff515422f71d18d41307cacde373 # v7.0.0 uses: golangci/golangci-lint-action@dec74fa03096ff515422f71d18d41307cacde373 # v7.0.0
with: with:
version: v2.0.2 version: v2.0.2
args: --build-tags=exclude_frontend
working-directory: backend working-directory: backend
only-new-issues: ${{ github.event_name == 'pull_request' }} only-new-issues: ${{ github.event_name == 'pull_request' }}

View File

@@ -1,50 +0,0 @@
name: Build and Push Docker Image
on:
release:
types: [published]
jobs:
build:
runs-on: ubuntu-22.04 # Using an older version because of https://github.com/actions/runner-images/issues/11471
permissions:
contents: read
packages: write
steps:
- name: checkout code
uses: actions/checkout@v3
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 'Login to GitHub Container Registry'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{github.repository_owner}}
password: ${{secrets.GITHUB_TOKEN}}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -17,6 +17,9 @@ jobs:
build: build:
if: github.event.pull_request.head.ref != 'i18n_crowdin' if: github.event.pull_request.head.ref != 'i18n_crowdin'
timeout-minutes: 20 timeout-minutes: 20
permissions:
contents: read
actions: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -44,13 +47,16 @@ jobs:
test-sqlite: test-sqlite:
if: github.event.pull_request.head.ref != 'i18n_crowdin' if: github.event.pull_request.head.ref != 'i18n_crowdin'
permissions:
contents: read
actions: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build needs: build
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: lts/* node-version: 22
cache: "npm" cache: "npm"
cache-dependency-path: frontend/package-lock.json cache-dependency-path: frontend/package-lock.json
@@ -72,34 +78,48 @@ jobs:
- name: Load Docker image - name: Load Docker image
run: docker load -i /tmp/docker-image.tar run: docker load -i /tmp/docker-image.tar
- name: Install frontend dependencies - name: Cache LLDAP Docker image
working-directory: ./frontend uses: actions/cache@v3
id: lldap-cache
with:
path: /tmp/lldap-image.tar
key: lldap-stable-${{ runner.os }}
- name: Pull and save LLDAP image
if: steps.lldap-cache.outputs.cache-hit != 'true'
run: |
docker pull nitnelave/lldap:stable
docker save nitnelave/lldap:stable > /tmp/lldap-image.tar
- name: Load LLDAP image from cache
if: steps.lldap-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/lldap-image.tar
- name: Install test dependencies
working-directory: ./tests
run: npm ci run: npm ci
- name: Install Playwright Browsers - name: Install Playwright Browsers
working-directory: ./frontend working-directory: ./tests
if: steps.playwright-cache.outputs.cache-hit != 'true' if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium run: npx playwright install --with-deps chromium
- name: Run Docker Container with Sqlite DB - name: Run Docker Container with Sqlite DB and LDAP
working-directory: ./tests/setup
run: | run: |
docker run -d --name pocket-id-sqlite \ docker compose up -d
-p 80:80 \ docker compose logs -f pocket-id &> /tmp/backend.log &
-e APP_ENV=test \
pocket-id:test
docker logs -f pocket-id-sqlite &> /tmp/backend.log &
- name: Run Playwright tests - name: Run Playwright tests
working-directory: ./frontend working-directory: ./tests
run: npx playwright test run: npx playwright test
- name: Upload Frontend Test Report - name: Upload Test Report
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin' if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with: with:
name: playwright-report-sqlite name: playwright-report-sqlite
path: frontend/tests/.report path: tests/.report
include-hidden-files: true include-hidden-files: true
retention-days: 15 retention-days: 15
@@ -114,13 +134,16 @@ jobs:
test-postgres: test-postgres:
if: github.event.pull_request.head.ref != 'i18n_crowdin' if: github.event.pull_request.head.ref != 'i18n_crowdin'
permissions:
contents: read
actions: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build needs: build
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: lts/* node-version: 22
cache: "npm" cache: "npm"
cache-dependency-path: frontend/package-lock.json cache-dependency-path: frontend/package-lock.json
@@ -150,6 +173,23 @@ jobs:
if: steps.postgres-cache.outputs.cache-hit == 'true' if: steps.postgres-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/postgres-image.tar run: docker load < /tmp/postgres-image.tar
- name: Cache LLDAP Docker image
uses: actions/cache@v3
id: lldap-cache
with:
path: /tmp/lldap-image.tar
key: lldap-stable-${{ runner.os }}
- name: Pull and save LLDAP image
if: steps.lldap-cache.outputs.cache-hit != 'true'
run: |
docker pull nitnelave/lldap:stable
docker save nitnelave/lldap:stable > /tmp/lldap-image.tar
- name: Load LLDAP image from cache
if: steps.lldap-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/lldap-image.tar
- name: Download Docker image artifact - name: Download Docker image artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
@@ -159,56 +199,26 @@ jobs:
- name: Load Docker image - name: Load Docker image
run: docker load -i /tmp/docker-image.tar run: docker load -i /tmp/docker-image.tar
- name: Install frontend dependencies - name: Install test dependencies
working-directory: ./frontend working-directory: ./tests
run: npm ci run: npm ci
- name: Install Playwright Browsers - name: Install Playwright Browsers
working-directory: ./frontend working-directory: ./tests
if: steps.playwright-cache.outputs.cache-hit != 'true' if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium run: npx playwright install --with-deps chromium
- name: Create Docker network - name: Run Docker Container with Postgres DB and LDAP
run: docker network create pocket-id-network working-directory: ./tests/setup
- name: Start Postgres DB
run: | run: |
docker run -d --name pocket-id-db \ docker compose -f docker-compose-postgres.yml up -d
--network pocket-id-network \ docker compose -f docker-compose-postgres.yml logs -f pocket-id &> /tmp/backend.log &
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=pocket-id \
-p 5432:5432 \
postgres:17
- name: Wait for Postgres to start
run: |
for i in {1..10}; do
if docker exec pocket-id-db pg_isready -U postgres; then
echo "Postgres is ready"
break
fi
echo "Waiting for Postgres..."
sleep 2
done
- name: Run Docker Container with Postgres DB
run: |
docker run -d --name pocket-id-postgres \
--network pocket-id-network \
-p 80:80 \
-e APP_ENV=test \
-e DB_PROVIDER=postgres \
-e DB_CONNECTION_STRING=postgresql://postgres:postgres@pocket-id-db:5432/pocket-id \
pocket-id:test
docker logs -f pocket-id-postgres &> /tmp/backend.log &
- name: Run Playwright tests - name: Run Playwright tests
working-directory: ./frontend working-directory: ./tests
run: npx playwright test run: npx playwright test
- name: Upload Frontend Test Report - name: Upload Test Report
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin' if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with: with:

106
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,106 @@
name: Release
on:
push:
tags:
- "v*.*.*"
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
attestations: write
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- uses: actions/setup-go@v5
with:
go-version-file: "backend/go.mod"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set DOCKER_IMAGE_NAME
run: |
# Lowercase REPO_OWNER which is required for containers
REPO_OWNER=${{ github.repository_owner }}
DOCKER_IMAGE_NAME="ghcr.io/${REPO_OWNER,,}/pocket-id"
echo "DOCKER_IMAGE_NAME=${DOCKER_IMAGE_NAME}" >>${GITHUB_ENV}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{github.repository_owner}}
password: ${{secrets.GITHUB_TOKEN}}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_IMAGE_NAME }}
tags: |
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v
type=semver,pattern={{major}},prefix=v
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Build frontend
working-directory: frontend
run: npm run build
- name: Build binaries
run: sh scripts/development/build-binaries.sh
- name: Build and push container image
uses: docker/build-push-action@v6
id: container-build-push
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
file: Dockerfile-prebuilt
- name: Binary attestation
uses: actions/attest-build-provenance@v2
with:
subject-path: "backend/.bin/pocket-id-**"
- name: Container image attestation
uses: actions/attest-build-provenance@v2
with:
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
subject-digest: ${{ steps.container-build-push.outputs.digest }}
push-to-registry: true
- name: Upload binaries to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload ${{ github.ref_name }} backend/.bin/*
publish-release:
runs-on: ubuntu-latest
needs: [build]
permissions:
contents: write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Mark release as published
run: gh release edit ${{ github.ref_name }} --draft=false

View File

@@ -39,7 +39,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "lts/*" node-version: 22
cache: "npm" cache: "npm"
cache-dependency-path: frontend/package-lock.json cache-dependency-path: frontend/package-lock.json

View File

@@ -11,13 +11,16 @@ on:
jobs: jobs:
test-backend: test-backend:
permissions:
contents: read
actions: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version-file: 'backend/go.mod' go-version-file: "backend/go.mod"
cache-dependency-path: 'backend/go.sum' cache-dependency-path: "backend/go.sum"
- name: Install dependencies - name: Install dependencies
working-directory: backend working-directory: backend
run: | run: |
@@ -26,7 +29,7 @@ jobs:
working-directory: backend working-directory: backend
run: | run: |
set -e -o pipefail set -e -o pipefail
go test -v ./... | tee /tmp/TestResults.log go test -tags=exclude_frontend -v ./... | tee /tmp/TestResults.log
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
if: always() if: always()
with: with:

View File

@@ -25,6 +25,7 @@ jobs:
run: | run: |
mkdir -p backend/resources mkdir -p backend/resources
jq -c 'map_values(.name)' data.json > backend/resources/aaguids.json jq -c 'map_values(.name)' data.json > backend/resources/aaguids.json
rm data.json
- name: Create Pull Request - name: Create Pull Request
uses: peter-evans/create-pull-request@v7 uses: peter-evans/create-pull-request@v7

11
.gitignore vendored
View File

@@ -9,6 +9,7 @@ node_modules
/frontend/.svelte-kit /frontend/.svelte-kit
/frontend/build /frontend/build
/backend/bin /backend/bin
pocket-id
# OS # OS
.DS_Store .DS_Store
@@ -17,7 +18,7 @@ Thumbs.db
# Env # Env
.env .env
.env.* .env.*
!.env.example !.env.development-example
!.env.test !.env.test
# Vite # Vite
@@ -30,13 +31,15 @@ vite.config.ts.timestamp-*
*.dll *.dll
*.so *.so
*.dylib *.dylib
/backend/.bin
/pocket-id
# Application specific # Application specific
data data
/frontend/tests/.auth /tests/.auth
/frontend/tests/.report /tests/.report
pocket-id-backend
/backend/GeoLite2-City.mmdb /backend/GeoLite2-City.mmdb
/backend/frontend/dist
# Misc # Misc
.DS_Store .DS_Store

View File

@@ -1 +1 @@
0.52.0 1.2.0

View File

@@ -1,5 +1,8 @@
{ {
"recommendations": [ "recommendations": [
"inlang.vs-code-extension" "golang.go",
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode"
] ]
} }

View File

@@ -1,3 +1,4 @@
{ {
"go.buildTags": "e2etest" "go.buildTags": "e2etest",
"prettier.documentSelectors": ["**/*.svelte"],
} }

37
.vscode/tasks.json vendored
View File

@@ -1,37 +0,0 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "Run Caddy",
"type": "shell",
"command": "caddy run --config reverse-proxy/Caddyfile",
"isBackground": true,
"problemMatcher": {
"owner": "custom",
"pattern": [
{
"regexp": ".",
"file": 1,
"location": 2,
"message": 3
}
],
"background": {
"activeOnStart": true,
"beginsPattern": ".*",
"endsPattern": "Caddyfile.*"
}
},
"presentation": {
"reveal": "always",
"panel": "new"
},
"runOptions": {
"runOn": "folderOpen",
"instanceLimit": 1
}
}
]
}

View File

@@ -1,3 +1,84 @@
## [](https://github.com/pocket-id/pocket-id/compare/v1.1.0...v) (2025-06-03)
### Features
* auto detect callback url ([#583](https://github.com/pocket-id/pocket-id/issues/583)) ([20d3f78](https://github.com/pocket-id/pocket-id/commit/20d3f780a2a431d0a48cece0f0764b6e4d53c1b9))
### Bug Fixes
* allow users to update their locale even when own account update disabled ([6c00aaa](https://github.com/pocket-id/pocket-id/commit/6c00aaa3efa75c76d340718698a0f4556e8de268))
* clear default app config variables from database ([decf8ec](https://github.com/pocket-id/pocket-id/commit/decf8ec70b5f6a69fe201d6e4ad60ee62e374ad0))
* don't use TOFU for logout callback URLs ([#588](https://github.com/pocket-id/pocket-id/issues/588)) ([256f74d](https://github.com/pocket-id/pocket-id/commit/256f74d0a348a835107fd5b17b9d57b1e845029e))
* fallback to primary language if no translation available for specific country ([2440379](https://github.com/pocket-id/pocket-id/commit/2440379cd11b4a6da7c52b122ba8f49d7c72ce1d))
* improve spacing on auth screens ([04fcf11](https://github.com/pocket-id/pocket-id/commit/04fcf1110e97b42dc5f0c20e169c569075d1e797))
* page scrolls up on form submisssion ([31ad904](https://github.com/pocket-id/pocket-id/commit/31ad904367e53dd47a15abcce5402dfe84828a14))
* run jobs at interval instead of specific time ([#585](https://github.com/pocket-id/pocket-id/issues/585)) ([6d6dc66](https://github.com/pocket-id/pocket-id/commit/6d6dc6646a39921a604b6c825d3e7e76af6c693b))
* show LAN for auditlog location for internal networks ([b874681](https://github.com/pocket-id/pocket-id/commit/b8746818240fde052e6f3b5db5c3355d7bbfcbda))
* small fixes in analytics_job ([#582](https://github.com/pocket-id/pocket-id/issues/582)) ([3d402fc](https://github.com/pocket-id/pocket-id/commit/3d402fc0ca30626c95b8f7accc274b9f2ab228b9))
* whitelist authorization header for CORS ([b9489b5](https://github.com/pocket-id/pocket-id/commit/b9489b5e9a32a2a3f54d48705e731a7bcf188d20))
## [](https://github.com/pocket-id/pocket-id/compare/v1.0.0...v) (2025-05-28)
### Features
* add daily heartbeat request for counting Pocket ID instances ([#578](https://github.com/pocket-id/pocket-id/issues/578)) ([e0ec607](https://github.com/pocket-id/pocket-id/commit/e0ec60719883c0230f1a16611b943d6f6f637157))
* require user verification for passkey sign in ([68e4b67](https://github.com/pocket-id/pocket-id/commit/68e4b67bd212e31ecc20277bfd293c94bf7f3642))
* show allowed group count on oidc client list ([#567](https://github.com/pocket-id/pocket-id/issues/567)) ([38d7ee4](https://github.com/pocket-id/pocket-id/commit/38d7ee4432e0dacc2cfbabad4bfd9336b8b84079))
### Bug Fixes
* run user group count inside a transaction ([f03b80f](https://github.com/pocket-id/pocket-id/commit/f03b80f9d7f2529d8cef23ca6a742a914a4ec883))
* use ldapAttributeUserUsername for finding group members ([#565](https://github.com/pocket-id/pocket-id/issues/565)) ([f66e8e8](https://github.com/pocket-id/pocket-id/commit/f66e8e8b4478c66ed1ada9168a272b33dbf494d0))
## [](https://github.com/pocket-id/pocket-id/compare/v0.53.0...v) (2025-05-24)
### ⚠ BREAKING CHANGES
* serve the static frontend trough the backend (#520)
* remove old DB env variables, and jwk migrations logic (#529)
### Features
* improve buttons styling ([c37386f](https://github.com/pocket-id/pocket-id/commit/c37386f8b2f2c64bd9e7c437879a2217846852b5))
### Bug Fixes
* add back month and year selection for date picker ([6c35570](https://github.com/pocket-id/pocket-id/commit/6c35570e78813ca6af1bae6a0374d7483bff9824))
* animation speed set to max of 300ms ([c726c16](https://github.com/pocket-id/pocket-id/commit/c726c1621b8bd88b20cb05263f6d10888f0af8e2))
* authorize page doesn't load ([c3a03db](https://github.com/pocket-id/pocket-id/commit/c3a03db8b0f87cddc927481cfad2ccc391f98869))
* custom logo not correctly loaded if UI configuration is disabled ([bf710ae](https://github.com/pocket-id/pocket-id/commit/bf710aec5625c9dcb43c83d920318a036a135bae))
* ldap tests ([4dc0b2f](https://github.com/pocket-id/pocket-id/commit/4dc0b2f37f9a57ba1c7ea084dc2a713f283d1b14))
* remove curly bracket from user group URL ([5fa15f6](https://github.com/pocket-id/pocket-id/commit/5fa15f60984a8f2a02f15900860c3a3097032e1b))
* remove nested button in user group list ([f57c8d3](https://github.com/pocket-id/pocket-id/commit/f57c8d347e127027378aad8831a8e4dfebfef060))
* show correct app name on sign out page ([131f470](https://github.com/pocket-id/pocket-id/commit/131f470757044fddd0989a76e9dc9e310f19819c))
* trim whitespaces from string inputs ([059073d](https://github.com/pocket-id/pocket-id/commit/059073d4c24e34c142dddd4c150c384779fb51a9))
* use pointer cursor for menu items ([f820fc8](https://github.com/pocket-id/pocket-id/commit/f820fc830161499edb0da2df334e4e473d5825ae))
* use same color as title for description in alert ([e19b33f](https://github.com/pocket-id/pocket-id/commit/e19b33fc2e2b9dd149da1f9351aca2e839ffae04))
### Code Refactoring
* remove old DB env variables, and jwk migrations logic ([#529](https://github.com/pocket-id/pocket-id/issues/529)) ([f115425](https://github.com/pocket-id/pocket-id/commit/f1154257c5a9ac5c95d81343a31c02251631b203))
* serve the static frontend trough the backend ([#520](https://github.com/pocket-id/pocket-id/issues/520)) ([f8a7467](https://github.com/pocket-id/pocket-id/commit/f8a7467ec0e939f90d19211a0a0efc5e17a58127))
## [](https://github.com/pocket-id/pocket-id/compare/v0.52.0...v) (2025-05-08)
### Features
* add support for `TZ` environment variable ([5e2e947](https://github.com/pocket-id/pocket-id/commit/5e2e947fe09fa881a7bbc70133a243a4baf30e90))
### Bug Fixes
* handle CORS correctly for endpoints that SPAs need ([#513](https://github.com/pocket-id/pocket-id/issues/513)) ([63a0c08](https://github.com/pocket-id/pocket-id/commit/63a0c08696938e1cefd12018f4bd38aa1808996a))
## [](https://github.com/pocket-id/pocket-id/compare/v0.51.1...v) (2025-05-06) ## [](https://github.com/pocket-id/pocket-id/compare/v0.51.1...v) (2025-05-06)

View File

@@ -17,7 +17,7 @@ Before you submit the pull request for review please ensure that
example: example:
``` ```
feat(share): add password protection fix: hide global audit log switch for non admin users
``` ```
Where `TYPE` can be: Where `TYPE` can be:
@@ -30,55 +30,65 @@ Before you submit the pull request for review please ensure that
- Your pull request has a detailed description - Your pull request has a detailed description
- You run `npm run format` to format the code - You run `npm run format` to format the code
## Setup project ## Development Environment
Pocket ID consists of a frontend, backend and a reverse proxy. There are two ways to get the development environment setup:
Pocket ID consists of a frontend and backend. In production the frontend gets statically served by the backend, but in development they run as separate processes to enable hot reloading.
There are two ways to get the development environment setup:
### 1. Install required tools
#### With Dev Containers
If you use [Dev Containers](https://code.visualstudio.com/docs/remote/containers) in VS Code, you don't need to install anything manually, just follow the steps below.
## 1. Using DevContainers
1. Make sure you have [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension installed 1. Make sure you have [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension installed
2. Clone and open the repo in VS Code 2. Clone and open the repo in VS Code
3. VS Code will detect .devcontainer and will prompt you to open the folder in devcontainer 3. VS Code will detect .devcontainer and will prompt you to open the folder in devcontainer
4. If the auto prompt does not work, hit `F1` and select `Dev Containers: Open Folder in Container.`, then select the pocket-id repo root folder and it'll open in container. 4. If the auto prompt does not work, hit `F1` and select `Dev Containers: Open Folder in Container.`, then select the pocket-id repo root folder and it'll open in container.
## 2. Manual #### Without Dev Containers
### Backend If you don't use Dev Containers, you need to install the following tools manually:
The backend is built with [Gin](https://gin-gonic.com) and written in Go. - [Node.js](https://nodejs.org/en/download/) >= 22
- [Go](https://golang.org/doc/install) >= 1.24
- [Git](https://git-scm.com/downloads)
#### Setup ### 2. Setup
#### Backend
The backend is built with [Gin](https://gin-gonic.com) and written in Go. To set it up, follow these steps:
1. Open the `backend` folder 1. Open the `backend` folder
2. Copy the `.env.example` file to `.env` and change the `APP_ENV` to `development` 2. Copy the `.env.development-example` file to `.env` and edit the variables as needed
3. Start the backend with `go run -tags e2etest ./cmd` 3. Start the backend with `go run -tags exclude_frontend ./cmd`
### Frontend ### Frontend
The frontend is built with [SvelteKit](https://kit.svelte.dev) and written in TypeScript. The frontend is built with [SvelteKit](https://kit.svelte.dev) and written in TypeScript. To set it up, follow these steps:
#### Setup
1. Open the `frontend` folder 1. Open the `frontend` folder
2. Copy the `.env.example` file to `.env` 2. Copy the `.env.development-example` file to `.env` and edit the variables as needed
3. Install the dependencies with `npm install` 3. Install the dependencies with `npm install`
4. Start the frontend with `npm run dev` 4. Start the frontend with `npm run dev`
### Reverse Proxy You're all set! The application is now listening on `localhost:3000`. The backend gets proxied trough the frontend in development mode.
We use [Caddy](https://caddyserver.com) as a reverse proxy. You can use any other reverse proxy if you want but you have to configure it yourself.
#### Setup
Run `caddy run --config reverse-proxy/Caddyfile` in the root folder.
You're all set!
## Debugging
1. The VS Code is currently setup to auto launch caddy on opening the folder. (Defined in [tasks.json](.vscode/tasks.json))
2. Press `F5` to start a debug session. This will launch both frontend and backend and attach debuggers to those process. (Defined in [launch.json](.vscode/launch.json))
### Testing ### Testing
We are using [Playwright](https://playwright.dev) for end-to-end testing. We are using [Playwright](https://playwright.dev) for end-to-end testing.
If you are contributing to a new feature please ensure that you add tests for it. The tests are located in the `tests` folder at the root of the project.
The tests can be run like this: The tests can be run like this:
1. Start the backend normally
2. Start the frontend in production mode with `npm run build && node --env-file=.env build/index.js` 1. Visit the setup folder by running `cd tests/setup`
3. Run the tests with `npm run test`
2. Start the test environment by running `docker compose up -d --build`
3. Go back to the test folder by running `cd ..`
4. Run the tests with `npx playwright test`
If you make any changes to the application, you have to rebuild the test environment by running `docker compose up -d --build` again.

View File

@@ -1,55 +1,52 @@
# This file uses multi-stage builds to build the application from source, including the front-end
# Tags passed to "go build" # Tags passed to "go build"
ARG BUILD_TAGS="" ARG BUILD_TAGS=""
ARG VERSION="unknown"
# Stage 1: Build Frontend # Stage 1: Build Frontend
FROM node:22-alpine AS frontend-builder FROM node:22-alpine AS frontend-builder
WORKDIR /app/frontend WORKDIR /build
COPY ./frontend/package*.json ./ COPY ./frontend/package*.json ./
RUN npm ci RUN npm ci
COPY ./frontend ./ COPY ./frontend ./
RUN npm run build RUN BUILD_OUTPUT_PATH=dist npm run build
RUN npm prune --production
# Stage 2: Build Backend # Stage 2: Build Backend
FROM golang:1.24-alpine AS backend-builder FROM golang:1.24-alpine AS backend-builder
ARG BUILD_TAGS ARG BUILD_TAGS
WORKDIR /app/backend WORKDIR /build
COPY ./backend/go.mod ./backend/go.sum ./ COPY ./backend/go.mod ./backend/go.sum ./
RUN go mod download RUN go mod download
RUN apk add --no-cache gcc musl-dev
COPY ./backend ./ COPY ./backend ./
WORKDIR /app/backend/cmd COPY --from=frontend-builder /build/dist ./frontend/dist
RUN CGO_ENABLED=1 \ COPY .version .version
WORKDIR /build/cmd
RUN VERSION=$(cat /build/.version) \
CGO_ENABLED=0 \
GOOS=linux \ GOOS=linux \
go build \ go build \
-tags "${BUILD_TAGS}" \ -tags "${BUILD_TAGS}" \
-ldflags="-X github.com/pocket-id/pocket-id/backend/internal/common.Version=${VERSION}" \ -ldflags="-X github.com/pocket-id/pocket-id/backend/internal/common.Version=${VERSION} -buildid=${VERSION}" \
-o /app/backend/pocket-id-backend \ -trimpath \
-o /build/pocket-id-backend \
. .
# Stage 3: Production Image # Stage 3: Production Image
FROM node:22-alpine FROM alpine
# Delete default node user
RUN deluser --remove-home node
RUN apk add --no-cache caddy curl su-exec
COPY ./reverse-proxy /etc/caddy/
WORKDIR /app WORKDIR /app
COPY --from=frontend-builder /app/frontend/build ./frontend/build
COPY --from=frontend-builder /app/frontend/node_modules ./frontend/node_modules
COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json
COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend RUN apk add --no-cache curl su-exec
COPY ./scripts ./scripts COPY --from=backend-builder /build/pocket-id-backend /app/pocket-id
RUN find ./scripts -name "*.sh" -exec chmod +x {} \; COPY ./scripts/docker /app/docker
EXPOSE 80 RUN chmod +x /app/pocket-id && \
find /app/docker -name "*.sh" -exec chmod +x {} \;
EXPOSE 1411
ENV APP_ENV=production ENV APP_ENV=production
ENTRYPOINT ["sh", "./scripts/docker/create-user.sh"] ENTRYPOINT ["sh", "/app/docker/entrypoint.sh"]
CMD ["sh", "./scripts/docker/entrypoint.sh"] CMD ["/app/pocket-id"]

20
Dockerfile-prebuilt Normal file
View File

@@ -0,0 +1,20 @@
# This Dockerfile embeds a pre-built binary for the given Linux architecture
# Binaries must be built using ./scripts/development/build-binaries.sh first
FROM alpine
# TARGETARCH can be "amd64" or "arm64"
ARG TARGETARCH
WORKDIR /app
RUN apk add --no-cache curl su-exec
COPY ./backend/.bin/pocket-id-linux-${TARGETARCH} /app/pocket-id
COPY ./scripts/docker /app/docker
EXPOSE 1411
ENV APP_ENV=production
ENTRYPOINT ["/app/docker/entrypoint.sh"]
CMD ["/app/pocket-id"]

View File

@@ -0,0 +1,9 @@
# Sample .env file for development
# All environment variables can be found on https://pocket-id.org/docs/configuration/environment-variables
APP_ENV=development
# Set the APP_URL to the URL where the frontend is listening
# In the development environment the backend gets proxied by the frontend
APP_URL=http://localhost:3000
PORT=1411
MAXMIND_LICENSE_KEY=your_license_key

View File

@@ -1,10 +0,0 @@
APP_ENV=production
PUBLIC_APP_URL=http://localhost
# /!\ If PUBLIC_APP_URL is not a localhost address, it must be HTTPS
DB_PROVIDER=sqlite
# MAXMIND_LICENSE_KEY=fixme # needed for IP geolocation in the audit log
SQLITE_DB_PATH=data/pocket-id.db
POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/pocket-id
UPLOAD_PATH=data/uploads
PORT=8080
HOST=0.0.0.0

1
backend/.gitignore vendored
View File

@@ -15,3 +15,4 @@
# vendor/ # vendor/
./data ./data
.env .env
pocket-id

View File

@@ -1,9 +1,15 @@
package main package main
import ( import (
"flag"
"fmt"
"log" "log"
_ "time/tzdata"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap" "github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/cmds"
"github.com/pocket-id/pocket-id/backend/internal/common"
) )
// @title Pocket ID API // @title Pocket ID API
@@ -11,7 +17,26 @@ import (
// @description.markdown // @description.markdown
func main() { func main() {
err := bootstrap.Bootstrap() // Get the command
// By default, this starts the server
var cmd string
flag.Parse()
args := flag.Args()
if len(args) > 0 {
cmd = args[0]
}
var err error
switch cmd {
case "version":
fmt.Println("pocket-id " + common.Version)
case "one-time-access-token":
err = cmds.OneTimeAccessToken(args)
default:
// Start the server
err = bootstrap.Bootstrap()
}
if err != nil { if err != nil {
log.Fatal(err.Error()) log.Fatal(err.Error())
} }

View File

@@ -0,0 +1,9 @@
//go:build exclude_frontend
package frontend
import "github.com/gin-gonic/gin"
func RegisterFrontend(router *gin.Engine) error {
return ErrFrontendNotIncluded
}

View File

@@ -0,0 +1,77 @@
//go:build !exclude_frontend
package frontend
import (
"embed"
"fmt"
"io/fs"
"net/http"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
)
//go:embed all:dist/*
var frontendFS embed.FS
func RegisterFrontend(router *gin.Engine) error {
distFS, err := fs.Sub(frontendFS, "dist")
if err != nil {
return fmt.Errorf("failed to create sub FS: %w", err)
}
cacheMaxAge := time.Hour * 24
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 = "/"
}
fileServer.ServeHTTP(c.Writer, c.Request)
})
return nil
}
// FileServerWithCaching wraps http.FileServer to add caching headers
type FileServerWithCaching struct {
root http.FileSystem
lastModified time.Time
cacheMaxAge int
lastModifiedHeaderValue string
cacheControlHeaderValue string
}
func NewFileServerWithCaching(root http.FileSystem, maxAge int) *FileServerWithCaching {
return &FileServerWithCaching{
root: root,
lastModified: time.Now(),
cacheMaxAge: maxAge,
lastModifiedHeaderValue: time.Now().UTC().Format(http.TimeFormat),
cacheControlHeaderValue: fmt.Sprintf("public, max-age=%d", maxAge),
}
}
func (f *FileServerWithCaching) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Check if the client has a cached version
if ifModifiedSince := r.Header.Get("If-Modified-Since"); ifModifiedSince != "" {
ifModifiedSinceTime, err := time.Parse(http.TimeFormat, ifModifiedSince)
if err == nil && f.lastModified.Before(ifModifiedSinceTime.Add(1*time.Second)) {
// Client's cached version is up to date
w.WriteHeader(http.StatusNotModified)
return
}
}
w.Header().Set("Last-Modified", f.lastModifiedHeaderValue)
w.Header().Set("Cache-Control", f.cacheControlHeaderValue)
http.FileServer(f.root).ServeHTTP(w, r)
}

View File

@@ -0,0 +1,5 @@
package frontend
import "errors"
var ErrFrontendNotIncluded = errors.New("frontend is not included")

View File

@@ -4,23 +4,25 @@ go 1.24.0
require ( require (
github.com/caarlos0/env/v11 v11.3.1 github.com/caarlos0/env/v11 v11.3.1
github.com/cenkalti/backoff/v5 v5.0.2
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
github.com/emersion/go-smtp v0.21.3 github.com/emersion/go-smtp v0.21.3
github.com/fxamacker/cbor/v2 v2.7.0 github.com/fxamacker/cbor/v2 v2.7.0
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.10.0
github.com/glebarez/sqlite v1.11.0
github.com/go-co-op/gocron/v2 v2.15.0 github.com/go-co-op/gocron/v2 v2.15.0
github.com/go-ldap/ldap/v3 v3.4.10 github.com/go-ldap/ldap/v3 v3.4.10
github.com/go-playground/validator/v10 v10.25.0 github.com/go-playground/validator/v10 v10.25.0
github.com/go-webauthn/webauthn v0.11.2 github.com/go-webauthn/webauthn v0.11.2
github.com/golang-migrate/migrate/v4 v4.18.2 github.com/golang-migrate/migrate/v4 v4.18.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/hashicorp/go-uuid v1.0.3
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/jwx/v3 v3.0.0-beta1 github.com/lestrrat-go/jwx/v3 v3.0.0-beta1
github.com/mileusna/useragent v1.3.5 github.com/mileusna/useragent v1.3.5
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2 github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
github.com/prometheus/client_golang v1.22.0
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0 go.opentelemetry.io/contrib/exporters/autoexport v0.59.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0 go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0
@@ -34,7 +36,6 @@ require (
golang.org/x/image v0.24.0 golang.org/x/image v0.24.0
golang.org/x/time v0.9.0 golang.org/x/time v0.9.0
gorm.io/driver/postgres v1.5.11 gorm.io/driver/postgres v1.5.11
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12 gorm.io/gorm v1.25.12
) )
@@ -49,9 +50,11 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/disintegration/gift v1.1.2 // indirect github.com/disintegration/gift v1.1.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect github.com/gin-contrib/sse v1.0.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
@@ -85,11 +88,14 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.22.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect github.com/prometheus/procfs v0.15.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/segmentio/asm v1.2.0 // indirect github.com/segmentio/asm v1.2.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
@@ -113,14 +119,18 @@ require (
go.opentelemetry.io/proto/otlp v1.5.0 // indirect go.opentelemetry.io/proto/otlp v1.5.0 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.14.0 // indirect golang.org/x/arch v0.14.0 // indirect
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
golang.org/x/net v0.38.0 // indirect golang.org/x/net v0.38.0 // indirect
golang.org/x/sync v0.12.0 // indirect golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.31.0 // indirect golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.23.0 // indirect golang.org/x/text v0.23.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // 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 google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/grpc v1.71.0 // indirect google.golang.org/grpc v1.71.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.65.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.10.0 // indirect
modernc.org/sqlite v1.37.0 // indirect
) )

View File

@@ -17,6 +17,8 @@ github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5m
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
@@ -43,6 +45,8 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY= github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY=
@@ -57,6 +61,10 @@ github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk= github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-co-op/gocron/v2 v2.15.0 h1:Kpvo71VSihE+RImmpA+3ta5CcMhoRzMGw4dJawrj4zo= github.com/go-co-op/gocron/v2 v2.15.0 h1:Kpvo71VSihE+RImmpA+3ta5CcMhoRzMGw4dJawrj4zo=
@@ -96,6 +104,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc= github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
@@ -187,6 +197,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
@@ -207,6 +219,8 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
@@ -297,8 +311,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
@@ -307,6 +321,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -327,8 +343,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -341,8 +357,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -371,6 +387,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0= google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU= google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
@@ -389,8 +407,30 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.6 h1:OhJUhmuJ6MVZdqL5qmnd0/my46DKGFhSX4WOR7ijfyE=
modernc.org/libc v1.65.6/go.mod h1:MOiGAM9lrMBT9L8xT1nO41qYl5eg9gCp9/kWhz5L7WA=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

View File

@@ -20,10 +20,6 @@ func Bootstrap() error {
initApplicationImages() initApplicationImages()
// Perform migrations for changes
migrateConfigDBConnstring()
migrateKey()
// Initialize the tracer and metrics exporter // Initialize the tracer and metrics exporter
shutdownFns, httpClient, err := initOtel(ctx, common.EnvConfig.MetricsEnabled, common.EnvConfig.TracingEnabled) shutdownFns, httpClient, err := initOtel(ctx, common.EnvConfig.MetricsEnabled, common.EnvConfig.TracingEnabled)
if err != nil { if err != nil {
@@ -31,7 +27,7 @@ func Bootstrap() error {
} }
// Connect to the database // Connect to the database
db := newDatabase() db := NewDatabase()
// Create all services // Create all services
svc, err := initServices(ctx, db, httpClient) svc, err := initServices(ctx, db, httpClient)
@@ -44,7 +40,7 @@ func Bootstrap() error {
if err != nil { if err != nil {
return fmt.Errorf("failed to create job scheduler: %w", err) return fmt.Errorf("failed to create job scheduler: %w", err)
} }
err = registerScheduledJobs(ctx, db, svc, scheduler) err = registerScheduledJobs(ctx, db, svc, httpClient, scheduler)
if err != nil { if err != nil {
return fmt.Errorf("failed to register scheduled jobs: %w", err) return fmt.Errorf("failed to register scheduled jobs: %w", err)
} }
@@ -52,7 +48,7 @@ func Bootstrap() error {
// Init the router // Init the router
router := initRouter(db, svc) router := initRouter(db, svc)
// Run all background serivces // Run all background services
// This call blocks until the context is canceled // This call blocks until the context is canceled
err = utils. err = utils.
NewServiceRunner(router, scheduler.Run). NewServiceRunner(router, scheduler.Run).

View File

@@ -1,34 +0,0 @@
package bootstrap
import (
"log"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
// Performs the migration of the database connection string
// See: https://github.com/pocket-id/pocket-id/pull/388
func migrateConfigDBConnstring() {
switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite:
// Check if we're using the deprecated SqliteDBPath env var
if common.EnvConfig.SqliteDBPath != "" {
connString := "file:" + common.EnvConfig.SqliteDBPath + "?_journal_mode=WAL&_busy_timeout=2500&_txlock=immediate"
common.EnvConfig.DbConnectionString = connString
common.EnvConfig.SqliteDBPath = ""
log.Printf("[WARN] Env var 'SQLITE_DB_PATH' is deprecated - use 'DB_CONNECTION_STRING' instead with the value: '%s'", connString)
}
case common.DbProviderPostgres:
// Check if we're using the deprecated PostgresConnectionString alias
if common.EnvConfig.PostgresConnectionString != "" {
common.EnvConfig.DbConnectionString = common.EnvConfig.PostgresConnectionString
common.EnvConfig.PostgresConnectionString = ""
log.Print("[WARN] Env var 'POSTGRES_CONNECTION_STRING' is deprecated - use 'DB_CONNECTION_STRING' instead with the same value")
}
default:
// We don't do anything here in the default case
// This is an error, but will be handled later on
}
}

View File

@@ -4,24 +4,26 @@ import (
"errors" "errors"
"fmt" "fmt"
"log" "log"
"net/url"
"os" "os"
"strings" "strings"
"time" "time"
"github.com/glebarez/sqlite"
"github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database" "github.com/golang-migrate/migrate/v4/database"
postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres" postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres"
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3" sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs" "github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/resources"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/resources"
) )
func newDatabase() (db *gorm.DB) { func NewDatabase() (db *gorm.DB) {
db, err := connectDatabase() db, err := connectDatabase()
if err != nil { if err != nil {
log.Fatalf("failed to connect to database: %v", err) log.Fatalf("failed to connect to database: %v", err)
@@ -86,7 +88,11 @@ func connectDatabase() (db *gorm.DB, err error) {
if !strings.HasPrefix(common.EnvConfig.DbConnectionString, "file:") { if !strings.HasPrefix(common.EnvConfig.DbConnectionString, "file:") {
return nil, errors.New("invalid value for env var 'DB_CONNECTION_STRING': does not begin with 'file:'") return nil, errors.New("invalid value for env var 'DB_CONNECTION_STRING': does not begin with 'file:'")
} }
dialector = sqlite.Open(common.EnvConfig.DbConnectionString) connString, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString)
if err != nil {
return nil, err
}
dialector = sqlite.Open(connString)
case common.DbProviderPostgres: case common.DbProviderPostgres:
if common.EnvConfig.DbConnectionString == "" { if common.EnvConfig.DbConnectionString == "" {
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database") return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
@@ -112,6 +118,50 @@ func connectDatabase() (db *gorm.DB, err error) {
return nil, err return nil, err
} }
// The official C implementation of SQLite allows some additional properties in the connection string
// that are not supported in the in the modernc.org/sqlite driver, and which must be passed as PRAGMA args instead.
// To ensure that people can use similar args as in the C driver, which was also used by Pocket ID
// previously (via github.com/mattn/go-sqlite3), we are converting some options.
func parseSqliteConnectionString(connString string) (string, error) {
if !strings.HasPrefix(connString, "file:") {
connString = "file:" + connString
}
connStringUrl, err := url.Parse(connString)
if err != nil {
return "", fmt.Errorf("failed to parse SQLite connection string: %w", err)
}
// Reference: https://github.com/mattn/go-sqlite3?tab=readme-ov-file#connection-string
// This only includes a subset of options, excluding those that are not relevant to us
qs := make(url.Values, len(connStringUrl.Query()))
for k, v := range connStringUrl.Query() {
switch k {
case "_auto_vacuum", "_vacuum":
qs.Add("_pragma", "auto_vacuum("+v[0]+")")
case "_busy_timeout", "_timeout":
qs.Add("_pragma", "busy_timeout("+v[0]+")")
case "_case_sensitive_like", "_cslike":
qs.Add("_pragma", "case_sensitive_like("+v[0]+")")
case "_foreign_keys", "_fk":
qs.Add("_pragma", "foreign_keys("+v[0]+")")
case "_locking_mode", "_locking":
qs.Add("_pragma", "locking_mode("+v[0]+")")
case "_secure_delete":
qs.Add("_pragma", "secure_delete("+v[0]+")")
case "_synchronous", "_sync":
qs.Add("_pragma", "synchronous("+v[0]+")")
default:
// Pass other query-string args as-is
qs[k] = v
}
}
connStringUrl.RawQuery = qs.Encode()
return connStringUrl.String(), nil
}
func getLogger() logger.Interface { func getLogger() logger.Interface {
isProduction := common.EnvConfig.AppEnv == "production" isProduction := common.EnvConfig.AppEnv == "production"

View File

@@ -0,0 +1,145 @@
package bootstrap
import (
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseSqliteConnectionString(t *testing.T) {
tests := []struct {
name string
input string
expected string
expectedError bool
}{
{
name: "basic file path",
input: "file:test.db",
expected: "file:test.db",
},
{
name: "adds file: prefix if missing",
input: "test.db",
expected: "file:test.db",
},
{
name: "converts _busy_timeout to pragma",
input: "file:test.db?_busy_timeout=5000",
expected: "file:test.db?_pragma=busy_timeout%285000%29",
},
{
name: "converts _timeout to pragma",
input: "file:test.db?_timeout=5000",
expected: "file:test.db?_pragma=busy_timeout%285000%29",
},
{
name: "converts _foreign_keys to pragma",
input: "file:test.db?_foreign_keys=1",
expected: "file:test.db?_pragma=foreign_keys%281%29",
},
{
name: "converts _fk to pragma",
input: "file:test.db?_fk=1",
expected: "file:test.db?_pragma=foreign_keys%281%29",
},
{
name: "converts _synchronous to pragma",
input: "file:test.db?_synchronous=NORMAL",
expected: "file:test.db?_pragma=synchronous%28NORMAL%29",
},
{
name: "converts _sync to pragma",
input: "file:test.db?_sync=NORMAL",
expected: "file:test.db?_pragma=synchronous%28NORMAL%29",
},
{
name: "converts _auto_vacuum to pragma",
input: "file:test.db?_auto_vacuum=FULL",
expected: "file:test.db?_pragma=auto_vacuum%28FULL%29",
},
{
name: "converts _vacuum to pragma",
input: "file:test.db?_vacuum=FULL",
expected: "file:test.db?_pragma=auto_vacuum%28FULL%29",
},
{
name: "converts _case_sensitive_like to pragma",
input: "file:test.db?_case_sensitive_like=1",
expected: "file:test.db?_pragma=case_sensitive_like%281%29",
},
{
name: "converts _cslike to pragma",
input: "file:test.db?_cslike=1",
expected: "file:test.db?_pragma=case_sensitive_like%281%29",
},
{
name: "converts _locking_mode to pragma",
input: "file:test.db?_locking_mode=EXCLUSIVE",
expected: "file:test.db?_pragma=locking_mode%28EXCLUSIVE%29",
},
{
name: "converts _locking to pragma",
input: "file:test.db?_locking=EXCLUSIVE",
expected: "file:test.db?_pragma=locking_mode%28EXCLUSIVE%29",
},
{
name: "converts _secure_delete to pragma",
input: "file:test.db?_secure_delete=1",
expected: "file:test.db?_pragma=secure_delete%281%29",
},
{
name: "preserves unrecognized parameters",
input: "file:test.db?mode=rw&cache=shared",
expected: "file:test.db?cache=shared&mode=rw",
},
{
name: "handles multiple parameters",
input: "file:test.db?_fk=1&mode=rw&_timeout=5000",
expected: "file:test.db?_pragma=foreign_keys%281%29&_pragma=busy_timeout%285000%29&mode=rw",
},
{
name: "invalid URL format",
input: "file:invalid#$%^&*@test.db",
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parseSqliteConnectionString(tt.input)
if tt.expectedError {
require.Error(t, err)
return
}
require.NoError(t, err)
// Parse both URLs to compare components independently
expectedURL, err := url.Parse(tt.expected)
require.NoError(t, err)
resultURL, err := url.Parse(result)
require.NoError(t, err)
// Compare scheme and path components
assert.Equal(t, expectedURL.Scheme, resultURL.Scheme)
assert.Equal(t, expectedURL.Path, resultURL.Path)
// Compare query parameters regardless of order
expectedQuery := expectedURL.Query()
resultQuery := resultURL.Query()
assert.Len(t, expectedQuery, len(resultQuery))
for key, expectedValues := range expectedQuery {
resultValues, ok := resultQuery[key]
_ = assert.True(t, ok) &&
assert.ElementsMatch(t, expectedValues, resultValues)
}
})
}
}

View File

@@ -14,7 +14,7 @@ import (
func init() { func init() {
registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services){ registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services){
func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) { func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
testService := service.NewTestService(db, svc.appConfigService, svc.jwtService) testService := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService)
controller.NewTestController(apiGroup, testService) controller.NewTestController(apiGroup, testService)
}, },
} }

View File

@@ -1,136 +0,0 @@
package bootstrap
import (
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"fmt"
"log"
"os"
"path/filepath"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
const (
privateKeyFilePem = "jwt_private_key.pem"
)
func migrateKey() {
err := migrateKeyInternal(common.EnvConfig.KeysPath)
if err != nil {
log.Fatalf("failed to perform migration of keys: %v", err)
}
}
func migrateKeyInternal(basePath string) error {
// First, check if there's already a JWK stored
jwkPath := filepath.Join(basePath, service.PrivateKeyFile)
ok, err := utils.FileExists(jwkPath)
if err != nil {
return fmt.Errorf("failed to check if private key file (JWK) exists at path '%s': %w", jwkPath, err)
}
if ok {
// There's already a key as JWK, so we don't do anything else here
return nil
}
// Check if there's a PEM file
pemPath := filepath.Join(basePath, privateKeyFilePem)
ok, err = utils.FileExists(pemPath)
if err != nil {
return fmt.Errorf("failed to check if private key file (PEM) exists at path '%s': %w", pemPath, err)
}
if !ok {
// No file to migrate, return
return nil
}
// Load and validate the key
key, err := loadKeyPEM(pemPath)
if err != nil {
return fmt.Errorf("failed to load private key file (PEM) at path '%s': %w", pemPath, err)
}
err = service.ValidateKey(key)
if err != nil {
return fmt.Errorf("key object is invalid: %w", err)
}
// Save the key as JWK
err = service.SaveKeyJWK(key, jwkPath)
if err != nil {
return fmt.Errorf("failed to save private key file at path '%s': %w", jwkPath, err)
}
// Finally, delete the PEM file
err = os.Remove(pemPath)
if err != nil {
return fmt.Errorf("failed to remove migrated key at path '%s': %w", pemPath, err)
}
return nil
}
func loadKeyPEM(path string) (jwk.Key, error) {
// Load the key from disk and parse it
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read key data: %w", err)
}
key, err := jwk.ParseKey(data, jwk.WithPEM(true))
if err != nil {
return nil, fmt.Errorf("failed to parse key: %w", err)
}
// Populate the key ID using the "legacy" algorithm
keyId, err := generateKeyID(key)
if err != nil {
return nil, fmt.Errorf("failed to generate key ID: %w", err)
}
err = key.Set(jwk.KeyIDKey, keyId)
if err != nil {
return nil, fmt.Errorf("failed to set key ID: %w", err)
}
// Populate other required fields
_ = key.Set(jwk.KeyUsageKey, service.KeyUsageSigning)
service.EnsureAlgInKey(key)
return key, nil
}
// generateKeyID generates a Key ID for the public key using the first 8 bytes of the SHA-256 hash of the public key's PKIX-serialized structure.
// This is used for legacy keys, imported from PEM.
func generateKeyID(key jwk.Key) (string, error) {
// Export the public key and serialize it to PKIX (not in a PEM block)
// This is for backwards-compatibility with the algorithm used before the switch to JWK
pubKey, err := key.PublicKey()
if err != nil {
return "", fmt.Errorf("failed to get public key: %w", err)
}
var pubKeyRaw any
err = jwk.Export(pubKey, &pubKeyRaw)
if err != nil {
return "", fmt.Errorf("failed to export public key: %w", err)
}
pubASN1, err := x509.MarshalPKIXPublicKey(pubKeyRaw)
if err != nil {
return "", fmt.Errorf("failed to marshal public key: %w", err)
}
// Compute SHA-256 hash of the public key
hash := sha256.New()
hash.Write(pubASN1)
hashed := hash.Sum(nil)
// Truncate the hash to the first 8 bytes for a shorter Key ID
shortHash := hashed[:8]
// Return Base64 encoded truncated hash as Key ID
return base64.RawURLEncoding.EncodeToString(shortHash), nil
}

View File

@@ -1,190 +0,0 @@
package bootstrap
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"os"
"path/filepath"
"testing"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
func TestMigrateKey(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
t.Run("no keys exist", func(t *testing.T) {
// Test when no keys exist
err := migrateKeyInternal(tempDir)
require.NoError(t, err)
})
t.Run("jwk already exists", func(t *testing.T) {
// Create a JWK file
jwkPath := filepath.Join(tempDir, service.PrivateKeyFile)
key, err := createTestRSAKey()
require.NoError(t, err)
err = service.SaveKeyJWK(key, jwkPath)
require.NoError(t, err)
// Run migration - should do nothing
err = migrateKeyInternal(tempDir)
require.NoError(t, err)
// Check the file still exists
exists, err := utils.FileExists(jwkPath)
require.NoError(t, err)
assert.True(t, exists)
// Delete for next test
err = os.Remove(jwkPath)
require.NoError(t, err)
})
t.Run("migrate pem to jwk", func(t *testing.T) {
// Create a PEM file
pemPath := filepath.Join(tempDir, privateKeyFilePem)
jwkPath := filepath.Join(tempDir, service.PrivateKeyFile)
// Generate RSA key and save as PEM
createRSAPrivateKeyPEM(t, pemPath)
// Run migration
err := migrateKeyInternal(tempDir)
require.NoError(t, err)
// Check PEM file is gone
exists, err := utils.FileExists(pemPath)
require.NoError(t, err)
assert.False(t, exists)
// Check JWK file exists
exists, err = utils.FileExists(jwkPath)
require.NoError(t, err)
assert.True(t, exists)
// Verify the JWK can be loaded
data, err := os.ReadFile(jwkPath)
require.NoError(t, err)
_, err = jwk.ParseKey(data)
require.NoError(t, err)
})
}
func TestLoadKeyPEM(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
t.Run("successfully load PEM key", func(t *testing.T) {
pemPath := filepath.Join(tempDir, "test_key.pem")
// Generate RSA key and save as PEM
createRSAPrivateKeyPEM(t, pemPath)
// Load the key
key, err := loadKeyPEM(pemPath)
require.NoError(t, err)
// Verify key properties
assert.NotEmpty(t, key)
// Check key ID is set
var keyID string
err = key.Get(jwk.KeyIDKey, &keyID)
require.NoError(t, err)
assert.NotEmpty(t, keyID)
// Check algorithm is set
var alg jwa.SignatureAlgorithm
err = key.Get(jwk.AlgorithmKey, &alg)
require.NoError(t, err)
assert.NotEmpty(t, alg)
// Check key usage is set
var keyUsage string
err = key.Get(jwk.KeyUsageKey, &keyUsage)
require.NoError(t, err)
assert.Equal(t, service.KeyUsageSigning, keyUsage)
})
t.Run("file not found", func(t *testing.T) {
key, err := loadKeyPEM(filepath.Join(tempDir, "nonexistent.pem"))
require.Error(t, err)
assert.Nil(t, key)
})
t.Run("invalid file content", func(t *testing.T) {
invalidPath := filepath.Join(tempDir, "invalid.pem")
err := os.WriteFile(invalidPath, []byte("not a valid PEM"), 0600)
require.NoError(t, err)
key, err := loadKeyPEM(invalidPath)
require.Error(t, err)
assert.Nil(t, key)
})
}
func TestGenerateKeyID(t *testing.T) {
key, err := createTestRSAKey()
require.NoError(t, err)
keyID, err := generateKeyID(key)
require.NoError(t, err)
// Key ID should be non-empty
assert.NotEmpty(t, keyID)
// Generate another key ID to prove it depends on the key
key2, err := createTestRSAKey()
require.NoError(t, err)
keyID2, err := generateKeyID(key2)
require.NoError(t, err)
// The two key IDs should be different
assert.NotEqual(t, keyID, keyID2)
}
// Helper functions
func createTestRSAKey() (jwk.Key, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, err
}
key, err := jwk.Import(privateKey)
if err != nil {
return nil, err
}
return key, nil
}
// createRSAPrivateKeyPEM generates an RSA private key and returns its PEM-encoded form
func createRSAPrivateKeyPEM(t *testing.T, pemPath string) ([]byte, *rsa.PrivateKey) {
// Generate RSA key
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
// Encode to PEM format
pemData := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
})
err = os.WriteFile(pemPath, pemData, 0600)
require.NoError(t, err)
return pemData, privKey
}

View File

@@ -2,12 +2,15 @@ package bootstrap
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log" "log"
"net" "net"
"net/http" "net/http"
"time" "time"
"github.com/pocket-id/pocket-id/backend/frontend"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
"golang.org/x/time/rate" "golang.org/x/time/rate"
@@ -45,6 +48,10 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
r := gin.Default() r := gin.Default()
r.Use(gin.Logger()) r.Use(gin.Logger())
if !common.EnvConfig.TrustProxy {
_ = r.SetTrustedProxies(nil)
}
if common.EnvConfig.TracingEnabled { if common.EnvConfig.TracingEnabled {
r.Use(otelgin.Middleware("pocket-id-backend")) r.Use(otelgin.Middleware("pocket-id-backend"))
} }
@@ -55,6 +62,13 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
r.Use(middleware.NewCorsMiddleware().Add()) r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewErrorHandlerMiddleware().Add()) r.Use(middleware.NewErrorHandlerMiddleware().Add())
err := frontend.RegisterFrontend(r)
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
log.Println("Frontend is not included in the build. Skipping frontend registration.")
} else if err != nil {
return nil, fmt.Errorf("failed to register frontend: %w", err)
}
// Initialize middleware for specific routes // Initialize middleware for specific routes
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyService, svc.userService, svc.jwtService) authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyService, svc.userService, svc.jwtService)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware() fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()

View File

@@ -3,13 +3,14 @@ package bootstrap
import ( import (
"context" "context"
"fmt" "fmt"
"net/http"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/job" "github.com/pocket-id/pocket-id/backend/internal/job"
) )
func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, scheduler *job.Scheduler) error { func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, httpClient *http.Client, scheduler *job.Scheduler) error {
err := scheduler.RegisterLdapJobs(ctx, svc.ldapService, svc.appConfigService) err := scheduler.RegisterLdapJobs(ctx, svc.ldapService, svc.appConfigService)
if err != nil { if err != nil {
return fmt.Errorf("failed to register LDAP jobs in scheduler: %w", err) return fmt.Errorf("failed to register LDAP jobs in scheduler: %w", err)
@@ -30,6 +31,10 @@ func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, sche
if err != nil { if err != nil {
return fmt.Errorf("failed to register API key expiration jobs in scheduler: %w", err) return fmt.Errorf("failed to register API key expiration jobs in scheduler: %w", err)
} }
err = scheduler.RegisterAnalyticsJob(ctx, svc.appConfigService, httpClient)
if err != nil {
return fmt.Errorf("failed to register analytics job in scheduler: %w", err)
}
return nil return nil
} }

View File

@@ -0,0 +1,82 @@
package cmds
import (
"context"
"errors"
"fmt"
"time"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils/signals"
)
// OneTimeAccessToken creates a one-time access token for the given user
// Args must contain the username or email of the user
func OneTimeAccessToken(args []string) error {
// Get a context that is canceled when the application is stopping
ctx := signals.SignalContext(context.Background())
// Get the username or email of the user
// Note length is 2 because the first argument is always the command (one-time-access-token)
if len(args) != 2 {
return errors.New("missing username or email of user; usage: one-time-access-token <username or email>")
}
userArg := args[1]
// Connect to the database
db := bootstrap.NewDatabase()
// Create the access token
var oneTimeAccessToken *model.OneTimeAccessToken
err := db.Transaction(func(tx *gorm.DB) error {
// Load the user to retrieve the user ID
var user model.User
queryCtx, queryCancel := context.WithTimeout(ctx, 10*time.Second)
defer queryCancel()
txErr := tx.
WithContext(queryCtx).
Where("username = ? OR email = ?", userArg, userArg).
First(&user).
Error
switch {
case errors.Is(txErr, gorm.ErrRecordNotFound):
return errors.New("user not found")
case txErr != nil:
return fmt.Errorf("failed to query for user: %w", txErr)
case user.ID == "":
return errors.New("invalid user loaded: ID is empty")
}
// Create a new access token that expires in 1 hour
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Now().Add(time.Hour))
if txErr != nil {
return fmt.Errorf("failed to generate access token: %w", txErr)
}
queryCtx, queryCancel = context.WithTimeout(ctx, 10*time.Second)
defer queryCancel()
txErr = tx.
WithContext(queryCtx).
Create(oneTimeAccessToken).
Error
if txErr != nil {
return fmt.Errorf("failed to save access token: %w", txErr)
}
return nil
})
if err != nil {
return err
}
// Print the result
fmt.Printf(`A one-time access token valid for 1 hour has been created for "%s".`+"\n", userArg)
fmt.Printf("Use the following URL to sign in once: %s/lc/%s\n", common.EnvConfig.AppURL, oneTimeAccessToken.Token)
return nil
}

View File

@@ -25,33 +25,31 @@ const (
type EnvConfigSchema struct { type EnvConfigSchema struct {
AppEnv string `env:"APP_ENV"` AppEnv string `env:"APP_ENV"`
AppURL string `env:"PUBLIC_APP_URL"` AppURL string `env:"APP_URL"`
DbProvider DbProvider `env:"DB_PROVIDER"` DbProvider DbProvider `env:"DB_PROVIDER"`
DbConnectionString string `env:"DB_CONNECTION_STRING"` DbConnectionString string `env:"DB_CONNECTION_STRING"`
SqliteDBPath string `env:"SQLITE_DB_PATH"` // Deprecated: use "DB_CONNECTION_STRING" instead
PostgresConnectionString string `env:"POSTGRES_CONNECTION_STRING"` // Deprecated: use "DB_CONNECTION_STRING" instead
UploadPath string `env:"UPLOAD_PATH"` UploadPath string `env:"UPLOAD_PATH"`
KeysPath string `env:"KEYS_PATH"` KeysPath string `env:"KEYS_PATH"`
Port string `env:"BACKEND_PORT"` Port string `env:"PORT"`
Host string `env:"HOST"` Host string `env:"HOST"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"` MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"` GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"` GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
UiConfigDisabled bool `env:"PUBLIC_UI_CONFIG_DISABLED"` UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
MetricsEnabled bool `env:"METRICS_ENABLED"` MetricsEnabled bool `env:"METRICS_ENABLED"`
TracingEnabled bool `env:"TRACING_ENABLED"` TracingEnabled bool `env:"TRACING_ENABLED"`
TrustProxy bool `env:"TRUST_PROXY"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
} }
var EnvConfig = &EnvConfigSchema{ var EnvConfig = &EnvConfigSchema{
AppEnv: "production", AppEnv: "production",
DbProvider: "sqlite", DbProvider: "sqlite",
DbConnectionString: "file:data/pocket-id.db?_journal_mode=WAL&_busy_timeout=2500&_txlock=immediate", DbConnectionString: "file:data/pocket-id.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2500)&_txlock=immediate",
SqliteDBPath: "",
PostgresConnectionString: "",
UploadPath: "data/uploads", UploadPath: "data/uploads",
KeysPath: "data/keys", KeysPath: "data/keys",
AppURL: "http://localhost", AppURL: "http://localhost:1411",
Port: "8080", Port: "1411",
Host: "0.0.0.0", Host: "0.0.0.0",
MaxMindLicenseKey: "", MaxMindLicenseKey: "",
GeoLiteDBPath: "data/GeoLite2-City.mmdb", GeoLiteDBPath: "data/GeoLite2-City.mmdb",
@@ -59,6 +57,8 @@ var EnvConfig = &EnvConfigSchema{
UiConfigDisabled: false, UiConfigDisabled: false,
MetricsEnabled: false, MetricsEnabled: false,
TracingEnabled: false, TracingEnabled: false,
TrustProxy: false,
AnalyticsDisabled: false,
} }
func init() { func init() {
@@ -82,9 +82,9 @@ func init() {
parsedAppUrl, err := url.Parse(EnvConfig.AppURL) parsedAppUrl, err := url.Parse(EnvConfig.AppURL)
if err != nil { if err != nil {
log.Fatal("PUBLIC_APP_URL is not a valid URL") log.Fatal("APP_URL is not a valid URL")
} }
if parsedAppUrl.Path != "" { if parsedAppUrl.Path != "" {
log.Fatal("PUBLIC_APP_URL must not contain a path") log.Fatal("APP_URL must not contain a path")
} }
} }

View File

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

View File

@@ -68,6 +68,13 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
return return
} }
// Manually add uiConfigDisabled which isn't in the database but defined with an environment variable
configVariablesDto = append(configVariablesDto, dto.PublicAppConfigVariableDto{
Key: "uiConfigDisabled",
Value: strconv.FormatBool(common.EnvConfig.UiConfigDisabled),
Type: "boolean",
})
c.JSON(http.StatusOK, configVariablesDto) c.JSON(http.StatusOK, configVariablesDto)
} }

View File

@@ -41,6 +41,16 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
return return
} }
if err := tc.TestService.SetLdapTestConfig(c.Request.Context()); err != nil {
_ = c.Error(err)
return
}
if err := tc.TestService.SyncLdap(c.Request.Context()); err != nil {
_ = c.Error(err)
return
}
tc.TestService.SetJWTKeys() tc.TestService.SetJWTKeys()
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)

View File

@@ -129,9 +129,6 @@ func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Contex
// @Success 200 {object} dto.OidcTokenResponseDto "Token response with access_token and optional id_token and refresh_token" // @Success 200 {object} dto.OidcTokenResponseDto "Token response with access_token and optional id_token and refresh_token"
// @Router /api/oidc/token [post] // @Router /api/oidc/token [post]
func (oc *OidcController) createTokensHandler(c *gin.Context) { func (oc *OidcController) createTokensHandler(c *gin.Context) {
// Disable cors for this endpoint
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
var input dto.OidcCreateTokensDto var input dto.OidcCreateTokensDto
if err := c.ShouldBind(&input); err != nil { if err := c.ShouldBind(&input); err != nil {
_ = c.Error(err) _ = c.Error(err)
@@ -139,13 +136,13 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
} }
// Validate that code is provided for authorization_code grant type // Validate that code is provided for authorization_code grant type
if input.GrantType == "authorization_code" && input.Code == "" { if input.GrantType == service.GrantTypeAuthorizationCode && input.Code == "" {
_ = c.Error(&common.OidcMissingAuthorizationCodeError{}) _ = c.Error(&common.OidcMissingAuthorizationCodeError{})
return return
} }
// Validate that refresh_token is provided for refresh_token grant type // Validate that refresh_token is provided for refresh_token grant type
if input.GrantType == "refresh_token" && input.RefreshToken == "" { if input.GrantType == service.GrantTypeRefreshToken && input.RefreshToken == "" {
_ = c.Error(&common.OidcMissingRefreshTokenError{}) _ = c.Error(&common.OidcMissingRefreshTokenError{})
return return
} }
@@ -155,8 +152,7 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
input.ClientID, input.ClientSecret, _ = c.Request.BasicAuth() input.ClientID, input.ClientSecret, _ = c.Request.BasicAuth()
} }
idToken, accessToken, refreshToken, expiresIn, err := tokens, err := oc.oidcService.CreateTokens(c.Request.Context(), input)
oc.oidcService.CreateTokens(c.Request.Context(), input)
switch { switch {
case errors.Is(err, &common.OidcAuthorizationPendingError{}): case errors.Is(err, &common.OidcAuthorizationPendingError{}):
@@ -174,23 +170,13 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
return return
} }
response := dto.OidcTokenResponseDto{ c.JSON(http.StatusOK, dto.OidcTokenResponseDto{
AccessToken: accessToken, AccessToken: tokens.AccessToken,
TokenType: "Bearer", TokenType: "Bearer",
ExpiresIn: expiresIn, ExpiresIn: int(tokens.ExpiresIn.Seconds()),
} IdToken: tokens.IdToken, // May be empty
RefreshToken: tokens.RefreshToken, // May be empty
// Include ID token only for authorization_code grant })
if idToken != "" {
response.IdToken = idToken
}
// Include refresh token if generated
if refreshToken != "" {
response.RefreshToken = refreshToken
}
c.JSON(http.StatusOK, response)
} }
// userInfoHandler godoc // userInfoHandler godoc
@@ -391,7 +377,7 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
// @Param limit query int false "Number of items per page" default(10) // @Param limit query int false "Number of items per page" default(10)
// @Param sort_column query string false "Column to sort by" default("name") // @Param sort_column query string false "Column to sort by" default("name")
// @Param sort_direction query string false "Sort direction (asc or desc)" default("asc") // @Param sort_direction query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.OidcClientDto] // @Success 200 {object} dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]
// @Security BearerAuth // @Security BearerAuth
// @Router /api/oidc/clients [get] // @Router /api/oidc/clients [get]
func (oc *OidcController) listClientsHandler(c *gin.Context) { func (oc *OidcController) listClientsHandler(c *gin.Context) {
@@ -408,13 +394,23 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
return return
} }
var clientsDto []dto.OidcClientDto // Map the user groups to DTOs
if err := dto.MapStructList(clients, &clientsDto); err != nil { var clientsDto = make([]dto.OidcClientWithAllowedGroupsCountDto, len(clients))
for i, client := range clients {
var clientDto dto.OidcClientWithAllowedGroupsCountDto
if err := dto.MapStruct(client, &clientDto); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }
clientDto.AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(c, client.ID)
if err != nil {
_ = c.Error(err)
return
}
clientsDto[i] = clientDto
}
c.JSON(http.StatusOK, dto.Paginated[dto.OidcClientDto]{ c.JSON(http.StatusOK, dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]{
Data: clientsDto, Data: clientsDto,
Pagination: pagination, Pagination: pagination,
}) })

View File

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

View File

@@ -77,7 +77,7 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
"introspection_endpoint": appUrl + "/api/oidc/introspect", "introspection_endpoint": appUrl + "/api/oidc/introspect",
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize", "device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
"jwks_uri": appUrl + "/.well-known/jwks.json", "jwks_uri": appUrl + "/.well-known/jwks.json",
"grant_types_supported": []string{"authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"}, "grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode},
"scopes_supported": []string{"openid", "profile", "email", "groups"}, "scopes_supported": []string{"openid", "profile", "email", "groups"},
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"}, "claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
"response_types_supported": []string{"code", "id_token"}, "response_types_supported": []string{"code", "id_token"},

View File

@@ -19,9 +19,14 @@ type OidcClientWithAllowedUserGroupsDto struct {
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"` AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
} }
type OidcClientWithAllowedGroupsCountDto struct {
OidcClientDto
AllowedUserGroupsCount int64 `json:"allowedUserGroupsCount"`
}
type OidcClientCreateDto struct { type OidcClientCreateDto struct {
Name string `json:"name" binding:"required,max=50"` Name string `json:"name" binding:"required,max=50"`
CallbackURLs []string `json:"callbackURLs" binding:"required"` CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"` LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"` IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"` PkceEnabled bool `json:"pkceEnabled"`

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,9 +2,10 @@ package job
import ( import (
"context" "context"
"log"
"time" "time"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/service"
) )
@@ -21,23 +22,8 @@ func (s *Scheduler) RegisterGeoLiteUpdateJobs(ctx context.Context, geoLiteServic
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService} jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
// Register the job to run every day, at 5 minutes past midnight // Run every 24 hours (and right away)
err := s.registerJob(ctx, "UpdateGeoLiteDB", "5 * */1 * *", jobs.updateGoeLiteDB) return s.registerJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, true)
if err != nil {
return err
}
// Run the job immediately on startup, with a 1s delay
go func() {
time.Sleep(time.Second)
err = jobs.updateGoeLiteDB(ctx)
if err != nil {
// Log the error only, but don't return it
log.Printf("Failed to Update GeoLite database: %v", err)
}
}()
return nil
} }
func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error { func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error {

View File

@@ -2,7 +2,9 @@ package job
import ( import (
"context" "context"
"log" "time"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/service"
) )
@@ -16,19 +18,7 @@ func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.L
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService} jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
// Register the job to run every hour // Register the job to run every hour
err := s.registerJob(ctx, "SyncLdap", "0 * * * *", jobs.syncLdap) return s.registerJob(ctx, "SyncLdap", gocron.DurationJob(time.Hour), jobs.syncLdap, true)
if err != nil {
return err
}
// Run the job immediately on startup
err = jobs.syncLdap(ctx)
if err != nil {
// Log the error only, but don't return it
log.Printf("Failed to sync LDAP: %v", err)
}
return nil
} }
func (j *LdapJobs) syncLdap(ctx context.Context) error { func (j *LdapJobs) syncLdap(ctx context.Context) error {

View File

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

View File

@@ -4,7 +4,6 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
) )
type CorsMiddleware struct{} type CorsMiddleware struct{}
@@ -15,17 +14,22 @@ func NewCorsMiddleware() *CorsMiddleware {
func (m *CorsMiddleware) Add() gin.HandlerFunc { func (m *CorsMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
// Allow all origins for the token endpoint path := c.FullPath()
switch c.FullPath() { if path == "" {
case "/api/oidc/token", "/api/oidc/introspect": // The router doesn't map preflight requests, so we need to use the raw URL path
c.Writer.Header().Set("Access-Control-Allow-Origin", "*") path = c.Request.URL.Path
default:
c.Writer.Header().Set("Access-Control-Allow-Origin", common.EnvConfig.AppURL)
} }
c.Writer.Header().Set("Access-Control-Allow-Headers", "*") if !isCorsPath(path) {
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT") c.Next()
return
}
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Authorization")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST")
// Preflight request
if c.Request.Method == http.MethodOptions { if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(204) c.AbortWithStatus(204)
return return
@@ -34,3 +38,17 @@ func (m *CorsMiddleware) Add() gin.HandlerFunc {
c.Next() c.Next()
} }
} }
func isCorsPath(path string) bool {
switch path {
case "/api/oidc/token",
"/api/oidc/userinfo",
"/oidc/end-session",
"/api/oidc/introspect",
"/.well-known/jwks.json",
"/.well-known/openid-configuration":
return true
default:
return false
}
}

View File

@@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"reflect" "reflect"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -40,6 +41,7 @@ type AppConfig struct {
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
LogoDarkImageType AppConfigVariable `key:"logoDarkImageType,internal"` // Internal LogoDarkImageType AppConfigVariable `key:"logoDarkImageType,internal"` // Internal
InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal
// Email // Email
SmtpHost AppConfigVariable `key:"smtpHost"` SmtpHost AppConfigVariable `key:"smtpHost"`
SmtpPort AppConfigVariable `key:"smtpPort"` SmtpPort AppConfigVariable `key:"smtpPort"`
@@ -107,24 +109,26 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
return res return res
} }
func (c *AppConfig) FieldByKey(key string) (string, error) { func (c *AppConfig) FieldByKey(key string) (defaultValue string, isInternal bool, err error) {
rv := reflect.ValueOf(c).Elem() rv := reflect.ValueOf(c).Elem()
rt := rv.Type() rt := rv.Type()
// Find the field in the struct whose "key" tag matches // Find the field in the struct whose "key" tag matches
for i := range rt.NumField() { for i := range rt.NumField() {
// Grab only the first part of the key, if there's a comma with additional properties // Grab only the first part of the key, if there's a comma with additional properties
tagValue, _, _ := strings.Cut(rt.Field(i).Tag.Get("key"), ",") tagValue := strings.Split(rt.Field(i).Tag.Get("key"), ",")
if tagValue != key { keyFromTag := tagValue[0]
isInternal = slices.Contains(tagValue, "internal")
if keyFromTag != key {
continue continue
} }
valueField := rv.Field(i).FieldByName("Value") valueField := rv.Field(i).FieldByName("Value")
return valueField.String(), nil return valueField.String(), isInternal, nil
} }
// If we are here, the config key was not found // If we are here, the config key was not found
return "", AppConfigKeyNotFoundError{field: key} return "", false, AppConfigKeyNotFoundError{field: key}
} }
func (c *AppConfig) UpdateField(key string, value string, noInternal bool) error { func (c *AppConfig) UpdateField(key string, value string, noInternal bool) error {

View File

@@ -103,7 +103,7 @@ func TestAppConfigStructMatchesUpdateDto(t *testing.T) {
// Verify every AppConfig field has a matching DTO field with the same name // Verify every AppConfig field has a matching DTO field with the same name
for fieldName, keyName := range appConfigFields { for fieldName, keyName := range appConfigFields {
if strings.HasSuffix(fieldName, "ImageType") { if strings.HasSuffix(fieldName, "ImageType") || keyName == "instanceId" {
// Skip internal fields that shouldn't be in the DTO // Skip internal fields that shouldn't be in the DTO
continue continue
} }

View File

@@ -2,6 +2,7 @@ package datatype
import ( import (
"database/sql/driver" "database/sql/driver"
"fmt"
"time" "time"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
@@ -10,9 +11,16 @@ import (
// DateTime custom type for time.Time to store date as unix timestamp for sqlite and as date for postgres // DateTime custom type for time.Time to store date as unix timestamp for sqlite and as date for postgres
type DateTime time.Time //nolint:recvcheck type DateTime time.Time //nolint:recvcheck
func (date *DateTime) Scan(value interface{}) (err error) { func (date *DateTime) Scan(value any) (err error) {
*date = DateTime(value.(time.Time)) switch v := value.(type) {
return case time.Time:
*date = DateTime(v)
case int64:
*date = DateTime(time.Unix(v, 0))
default:
return fmt.Errorf("unexpected type for DateTime: %T", value)
}
return nil
} }
func (date DateTime) Value() (driver.Value, error) { func (date DateTime) Value() (driver.Value, error) {

View File

@@ -5,6 +5,7 @@ import (
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils"
) )

View File

@@ -8,10 +8,13 @@ import (
"mime/multipart" "mime/multipart"
"os" "os"
"reflect" "reflect"
"slices"
"strings" "strings"
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/hashicorp/go-uuid"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -36,6 +39,11 @@ func NewAppConfigService(initCtx context.Context, db *gorm.DB) *AppConfigService
log.Fatalf("Failed to initialize app config service: %v", err) log.Fatalf("Failed to initialize app config service: %v", err)
} }
err = service.initInstanceID(initCtx)
if err != nil {
log.Fatalf("Failed to initialize instance ID: %v", err)
}
return service return service
} }
@@ -64,6 +72,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
BackgroundImageType: model.AppConfigVariable{Value: "jpg"}, BackgroundImageType: model.AppConfigVariable{Value: "jpg"},
LogoLightImageType: model.AppConfigVariable{Value: "svg"}, LogoLightImageType: model.AppConfigVariable{Value: "svg"},
LogoDarkImageType: model.AppConfigVariable{Value: "svg"}, LogoDarkImageType: model.AppConfigVariable{Value: "svg"},
InstanceID: model.AppConfigVariable{Value: ""},
// Email // Email
SmtpHost: model.AppConfigVariable{}, SmtpHost: model.AppConfigVariable{},
SmtpPort: model.AppConfigVariable{}, SmtpPort: model.AppConfigVariable{},
@@ -188,7 +197,7 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon
// Skip values that are internal only and can't be updated // Skip values that are internal only and can't be updated
if value == "" { if value == "" {
// Ignore errors here as we know the key exists // Ignore errors here as we know the key exists
defaultValue, _ := defaultCfg.FieldByKey(key) defaultValue, _, _ := defaultCfg.FieldByKey(key)
err = cfg.UpdateField(key, defaultValue, true) err = cfg.UpdateField(key, defaultValue, true)
} else { } else {
err = cfg.UpdateField(key, value, true) err = cfg.UpdateField(key, value, true)
@@ -229,10 +238,6 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon
// UpdateAppConfigValues // UpdateAppConfigValues
func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error { func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error {
if common.EnvConfig.UiConfigDisabled {
return &common.UiConfigDisabledError{}
}
// Count of keysAndValues must be even // Count of keysAndValues must be even
if len(keysAndValues)%2 != 0 { if len(keysAndValues)%2 != 0 {
return errors.New("invalid number of arguments received") return errors.New("invalid number of arguments received")
@@ -267,10 +272,13 @@ func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndVal
// Ensure that the field is valid // Ensure that the field is valid
// We do this by grabbing the default value // We do this by grabbing the default value
var defaultValue string var defaultValue string
defaultValue, err = defaultCfg.FieldByKey(key) defaultValue, isInternal, err := defaultCfg.FieldByKey(key)
if err != nil { if err != nil {
return fmt.Errorf("invalid configuration key '%s': %w", key, err) return fmt.Errorf("invalid configuration key '%s': %w", key, err)
} }
if !isInternal && common.EnvConfig.UiConfigDisabled {
return &common.UiConfigDisabledError{}
}
// Update the in-memory config value // Update the in-memory config value
// If the new value is an empty string, then we set the in-memory value to the default one // If the new value is an empty string, then we set the in-memory value to the default one
@@ -351,7 +359,7 @@ func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) {
// If the UI config is disabled, only load from the env // If the UI config is disabled, only load from the env
if common.EnvConfig.UiConfigDisabled { if common.EnvConfig.UiConfigDisabled {
dest, err = s.loadDbConfigFromEnv() dest, err = s.loadDbConfigFromEnv(ctx, s.db)
} else { } else {
dest, err = s.loadDbConfigInternal(ctx, s.db) dest, err = s.loadDbConfigInternal(ctx, s.db)
} }
@@ -365,7 +373,7 @@ func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) {
return nil return nil
} }
func (s *AppConfigService) loadDbConfigFromEnv() (*model.AppConfig, error) { func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) {
// First, start from the default configuration // First, start from the default configuration
dest := s.getDefaultDbConfig() dest := s.getDefaultDbConfig()
@@ -375,9 +383,25 @@ func (s *AppConfigService) loadDbConfigFromEnv() (*model.AppConfig, error) {
for i := range rt.NumField() { for i := range rt.NumField() {
field := rt.Field(i) field := rt.Field(i)
// Get the value of the key tag, taking only what's before the comma // Get the key and internal tag values
// The env var name is the key converted to SCREAMING_SNAKE_CASE tagValue := strings.Split(field.Tag.Get("key"), ",")
key, _, _ := strings.Cut(field.Tag.Get("key"), ",") key := tagValue[0]
isInternal := slices.Contains(tagValue, "internal")
// Internal fields are loaded from the database as they can't be set from the environment
if isInternal {
var value string
err := tx.WithContext(ctx).
Model(&model.AppConfigVariable{}).
Where("key = ?", key).
Select("value").
First(&value).Error
if err == nil {
rv.Field(i).FieldByName("Value").SetString(value)
}
continue
}
envVarName := utils.CamelCaseToScreamingSnakeCase(key) envVarName := utils.CamelCaseToScreamingSnakeCase(key)
// Set the value if it's set // Set the value if it's set
@@ -396,7 +420,7 @@ func (s *AppConfigService) loadDbConfigInternal(ctx context.Context, tx *gorm.DB
// Load all configuration values from the database // Load all configuration values from the database
// This loads all values in a single shot // This loads all values in a single shot
loaded := []model.AppConfigVariable{} var loaded []model.AppConfigVariable
queryCtx, queryCancel := context.WithTimeout(ctx, 10*time.Second) queryCtx, queryCancel := context.WithTimeout(ctx, 10*time.Second)
defer queryCancel() defer queryCancel()
err := tx. err := tx.
@@ -408,11 +432,6 @@ func (s *AppConfigService) loadDbConfigInternal(ctx context.Context, tx *gorm.DB
// Iterate through all values loaded from the database // Iterate through all values loaded from the database
for _, v := range loaded { for _, v := range loaded {
// If the value is empty, it means we are using the default value
if v.Value == "" {
continue
}
// Find the field in the struct whose "key" tag matches, then update that // Find the field in the struct whose "key" tag matches, then update that
err = dest.UpdateField(v.Key, v.Value, false) err = dest.UpdateField(v.Key, v.Value, false)
@@ -424,3 +443,23 @@ func (s *AppConfigService) loadDbConfigInternal(ctx context.Context, tx *gorm.DB
return dest, nil return dest, nil
} }
func (s *AppConfigService) initInstanceID(ctx context.Context) error {
// Check if the instance ID is already set
instanceID := s.GetDbConfig().InstanceID.Value
if instanceID != "" {
return nil
}
newInstanceID, err := uuid.GenerateUUID()
if err != nil {
return fmt.Errorf("failed to generate new instance ID: %w", err)
}
err = s.UpdateAppConfigValues(ctx, "instanceId", newInstanceID)
if err != nil {
return fmt.Errorf("failed to update instance ID in the database: %w", err)
}
return nil
}

View File

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

View File

@@ -29,15 +29,16 @@ type TestService struct {
db *gorm.DB db *gorm.DB
jwtService *JwtService jwtService *JwtService
appConfigService *AppConfigService appConfigService *AppConfigService
ldapService *LdapService
} }
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService) *TestService { func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService) *TestService {
return &TestService{db: db, appConfigService: appConfigService, jwtService: jwtService} return &TestService{db: db, appConfigService: appConfigService, jwtService: jwtService, ldapService: ldapService}
} }
//nolint:gocognit //nolint:gocognit
func (s *TestService) SeedDatabase() error { func (s *TestService) SeedDatabase() error {
return s.db.Transaction(func(tx *gorm.DB) error { err := s.db.Transaction(func(tx *gorm.DB) error {
users := []model.User{ users := []model.User{
{ {
Base: model.Base{ Base: model.Base{
@@ -238,6 +239,12 @@ func (s *TestService) SeedDatabase() error {
return nil return nil
}) })
if err != nil {
return err
}
return nil
} }
func (s *TestService) ResetDatabase() error { func (s *TestService) ResetDatabase() error {
@@ -349,3 +356,52 @@ func (s *TestService) getCborPublicKey(base64PublicKey string) ([]byte, error) {
return cborPublicKey, nil return cborPublicKey, nil
} }
// SyncLdap triggers an LDAP synchronization
func (s *TestService) SyncLdap(ctx context.Context) error {
return s.ldapService.SyncAll(ctx)
}
// SetLdapTestConfig writes the test LDAP config variables directly to the database.
func (s *TestService) SetLdapTestConfig(ctx context.Context) error {
err := s.db.Transaction(func(tx *gorm.DB) error {
ldapConfigs := map[string]string{
"ldapUrl": "ldap://lldap:3890",
"ldapBindDn": "uid=admin,ou=people,dc=pocket-id,dc=org",
"ldapBindPassword": "admin_password",
"ldapBase": "dc=pocket-id,dc=org",
"ldapUserSearchFilter": "(objectClass=person)",
"ldapUserGroupSearchFilter": "(objectClass=groupOfNames)",
"ldapSkipCertVerify": "true",
"ldapAttributeUserUniqueIdentifier": "uuid",
"ldapAttributeUserUsername": "uid",
"ldapAttributeUserEmail": "mail",
"ldapAttributeUserFirstName": "givenName",
"ldapAttributeUserLastName": "sn",
"ldapAttributeGroupUniqueIdentifier": "uuid",
"ldapAttributeGroupName": "uid",
"ldapAttributeGroupMember": "member",
"ldapAttributeAdminGroup": "admin_group",
"ldapSoftDeleteUsers": "true",
"ldapEnabled": "true",
}
for key, value := range ldapConfigs {
configVar := model.AppConfigVariable{Key: key, Value: value}
if err := tx.Create(&configVar).Error; err != nil {
return fmt.Errorf("failed to create config variable '%s': %w", key, err)
}
}
return nil
})
if err != nil {
return fmt.Errorf("failed to set LDAP test config: %w", err)
}
if err := s.appConfigService.LoadDbConfig(ctx); err != nil {
return fmt.Errorf("failed to load app config: %w", err)
}
return nil
}

View File

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

View File

@@ -148,22 +148,44 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.Value) groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.Value)
membersUserId := make([]string, 0, len(groupMembers)) membersUserId := make([]string, 0, len(groupMembers))
for _, member := range groupMembers { for _, member := range groupMembers {
ldapId := getDNProperty("uid", member) username := getDNProperty(dbConfig.LdapAttributeUserUsername.Value, member)
if ldapId == "" {
// If username extraction fails, try to query LDAP directly for the user
if username == "" {
// Query LDAP to get the user by their DN
userSearchReq := ldap.NewSearchRequest(
member,
ldap.ScopeBaseObject,
0, 0, 0, false,
"(objectClass=*)",
[]string{dbConfig.LdapAttributeUserUsername.Value, dbConfig.LdapAttributeUserUniqueIdentifier.Value},
[]ldap.Control{},
)
userResult, err := client.Search(userSearchReq)
if err != nil || len(userResult.Entries) == 0 {
log.Printf("Could not resolve group member DN '%s': %v", member, err)
continue continue
} }
username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)
if username == "" {
log.Printf("Could not extract username from group member DN '%s'", member)
continue
}
}
var databaseUser model.User var databaseUser model.User
err = tx. err = tx.
WithContext(ctx). WithContext(ctx).
Where("username = ? AND ldap_id IS NOT NULL", ldapId). Where("username = ? AND ldap_id IS NOT NULL", username).
First(&databaseUser). First(&databaseUser).
Error Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
// The user collides with a non-LDAP user, so we skip it // The user collides with a non-LDAP user, so we skip it
continue continue
} else if err != nil { } else if err != nil {
return fmt.Errorf("failed to query for existing user '%s': %w", ldapId, err) return fmt.Errorf("failed to query for existing user '%s': %w", username, err)
} }
membersUserId = append(membersUserId, databaseUser.ID) membersUserId = append(membersUserId, databaseUser.ID)
@@ -305,7 +327,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
// Check if user is admin by checking if they are in the admin group // Check if user is admin by checking if they are in the admin group
isAdmin := false isAdmin := false
for _, group := range value.GetAttributeValues("memberOf") { for _, group := range value.GetAttributeValues("memberOf") {
if getDNProperty("cn", group) == dbConfig.LdapAttributeAdminGroup.Value { if getDNProperty(dbConfig.LdapAttributeGroupName.Value, group) == dbConfig.LdapAttributeAdminGroup.Value {
isAdmin = true isAdmin = true
break break
} }

View File

@@ -15,17 +15,22 @@ import (
"strings" "strings"
"time" "time"
"gorm.io/gorm/clause"
"github.com/lestrrat-go/jwx/v3/jwt" "github.com/lestrrat-go/jwx/v3/jwt"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils"
"golang.org/x/crypto/bcrypt" )
"gorm.io/gorm"
const (
GrantTypeAuthorizationCode = "authorization_code"
GrantTypeRefreshToken = "refresh_token"
GrantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code"
) )
type OidcService struct { type OidcService struct {
@@ -68,7 +73,7 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
} }
// Get the callback URL of the client. Return an error if the provided callback URL is not allowed // Get the callback URL of the client. Return an error if the provided callback URL is not allowed
callbackURL, err := s.getCallbackURL(client.CallbackURLs, input.CallbackURL) callbackURL, err := s.getCallbackURL(&client, input.CallbackURL, tx, ctx)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@@ -167,139 +172,158 @@ func (s *OidcService) IsUserGroupAllowedToAuthorize(user model.User, client mode
return isAllowedToAuthorize return isAllowedToAuthorize
} }
func (s *OidcService) CreateTokens(ctx context.Context, input dto.OidcCreateTokensDto) (idToken string, accessToken string, newRefreshToken string, exp int, err error) { type CreatedTokens struct {
IdToken string
AccessToken string
RefreshToken string
ExpiresIn time.Duration
}
func (s *OidcService) CreateTokens(ctx context.Context, input dto.OidcCreateTokensDto) (CreatedTokens, error) {
switch input.GrantType { switch input.GrantType {
case "authorization_code": case GrantTypeAuthorizationCode:
return s.createTokenFromAuthorizationCode(ctx, input.Code, input.ClientID, input.ClientSecret, input.CodeVerifier) return s.createTokenFromAuthorizationCode(ctx, input)
case "refresh_token": case GrantTypeRefreshToken:
accessToken, newRefreshToken, exp, err = s.createTokenFromRefreshToken(ctx, input.RefreshToken, input.ClientID, input.ClientSecret) return s.createTokenFromRefreshToken(ctx, input)
return "", accessToken, newRefreshToken, exp, err case GrantTypeDeviceCode:
case "urn:ietf:params:oauth:grant-type:device_code": return s.createTokenFromDeviceCode(ctx, input)
return s.createTokenFromDeviceCode(ctx, input.DeviceCode, input.ClientID, input.ClientSecret)
default: default:
return "", "", "", 0, &common.OidcGrantTypeNotSupportedError{} return CreatedTokens{}, &common.OidcGrantTypeNotSupportedError{}
} }
} }
func (s *OidcService) createTokenFromDeviceCode(ctx context.Context, deviceCode, clientID string, clientSecret string) (idToken string, accessToken string, refreshToken string, exp int, err error) { func (s *OidcService) createTokenFromDeviceCode(ctx context.Context, input dto.OidcCreateTokensDto) (CreatedTokens, error) {
tx := s.db.Begin() tx := s.db.Begin()
defer func() { defer func() {
tx.Rollback() tx.Rollback()
}() }()
_, err = s.verifyClientCredentialsInternal(ctx, clientID, clientSecret, tx) _, err := s.verifyClientCredentialsInternal(ctx, input.ClientID, input.ClientSecret, tx)
if err != nil { if err != nil {
return "", "", "", 0, err return CreatedTokens{}, err
} }
// Get the device authorization from database with explicit query conditions // Get the device authorization from database with explicit query conditions
var deviceAuth model.OidcDeviceCode var deviceAuth model.OidcDeviceCode
if err := tx.WithContext(ctx).Preload("User").Where("device_code = ? AND client_id = ?", deviceCode, clientID).First(&deviceAuth).Error; err != nil { err = tx.
WithContext(ctx).
Preload("User").
Where("device_code = ? AND client_id = ?", input.DeviceCode, input.ClientID).
First(&deviceAuth).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return "", "", "", 0, &common.OidcInvalidDeviceCodeError{} return CreatedTokens{}, &common.OidcInvalidDeviceCodeError{}
} }
return "", "", "", 0, err return CreatedTokens{}, err
} }
// Check if device code has expired // Check if device code has expired
if time.Now().After(deviceAuth.ExpiresAt.ToTime()) { if time.Now().After(deviceAuth.ExpiresAt.ToTime()) {
return "", "", "", 0, &common.OidcDeviceCodeExpiredError{} return CreatedTokens{}, &common.OidcDeviceCodeExpiredError{}
} }
// Check if device code has been authorized // Check if device code has been authorized
if !deviceAuth.IsAuthorized || deviceAuth.UserID == nil { if !deviceAuth.IsAuthorized || deviceAuth.UserID == nil {
return "", "", "", 0, &common.OidcAuthorizationPendingError{} return CreatedTokens{}, &common.OidcAuthorizationPendingError{}
} }
// Get user claims for the ID token - ensure UserID is not nil // Get user claims for the ID token - ensure UserID is not nil
if deviceAuth.UserID == nil { if deviceAuth.UserID == nil {
return "", "", "", 0, &common.OidcAuthorizationPendingError{} return CreatedTokens{}, &common.OidcAuthorizationPendingError{}
} }
userClaims, err := s.getUserClaimsForClientInternal(ctx, *deviceAuth.UserID, clientID, tx) userClaims, err := s.getUserClaimsForClientInternal(ctx, *deviceAuth.UserID, input.ClientID, tx)
if err != nil { if err != nil {
return "", "", "", 0, err return CreatedTokens{}, err
} }
// Explicitly use the input clientID for the audience claim to ensure consistency // Explicitly use the input clientID for the audience claim to ensure consistency
idToken, err = s.jwtService.GenerateIDToken(userClaims, clientID, "") idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, "")
if err != nil { if err != nil {
return "", "", "", 0, err return CreatedTokens{}, err
} }
refreshToken, err = s.createRefreshToken(ctx, clientID, *deviceAuth.UserID, deviceAuth.Scope, tx) refreshToken, err := s.createRefreshToken(ctx, input.ClientID, *deviceAuth.UserID, deviceAuth.Scope, tx)
if err != nil { if err != nil {
return "", "", "", 0, err return CreatedTokens{}, err
} }
accessToken, err = s.jwtService.GenerateOauthAccessToken(deviceAuth.User, clientID) accessToken, err := s.jwtService.GenerateOauthAccessToken(deviceAuth.User, input.ClientID)
if err != nil { if err != nil {
return "", "", "", 0, err return CreatedTokens{}, err
} }
// Delete the used device code // Delete the used device code
if err := tx.WithContext(ctx).Delete(&deviceAuth).Error; err != nil { err = tx.WithContext(ctx).Delete(&deviceAuth).Error
return "", "", "", 0, err if err != nil {
return CreatedTokens{}, err
} }
if err := tx.Commit().Error; err != nil { err = tx.Commit().Error
return "", "", "", 0, err if err != nil {
return CreatedTokens{}, err
} }
return idToken, accessToken, refreshToken, 3600, nil return CreatedTokens{
IdToken: idToken,
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: time.Hour,
}, nil
} }
func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, code, clientID, clientSecret, codeVerifier string) (idToken string, accessToken string, refreshToken string, exp int, err error) { func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, input dto.OidcCreateTokensDto) (CreatedTokens, error) {
tx := s.db.Begin() tx := s.db.Begin()
defer func() { defer func() {
tx.Rollback() tx.Rollback()
}() }()
client, err := s.verifyClientCredentialsInternal(ctx, clientID, clientSecret, tx) client, err := s.verifyClientCredentialsInternal(ctx, input.ClientID, input.ClientSecret, tx)
if err != nil { if err != nil {
return "", "", "", 0, err return CreatedTokens{}, err
} }
var authorizationCodeMetaData model.OidcAuthorizationCode var authorizationCodeMetaData model.OidcAuthorizationCode
err = tx. err = tx.
WithContext(ctx). WithContext(ctx).
Preload("User"). Preload("User").
First(&authorizationCodeMetaData, "code = ?", code). First(&authorizationCodeMetaData, "code = ?", input.Code).
Error Error
if err != nil { if err != nil {
return "", "", "", 0, &common.OidcInvalidAuthorizationCodeError{} return CreatedTokens{}, &common.OidcInvalidAuthorizationCodeError{}
} }
// If the client is public or PKCE is enabled, the code verifier must match the code challenge // If the client is public or PKCE is enabled, the code verifier must match the code challenge
if client.IsPublic || client.PkceEnabled { if client.IsPublic || client.PkceEnabled {
if !s.validateCodeVerifier(codeVerifier, *authorizationCodeMetaData.CodeChallenge, *authorizationCodeMetaData.CodeChallengeMethodSha256) { if !s.validateCodeVerifier(input.CodeVerifier, *authorizationCodeMetaData.CodeChallenge, *authorizationCodeMetaData.CodeChallengeMethodSha256) {
return "", "", "", 0, &common.OidcInvalidCodeVerifierError{} return CreatedTokens{}, &common.OidcInvalidCodeVerifierError{}
} }
} }
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) { if authorizationCodeMetaData.ClientID != input.ClientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
return "", "", "", 0, &common.OidcInvalidAuthorizationCodeError{} return CreatedTokens{}, &common.OidcInvalidAuthorizationCodeError{}
} }
userClaims, err := s.getUserClaimsForClientInternal(ctx, authorizationCodeMetaData.UserID, clientID, tx) userClaims, err := s.getUserClaimsForClientInternal(ctx, authorizationCodeMetaData.UserID, input.ClientID, tx)
if err != nil { if err != nil {
return "", "", "", 0, err return CreatedTokens{}, err
} }
idToken, err = s.jwtService.GenerateIDToken(userClaims, clientID, authorizationCodeMetaData.Nonce) idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, authorizationCodeMetaData.Nonce)
if err != nil { if err != nil {
return "", "", "", 0, err return CreatedTokens{}, err
} }
// Generate a refresh token // Generate a refresh token
refreshToken, err = s.createRefreshToken(ctx, clientID, authorizationCodeMetaData.UserID, authorizationCodeMetaData.Scope, tx) refreshToken, err := s.createRefreshToken(ctx, input.ClientID, authorizationCodeMetaData.UserID, authorizationCodeMetaData.Scope, tx)
if err != nil { if err != nil {
return "", "", "", 0, err return CreatedTokens{}, err
} }
accessToken, err = s.jwtService.GenerateOauthAccessToken(authorizationCodeMetaData.User, clientID) accessToken, err := s.jwtService.GenerateOauthAccessToken(authorizationCodeMetaData.User, input.ClientID)
if err != nil { if err != nil {
return "", "", "", 0, err return CreatedTokens{}, err
} }
err = tx. err = tx.
@@ -307,20 +331,25 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, code
Delete(&authorizationCodeMetaData). Delete(&authorizationCodeMetaData).
Error Error
if err != nil { if err != nil {
return "", "", "", 0, err return CreatedTokens{}, err
} }
err = tx.Commit().Error err = tx.Commit().Error
if err != nil { if err != nil {
return "", "", "", 0, err return CreatedTokens{}, err
} }
return idToken, accessToken, refreshToken, 3600, nil return CreatedTokens{
IdToken: idToken,
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: time.Hour,
}, nil
} }
func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, refreshToken, clientID, clientSecret string) (accessToken string, newRefreshToken string, exp int, err error) { func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto.OidcCreateTokensDto) (CreatedTokens, error) {
if refreshToken == "" { if input.RefreshToken == "" {
return "", "", 0, &common.OidcMissingRefreshTokenError{} return CreatedTokens{}, &common.OidcMissingRefreshTokenError{}
} }
tx := s.db.Begin() tx := s.db.Begin()
@@ -328,9 +357,9 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, refreshTo
tx.Rollback() tx.Rollback()
}() }()
_, err = s.verifyClientCredentialsInternal(ctx, clientID, clientSecret, tx) _, err := s.verifyClientCredentialsInternal(ctx, input.ClientID, input.ClientSecret, tx)
if err != nil { if err != nil {
return "", "", 0, err return CreatedTokens{}, err
} }
// Verify refresh token // Verify refresh token
@@ -338,31 +367,31 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, refreshTo
err = tx. err = tx.
WithContext(ctx). WithContext(ctx).
Preload("User"). Preload("User").
Where("token = ? AND expires_at > ?", utils.CreateSha256Hash(refreshToken), datatype.DateTime(time.Now())). Where("token = ? AND expires_at > ?", utils.CreateSha256Hash(input.RefreshToken), datatype.DateTime(time.Now())).
First(&storedRefreshToken). First(&storedRefreshToken).
Error Error
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return "", "", 0, &common.OidcInvalidRefreshTokenError{} return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
} }
return "", "", 0, err return CreatedTokens{}, err
} }
// Verify that the refresh token belongs to the provided client // Verify that the refresh token belongs to the provided client
if storedRefreshToken.ClientID != clientID { if storedRefreshToken.ClientID != input.ClientID {
return "", "", 0, &common.OidcInvalidRefreshTokenError{} return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
} }
// Generate a new access token // Generate a new access token
accessToken, err = s.jwtService.GenerateOauthAccessToken(storedRefreshToken.User, clientID) accessToken, err := s.jwtService.GenerateOauthAccessToken(storedRefreshToken.User, input.ClientID)
if err != nil { if err != nil {
return "", "", 0, err return CreatedTokens{}, err
} }
// Generate a new refresh token and invalidate the old one // Generate a new refresh token and invalidate the old one
newRefreshToken, err = s.createRefreshToken(ctx, clientID, storedRefreshToken.UserID, storedRefreshToken.Scope, tx) newRefreshToken, err := s.createRefreshToken(ctx, input.ClientID, storedRefreshToken.UserID, storedRefreshToken.Scope, tx)
if err != nil { if err != nil {
return "", "", 0, err return CreatedTokens{}, err
} }
// Delete the used refresh token // Delete the used refresh token
@@ -371,15 +400,19 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, refreshTo
Delete(&storedRefreshToken). Delete(&storedRefreshToken).
Error Error
if err != nil { if err != nil {
return "", "", 0, err return CreatedTokens{}, err
} }
err = tx.Commit().Error err = tx.Commit().Error
if err != nil { if err != nil {
return "", "", 0, err return CreatedTokens{}, err
} }
return accessToken, newRefreshToken, 3600, nil return CreatedTokens{
AccessToken: accessToken,
RefreshToken: newRefreshToken,
ExpiresIn: time.Hour,
}, nil
} }
func (s *OidcService) IntrospectToken(ctx context.Context, clientID, clientSecret, tokenString string) (introspectDto dto.OidcIntrospectionResponseDto, err error) { func (s *OidcService) IntrospectToken(ctx context.Context, clientID, clientSecret, tokenString string) (introspectDto dto.OidcIntrospectionResponseDto, err error) {
@@ -479,24 +512,32 @@ func (s *OidcService) getClientInternal(ctx context.Context, clientID string, tx
return client, nil return client, nil
} }
func (s *OidcService) ListClients(ctx context.Context, searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.OidcClient, utils.PaginationResponse, error) { func (s *OidcService) ListClients(ctx context.Context, name string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.OidcClient, utils.PaginationResponse, error) {
var clients []model.OidcClient var clients []model.OidcClient
query := s.db. query := s.db.
WithContext(ctx). WithContext(ctx).
Preload("CreatedBy"). Preload("CreatedBy").
Model(&model.OidcClient{}) Model(&model.OidcClient{})
if searchTerm != "" {
searchPattern := "%" + searchTerm + "%" if name != "" {
query = query.Where("name LIKE ?", searchPattern) query = query.Where("name LIKE ?", "%"+name+"%")
} }
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &clients) // As allowedUserGroupsCount is not a column, we need to manually sort it
if err != nil { isValidSortDirection := sortedPaginationRequest.Sort.Direction == "asc" || sortedPaginationRequest.Sort.Direction == "desc"
return nil, utils.PaginationResponse{}, err if sortedPaginationRequest.Sort.Column == "allowedUserGroupsCount" && isValidSortDirection {
query = query.Select("oidc_clients.*, COUNT(oidc_clients_allowed_user_groups.oidc_client_id)").
Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id").
Group("oidc_clients.id").
Order("COUNT(oidc_clients_allowed_user_groups.oidc_client_id) " + sortedPaginationRequest.Sort.Direction)
response, err := utils.Paginate(sortedPaginationRequest.Pagination.Page, sortedPaginationRequest.Pagination.Limit, query, &clients)
return clients, response, err
} }
return clients, pagination, nil response, err := utils.PaginateAndSort(sortedPaginationRequest, query, &clients)
return clients, response, err
} }
func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) { func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) {
@@ -906,13 +947,12 @@ func (s *OidcService) ValidateEndSession(ctx context.Context, input dto.OidcLogo
return "", &common.OidcNoCallbackURLError{} return "", &common.OidcNoCallbackURLError{}
} }
callbackURL, err := s.getCallbackURL(userAuthorizedOIDCClient.Client.LogoutCallbackURLs, input.PostLogoutRedirectUri) callbackURL, err := s.getLogoutCallbackURL(&userAuthorizedOIDCClient.Client, input.PostLogoutRedirectUri)
if err != nil { if err != nil {
return "", err return "", err
} }
return callbackURL, nil return callbackURL, nil
} }
func (s *OidcService) createAuthorizationCode(ctx context.Context, clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string, tx *gorm.DB) (string, error) { func (s *OidcService) createAuthorizationCode(ctx context.Context, clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string, tx *gorm.DB) (string, error) {
@@ -965,11 +1005,52 @@ func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, c
return encodedVerifierHash == codeChallenge return encodedVerifierHash == codeChallenge
} }
func (s *OidcService) getCallbackURL(urls []string, inputCallbackURL string) (callbackURL string, err error) { func (s *OidcService) getCallbackURL(client *model.OidcClient, inputCallbackURL string, tx *gorm.DB, ctx context.Context) (callbackURL string, err error) {
// If no input callback URL provided, use the first configured URL
if inputCallbackURL == "" { if inputCallbackURL == "" {
return urls[0], nil if len(client.CallbackURLs) > 0 {
return client.CallbackURLs[0], nil
}
// If no URLs are configured and no input URL, this is an error
return "", &common.OidcMissingCallbackURLError{}
} }
// If URLs are already configured, validate against them
if len(client.CallbackURLs) > 0 {
matched, err := s.getCallbackURLFromList(client.CallbackURLs, inputCallbackURL)
if err != nil {
return "", err
} else if matched == "" {
return "", &common.OidcInvalidCallbackURLError{}
}
return matched, nil
}
// If no URLs are configured, trust and store the first URL (TOFU)
err = s.addCallbackURLToClient(ctx, client, inputCallbackURL, tx)
if err != nil {
return "", err
}
return inputCallbackURL, nil
}
func (s *OidcService) getLogoutCallbackURL(client *model.OidcClient, inputLogoutCallbackURL string) (callbackURL string, err error) {
if inputLogoutCallbackURL == "" {
return client.LogoutCallbackURLs[0], nil
}
matched, err := s.getCallbackURLFromList(client.LogoutCallbackURLs, inputLogoutCallbackURL)
if err != nil {
return "", err
} else if matched == "" {
return "", &common.OidcInvalidCallbackURLError{}
}
return matched, nil
}
func (s *OidcService) getCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL string, err error) {
for _, callbackPattern := range urls { for _, callbackPattern := range urls {
regexPattern := "^" + strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$" regexPattern := "^" + strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$"
matched, err := regexp.MatchString(regexPattern, inputCallbackURL) matched, err := regexp.MatchString(regexPattern, inputCallbackURL)
@@ -981,7 +1062,19 @@ func (s *OidcService) getCallbackURL(urls []string, inputCallbackURL string) (ca
} }
} }
return "", &common.OidcInvalidCallbackURLError{} return "", nil
}
func (s *OidcService) addCallbackURLToClient(ctx context.Context, client *model.OidcClient, callbackURL string, tx *gorm.DB) error {
// Add the new callback URL to the existing list
client.CallbackURLs = append(client.CallbackURLs, callbackURL)
err := tx.WithContext(ctx).Save(client).Error
if err != nil {
return err
}
return nil
} }
func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.OidcDeviceAuthorizationRequestDto) (*dto.OidcDeviceAuthorizationResponseDto, error) { func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.OidcDeviceAuthorizationRequestDto) (*dto.OidcDeviceAuthorizationResponseDto, error) {
@@ -1133,6 +1226,23 @@ func (s *OidcService) GetDeviceCodeInfo(ctx context.Context, userCode string, us
}, nil }, nil
} }
func (s *OidcService) GetAllowedGroupsCountOfClient(ctx context.Context, id string) (int64, error) {
// We only perform select queries here, so we can rollback in all cases
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var client model.OidcClient
err := tx.WithContext(ctx).Where("id = ?", id).First(&client).Error
if err != nil {
return 0, err
}
count := tx.WithContext(ctx).Model(&client).Association("AllowedUserGroups").Count()
return count, nil
}
func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, tx *gorm.DB) (string, error) { func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, tx *gorm.DB) (string, error) {
refreshToken, err := utils.GenerateRandomAlphanumericString(40) refreshToken, err := utils.GenerateRandomAlphanumericString(40)
if err != nil { if err != nil {
@@ -1181,9 +1291,12 @@ func (s *OidcService) createAuthorizedClientInternal(ctx context.Context, userID
} }
func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, clientID, clientSecret string, tx *gorm.DB) (model.OidcClient, error) { func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, clientID, clientSecret string, tx *gorm.DB) (model.OidcClient, error) {
// First, ensure we have a valid client ID
if clientID == "" { if clientID == "" {
return model.OidcClient{}, &common.OidcMissingClientCredentialsError{} return model.OidcClient{}, &common.OidcMissingClientCredentialsError{}
} }
// Load the OIDC client's configuration
var client model.OidcClient var client model.OidcClient
err := tx. err := tx.
WithContext(ctx). WithContext(ctx).
@@ -1193,10 +1306,16 @@ func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, clien
return model.OidcClient{}, err return model.OidcClient{}, err
} }
if !client.IsPublic { // If we have a client secret, we validate it
if err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret)); err != nil { // Otherwise, we require the client to be public
if clientSecret != "" {
err = bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
if err != nil {
return model.OidcClient{}, &common.OidcClientSecretInvalidError{} return model.OidcClient{}, &common.OidcClientSecretInvalidError{}
} }
return client, nil
} else if !client.IsPublic {
return model.OidcClient{}, &common.OidcMissingClientCredentialsError{}
} }
return client, nil return client, nil

View File

@@ -294,10 +294,10 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
// Check if this is an LDAP user and LDAP is enabled // Check if this is an LDAP user and LDAP is enabled
isLdapUser := user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() isLdapUser := user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue()
allowOwnAccountEdit := s.appConfigService.GetDbConfig().AllowOwnAccountEdit.IsTrue()
// For LDAP users, only allow updating the locale unless it's an LDAP sync // For LDAP users or if own account editing is not allowed, only allow updating the locale unless it's an LDAP sync
if !isLdapSync && isLdapUser { if !isLdapSync && (isLdapUser || (!allowOwnAccountEdit && !updateOwnUser)) {
// Only update the locale for LDAP users
user.Locale = updatedUser.Locale user.Locale = updatedUser.Locale
} else { } else {
user.FirstName = updatedUser.FirstName user.FirstName = updatedUser.FirstName
@@ -420,24 +420,12 @@ func (s *UserService) CreateOneTimeAccessToken(ctx context.Context, userID strin
} }
func (s *UserService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, expiresAt time.Time, tx *gorm.DB) (string, error) { func (s *UserService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, expiresAt time.Time, tx *gorm.DB) (string, error) {
// If expires at is less than 15 minutes, use an 6 character token instead of 16 oneTimeAccessToken, err := NewOneTimeAccessToken(userID, expiresAt)
tokenLength := 16
if time.Until(expiresAt) <= 15*time.Minute {
tokenLength = 6
}
randomString, err := utils.GenerateRandomAlphanumericString(tokenLength)
if err != nil { if err != nil {
return "", err return "", err
} }
oneTimeAccessToken := model.OneTimeAccessToken{ if err := tx.WithContext(ctx).Create(oneTimeAccessToken).Error; err != nil {
UserID: userID,
ExpiresAt: datatype.DateTime(expiresAt),
Token: randomString,
}
if err := tx.WithContext(ctx).Create(&oneTimeAccessToken).Error; err != nil {
return "", err return "", err
} }
@@ -641,3 +629,24 @@ func (s *UserService) disableUserInternal(ctx context.Context, userID string, tx
Update("disabled", true). Update("disabled", true).
Error Error
} }
func NewOneTimeAccessToken(userID string, expiresAt time.Time) (*model.OneTimeAccessToken, error) {
// If expires at is less than 15 minutes, use a 6-character token instead of 16
tokenLength := 16
if time.Until(expiresAt) <= 15*time.Minute {
tokenLength = 6
}
randomString, err := utils.GenerateRandomAlphanumericString(tokenLength)
if err != nil {
return nil, err
}
o := &model.OneTimeAccessToken{
UserID: userID,
ExpiresAt: datatype.DateTime(expiresAt),
Token: randomString,
}
return o, nil
}

View File

@@ -29,6 +29,9 @@ func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *Au
RPDisplayName: appConfigService.GetDbConfig().AppName.Value, RPDisplayName: appConfigService.GetDbConfig().AppName.Value,
RPID: utils.GetHostnameFromURL(common.EnvConfig.AppURL), RPID: utils.GetHostnameFromURL(common.EnvConfig.AppURL),
RPOrigins: []string{common.EnvConfig.AppURL}, RPOrigins: []string{common.EnvConfig.AppURL},
AuthenticatorSelection: protocol.AuthenticatorSelection{
UserVerification: protocol.VerificationRequired,
},
Timeouts: webauthn.TimeoutsConfig{ Timeouts: webauthn.TimeoutsConfig{
Login: webauthn.TimeoutConfig{ Login: webauthn.TimeoutConfig{
Enforce: true, Enforce: true,

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -4,12 +4,12 @@ services:
restart: unless-stopped restart: unless-stopped
env_file: .env env_file: .env
ports: ports:
- 3000:80 - 1411:1411
volumes: volumes:
- "./data:/app/backend/data" - "./data:/app/data"
# Optional healthcheck # Optional healthcheck
healthcheck: healthcheck:
test: "curl -f http://localhost/healthz" test: "curl -f http://localhost:1411/healthz"
interval: 1m30s interval: 1m30s
timeout: 5s timeout: 5s
retries: 2 retries: 2

View File

@@ -0,0 +1,3 @@
# If the backend in your development environment is running on a different port, change the value of the variable below.
DEVELOPMENT_BACKEND_URL=http://localhost:1411
PORT=3000

View File

@@ -1,3 +0,0 @@
PUBLIC_APP_URL=http://localhost
# /!\ If PUBLIC_APP_URL is not a localhost address, it must be HTTPS
INTERNAL_BACKEND_URL=http://localhost:8080

View File

@@ -1,5 +1,5 @@
{ {
"$schema": "https://shadcn-svelte.com/schema.json", "$schema": "https://next.shadcn-svelte.com/schema.json",
"style": "default", "style": "default",
"tailwind": { "tailwind": {
"config": "tailwind.config.ts", "config": "tailwind.config.ts",
@@ -8,7 +8,11 @@
}, },
"aliases": { "aliases": {
"components": "$lib/components", "components": "$lib/components",
"utils": "$lib/utils/style" "utils": "$lib/utils/style",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
}, },
"typescript": true "typescript": true,
"registry": "https://next.shadcn-svelte.com/registry"
} }

View File

@@ -40,8 +40,8 @@
"an_unknown_error_occurred": "Došlo k neznámé chybě", "an_unknown_error_occurred": "Došlo k neznámé chybě",
"authentication_process_was_aborted": "Proces přihlášení byl přerušen", "authentication_process_was_aborted": "Proces přihlášení byl přerušen",
"error_occurred_with_authenticator": "Došlo k chybě s autentifikátorem", "error_occurred_with_authenticator": "Došlo k chybě s autentifikátorem",
"authenticator_does_not_support_discoverable_credentials": "Autentifikátor nepodporuje zobrazitelné přihlašovací údaje", "authenticator_does_not_support_discoverable_credentials": "Autentifikátor nepodporuje vyhledatelné přihlašovací údaje",
"authenticator_does_not_support_resident_keys": "Autentikátor nepodporuje rezidentní klíče.", "authenticator_does_not_support_resident_keys": "Autentikátor nepodporuje rezidentní klíče",
"passkey_was_previously_registered": "Tento přístupový klíč byl již dříve zaregistrován", "passkey_was_previously_registered": "Tento přístupový klíč byl již dříve zaregistrován",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Autentikátor nepodporuje žádný z požadovaných algoritmů", "authenticator_does_not_support_any_of_the_requested_algorithms": "Autentikátor nepodporuje žádný z požadovaných algoritmů",
"authenticator_timed_out": "Vypršel časový limit autentifikátoru", "authenticator_timed_out": "Vypršel časový limit autentifikátoru",
@@ -49,7 +49,7 @@
"sign_in_to": "Přihlásit se k {name}", "sign_in_to": "Přihlásit se k {name}",
"client_not_found": "Klient nebyl nalezen", "client_not_found": "Klient nebyl nalezen",
"client_wants_to_access_the_following_information": "<b>{client}</b> chce získat přístup k následujícím informacím:", "client_wants_to_access_the_following_information": "<b>{client}</b> chce získat přístup k následujícím informacím:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Chcete se přihlásit do <b>{client}</b> s vaším <b>{appName}</b> účtem?", "do_you_want_to_sign_in_to_client_with_your_app_name_account": "Chcete se přihlásit do <b>{client}</b> s vaším {appName} účtem?",
"email": "E-mail", "email": "E-mail",
"view_your_email_address": "Zobrazit vaši e-mailovou adresu", "view_your_email_address": "Zobrazit vaši e-mailovou adresu",
"profile": "Profil", "profile": "Profil",
@@ -340,11 +340,14 @@
"login_code_email_success": "Přihlašovací kód byl odeslán uživateli.", "login_code_email_success": "Přihlašovací kód byl odeslán uživateli.",
"send_email": "Odeslat e-mail", "send_email": "Odeslat e-mail",
"show_code": "Zobrazit kód", "show_code": "Zobrazit kód",
"callback_url_description": "URL poskytnuté klientem. Klientské zástupné znaky (*) jsou podporovány, ale raději se jim vyhýbejte, pro lepší bezpečnost.", "callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "Vypršení platnosti API klíče", "api_key_expiration": "Vypršení platnosti API klíče",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Pošlete uživateli e-mail, jakmile jejich API klíč brzy vyprší.", "send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Pošlete uživateli e-mail, jakmile jejich API klíč brzy vyprší.",
"authorize_device": "Authorize Device", "authorize_device": "Autorizovat zařízení",
"the_device_has_been_authorized": "The device has been authorized.", "the_device_has_been_authorized": "Zařízení bylo autorizováno.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.", "enter_code_displayed_in_previous_step": "Zadejte kód, který byl zobrazen v předchozím kroku.",
"authorize": "Authorize" "authorize": "Autorizovat",
"oidc_allowed_group_count": "Počet povolených skupin",
"unrestricted": "Bez omezení"
} }

View File

@@ -49,7 +49,7 @@
"sign_in_to": "Bei {name} anmelden", "sign_in_to": "Bei {name} anmelden",
"client_not_found": "Client nicht gefunden", "client_not_found": "Client nicht gefunden",
"client_wants_to_access_the_following_information": "<b>{client}</b> möchte auf die folgenden Informationen zugreifen:", "client_wants_to_access_the_following_information": "<b>{client}</b> möchte auf die folgenden Informationen zugreifen:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Möchtest du dich bei <b>{client}</b> mit deinem <b>{appName}</b> Konto anmelden?", "do_you_want_to_sign_in_to_client_with_your_app_name_account": "Möchtest du dich bei <b>{client}</b> mit deinem {appName} Konto anmelden?",
"email": "E-Mail", "email": "E-Mail",
"view_your_email_address": "Deine E-Mail-Adresse anzeigen", "view_your_email_address": "Deine E-Mail-Adresse anzeigen",
"profile": "Profil", "profile": "Profil",
@@ -340,11 +340,14 @@
"login_code_email_success": "Der Login-Code wurde an den Benutzer gesendet.", "login_code_email_success": "Der Login-Code wurde an den Benutzer gesendet.",
"send_email": "E-Mail senden", "send_email": "E-Mail senden",
"show_code": "Code anzeigen", "show_code": "Code anzeigen",
"callback_url_description": "URL(s) die von deinem Client bereitgestellt werden. Wildcards (*) werden unterstützt, sollten für bessere Sicherheit jedoch lieber vermieden werden.", "callback_url_description": "URL(s) die von deinem Client bereitgestellt werden. Automatische Ergänzung bei leerem Feld. Wildcards (*) werden unterstützt, sollten für bessere Sicherheit jedoch vermieden werden.",
"logout_callback_url_description": "URL(s) die von deinem Client für die Abmeldung bereitgestellt werden. Wildcards (*) werden unterstützt, sollten für bessere Sicherheit jedoch vermieden werden.",
"api_key_expiration": "API Key Ablauf", "api_key_expiration": "API Key Ablauf",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Sende eine E-Mail an den Benutzer, wenn sein API Key ablaufen wird.", "send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Sende eine E-Mail an den Benutzer, wenn sein API Key ablaufen wird.",
"authorize_device": "Gerät autorisieren", "authorize_device": "Gerät autorisieren",
"the_device_has_been_authorized": "Das Gerät wurde autorisiert.", "the_device_has_been_authorized": "Das Gerät wurde autorisiert.",
"enter_code_displayed_in_previous_step": "Gib den Code ein, der im vorherigen Schritt angezeigt wurde.", "enter_code_displayed_in_previous_step": "Gib den Code ein, der im vorherigen Schritt angezeigt wurde.",
"authorize": "Autorisieren" "authorize": "Autorisieren",
"oidc_allowed_group_count": "Erlaubte Gruppenanzahl",
"unrestricted": "Uneingeschränkt"
} }

View File

@@ -49,7 +49,7 @@
"sign_in_to": "Sign in to {name}", "sign_in_to": "Sign in to {name}",
"client_not_found": "Client not found", "client_not_found": "Client not found",
"client_wants_to_access_the_following_information": "<b>{client}</b> wants to access the following information:", "client_wants_to_access_the_following_information": "<b>{client}</b> wants to access the following information:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Do you want to sign in to <b>{client}</b> with your <b>{appName}</b> account?", "do_you_want_to_sign_in_to_client_with_your_app_name_account": "Do you want to sign in to <b>{client}</b> with your {appName} account?",
"email": "Email", "email": "Email",
"view_your_email_address": "View your email address", "view_your_email_address": "View your email address",
"profile": "Profile", "profile": "Profile",
@@ -61,7 +61,7 @@
"try_again": "Try again", "try_again": "Try again",
"client_logo": "Client Logo", "client_logo": "Client Logo",
"sign_out": "Sign out", "sign_out": "Sign out",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Do you want to sign out of Pocket ID with the account <b>{username}</b>?", "do_you_want_to_sign_out_of_pocketid_with_the_account": "Do you want to sign out of {appName} with the account <b>{username}</b>?",
"sign_in_to_appname": "Sign in to {appName}", "sign_in_to_appname": "Sign in to {appName}",
"please_try_to_sign_in_again": "Please try to sign in again.", "please_try_to_sign_in_again": "Please try to sign in again.",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Authenticate yourself with your passkey to access the admin panel.", "authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Authenticate yourself with your passkey to access the admin panel.",
@@ -340,11 +340,14 @@
"login_code_email_success": "The login code has been sent to the user.", "login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email", "send_email": "Send Email",
"show_code": "Show Code", "show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.", "callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration", "api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.", "send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
"authorize_device": "Authorize Device", "authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.", "the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.", "enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize" "authorize": "Authorize",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
} }

View File

@@ -49,7 +49,7 @@
"sign_in_to": "Iniciar sesión en {name}", "sign_in_to": "Iniciar sesión en {name}",
"client_not_found": "Cliente no encontrado", "client_not_found": "Cliente no encontrado",
"client_wants_to_access_the_following_information": "<b>{client}</b> quiere acceder a la siguiente información:", "client_wants_to_access_the_following_information": "<b>{client}</b> quiere acceder a la siguiente información:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "¿Quieres iniciar sesión en <b>{client}</b> con tu cuenta <b>{appName}</b>?", "do_you_want_to_sign_in_to_client_with_your_app_name_account": "¿Quieres iniciar sesión en <b>{client}</b> con tu cuenta {appName}?",
"email": "Correo electrónico", "email": "Correo electrónico",
"view_your_email_address": "Ver su dirección de correo electrónico", "view_your_email_address": "Ver su dirección de correo electrónico",
"profile": "Perfil", "profile": "Perfil",
@@ -103,94 +103,94 @@
"client": "Cliente", "client": "Cliente",
"unknown": "Desconocido", "unknown": "Desconocido",
"account_details_updated_successfully": "Detalles de la cuenta actualizados exitosamente", "account_details_updated_successfully": "Detalles de la cuenta actualizados exitosamente",
"profile_picture_updated_successfully": "Profile picture updated successfully. It may take a few minutes to update.", "profile_picture_updated_successfully": "Imagen de perfil actualizada correctamente. Puede tardar unos minutos en actualizarse.",
"account_settings": "Account Settings", "account_settings": "Configuración de la cuenta",
"passkey_missing": "Passkey missing", "passkey_missing": "Passkey no encontrada",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Please add a passkey to prevent losing access to your account.", "please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Por favor, añade una clave de acceso o passkey para evitar que pierdas el acceso a tu cuenta.",
"single_passkey_configured": "Single Passkey Configured", "single_passkey_configured": "Clave única configurada",
"it_is_recommended_to_add_more_than_one_passkey": "It is recommended to add more than one passkey to avoid losing access to your account.", "it_is_recommended_to_add_more_than_one_passkey": "Se recomienda añadir más de una clave de acceso para evitar perder el acceso a tu cuenta.",
"account_details": "Account Details", "account_details": "Detalles de la cuenta",
"passkeys": "Passkeys", "passkeys": "Claves de acceso",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Manage your passkeys that you can use to authenticate yourself.", "manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Administra las claves de acceso que te permiten autenticarte.",
"add_passkey": "Add Passkey", "add_passkey": "Añade una clave de acceso",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Create a one-time login code to sign in from a different device without a passkey.", "create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Crea un código de inicio de sesión único para iniciar sesión desde un dispositivo diferente sin una clave.",
"create": "Create", "create": "Crear",
"first_name": "First name", "first_name": "Nombre",
"last_name": "Last name", "last_name": "Apellido",
"username": "Username", "username": "Nombre de usuario",
"save": "Save", "save": "Guardar",
"username_can_only_contain": "Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols", "username_can_only_contain": "El nombre de usuario solo puede contener letras minúsculas, números, guiones bajos, puntos, guiones y símbolos '@'",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Sign in using the following code. The code will expire in 15 minutes.", "sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Inicia sesión usando el siguiente código. El código caducará en 15 minutos.",
"or_visit": "or visit", "or_visit": "o visita",
"added_on": "Added on", "added_on": "Añadido el",
"rename": "Rename", "rename": "Renombrar",
"delete": "Delete", "delete": "Borrar",
"are_you_sure_you_want_to_delete_this_passkey": "Are you sure you want to delete this passkey?", "are_you_sure_you_want_to_delete_this_passkey": "¿Está seguro de que desea eliminar esta passkey?",
"passkey_deleted_successfully": "Passkey deleted successfully", "passkey_deleted_successfully": "Passkey eliminada con éxito",
"delete_passkey_name": "Delete {passkeyName}", "delete_passkey_name": "Borrar {passkeyName}",
"passkey_name_updated_successfully": "Passkey name updated successfully", "passkey_name_updated_successfully": "Nombre de la clave de acceso actualizado correctamente",
"name_passkey": "Name Passkey", "name_passkey": "Nombre para la clave de acceso",
"name_your_passkey_to_easily_identify_it_later": "Name your passkey to easily identify it later.", "name_your_passkey_to_easily_identify_it_later": "Nombra tu clave de acceso para poder identificarla fácilmente más tarde.",
"create_api_key": "Create API Key", "create_api_key": "Crear API Key",
"add_a_new_api_key_for_programmatic_access": "Add a new API key for programmatic access.", "add_a_new_api_key_for_programmatic_access": "Añade una nueva API key para el acceso programático.",
"add_api_key": "Add API Key", "add_api_key": "Añade una API Key",
"manage_api_keys": "Manage API Keys", "manage_api_keys": "Gestiona las API Keys",
"api_key_created": "API Key Created", "api_key_created": "API Key creada",
"for_security_reasons_this_key_will_only_be_shown_once": "For security reasons, this key will only be shown once. Please store it securely.", "for_security_reasons_this_key_will_only_be_shown_once": "Por razones de seguridad, esta clave sólo se mostrará una vez. Por favor, guárdala de forma segura.",
"description": "Description", "description": "Descripción",
"api_key": "API Key", "api_key": "API Key",
"close": "Close", "close": "Cerrar",
"name_to_identify_this_api_key": "Name to identify this API key.", "name_to_identify_this_api_key": "Nombra esta API Key para identificarla.",
"expires_at": "Expires At", "expires_at": "Expira el",
"when_this_api_key_will_expire": "When this API key will expire.", "when_this_api_key_will_expire": "Cuando esta clave de API caducará.",
"optional_description_to_help_identify_this_keys_purpose": "Optional description to help identify this key's purpose.", "optional_description_to_help_identify_this_keys_purpose": "Descripción opcional para ayudar a identificar el propósito de esta clave.",
"name_must_be_at_least_3_characters": "Name must be at least 3 characters", "name_must_be_at_least_3_characters": "El nombre debe tener al menos 3 caracteres",
"name_cannot_exceed_50_characters": "Name cannot exceed 50 characters", "name_cannot_exceed_50_characters": "El nombre no puede exceder los 50 caracteres",
"expiration_date_must_be_in_the_future": "Expiration date must be in the future", "expiration_date_must_be_in_the_future": "La fecha de caducidad debe ser en el futuro",
"revoke_api_key": "Revoke API Key", "revoke_api_key": "Invalidar API Key",
"never": "Never", "never": "Nunca",
"revoke": "Revoke", "revoke": "Invalidar",
"api_key_revoked_successfully": "API key revoked successfully", "api_key_revoked_successfully": "La clave API se ha revocado con éxito",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Are you sure you want to revoke the API key \"{apiKeyName}\"? This will break any integrations using this key.", "are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "¿Estás seguro de que deseas invalidar la API Key \"{apiKeyName}\"? Esto romperá cualquier integración que esté usando esta clave.",
"last_used": "Last Used", "last_used": "Utilizado por última vez",
"actions": "Actions", "actions": "Acciones",
"images_updated_successfully": "Images updated successfully", "images_updated_successfully": "Imágenes actualizadas correctamente",
"general": "General", "general": "General",
"configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.", "configure_smtp_to_send_emails": "Habilita las notificaciones por correo electrónico para alertar a los usuarios cuando se detecta un inicio de sesión desde un nuevo dispositivo o ubicación.",
"ldap": "LDAP", "ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.", "configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configura los ajustes LDAP para sincronizar usuarios y grupos desde un servidor LDAP.",
"images": "Images", "images": "Imágenes",
"update": "Update", "update": "Actualización",
"email_configuration_updated_successfully": "Email configuration updated successfully", "email_configuration_updated_successfully": "Configuración de correo electrónico actualizada correctamente",
"save_changes_question": "Save changes?", "save_changes_question": "¿Guardar los cambios?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "You have to save the changes before sending a test email. Do you want to save now?", "you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "Tienes que guardar los cambios antes de enviar un correo electrónico de prueba. ¿Quieres guardar ahora?",
"save_and_send": "Save and send", "save_and_send": "Guardar y enviar",
"test_email_sent_successfully": "Test email sent successfully to your email address.", "test_email_sent_successfully": "Correo electrónico de prueba enviado con éxito a tu dirección de correo electrónico.",
"failed_to_send_test_email": "Failed to send test email. Check the server logs for more information.", "failed_to_send_test_email": "Error al enviar el email de prueba. Revisa los registros del servidor para más información.",
"smtp_configuration": "SMTP Configuration", "smtp_configuration": "Configuración SMTP",
"smtp_host": "SMTP Host", "smtp_host": "Servidor SMTP",
"smtp_port": "SMTP Port", "smtp_port": "Puerto SMTP",
"smtp_user": "SMTP User", "smtp_user": "Usuario SMTP",
"smtp_password": "SMTP Password", "smtp_password": "Contraseña SMTP",
"smtp_from": "SMTP From", "smtp_from": "SMTP From",
"smtp_tls_option": "SMTP TLS Option", "smtp_tls_option": "SMTP TLS Option",
"email_tls_option": "Email TLS Option", "email_tls_option": "Email TLS Option",
"skip_certificate_verification": "Skip Certificate Verification", "skip_certificate_verification": "Omitir la verificación del certificado",
"this_can_be_useful_for_selfsigned_certificates": "This can be useful for self-signed certificates.", "this_can_be_useful_for_selfsigned_certificates": "Esto puede ser útil para certificados autofirmados.",
"enabled_emails": "Enabled Emails", "enabled_emails": "Enabled Emails",
"email_login_notification": "Email Login Notification", "email_login_notification": "Email Login Notification",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send an email to the user when they log in from a new device.", "send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Enviar un correo electrónico al usuario cuando inicie sesión desde un dispositivo nuevo.",
"emai_login_code_requested_by_user": "Email Login Code Requested by User", "emai_login_code_requested_by_user": "Código de acceso solicitado por el usuario",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.", "allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Permite a los usuarios saltarse las claves de acceso solicitando un código de acceso enviado a su correo electrónico. Esto reduce la seguridad significativamente, ya que cualquiera con acceso al correo electrónico del usuario puede obtener acceso.",
"email_login_code_from_admin": "Email Login Code from Admin", "email_login_code_from_admin": "Email Login Code from Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.", "allows_an_admin_to_send_a_login_code_to_the_user": "Permite a un administrador enviar un código de acceso al usuario por correo electrónico.",
"send_test_email": "Send test email", "send_test_email": "Enviar correo de prueba",
"application_configuration_updated_successfully": "Application configuration updated successfully", "application_configuration_updated_successfully": "Configuración actualizada correctamente",
"application_name": "Application Name", "application_name": "Nombre de la aplicación",
"session_duration": "Session Duration", "session_duration": "Duración de la sesión",
"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.", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "La duración de una sesn en minutos antes de que el usuario tenga que iniciar sesión de nuevo.",
"enable_self_account_editing": "Enable Self-Account Editing", "enable_self_account_editing": "Enable Self-Account Editing",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Whether the users should be able to edit their own account details.", "whether_the_users_should_be_able_to_edit_their_own_account_details": "Si los usuarios deberían poder editar los detalles de su propia cuenta.",
"emails_verified": "Emails Verified", "emails_verified": "Emails Verified",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Whether the user's email should be marked as verified for the OIDC clients.", "whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Whether the user's email should be marked as verified for the OIDC clients.",
"ldap_configuration_updated_successfully": "LDAP configuration updated successfully", "ldap_configuration_updated_successfully": "LDAP configuration updated successfully",
@@ -208,8 +208,8 @@
"attribute_mapping": "Attribute Mapping", "attribute_mapping": "Attribute Mapping",
"user_unique_identifier_attribute": "User Unique Identifier Attribute", "user_unique_identifier_attribute": "User Unique Identifier Attribute",
"the_value_of_this_attribute_should_never_change": "The value of this attribute should never change.", "the_value_of_this_attribute_should_never_change": "The value of this attribute should never change.",
"username_attribute": "Username Attribute", "username_attribute": "Atributo Nombre de usuario",
"user_mail_attribute": "User Mail Attribute", "user_mail_attribute": "Atributo de Correo de Usuario",
"user_first_name_attribute": "User First Name Attribute", "user_first_name_attribute": "User First Name Attribute",
"user_last_name_attribute": "User Last Name Attribute", "user_last_name_attribute": "User Last Name Attribute",
"user_profile_picture_attribute": "User Profile Picture Attribute", "user_profile_picture_attribute": "User Profile Picture Attribute",
@@ -340,11 +340,14 @@
"login_code_email_success": "The login code has been sent to the user.", "login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email", "send_email": "Send Email",
"show_code": "Show Code", "show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.", "callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration", "api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.", "send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
"authorize_device": "Authorize Device", "authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.", "the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.", "enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize" "authorize": "Authorize",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
} }

View File

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

View File

@@ -49,7 +49,7 @@
"sign_in_to": "Accedi a {name}", "sign_in_to": "Accedi a {name}",
"client_not_found": "Client non trovato", "client_not_found": "Client non trovato",
"client_wants_to_access_the_following_information": "<b>{client}</b> vuole accedere alle seguenti informazioni:", "client_wants_to_access_the_following_information": "<b>{client}</b> vuole accedere alle seguenti informazioni:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Vuoi accedere a <b>{client}</b> con il tuo account <b>{appName}</b>?", "do_you_want_to_sign_in_to_client_with_your_app_name_account": "Vuoi accedere a <b>{client}</b> con il tuo account {appName}?",
"email": "Email", "email": "Email",
"view_your_email_address": "Visualizza il tuo indirizzo email", "view_your_email_address": "Visualizza il tuo indirizzo email",
"profile": "Profilo", "profile": "Profilo",
@@ -340,11 +340,14 @@
"login_code_email_success": "Il codice di accesso è stato inviato all'utente.", "login_code_email_success": "Il codice di accesso è stato inviato all'utente.",
"send_email": "Invia email", "send_email": "Invia email",
"show_code": "Mostra codice", "show_code": "Mostra codice",
"callback_url_description": "URL forniti dal tuo client. Wildcard (*) sono supportati, ma meglio evitarli per una migliore sicurezza.", "callback_url_description": "URL forniti dal client. Verrà automaticamente aggiunto se lasciato vuoto. I caratteri jolly (*) sono supportati, ma è meglio evitarli per maggiore sicurezza.",
"logout_callback_url_description": "URL forniti dal client per il logout. I caratteri jolly (*) sono supportati, ma meglio evitarli per una migliore sicurezza.",
"api_key_expiration": "Scadenza Chiave API", "api_key_expiration": "Scadenza Chiave API",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Invia un'email all'utente quando la sua chiave API sta per scadere.", "send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Invia un'email all'utente quando la sua chiave API sta per scadere.",
"authorize_device": "Autorizza Dispositivo", "authorize_device": "Autorizza Dispositivo",
"the_device_has_been_authorized": "Il dispositivo è stato autorizzato.", "the_device_has_been_authorized": "Il dispositivo è stato autorizzato.",
"enter_code_displayed_in_previous_step": "Inserisci il codice visualizzato nel passaggio precedente.", "enter_code_displayed_in_previous_step": "Inserisci il codice visualizzato nel passaggio precedente.",
"authorize": "Autorizza" "authorize": "Autorizza",
"oidc_allowed_group_count": "Numero Gruppi Consentiti",
"unrestricted": "Illimitati"
} }

View File

@@ -49,7 +49,7 @@
"sign_in_to": "Meld u aan bij {name}", "sign_in_to": "Meld u aan bij {name}",
"client_not_found": "Client niet gevonden", "client_not_found": "Client niet gevonden",
"client_wants_to_access_the_following_information": "<b>{client}</b> wil toegang tot de volgende informatie:", "client_wants_to_access_the_following_information": "<b>{client}</b> wil toegang tot de volgende informatie:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Wilt u zich aanmelden bij <b>{client}</b> met uw <b>{appName}</b> account?", "do_you_want_to_sign_in_to_client_with_your_app_name_account": "Wilt u zich aanmelden bij <b>{client}</b> met uw {appName} account?",
"email": "E-mail", "email": "E-mail",
"view_your_email_address": "Bekijk uw e-mailadres", "view_your_email_address": "Bekijk uw e-mailadres",
"profile": "Profiel", "profile": "Profiel",
@@ -340,11 +340,14 @@
"login_code_email_success": "The login code has been sent to the user.", "login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email", "send_email": "Send Email",
"show_code": "Show Code", "show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.", "callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration", "api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.", "send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
"authorize_device": "Authorize Device", "authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.", "the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.", "enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize" "authorize": "Authorize",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
} }

353
frontend/messages/pl.json Normal file
View File

@@ -0,0 +1,353 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "Moje konto",
"logout": "Wyloguj się",
"confirm": "Potwierdź",
"key": "Klucz",
"value": "Wartość",
"remove_custom_claim": "Usuń niestandardowy atrybut",
"add_custom_claim": "Dodaj niestandardowy atrybut",
"add_another": "Dodaj kolejny",
"select_a_date": "Wybierz datę",
"select_file": "Wybierz plik",
"profile_picture": "Zdjęcie profilowe",
"profile_picture_is_managed_by_ldap_server": "Zdjęcie profilowe jest zarządzane przez serwer LDAP i nie można go tutaj zmienić.",
"click_profile_picture_to_upload_custom": "Kliknij zdjęcie profilowe, aby przesłać własne z plików.",
"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",
"search": "Szukaj...",
"expand_card": "Rozwiń kartę",
"copied": "Skopiowano",
"click_to_copy": "Kliknij, aby skopiować",
"something_went_wrong": "Coś poszło nie tak",
"go_back_to_home": "Wróć do strony głównej",
"dont_have_access_to_your_passkey": "Nie masz dostępu do swojego klucza?",
"login_background": "Tło logowania",
"logo": "Logo",
"login_code": "Kod logowania",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Utwórz kod logowania, aby użytkownik mógł się zalogować bez klucza jednorazowo.",
"one_hour": "1 godzina",
"twelve_hours": "12 godzin",
"one_day": "1 dzień",
"one_week": "1 tydzień",
"one_month": "1 miesiąc",
"expiration": "Wygaśnięcie",
"generate_code": "Wygeneruj kod",
"name": "Nazwa",
"browser_unsupported": "Przeglądarka nieobsługiwana",
"this_browser_does_not_support_passkeys": "Ta przeglądarka nie obsługuje kluczy. Użyj innej metody logowania.",
"an_unknown_error_occurred": "Wystąpił nieznany błąd",
"authentication_process_was_aborted": "Proces uwierzytelniania został przerwany",
"error_occurred_with_authenticator": "Wystąpił błąd z autoryzatorem",
"authenticator_does_not_support_discoverable_credentials": "Autoryzator nie obsługuje wykrywalnych poświadczeń",
"authenticator_does_not_support_resident_keys": "Autoryzator nie obsługuje kluczy rezydentnych",
"passkey_was_previously_registered": "Ten klucz był już wcześniej zarejestrowany",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Autoryzator nie obsługuje żadnego z żądanych algorytmów",
"authenticator_timed_out": "Czas autoryzatora upłynął",
"critical_error_occurred_contact_administrator": "Wystąpił krytyczny błąd. Skontaktuj się z administratorem.",
"sign_in_to": "Zaloguj się do {name}",
"client_not_found": "Nie znaleziono klienta",
"client_wants_to_access_the_following_information": "<b>{client}</b> chce uzyskać dostęp do następujących informacji:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Czy chcesz zalogować się do <b>{client}</b> używając konta {appName}?",
"email": "E-mail",
"view_your_email_address": "Zobacz swój adres e-mail",
"profile": "Profil",
"view_your_profile_information": "Zobacz informacje o swoim profilu",
"groups": "Grupy",
"view_the_groups_you_are_a_member_of": "Zobacz grupy, do których należysz",
"cancel": "Anuluj",
"sign_in": "Zaloguj się",
"try_again": "Spróbuj ponownie",
"client_logo": "Logo klienta",
"sign_out": "Wyloguj się",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Czy chcesz się wylogować z Pocket ID z konta <b>{username}</b>?",
"sign_in_to_appname": "Zaloguj się do {appName}",
"please_try_to_sign_in_again": "Spróbuj zalogować się ponownie.",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Uwierzytelnij się swoim kluczem, aby uzyskać dostęp do panelu administracyjnego.",
"authenticate": "Uwierzytelnij",
"appname_setup": "Konfiguracja {appName}",
"please_try_again": "Spróbuj ponownie.",
"you_are_about_to_sign_in_to_the_initial_admin_account": "Zaraz zalogujesz się na początkowe konto administratora. Każdy z tym linkiem ma dostęp do konta, dopóki nie zostanie dodany klucz. Dodaj klucz jak najszybciej, aby zapobiec nieautoryzowanemu dostępowi.",
"continue": "Kontynuuj",
"alternative_sign_in": "Alternatywne logowanie",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Jeśli nie masz dostępu do swojego klucza, możesz zalogować się, używając jednej z następujących metod.",
"use_your_passkey_instead": "Użyj swojego klucza zamiast tego?",
"email_login": "Logowanie przez e-mail",
"enter_a_login_code_to_sign_in": "Wprowadź kod logowania, aby się zalogować.",
"request_a_login_code_via_email": "Poproś o kod logowania przez e-mail.",
"go_back": "Wróć",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "E-mail został wysłany na podany adres, jeśli istnieje w systemie.",
"enter_code": "Wprowadź kod",
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Wprowadź swój adres e-mail, aby otrzymać e-mail z kodem logowania.",
"your_email": "Twój e-mail",
"submit": "Wyślij",
"enter_the_code_you_received_to_sign_in": "Wprowadź kod, który otrzymałeś, aby się zalogować.",
"code": "Kod",
"invalid_redirect_url": "Nieprawidłowy adres URL przekierowania",
"audit_log": "Dziennik audytu",
"users": "Użytkownicy",
"user_groups": "Grupy użytkowników",
"oidc_clients": "Klienci OIDC",
"api_keys": "Klucze API",
"application_configuration": "Konfiguracja aplikacji",
"settings": "Ustawienia",
"update_pocket_id": "Aktualizuj Pocket ID",
"powered_by": "Zasilane przez",
"see_your_account_activities_from_the_last_3_months": "Zobacz aktywności swojego konta z ostatnich 3 miesięcy.",
"time": "Czas",
"event": "Wydarzenie",
"approximate_location": "Przybliżona lokalizacja",
"ip_address": "Adres IP",
"device": "Urządzenie",
"client": "Klient",
"unknown": "Nieznany",
"account_details_updated_successfully": "Sukces! Szczegóły konta zostały zaktualizowane.",
"profile_picture_updated_successfully": "Sukces! Zdjęcie profilowe zostało zaktualizowane. Może to potrwać kilka minut.",
"account_settings": "Ustawienia konta",
"passkey_missing": "Brak klucza",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Dodaj klucz, aby uniknąć utraty dostępu do swojego konta.",
"single_passkey_configured": "Skonfigurowano pojedynczy klucz",
"it_is_recommended_to_add_more_than_one_passkey": "Zaleca się dodanie więcej niż jednego klucza, aby uniknąć utraty dostępu do konta.",
"account_details": "Szczegóły konta",
"passkeys": "Klucze",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Zarządzaj swoimi kluczami, których możesz użyć do uwierzytelnienia siebie.",
"add_passkey": "Dodaj klucz",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Utwórz jednorazowy kod logowania, aby zalogować się z innego urządzenia bez klucza.",
"create": "Utwórz",
"first_name": "Imię",
"last_name": "Nazwisko",
"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 '@'",
"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",
"rename": "Zmień nazwę",
"delete": "Usuń",
"are_you_sure_you_want_to_delete_this_passkey": "Czy na pewno chcesz usunąć ten klucz?",
"passkey_deleted_successfully": "Sukces! Klucz został usunięty.",
"delete_passkey_name": "Usuń {passkeyName}",
"passkey_name_updated_successfully": "Sukces! Nazwa klucza została zaktualizowana.",
"name_passkey": "Nazwa klucza",
"name_your_passkey_to_easily_identify_it_later": "Nazwij swój klucz, aby łatwo go zidentyfikować później.",
"create_api_key": "Utwórz klucz API",
"add_a_new_api_key_for_programmatic_access": "Dodaj nowy klucz API dla dostępu programowego.",
"add_api_key": "Dodaj klucz API",
"manage_api_keys": "Zarządzaj kluczami API",
"api_key_created": "Sukces! Klucz API został utworzony.",
"for_security_reasons_this_key_will_only_be_shown_once": "Z powodów bezpieczeństwa ten klucz zostanie pokazany tylko raz. Proszę przechować go w bezpiecznym miejscu.",
"description": "Opis",
"api_key": "Klucz API",
"close": "Zamknij",
"name_to_identify_this_api_key": "Nazwa do identyfikacji tego klucza API.",
"expires_at": "Wygasa o",
"when_this_api_key_will_expire": "Kiedy ten klucz API wygaśnie.",
"optional_description_to_help_identify_this_keys_purpose": "Opcjonalny opis, aby pomóc zidentyfikować cel tego klucza.",
"name_must_be_at_least_3_characters": "Nazwa musi mieć co najmniej 3 znaki",
"name_cannot_exceed_50_characters": "Nazwa nie może przekraczać 50 znaków",
"expiration_date_must_be_in_the_future": "Data wygaśnięcia musi być w przyszłości",
"revoke_api_key": "Unieważnij klucz API",
"never": "Nigdy",
"revoke": "Unieważnij",
"api_key_revoked_successfully": "Sukces! Klucz API został unieważniony.",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Czy na pewno chcesz unieważnić klucz API \"{apiKeyName}\"? To przerwie wszelkie integracje korzystające z tego klucza.",
"last_used": "Ostatnio używane",
"actions": "Akcje",
"images_updated_successfully": "Sukces! Obrazy zostały zaktualizowane.",
"general": "Ogólne",
"configure_smtp_to_send_emails": "Włącz powiadomienia e-mail, aby informować użytkowników, gdy logowanie zostanie wykryte z nowego urządzenia lub lokalizacji.",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Skonfiguruj ustawienia LDAP, aby synchronizować użytkowników i grupy z serwera LDAP.",
"images": "Obrazy",
"update": "Aktualizuj",
"email_configuration_updated_successfully": "Sukces! Konfiguracja e-mail została zaktualizowana.",
"save_changes_question": "Zapisz zmiany?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "Musisz zapisać zmiany przed wysłaniem testowego e-maila. Czy chcesz zapisać teraz?",
"save_and_send": "Zapisz i wyślij",
"test_email_sent_successfully": "Sukces! Testowy e-mail został wysłany na twój adres e-mail.",
"failed_to_send_test_email": "Nie udało się wysłać testowego e-maila. Sprawdź dzienniki serwera, aby uzyskać więcej informacji.",
"smtp_configuration": "Konfiguracja SMTP",
"smtp_host": "Host SMTP",
"smtp_port": "Port SMTP",
"smtp_user": "Użytkownik SMTP",
"smtp_password": "Hasło SMTP",
"smtp_from": "SMTP Od",
"smtp_tls_option": "Opcja TLS SMTP",
"email_tls_option": "Opcja TLS e-mail",
"skip_certificate_verification": "Pomiń weryfikację certyfikatu",
"this_can_be_useful_for_selfsigned_certificates": "To może być przydatne w przypadku certyfikatów samopodpisanych.",
"enabled_emails": "Włączone e-maile",
"email_login_notification": "Powiadomienie o logowaniu przez e-mail",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Wyślij e-mail do użytkownika, gdy zaloguje się z nowego urządzenia.",
"emai_login_code_requested_by_user": "Kod logowania e-mailem zażądany przez użytkownika",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Pozwól użytkownikom zalogować się za pomocą kodu logowania wysłanego na ich e-mail. Znacząco obniża to bezpieczeństwo, ponieważ każdy, kto ma dostęp do e-maila użytkownika, może uzyskać dostęp.",
"email_login_code_from_admin": "Kod logowania e-mailem od administratora",
"allows_an_admin_to_send_a_login_code_to_the_user": "Pozwala administratorowi wysłać kod logowania do użytkownika za pomocą e-maila.",
"send_test_email": "Wyślij testowy e-mail",
"application_configuration_updated_successfully": "Sukces! Konfiguracja aplikacji została zaktualizowana.",
"application_name": "Nazwa aplikacji",
"session_duration": "Czas trwania sesji",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Czas trwania sesji w minutach, zanim użytkownik będzie musiał ponownie się zalogować.",
"enable_self_account_editing": "Włącz edytowanie konta przez użytkownika",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Czy użytkownicy powinni mieć możliwość edytowania szczegółów swojego konta.",
"emails_verified": "E-maile zweryfikowane",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Czy adres e-mail użytkownika powinien być oznaczony jako zweryfikowany dla klientów OIDC.",
"ldap_configuration_updated_successfully": "Sukces! Konfiguracja LDAP została zaktualizowana.",
"ldap_disabled_successfully": "Sukces! LDAP został wyłączony.",
"ldap_sync_finished": "Synchronizacja LDAP zakończona",
"client_configuration": "Konfiguracja klienta",
"ldap_url": "URL LDAP",
"ldap_bind_dn": "DN powiązania LDAP",
"ldap_bind_password": "Hasło powiązania LDAP",
"ldap_base_dn": "Podstawowy DN LDAP",
"user_search_filter": "Filtr wyszukiwania użytkownika",
"the_search_filter_to_use_to_search_or_sync_users": "Filtr wyszukiwania do użycia w celu wyszukiwania/synchronizacji użytkowników.",
"groups_search_filter": "Filtr wyszukiwania grup",
"the_search_filter_to_use_to_search_or_sync_groups": "Filtr wyszukiwania do użycia w celu wyszukiwania/synchronizacji grup.",
"attribute_mapping": "Mapowanie atrybutów",
"user_unique_identifier_attribute": "Atrybut unikalnego identyfikatora użytkownika",
"the_value_of_this_attribute_should_never_change": "Wartość tego atrybutu nigdy nie powinna się zmieniać.",
"username_attribute": "Atrybut nazwy użytkownika",
"user_mail_attribute": "Atrybut maila użytkownika",
"user_first_name_attribute": "Atrybut imienia użytkownika",
"user_last_name_attribute": "Atrybut nazwiska użytkownika",
"user_profile_picture_attribute": "Atrybut zdjęcia profilowego użytkownika",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "Wartość tego atrybutu może być adresem URL, binarnym obrazem lub obrazem zakodowanym w base64.",
"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",
"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",
"sync_now": "Synchronizuj teraz",
"enable": "Włącz",
"user_created_successfully": "Sukces! Użytkownik został utworzony.",
"create_user": "Utwórz użytkownika",
"add_a_new_user_to_appname": "Dodaj nowego użytkownika do {appName}",
"add_user": "Dodaj użytkownika",
"manage_users": "Zarządzaj użytkownikami",
"admin_privileges": "Uprawnienia administratora",
"admins_have_full_access_to_the_admin_panel": "Administratorzy mają pełny dostęp do panelu administracyjnego.",
"delete_firstname_lastname": "Usuń {firstName} {lastName}",
"are_you_sure_you_want_to_delete_this_user": "Czy na pewno chcesz usunąć tego użytkownika?",
"user_deleted_successfully": "Sukces! Użytkownik został usunięty.",
"role": "Rola",
"source": "Źródło",
"admin": "Administrator",
"user": "Użytkownik",
"local": "Lokalny",
"toggle_menu": "Przełącz menu",
"edit": "Edytuj",
"user_groups_updated_successfully": "Sukces! Grupy użytkowników zostały zaktualizowane.",
"user_updated_successfully": "Sukces! Użytkownik został zaktualizowany.",
"custom_claims_updated_successfully": "Sukces! Niestandardowe atrybuty zostały zaktualizowane.",
"back": "Wstecz",
"user_details_firstname_lastname": "Szczegóły użytkownika {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Zarządzaj, do jakich grup należy ten użytkownik.",
"custom_claims": "Niestandardowe atrybuty",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Niestandardowe atrybuty to pary klucz-wartość, które można wykorzystać do przechowywania dodatkowych informacji o użytkowniku. Atrybuty te będą dołączane do tokena ID, jeśli żądany jest zakres 'profil'.",
"user_group_created_successfully": "Sukces! Grupa użytkowników została utworzona.",
"create_user_group": "Utwórz grupę użytkowników",
"create_a_new_group_that_can_be_assigned_to_users": "Utwórz nową grupę, która może być przypisana do użytkowników.",
"add_group": "Dodaj grupę",
"manage_user_groups": "Zarządzaj grupami użytkowników",
"friendly_name": "Przyjazna nazwa",
"name_that_will_be_displayed_in_the_ui": "Nazwa, która będzie wyświetlana w interfejsie użytkownika",
"name_that_will_be_in_the_groups_claim": "Nazwa, która będzie w atrybucie \"grupy\"",
"delete_name": "Usuń {name}",
"are_you_sure_you_want_to_delete_this_user_group": "Czy na pewno chcesz usunąć tę grupę użytkowników?",
"user_group_deleted_successfully": "Sukces! Grupa użytkowników została usunięta.",
"user_count": "Liczba użytkowników",
"user_group_updated_successfully": "Sukces! Grupa użytkowników została zaktualizowana.",
"users_updated_successfully": "Sukces! Użytkownicy zostali zaktualizowani.",
"user_group_details_name": "Szczegóły grupy użytkowników {name}",
"assign_users_to_this_group": "Przypisz użytkowników do tej grupy.",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Niestandardowe atrybuty to pary klucz-wartość, które można wykorzystać do przechowywania dodatkowych informacji o użytkowniku. Atrybuty te będą dołączane do tokena ID, jeśli żądany jest zakres 'profil'. Niestandardowe atrybuty zdefiniowane dla użytkownika będą miały priorytet w przypadku konfliktów.",
"oidc_client_created_successfully": "Sukces! Klient OIDC został utworzony.",
"create_oidc_client": "Utwórz klienta OIDC",
"add_a_new_oidc_client_to_appname": "Dodaj nowego klienta OIDC do {appName}.",
"add_oidc_client": "Dodaj klienta OIDC",
"manage_oidc_clients": "Zarządzaj klientami OIDC",
"one_time_link": "Link jednorazowy",
"use_this_link_to_sign_in_once": "Użyj tego linku, aby zalogować się jednorazowo. Jest to potrzebne dla użytkowników, którzy jeszcze nie dodali klucza lub go zgubili.",
"add": "Dodaj",
"callback_urls": "URL-e zwrotne",
"logout_callback_urls": "URL-e zwrotne po wylogowaniu",
"public_client": "Klient publiczny",
"public_clients_description": "Klienci publiczni nie mają tajnego klucza. Są zaprojektowane dla aplikacji mobilnych, webowych i natywnych, gdzie tajne klucze nie mogą być bezpiecznie przechowywane.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Wymiana kodu publicznego klucza to funkcja zabezpieczająca, która zapobiega atakom CSRF i przechwytywaniu kodu autoryzacyjnego.",
"name_logo": "Logo {name}",
"change_logo": "Zmień logo",
"upload_logo": "Prześlij logo",
"remove_logo": "Usuń logo",
"are_you_sure_you_want_to_delete_this_oidc_client": "Czy na pewno chcesz usunąć tego klienta OIDC?",
"oidc_client_deleted_successfully": "Sukces! Klient OIDC został usunięty.",
"authorization_url": "URL autoryzacji",
"oidc_discovery_url": "URL odkrywania OIDC",
"token_url": "URL tokena",
"userinfo_url": "URL informacji o użytkowniku",
"logout_url": "URL wylogowania",
"certificate_url": "URL certyfikatu",
"enabled": "Włączony",
"disabled": "Wyłączony",
"oidc_client_updated_successfully": "Sukces! Klient OIDC został zaktualizowany.",
"create_new_client_secret": "Utwórz nowy tajny klucz klienta",
"are_you_sure_you_want_to_create_a_new_client_secret": "Czy na pewno chcesz utworzyć nowy tajny klucz klienta? Stary zostanie unieważniony.",
"generate": "Generuj",
"new_client_secret_created_successfully": "Sukces! Nowy tajny klucz klienta został utworzony.",
"allowed_user_groups_updated_successfully": "Sukces! Dozwolone grupy użytkowników zostały zaktualizowane.",
"oidc_client_name": "Klient OIDC {name}",
"client_id": "ID klienta",
"client_secret": "Tajny klucz klienta",
"show_more_details": "Pokaż więcej szczegółów",
"allowed_user_groups": "Dozwolone grupy użytkowników",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Dodaj grupy użytkowników do tego klienta, aby ograniczyć dostęp do użytkowników w tych grupach. Jeśli nie wybrano żadnych grup użytkowników, wszyscy użytkownicy będą mieli dostęp do tego klienta.",
"favicon": "Ikona ulubionych",
"light_mode_logo": "Logo w trybie jasnym",
"dark_mode_logo": "Logo w trybie ciemnym",
"background_image": "Obraz tła",
"language": "Język",
"reset_profile_picture_question": "Zresetować zdjęcie profilowe?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "To usunie przesłany obraz i zresetuje zdjęcie profilowe do domyślnego. Czy chcesz kontynuować?",
"reset": "Zresetuj",
"reset_to_default": "Zresetuj do domyślnych",
"profile_picture_has_been_reset": "Zdjęcie profilowe zostało zresetowane. Może to potrwać kilka minut.",
"select_the_language_you_want_to_use": "Wybierz język, którego chcesz używać. Niektóre języki mogą nie być w pełni przetłumaczone.",
"personal": "Osobiste",
"global": "Globalne",
"all_users": "Wszyscy użytkownicy",
"all_events": "Wszystkie wydarzenia",
"all_clients": "Wszyscy klienci",
"global_audit_log": "Globalny dziennik audytu",
"see_all_account_activities_from_the_last_3_months": "Zobacz wszystkie działania użytkowników z ostatnich 3 miesięcy.",
"token_sign_in": "Logowanie za pomocą tokena",
"client_authorization": "Autoryzacja klienta",
"new_client_authorization": "Nowa autoryzacja klienta",
"disable_animations": "Wyłącz animacje",
"turn_off_all_animations_throughout_the_admin_ui": "Wyłącz wszystkie animacje w całym interfejsie administracyjnym.",
"user_disabled": "Konto wyłączone",
"disabled_users_cannot_log_in_or_use_services": "Wyłączone konta użytkowników nie mogą się logować ani korzystać z usług.",
"user_disabled_successfully": "Sukces! Konto zostało wyłączone.",
"user_enabled_successfully": "Sukces! Konto zostało włączone.",
"status": "Status",
"disable_firstname_lastname": "Wyłącz {firstName} {lastName}",
"are_you_sure_you_want_to_disable_this_user": "Czy na pewno chcesz wyłączyć tego użytkownika? Nie będą mogli się zalogować ani uzyskać dostępu do żadnych usług.",
"ldap_soft_delete_users": "Zachowaj wyłączonych użytkowników z LDAP.",
"ldap_soft_delete_users_description": "Gdy jest włączone, użytkownicy usunięci z LDAP będą wyłączani, a nie usuwani z systemu.",
"login_code_email_success": "Kod logowania został wysłany do użytkownika.",
"send_email": "Wyślij e-mail",
"show_code": "Pokaż kod",
"callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "Wygaszenie klucza API",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Wyślij e-mail do użytkownika, gdy jego klucz API ma wygasnąć.",
"authorize_device": "Autoryzuj urządzenie",
"the_device_has_been_authorized": "Urządzenie zostało autoryzowane.",
"enter_code_displayed_in_previous_step": "Wprowadź kod wyświetlony w poprzednim kroku.",
"authorize": "Autoryzuj",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
}

View File

@@ -49,7 +49,7 @@
"sign_in_to": "Entrar em {name}", "sign_in_to": "Entrar em {name}",
"client_not_found": "Cliente não encontrado", "client_not_found": "Cliente não encontrado",
"client_wants_to_access_the_following_information": "<b>{client}</b> quer acessar as seguintes informações:", "client_wants_to_access_the_following_information": "<b>{client}</b> quer acessar as seguintes informações:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Você quer entrar em <b>{client}</b> com a sua conta <b>{appName}</b>?", "do_you_want_to_sign_in_to_client_with_your_app_name_account": "Você quer entrar em <b>{client}</b> com a sua conta {appName}?",
"email": "E-mail", "email": "E-mail",
"view_your_email_address": "Ver seu endereço de e-mail", "view_your_email_address": "Ver seu endereço de e-mail",
"profile": "Profile", "profile": "Profile",
@@ -340,11 +340,14 @@
"login_code_email_success": "The login code has been sent to the user.", "login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email", "send_email": "Send Email",
"show_code": "Show Code", "show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.", "callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration", "api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.", "send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
"authorize_device": "Authorize Device", "authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.", "the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.", "enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize" "authorize": "Authorize",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
} }

View File

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

View File

@@ -49,7 +49,7 @@
"sign_in_to": "Вход в {name}", "sign_in_to": "Вход в {name}",
"client_not_found": "Клиент не найден", "client_not_found": "Клиент не найден",
"client_wants_to_access_the_following_information": "<b>{client}</b> запрашивает доступ к следующей информации:", "client_wants_to_access_the_following_information": "<b>{client}</b> запрашивает доступ к следующей информации:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Вы хотите войти в <b>{client}</b> с помощью вашей учетной записи <b>{appName}</b>?", "do_you_want_to_sign_in_to_client_with_your_app_name_account": "Вы хотите войти в <b>{client}</b> с помощью вашей учетной записи {appName}?",
"email": "Электронная почта", "email": "Электронная почта",
"view_your_email_address": "Просмотр адреса электронной почты", "view_your_email_address": "Просмотр адреса электронной почты",
"profile": "Профиль", "profile": "Профиль",
@@ -340,11 +340,14 @@
"login_code_email_success": "Код входа был отправлен пользователю.", "login_code_email_success": "Код входа был отправлен пользователю.",
"send_email": "Отправить письмо", "send_email": "Отправить письмо",
"show_code": "Показать код", "show_code": "Показать код",
"callback_url_description": "URL-адреса, предоставленные клиентом. Поддерживаются wildcard-адреса (*), но лучше всего избегать их для лучшей безопасности.", "callback_url_description": "URL-адрес(а) предоставленные вашим клиентом. Будет автоматически добавлен если оставить пустым. Маски (*) поддерживаются, но лучше избегайте их для повышения безопасности.",
"logout_callback_url_description": "URL-адрес(а), предоставленный вашим клиентом для выхода. Маски (*) поддерживаются, но лучше избегайте их для повышения безопасности.",
"api_key_expiration": "Истечение срока действия API ключа", "api_key_expiration": "Истечение срока действия API ключа",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Отправлять пользователю письмо, когда истечет срок действия API ключа.", "send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Отправлять пользователю письмо, когда истечет срок действия API ключа.",
"authorize_device": "Авторизовать устройство", "authorize_device": "Авторизовать устройство",
"the_device_has_been_authorized": "Устройство авторизовано.", "the_device_has_been_authorized": "Устройство авторизовано.",
"enter_code_displayed_in_previous_step": "Введите код, который был отображен на предыдущем шаге.", "enter_code_displayed_in_previous_step": "Введите код, который был отображен на предыдущем шаге.",
"authorize": "Авторизируйте" "authorize": "Авторизируйте",
"oidc_allowed_group_count": "Кол-во разрешенных групп",
"unrestricted": "Не ограничено"
} }

View File

@@ -49,7 +49,7 @@
"sign_in_to": "登录到 {name}", "sign_in_to": "登录到 {name}",
"client_not_found": "客户端未找到", "client_not_found": "客户端未找到",
"client_wants_to_access_the_following_information": "<b>{client}</b> 希望访问以下信息:", "client_wants_to_access_the_following_information": "<b>{client}</b> 希望访问以下信息:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "您是否希望使用您的 <b>{appName}</b> 账户登录到 <b>{client}</b>", "do_you_want_to_sign_in_to_client_with_your_app_name_account": "您是否希望使用您的 {appName} 账户登录到 <b>{client}</b>",
"email": "电子邮件", "email": "电子邮件",
"view_your_email_address": "查看您的电子邮件地址", "view_your_email_address": "查看您的电子邮件地址",
"profile": "个人资料", "profile": "个人资料",
@@ -341,6 +341,13 @@
"send_email": "发送电子邮件", "send_email": "发送电子邮件",
"show_code": "显示登录码", "show_code": "显示登录码",
"callback_url_description": "由您的客户端提供的 URL。支持通配符 (*),但为了更好的安全性最好避免使用。", "callback_url_description": "由您的客户端提供的 URL。支持通配符 (*),但为了更好的安全性最好避免使用。",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API 密钥过期", "api_key_expiration": "API 密钥过期",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "当用户的 API 密钥即将过期时,向其发送电子邮件。" "send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "当用户的 API 密钥即将过期时,向其发送电子邮件。",
"authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.52.0", "version": "1.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -13,48 +13,47 @@
"format": "prettier --write ." "format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@lucide/svelte": "^0.511.0",
"@simplewebauthn/browser": "^13.1.0", "@simplewebauthn/browser": "^13.1.0",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.1.7",
"axios": "^1.8.2", "axios": "^1.8.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto": "^1.0.1", "crypto": "^1.0.1",
"formsnap": "^1.0.1",
"jose": "^5.9.6", "jose": "^5.9.6",
"lucide-svelte": "^0.487.0",
"mode-watcher": "^0.5.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"svelte-sonner": "^0.3.28",
"sveltekit-superforms": "^2.23.1", "sveltekit-superforms": "^2.23.1",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^3.3.0",
"tailwind-variants": "^0.3.1",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@inlang/paraglide-js": "^2.0.0", "@inlang/paraglide-js": "^2.0.13",
"@inlang/plugin-m-function-matcher": "^2.0.7", "@inlang/plugin-m-function-matcher": "^2.0.10",
"@inlang/plugin-message-format": "^4.0.0", "@inlang/plugin-message-format": "^4.0.0",
"@internationalized/date": "^3.7.0", "@internationalized/date": "^3.7.0",
"@playwright/test": "^1.50.0", "@playwright/test": "^1.50.0",
"@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.20.7", "@sveltejs/kit": "^2.20.7",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/node": "^22.10.10", "@types/node": "^22.10.10",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"bits-ui": "^0.22.0", "bits-ui": "^1.5.3",
"cmdk-sv": "^0.0.19",
"eslint": "^9.19.0", "eslint": "^9.19.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^2.46.1", "eslint-plugin-svelte": "^2.46.1",
"formsnap": "^2.0.1",
"globals": "^15.14.0", "globals": "^15.14.0",
"mode-watcher": "^1.0.7",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.19.3", "svelte": "^5.31.1",
"svelte-check": "^4.1.4", "svelte-check": "^4.1.4",
"tailwindcss": "^4.0.0", "svelte-sonner": "^1.0.1",
"tailwind-variants": "^1.0.0",
"tailwindcss": "^4.1.7",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"tw-animate-css": "^1.3.0",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"typescript-eslint": "^8.21.0", "typescript-eslint": "^8.21.0",
"vite": "^6.3.4" "vite": "^6.3.4"

View File

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

View File

@@ -1,70 +1,199 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import 'tw-animate-css';
@config '../tailwind.config.ts'; /*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base { @layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
:root { :root {
--background: 0 0% 100%; --background: hsl(0 0% 100%);
--foreground: 240 10% 3.9%; --foreground: hsl(240 10% 3.9%);
--muted: 240 4.8% 95.9%; --muted: hsl(240 4.8% 95.9%);
--muted-foreground: 240 3.8% 46.1%; --muted-foreground: hsl(240 3.8% 46.1%);
--popover: 0 0% 100%; --popover: hsl(0 0% 100%);
--popover-foreground: 240 10% 3.9%; --popover-foreground: hsl(240 10% 3.9%);
--card: 0 0% 100%; --card: hsl(0 0% 100%);
--card-foreground: 240 10% 3.9%; --card-foreground: hsl(240 10% 3.9%);
--border: 240 5.9% 90%; --border: hsl(240 5.9% 90%);
--input: 240 5.9% 90%; --input: hsl(240 5.9% 90%);
--primary: 240 5.9% 10%; --primary: hsl(240 5.9% 10%);
--primary-foreground: 0 0% 98%; --primary-foreground: hsl(0 0% 98%);
--secondary: 240 4.8% 95.9%; --secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: 240 5.9% 10%; --secondary-foreground: hsl(240 5.9% 10%);
--accent: 240 4.8% 95.9%; --accent: hsl(240 4.8% 95.9%);
--accent-foreground: 240 5.9% 10%; --accent-foreground: hsl(240 5.9% 10%);
--destructive: 0 72.2% 50.6%; --destructive: hsl(0 72.2% 50.6%);
--destructive-foreground: 0 0% 98%; --destructive-foreground: hsl(0 0% 98%);
--ring: 240 10% 3.9%; --ring: hsl(240 10% 3.9%);
--radius: 0.5rem; --radius: 0.5rem;
} }
.dark { .dark {
--background: 240 10% 3.9%; --background: hsl(240 10% 3.9%);
--foreground: 0 0% 98%; --foreground: hsl(0 0% 98%);
--muted: 240 3.7% 15.9%; --muted: hsl(240 3.7% 15.9%);
--muted-foreground: 240 5% 64.9%; --muted-foreground: hsl(240 5% 64.9%);
--popover: 240 10% 3.9%; --popover: hsl(240 10% 3.9%);
--popover-foreground: 0 0% 98%; --popover-foreground: hsl(0 0% 98%);
--card: 240 10% 3.9%; --card: hsl(240 10% 3.9%);
--card-foreground: 0 0% 98%; --card-foreground: hsl(0 0% 98%);
--border: 240 3.7% 15.9%; --border: hsl(240 3.7% 15.9%);
--input: 240 3.7% 15.9%; --input: hsl(240 3.7% 15.9%);
--primary: 0 0% 98%; --primary: hsl(0 0% 98%);
--primary-foreground: 240 5.9% 10%; --primary-foreground: hsl(240 5.9% 10%);
--secondary: 240 3.7% 15.9%; --secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: 0 0% 98%; --secondary-foreground: hsl(0 0% 98%);
--accent: 240 3.7% 15.9%; --accent: hsl(240 3.7% 15.9%);
--accent-foreground: 0 0% 98%; --accent-foreground: hsl(0 0% 98%);
--destructive: 0 62.8% 30.6%; --destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: 0 0% 98%; --destructive-foreground: hsl(0 0% 98%);
--ring: 240 4.9% 83.9%; --ring: hsl(240 4.9% 83.9%);
}
@theme inline {
/* Radius (for rounded-*) */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
/* Colors */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-ring: var(--ring);
--color-radius: var(--radius);
--color-sidebar-background: var(--sidebar-background);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
/* Animations */
--animate-accordion-up: accordion-up 0.2s ease-out;
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-caret-blink: caret-blink 1.25s ease-out infinite;
/* Font */
--font-playfair: 'Playfair Display', serif;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
button {
@apply cursor-pointer;
}
@font-face {
font-family: 'Playfair Display';
font-weight: 400;
src: url('/fonts/PlayfairDisplay-Regular.woff') format('woff');
}
@font-face {
font-family: 'Playfair Display';
font-weight: 500;
src: url('/fonts/PlayfairDisplay-Medium.woff') format('woff');
}
@font-face {
font-family: 'Playfair Display';
font-weight: 600;
src: url('/fonts/PlayfairDisplay-SemiBold.woff') format('woff');
}
@font-face {
font-family: 'Playfair Display';
font-weight: 700;
src: url('/fonts/PlayfairDisplay-Bold.woff') format('woff');
}
}
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--bits-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--bits-accordion-content-height);
}
to {
height: 0;
}
}
@keyframes caret-blink {
0%,
70%,
100% {
opacity: 1;
}
20%,
50% {
opacity: 0;
} }
} }
@@ -102,7 +231,6 @@
animation: slide-bg-container 1.2s cubic-bezier(0.33, 1, 0.68, 1) forwards; animation: slide-bg-container 1.2s cubic-bezier(0.33, 1, 0.68, 1) forwards;
} }
/* Fade in for content after the slide is mostly complete */
@keyframes delayed-fade { @keyframes delayed-fade {
0%, 0%,
40% { 40% {
@@ -116,38 +244,3 @@
.animate-delayed-fade { .animate-delayed-fade {
animation: delayed-fade 1.5s ease-out forwards; animation: delayed-fade 1.5s ease-out forwards;
} }
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
button {
@apply cursor-pointer;
}
@font-face {
font-family: 'Playfair Display';
font-weight: 400;
src: url('/fonts/PlayfairDisplay-Regular.woff') format('woff');
}
@font-face {
font-family: 'Playfair Display';
font-weight: 500;
src: url('/fonts/PlayfairDisplay-Medium.woff') format('woff');
}
@font-face {
font-family: 'Playfair Display';
font-weight: 600;
src: url('/fonts/PlayfairDisplay-SemiBold.woff') format('woff');
}
@font-face {
font-family: 'Playfair Display';
font-weight: 700;
src: url('/fonts/PlayfairDisplay-Bold.woff') format('woff');
}
}

View File

@@ -1,87 +0,0 @@
import { env } from '$env/dynamic/private';
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
import { paraglideMiddleware } from '$lib/paraglide/server';
import type { Handle, HandleServerError } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { AxiosError } from 'axios';
import { decodeJwt } from 'jose';
// Workaround so that we can also import this environment variable into client-side code
// If we would directly import $env/dynamic/private into the api-service.ts file, it would throw an error
// this is still secure as process will just be undefined in the browser
process.env.INTERNAL_BACKEND_URL = env.INTERNAL_BACKEND_URL ?? 'http://localhost:8080';
// Handle to use the paraglide middleware
const paraglideHandle: Handle = ({ event, resolve }) => {
return paraglideMiddleware(event.request, ({ locale }) => {
return resolve(event, {
transformPageChunk: ({ html }) => html.replace('%lang%', locale)
});
});
};
const authenticationHandle: Handle = async ({ event, resolve }) => {
const { isSignedIn, isAdmin } = verifyJwt(event.cookies.get(ACCESS_TOKEN_COOKIE_NAME));
const path = event.url.pathname;
const isUnauthenticatedOnlyPath = path == '/login' || path.startsWith('/login/') || path == '/lc' || path.startsWith('/lc/')
const isPublicPath = ['/authorize', '/device', '/health', '/healthz'].includes(path);
const isAdminPath = path == '/settings/admin' || path.startsWith('/settings/admin/');
if (!isUnauthenticatedOnlyPath && !isPublicPath && !isSignedIn) {
return new Response(null, {
status: 303,
headers: { location: '/login' }
});
}
if (isUnauthenticatedOnlyPath && isSignedIn) {
return new Response(null, {
status: 303,
headers: { location: '/settings' }
});
}
if (isAdminPath && !isAdmin) {
return new Response(null, {
status: 303,
headers: { location: '/settings' }
});
}
return resolve(event);
};
export const handle: Handle = sequence(paraglideHandle, authenticationHandle);
export const handleError: HandleServerError = async ({ error, message, status }) => {
if (error instanceof AxiosError) {
message = error.response?.data.error || message;
status = error.response?.status || status;
console.error(
`Axios error: ${error.request.path} - ${error.response?.data.error ?? error.message}`
);
} else {
console.error(error);
}
return {
message,
status
};
};
function verifyJwt(accessToken: string | undefined) {
let isSignedIn = false;
let isAdmin = false;
if (accessToken) {
const jwtPayload = decodeJwt<{ isAdmin: boolean }>(accessToken);
if (jwtPayload?.exp && jwtPayload.exp * 1000 > Date.now()) {
isSignedIn = true;
isAdmin = !!(jwtPayload?.isAdmin);
}
}
return { isSignedIn, isAdmin };
}

19
frontend/src/hooks.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { HandleServerError } from '@sveltejs/kit';
import { AxiosError } from 'axios';
export const handleError: HandleServerError = async ({ error, message, status }) => {
if (error instanceof AxiosError) {
message = error.response?.data.error || message;
status = error.response?.status || status;
console.error(
`Axios error: ${error.request.path} - ${error.response?.data.error ?? error.message}`
);
} else {
console.error(error);
}
return {
message,
status
};
};

View File

@@ -8,7 +8,7 @@
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import { debounced } from '$lib/utils/debounce-util'; import { debounced } from '$lib/utils/debounce-util';
import { cn } from '$lib/utils/style'; import { cn } from '$lib/utils/style';
import { ChevronDown } from 'lucide-svelte'; import { ChevronDown } from '@lucide/svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import Button from './ui/button/button.svelte'; import Button from './ui/button/button.svelte';
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
@@ -96,7 +96,7 @@
)} )}
placeholder={m.search()} placeholder={m.search()}
type="text" type="text"
oninput={(e) => onSearch((e.target as HTMLInputElement).value)} oninput={(e: Event) => onSearch((e.currentTarget as HTMLInputElement).value)}
/> />
{/if} {/if}
@@ -114,7 +114,7 @@
<Checkbox <Checkbox
disabled={selectionDisabled} disabled={selectionDisabled}
checked={allChecked} checked={allChecked}
onCheckedChange={(c) => onAllCheck(c as boolean)} onCheckedChange={(c: boolean) => onAllCheck(c as boolean)}
/> />
</Table.Head> </Table.Head>
{/if} {/if}
@@ -124,7 +124,7 @@
<Button <Button
variant="ghost" variant="ghost"
class="flex items-center" class="flex items-center"
on:click={() => onclick={() =>
onSort( onSort(
column.sortColumn, column.sortColumn,
requestOptions.sort?.direction === 'desc' ? 'asc' : 'desc' requestOptions.sort?.direction === 'desc' ? 'asc' : 'desc'
@@ -134,7 +134,7 @@
{#if requestOptions.sort?.column === column.sortColumn} {#if requestOptions.sort?.column === column.sortColumn}
<ChevronDown <ChevronDown
class={cn( class={cn(
'ml-2 h-4 w-4', 'ml-2 size-4',
requestOptions.sort?.direction === 'asc' ? 'rotate-180' : '' requestOptions.sort?.direction === 'asc' ? 'rotate-180' : ''
)} )}
/> />
@@ -155,7 +155,7 @@
<Checkbox <Checkbox
disabled={selectionDisabled} disabled={selectionDisabled}
checked={selectedIds.includes(item.id)} checked={selectedIds.includes(item.id)}
onCheckedChange={(c) => onCheck(c as boolean, item.id)} onCheckedChange={(c: boolean) => onCheck(c, item.id)}
/> />
</Table.Cell> </Table.Cell>
{/if} {/if}
@@ -169,18 +169,16 @@
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<p class="text-sm font-medium">{m.items_per_page()}</p> <p class="text-sm font-medium">{m.items_per_page()}</p>
<Select.Root <Select.Root
selected={{ type="single"
label: items.pagination.itemsPerPage.toString(), value={items.pagination.itemsPerPage.toString()}
value: items.pagination.itemsPerPage onValueChange={(v) => onPageSizeChange(Number(v))}
}}
onSelectedChange={(v) => onPageSizeChange(v?.value as number)}
> >
<Select.Trigger class="h-9 w-[80px]"> <Select.Trigger class="h-9 w-[80px]">
<Select.Value>{items.pagination.itemsPerPage}</Select.Value> {items.pagination.itemsPerPage}
</Select.Trigger> </Select.Trigger>
<Select.Content> <Select.Content>
{#each availablePageSizes as size} {#each availablePageSizes as size}
<Select.Item value={size}>{size}</Select.Item> <Select.Item value={size.toString()}>{size}</Select.Item>
{/each} {/each}
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
@@ -191,8 +189,8 @@
perPage={items.pagination.itemsPerPage} perPage={items.pagination.itemsPerPage}
{onPageChange} {onPageChange}
page={items.pagination.currentPage} page={items.pagination.currentPage}
let:pages
> >
{#snippet children({ pages })}
<Pagination.Content class="flex justify-end"> <Pagination.Content class="flex justify-end">
<Pagination.Item> <Pagination.Item>
<Pagination.PrevButton /> <Pagination.PrevButton />
@@ -210,6 +208,7 @@
<Pagination.NextButton /> <Pagination.NextButton />
</Pagination.Item> </Pagination.Item>
</Pagination.Content> </Pagination.Content>
{/snippet}
</Pagination.Root> </Pagination.Root>
</div> </div>
{/if} {/if}

View File

@@ -58,7 +58,7 @@
</Table.Cell> </Table.Cell>
{/if} {/if}
<Table.Cell> <Table.Cell>
<Badge variant="outline">{toFriendlyEventString(item.event)}</Badge> <Badge class="rounded-full" variant="outline">{toFriendlyEventString(item.event)}</Badge>
</Table.Cell> </Table.Cell>
<Table.Cell <Table.Cell
>{item.city && item.country ? `${item.city}, ${item.country}` : m.unknown()}</Table.Cell >{item.city && item.country ? `${item.city}, ${item.country}` : m.unknown()}</Table.Cell

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import { cn } from '$lib/utils/style'; import { cn } from '$lib/utils/style';
import { LucideChevronDown, type Icon as IconType } from 'lucide-svelte'; import { LucideChevronDown, type Icon as IconType } from '@lucide/svelte';
import { onMount, type Snippet } from 'svelte'; import { onMount, type Snippet } from 'svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { Button } from './ui/button'; import { Button } from './ui/button';
@@ -50,12 +50,12 @@
</script> </script>
<Card.Root> <Card.Root>
<Card.Header class="cursor-pointer" onclick={toggleExpanded}> <Card.Header class="bg-card cursor-pointer" onclick={toggleExpanded}>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<Card.Title class="flex items-center gap-2 text-xl font-semibold"> <Card.Title class="flex items-center gap-2 text-xl font-semibold">
{#if icon}{@const Icon = icon} {#if icon}{@const Icon = icon}
<Icon class="text-primary/80 h-5 w-5" /> <Icon class="text-primary/80 size-5" />
{/if} {/if}
{title} {title}
</Card.Title> </Card.Title>
@@ -65,17 +65,14 @@
</div> </div>
<Button class="ml-10 h-8 p-3" variant="ghost" aria-label={m.expand_card()}> <Button class="ml-10 h-8 p-3" variant="ghost" aria-label={m.expand_card()}>
<LucideChevronDown <LucideChevronDown
class={cn( class={cn('size-5 transition-transform duration-200', expanded && 'rotate-180 transform')}
'h-5 w-5 transition-transform duration-200',
expanded && 'rotate-180 transform'
)}
/> />
</Button> </Button>
</div> </div>
</Card.Header> </Card.Header>
{#if expanded} {#if expanded}
<div transition:slide={{ duration: 200 }}> <div transition:slide={{ duration: 200 }}>
<Card.Content class="bg-muted/20 pt-5"> <Card.Content class="pt-5">
{@render children()} {@render children()}
</Card.Content> </Card.Content>
</div> </div>

View File

@@ -14,16 +14,18 @@
</AlertDialog.Header> </AlertDialog.Header>
<AlertDialog.Footer> <AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel> <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action asChild> <AlertDialog.Action>
{#snippet child()}
<Button <Button
variant={$confirmDialogStore.confirm.destructive ? 'destructive' : 'default'} variant={$confirmDialogStore.confirm.destructive ? 'destructive' : 'default'}
on:click={() => { onclick={() => {
$confirmDialogStore.confirm.action(); $confirmDialogStore.confirm.action();
$confirmDialogStore.open = false; $confirmDialogStore.open = false;
}} }}
> >
{$confirmDialogStore.confirm.label} {$confirmDialogStore.confirm.label}
</Button> </Button>
{/snippet}
</AlertDialog.Action> </AlertDialog.Action>
</AlertDialog.Footer> </AlertDialog.Footer>
</AlertDialog.Content> </AlertDialog.Content>

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import { LucideCheck } from 'lucide-svelte'; import { LucideCheck } from '@lucide/svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
let { value, children }: { value: string; children: Snippet } = $props(); let { value, children }: { value: string; children: Snippet } = $props();
@@ -17,7 +17,7 @@
function onOpenChange(state: boolean) { function onOpenChange(state: boolean) {
open = state; open = state;
if (!state) { if (!state) {
copied = false; setTimeout(() => (copied = false), 500);
} }
} }
@@ -28,13 +28,15 @@
} }
</script> </script>
<Tooltip.Root closeOnPointerDown={false} {onOpenChange} {open}> <Tooltip.Provider>
<Tooltip.Trigger class="text-start" tabindex={-1} onclick={onClick}>{@render children()}</Tooltip.Trigger> <Tooltip.Root {onOpenChange} {open} disableCloseOnTriggerClick>
<Tooltip.Trigger class="text-start" onclick={onClick}>{@render children()}</Tooltip.Trigger>
<Tooltip.Content onclick={copyToClipboard}> <Tooltip.Content onclick={copyToClipboard}>
{#if copied} {#if copied}
<span class="flex items-center"><LucideCheck class="mr-1 h-4 w-4" /> {m.copied()}</span> <span class="flex items-center"><LucideCheck class="mr-1 size-4" /> {m.copied()}</span>
{:else} {:else}
<span>{m.click_to_copy()}</span> <span>{m.click_to_copy()}</span>
{/if} {/if}
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
</Tooltip.Provider>

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