Compare commits

...

58 Commits

Author SHA1 Message Date
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
361 changed files with 12491 additions and 10707 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",
// 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",
"features": {
"ghcr.io/devcontainers/features/go:1": {},
"ghcr.io/devcontainers-extra/features/caddy:1": {}
"ghcr.io/devcontainers/features/go:1": {}
},
"customizations": {
"vscode": {
"extensions": [
"golang.go",
"svelte.svelte-vscode"
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode"
]
}
},
// Use 'postCreateCommand' to run commands after the container is created.
// Install npm dependencies for the frontend.
"postCreateCommand": "npm install --prefix frontend"
// 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"
}
"containerEnv": {
"HOST": "0.0.0.0"
},
"postCreateCommand": "npm install --prefix frontend && cd backend && go mod download"
}

View File

@@ -6,6 +6,9 @@ node_modules
/frontend/.svelte-kit
/frontend/build
/backend/bin
/backend/frontend/dist
/tests/.auth
/tests/.report
# Env
@@ -15,4 +18,5 @@ node_modules
# Application specific
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
PUBLIC_APP_URL=http://localhost
APP_URL=http://localhost:1411
TRUST_PROXY=false
MAXMIND_LICENSE_KEY=
PUID=1000

View File

@@ -35,5 +35,6 @@ jobs:
uses: golangci/golangci-lint-action@dec74fa03096ff515422f71d18d41307cacde373 # v7.0.0
with:
version: v2.0.2
args: --build-tags=exclude_frontend
working-directory: backend
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:
if: github.event.pull_request.head.ref != 'i18n_crowdin'
timeout-minutes: 20
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -44,13 +47,16 @@ jobs:
test-sqlite:
if: github.event.pull_request.head.ref != 'i18n_crowdin'
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
@@ -72,34 +78,48 @@ jobs:
- name: Load Docker image
run: docker load -i /tmp/docker-image.tar
- name: Install frontend dependencies
working-directory: ./frontend
- 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: Install test dependencies
working-directory: ./tests
run: npm ci
- name: Install Playwright Browsers
working-directory: ./frontend
working-directory: ./tests
if: steps.playwright-cache.outputs.cache-hit != 'true'
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: |
docker run -d --name pocket-id-sqlite \
-p 80:80 \
-e APP_ENV=test \
pocket-id:test
docker logs -f pocket-id-sqlite &> /tmp/backend.log &
docker compose up -d
docker compose logs -f pocket-id &> /tmp/backend.log &
- name: Run Playwright tests
working-directory: ./frontend
working-directory: ./tests
run: npx playwright test
- name: Upload Frontend Test Report
- name: Upload Test Report
uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with:
name: playwright-report-sqlite
path: frontend/tests/.report
path: tests/.report
include-hidden-files: true
retention-days: 15
@@ -114,13 +134,16 @@ jobs:
test-postgres:
if: github.event.pull_request.head.ref != 'i18n_crowdin'
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
@@ -150,6 +173,23 @@ jobs:
if: steps.postgres-cache.outputs.cache-hit == 'true'
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
uses: actions/download-artifact@v4
with:
@@ -159,56 +199,26 @@ jobs:
- name: Load Docker image
run: docker load -i /tmp/docker-image.tar
- name: Install frontend dependencies
working-directory: ./frontend
- name: Install test dependencies
working-directory: ./tests
run: npm ci
- name: Install Playwright Browsers
working-directory: ./frontend
working-directory: ./tests
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Create Docker network
run: docker network create pocket-id-network
- name: Start Postgres DB
- name: Run Docker Container with Postgres DB and LDAP
working-directory: ./tests/setup
run: |
docker run -d --name pocket-id-db \
--network pocket-id-network \
-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 &
docker compose -f docker-compose-postgres.yml up -d
docker compose -f docker-compose-postgres.yml logs -f pocket-id &> /tmp/backend.log &
- name: Run Playwright tests
working-directory: ./frontend
working-directory: ./tests
run: npx playwright test
- name: Upload Frontend Test Report
- name: Upload Test Report
uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with:

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

@@ -0,0 +1,105 @@
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
- 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
uses: actions/setup-node@v4
with:
node-version: "lts/*"
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json

View File

@@ -11,13 +11,16 @@ on:
jobs:
test-backend:
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: 'backend/go.mod'
cache-dependency-path: 'backend/go.sum'
go-version-file: "backend/go.mod"
cache-dependency-path: "backend/go.sum"
- name: Install dependencies
working-directory: backend
run: |
@@ -26,7 +29,7 @@ jobs:
working-directory: backend
run: |
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
if: always()
with:

View File

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

11
.gitignore vendored
View File

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

View File

@@ -1 +1 @@
0.52.0
1.0.0

View File

@@ -1,5 +1,8 @@
{
"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,48 @@
## [](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)

View File

@@ -17,7 +17,7 @@ Before you submit the pull request for review please ensure that
example:
```
feat(share): add password protection
fix: hide global audit log switch for non admin users
```
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
- You run `npm run format` to format the code
## Setup project
Pocket ID consists of a frontend, backend and a reverse proxy. There are two ways to get the development environment setup:
## Development Environment
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
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
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
2. Copy the `.env.example` file to `.env` and change the `APP_ENV` to `development`
3. Start the backend with `go run -tags e2etest ./cmd`
2. Copy the `.env.development-example` file to `.env` and edit the variables as needed
3. Start the backend with `go run -tags exclude_frontend ./cmd`
### Frontend
The frontend is built with [SvelteKit](https://kit.svelte.dev) and written in TypeScript.
#### Setup
The frontend is built with [SvelteKit](https://kit.svelte.dev) and written in TypeScript. To set it up, follow these steps:
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`
4. Start the frontend with `npm run dev`
### Reverse Proxy
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))
You're all set! The application is now listening on `localhost:3000`. The backend gets proxied trough the frontend in development mode.
### 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:
1. Start the backend normally
2. Start the frontend in production mode with `npm run build && node --env-file=.env build/index.js`
3. Run the tests with `npm run test`
1. Visit the setup folder by running `cd tests/setup`
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"
ARG BUILD_TAGS=""
ARG VERSION="unknown"
# Stage 1: Build Frontend
FROM node:22-alpine AS frontend-builder
WORKDIR /app/frontend
WORKDIR /build
COPY ./frontend/package*.json ./
RUN npm ci
COPY ./frontend ./
RUN npm run build
RUN npm prune --production
RUN BUILD_OUTPUT_PATH=dist npm run build
# Stage 2: Build Backend
FROM golang:1.24-alpine AS backend-builder
ARG BUILD_TAGS
WORKDIR /app/backend
WORKDIR /build
COPY ./backend/go.mod ./backend/go.sum ./
RUN go mod download
RUN apk add --no-cache gcc musl-dev
COPY ./backend ./
WORKDIR /app/backend/cmd
RUN CGO_ENABLED=1 \
COPY --from=frontend-builder /build/dist ./frontend/dist
COPY .version .version
WORKDIR /build/cmd
RUN VERSION=$(cat /build/.version) \
CGO_ENABLED=0 \
GOOS=linux \
go build \
-tags "${BUILD_TAGS}" \
-ldflags="-X github.com/pocket-id/pocket-id/backend/internal/common.Version=${VERSION}" \
-o /app/backend/pocket-id-backend \
.
-tags "${BUILD_TAGS}" \
-ldflags="-X github.com/pocket-id/pocket-id/backend/internal/common.Version=${VERSION} -buildid=${VERSION}" \
-trimpath \
-o /build/pocket-id-backend \
.
# Stage 3: Production Image
FROM node:22-alpine
# Delete default node user
RUN deluser --remove-home node
RUN apk add --no-cache caddy curl su-exec
COPY ./reverse-proxy /etc/caddy/
FROM alpine
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
RUN find ./scripts -name "*.sh" -exec chmod +x {} \;
COPY --from=backend-builder /build/pocket-id-backend /app/pocket-id
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
ENTRYPOINT ["sh", "./scripts/docker/create-user.sh"]
CMD ["sh", "./scripts/docker/entrypoint.sh"]
ENTRYPOINT ["sh", "/app/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/
./data
.env
pocket-id

View File

@@ -1,9 +1,15 @@
package main
import (
"flag"
"fmt"
"log"
_ "time/tzdata"
"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
@@ -11,7 +17,26 @@ import (
// @description.markdown
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 {
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

@@ -10,6 +10,7 @@ require (
github.com/emersion/go-smtp v0.21.3
github.com/fxamacker/cbor/v2 v2.7.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-ldap/ldap/v3 v3.4.10
github.com/go-playground/validator/v10 v10.25.0
@@ -20,7 +21,6 @@ require (
github.com/lestrrat-go/jwx/v3 v3.0.0-beta1
github.com/mileusna/useragent v1.3.5
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
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0
@@ -34,7 +34,6 @@ require (
golang.org/x/image v0.24.0
golang.org/x/time v0.9.0
gorm.io/driver/postgres v1.5.11
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12
)
@@ -49,9 +48,11 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // 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/gabriel-vasile/mimetype v1.4.8 // 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-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -85,11 +86,14 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // 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/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/common v0.62.0 // 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/segmentio/asm v1.2.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
@@ -113,14 +117,18 @@ require (
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
go.uber.org/atomic v1.11.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/sync v0.12.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.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/rpc v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/grpc v1.71.0 // indirect
google.golang.org/protobuf v1.36.5 // 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

@@ -43,6 +43,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-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
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/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY=
@@ -57,6 +59,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-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
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/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-co-op/gocron/v2 v2.15.0 h1:Kpvo71VSihE+RImmpA+3ta5CcMhoRzMGw4dJawrj4zo=
@@ -96,6 +102,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/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
@@ -187,6 +195,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/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/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/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
@@ -207,6 +217,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/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
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/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
@@ -297,8 +309,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.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
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-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
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.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
@@ -307,6 +319,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.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.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-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -327,8 +341,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.7.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.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -341,8 +355,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.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.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
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/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=
@@ -371,6 +385,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.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.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=
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=
@@ -389,8 +405,30 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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/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/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=

View File

@@ -20,10 +20,6 @@ func Bootstrap() error {
initApplicationImages()
// Perform migrations for changes
migrateConfigDBConnstring()
migrateKey()
// Initialize the tracer and metrics exporter
shutdownFns, httpClient, err := initOtel(ctx, common.EnvConfig.MetricsEnabled, common.EnvConfig.TracingEnabled)
if err != nil {
@@ -31,7 +27,7 @@ func Bootstrap() error {
}
// Connect to the database
db := newDatabase()
db := NewDatabase()
// Create all services
svc, err := initServices(ctx, db, httpClient)

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"
"fmt"
"log"
"net/url"
"os"
"strings"
"time"
"github.com/glebarez/sqlite"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database"
postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres"
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
"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/sqlite"
"gorm.io/gorm"
"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()
if err != nil {
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:") {
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:
if common.EnvConfig.DbConnectionString == "" {
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
}
// 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 {
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() {
registerTestControllers = []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)
},
}

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 (
"context"
"errors"
"fmt"
"log"
"net"
"net/http"
"time"
"github.com/pocket-id/pocket-id/backend/frontend"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
"golang.org/x/time/rate"
@@ -45,6 +48,10 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
r := gin.Default()
r.Use(gin.Logger())
if !common.EnvConfig.TrustProxy {
_ = r.SetTrustedProxies(nil)
}
if common.EnvConfig.TracingEnabled {
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.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
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyService, svc.userService, svc.jwtService)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()

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

@@ -24,41 +24,39 @@ const (
)
type EnvConfigSchema struct {
AppEnv string `env:"APP_ENV"`
AppURL string `env:"PUBLIC_APP_URL"`
DbProvider DbProvider `env:"DB_PROVIDER"`
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"`
KeysPath string `env:"KEYS_PATH"`
Port string `env:"BACKEND_PORT"`
Host string `env:"HOST"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
UiConfigDisabled bool `env:"PUBLIC_UI_CONFIG_DISABLED"`
MetricsEnabled bool `env:"METRICS_ENABLED"`
TracingEnabled bool `env:"TRACING_ENABLED"`
AppEnv string `env:"APP_ENV"`
AppURL string `env:"APP_URL"`
DbProvider DbProvider `env:"DB_PROVIDER"`
DbConnectionString string `env:"DB_CONNECTION_STRING"`
UploadPath string `env:"UPLOAD_PATH"`
KeysPath string `env:"KEYS_PATH"`
Port string `env:"PORT"`
Host string `env:"HOST"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
MetricsEnabled bool `env:"METRICS_ENABLED"`
TracingEnabled bool `env:"TRACING_ENABLED"`
TrustProxy bool `env:"TRUST_PROXY"`
}
var EnvConfig = &EnvConfigSchema{
AppEnv: "production",
DbProvider: "sqlite",
DbConnectionString: "file:data/pocket-id.db?_journal_mode=WAL&_busy_timeout=2500&_txlock=immediate",
SqliteDBPath: "",
PostgresConnectionString: "",
UploadPath: "data/uploads",
KeysPath: "data/keys",
AppURL: "http://localhost",
Port: "8080",
Host: "0.0.0.0",
MaxMindLicenseKey: "",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
UiConfigDisabled: false,
MetricsEnabled: false,
TracingEnabled: false,
AppEnv: "production",
DbProvider: "sqlite",
DbConnectionString: "file:data/pocket-id.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2500)&_txlock=immediate",
UploadPath: "data/uploads",
KeysPath: "data/keys",
AppURL: "http://localhost:1411",
Port: "1411",
Host: "0.0.0.0",
MaxMindLicenseKey: "",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
UiConfigDisabled: false,
MetricsEnabled: false,
TracingEnabled: false,
TrustProxy: false,
}
func init() {
@@ -82,9 +80,9 @@ func init() {
parsedAppUrl, err := url.Parse(EnvConfig.AppURL)
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 != "" {
log.Fatal("PUBLIC_APP_URL must not contain a path")
log.Fatal("APP_URL must not contain a path")
}
}

View File

@@ -68,6 +68,13 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
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)
}

View File

@@ -41,6 +41,16 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
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()
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"
// @Router /api/oidc/token [post]
func (oc *OidcController) createTokensHandler(c *gin.Context) {
// Disable cors for this endpoint
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
var input dto.OidcCreateTokensDto
if err := c.ShouldBind(&input); err != nil {
_ = c.Error(err)
@@ -139,13 +136,13 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
}
// 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{})
return
}
// 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{})
return
}
@@ -155,8 +152,7 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
input.ClientID, input.ClientSecret, _ = c.Request.BasicAuth()
}
idToken, accessToken, refreshToken, expiresIn, err :=
oc.oidcService.CreateTokens(c.Request.Context(), input)
tokens, err := oc.oidcService.CreateTokens(c.Request.Context(), input)
switch {
case errors.Is(err, &common.OidcAuthorizationPendingError{}):
@@ -174,23 +170,13 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
return
}
response := dto.OidcTokenResponseDto{
AccessToken: accessToken,
TokenType: "Bearer",
ExpiresIn: expiresIn,
}
// 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)
c.JSON(http.StatusOK, dto.OidcTokenResponseDto{
AccessToken: tokens.AccessToken,
TokenType: "Bearer",
ExpiresIn: int(tokens.ExpiresIn.Seconds()),
IdToken: tokens.IdToken, // May be empty
RefreshToken: tokens.RefreshToken, // May be empty
})
}
// userInfoHandler godoc

View File

@@ -77,7 +77,7 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
"introspection_endpoint": appUrl + "/api/oidc/introspect",
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
"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"},
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
"response_types_supported": []string{"code", "id_token"},

View File

@@ -4,7 +4,6 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
type CorsMiddleware struct{}
@@ -15,17 +14,21 @@ func NewCorsMiddleware() *CorsMiddleware {
func (m *CorsMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
// Allow all origins for the token endpoint
switch c.FullPath() {
case "/api/oidc/token", "/api/oidc/introspect":
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
default:
c.Writer.Header().Set("Access-Control-Allow-Origin", common.EnvConfig.AppURL)
path := c.FullPath()
if path == "" {
// The router doesn't map preflight requests, so we need to use the raw URL path
path = c.Request.URL.Path
}
c.Writer.Header().Set("Access-Control-Allow-Headers", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT")
if !isCorsPath(path) {
c.Next()
return
}
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST")
// Preflight request
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(204)
return
@@ -34,3 +37,17 @@ func (m *CorsMiddleware) Add() gin.HandlerFunc {
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"
"fmt"
"reflect"
"slices"
"strconv"
"strings"
"time"
@@ -107,24 +108,26 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
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()
rt := rv.Type()
// Find the field in the struct whose "key" tag matches
for i := range rt.NumField() {
// 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"), ",")
if tagValue != key {
tagValue := strings.Split(rt.Field(i).Tag.Get("key"), ",")
keyFromTag := tagValue[0]
isInternal = slices.Contains(tagValue, "internal")
if keyFromTag != key {
continue
}
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
return "", AppConfigKeyNotFoundError{field: key}
return "", false, AppConfigKeyNotFoundError{field: key}
}
func (c *AppConfig) UpdateField(key string, value string, noInternal bool) error {

View File

@@ -2,6 +2,7 @@ package datatype
import (
"database/sql/driver"
"fmt"
"time"
"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
type DateTime time.Time //nolint:recvcheck
func (date *DateTime) Scan(value interface{}) (err error) {
*date = DateTime(value.(time.Time))
return
func (date *DateTime) Scan(value any) (err error) {
switch v := value.(type) {
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) {

View File

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

View File

@@ -8,6 +8,7 @@ import (
"mime/multipart"
"os"
"reflect"
"slices"
"strings"
"sync/atomic"
"time"
@@ -188,7 +189,7 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon
// Skip values that are internal only and can't be updated
if value == "" {
// Ignore errors here as we know the key exists
defaultValue, _ := defaultCfg.FieldByKey(key)
defaultValue, _, _ := defaultCfg.FieldByKey(key)
err = cfg.UpdateField(key, defaultValue, true)
} else {
err = cfg.UpdateField(key, value, true)
@@ -229,10 +230,6 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon
// UpdateAppConfigValues
func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error {
if common.EnvConfig.UiConfigDisabled {
return &common.UiConfigDisabledError{}
}
// Count of keysAndValues must be even
if len(keysAndValues)%2 != 0 {
return errors.New("invalid number of arguments received")
@@ -267,10 +264,13 @@ func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndVal
// Ensure that the field is valid
// We do this by grabbing the default value
var defaultValue string
defaultValue, err = defaultCfg.FieldByKey(key)
defaultValue, isInternal, err := defaultCfg.FieldByKey(key)
if err != nil {
return fmt.Errorf("invalid configuration key '%s': %w", key, err)
}
if !isInternal && common.EnvConfig.UiConfigDisabled {
return &common.UiConfigDisabledError{}
}
// 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
@@ -351,7 +351,7 @@ func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) {
// If the UI config is disabled, only load from the env
if common.EnvConfig.UiConfigDisabled {
dest, err = s.loadDbConfigFromEnv()
dest, err = s.loadDbConfigFromEnv(ctx, s.db)
} else {
dest, err = s.loadDbConfigInternal(ctx, s.db)
}
@@ -365,7 +365,7 @@ func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) {
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
dest := s.getDefaultDbConfig()
@@ -375,9 +375,25 @@ func (s *AppConfigService) loadDbConfigFromEnv() (*model.AppConfig, error) {
for i := range rt.NumField() {
field := rt.Field(i)
// Get the value of the key tag, taking only what's before the comma
// The env var name is the key converted to SCREAMING_SNAKE_CASE
key, _, _ := strings.Cut(field.Tag.Get("key"), ",")
// Get the key and internal tag values
tagValue := strings.Split(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)
// Set the value if it's set
@@ -396,7 +412,7 @@ func (s *AppConfigService) loadDbConfigInternal(ctx context.Context, tx *gorm.DB
// Load all configuration values from the database
// This loads all values in a single shot
loaded := []model.AppConfigVariable{}
var loaded []model.AppConfigVariable
queryCtx, queryCancel := context.WithTimeout(ctx, 10*time.Second)
defer queryCancel()
err := tx.

View File

@@ -5,7 +5,7 @@ import (
"testing"
"time"
"gorm.io/driver/sqlite"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"

View File

@@ -29,15 +29,16 @@ type TestService struct {
db *gorm.DB
jwtService *JwtService
appConfigService *AppConfigService
ldapService *LdapService
}
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService) *TestService {
return &TestService{db: db, appConfigService: appConfigService, jwtService: jwtService}
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService) *TestService {
return &TestService{db: db, appConfigService: appConfigService, jwtService: jwtService, ldapService: ldapService}
}
//nolint:gocognit
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{
{
Base: model.Base{
@@ -238,6 +239,12 @@ func (s *TestService) SeedDatabase() error {
return nil
})
if err != nil {
return err
}
return nil
}
func (s *TestService) ResetDatabase() error {
@@ -349,3 +356,52 @@ func (s *TestService) getCborPublicKey(base64PublicKey string) ([]byte, error) {
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

@@ -15,17 +15,22 @@ import (
"strings"
"time"
"gorm.io/gorm/clause"
"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/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"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 {
@@ -167,139 +172,158 @@ func (s *OidcService) IsUserGroupAllowedToAuthorize(user model.User, client mode
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 {
case "authorization_code":
return s.createTokenFromAuthorizationCode(ctx, input.Code, input.ClientID, input.ClientSecret, input.CodeVerifier)
case "refresh_token":
accessToken, newRefreshToken, exp, err = s.createTokenFromRefreshToken(ctx, input.RefreshToken, input.ClientID, input.ClientSecret)
return "", accessToken, newRefreshToken, exp, err
case "urn:ietf:params:oauth:grant-type:device_code":
return s.createTokenFromDeviceCode(ctx, input.DeviceCode, input.ClientID, input.ClientSecret)
case GrantTypeAuthorizationCode:
return s.createTokenFromAuthorizationCode(ctx, input)
case GrantTypeRefreshToken:
return s.createTokenFromRefreshToken(ctx, input)
case GrantTypeDeviceCode:
return s.createTokenFromDeviceCode(ctx, input)
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()
defer func() {
tx.Rollback()
}()
_, err = s.verifyClientCredentialsInternal(ctx, clientID, clientSecret, tx)
_, err := s.verifyClientCredentialsInternal(ctx, input.ClientID, input.ClientSecret, tx)
if err != nil {
return "", "", "", 0, err
return CreatedTokens{}, err
}
// Get the device authorization from database with explicit query conditions
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) {
return "", "", "", 0, &common.OidcInvalidDeviceCodeError{}
return CreatedTokens{}, &common.OidcInvalidDeviceCodeError{}
}
return "", "", "", 0, err
return CreatedTokens{}, err
}
// Check if device code has expired
if time.Now().After(deviceAuth.ExpiresAt.ToTime()) {
return "", "", "", 0, &common.OidcDeviceCodeExpiredError{}
return CreatedTokens{}, &common.OidcDeviceCodeExpiredError{}
}
// Check if device code has been authorized
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
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 {
return "", "", "", 0, err
return CreatedTokens{}, err
}
// 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 {
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 {
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 {
return "", "", "", 0, err
return CreatedTokens{}, err
}
// Delete the used device code
if err := tx.WithContext(ctx).Delete(&deviceAuth).Error; err != nil {
return "", "", "", 0, err
err = tx.WithContext(ctx).Delete(&deviceAuth).Error
if err != nil {
return CreatedTokens{}, err
}
if err := tx.Commit().Error; err != nil {
return "", "", "", 0, err
err = tx.Commit().Error
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()
defer func() {
tx.Rollback()
}()
client, err := s.verifyClientCredentialsInternal(ctx, clientID, clientSecret, tx)
client, err := s.verifyClientCredentialsInternal(ctx, input.ClientID, input.ClientSecret, tx)
if err != nil {
return "", "", "", 0, err
return CreatedTokens{}, err
}
var authorizationCodeMetaData model.OidcAuthorizationCode
err = tx.
WithContext(ctx).
Preload("User").
First(&authorizationCodeMetaData, "code = ?", code).
First(&authorizationCodeMetaData, "code = ?", input.Code).
Error
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 client.IsPublic || client.PkceEnabled {
if !s.validateCodeVerifier(codeVerifier, *authorizationCodeMetaData.CodeChallenge, *authorizationCodeMetaData.CodeChallengeMethodSha256) {
return "", "", "", 0, &common.OidcInvalidCodeVerifierError{}
if !s.validateCodeVerifier(input.CodeVerifier, *authorizationCodeMetaData.CodeChallenge, *authorizationCodeMetaData.CodeChallengeMethodSha256) {
return CreatedTokens{}, &common.OidcInvalidCodeVerifierError{}
}
}
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
return "", "", "", 0, &common.OidcInvalidAuthorizationCodeError{}
if authorizationCodeMetaData.ClientID != input.ClientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
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 {
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 {
return "", "", "", 0, err
return CreatedTokens{}, err
}
// 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 {
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 {
return "", "", "", 0, err
return CreatedTokens{}, err
}
err = tx.
@@ -307,20 +331,25 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, code
Delete(&authorizationCodeMetaData).
Error
if err != nil {
return "", "", "", 0, err
return CreatedTokens{}, err
}
err = tx.Commit().Error
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) {
if refreshToken == "" {
return "", "", 0, &common.OidcMissingRefreshTokenError{}
func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto.OidcCreateTokensDto) (CreatedTokens, error) {
if input.RefreshToken == "" {
return CreatedTokens{}, &common.OidcMissingRefreshTokenError{}
}
tx := s.db.Begin()
@@ -328,9 +357,9 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, refreshTo
tx.Rollback()
}()
_, err = s.verifyClientCredentialsInternal(ctx, clientID, clientSecret, tx)
_, err := s.verifyClientCredentialsInternal(ctx, input.ClientID, input.ClientSecret, tx)
if err != nil {
return "", "", 0, err
return CreatedTokens{}, err
}
// Verify refresh token
@@ -338,31 +367,31 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, refreshTo
err = tx.
WithContext(ctx).
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).
Error
if err != nil {
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
if storedRefreshToken.ClientID != clientID {
return "", "", 0, &common.OidcInvalidRefreshTokenError{}
if storedRefreshToken.ClientID != input.ClientID {
return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
}
// 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 {
return "", "", 0, err
return CreatedTokens{}, err
}
// 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 {
return "", "", 0, err
return CreatedTokens{}, err
}
// Delete the used refresh token
@@ -371,15 +400,19 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, refreshTo
Delete(&storedRefreshToken).
Error
if err != nil {
return "", "", 0, err
return CreatedTokens{}, err
}
err = tx.Commit().Error
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) {
@@ -1181,9 +1214,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) {
// First, ensure we have a valid client ID
if clientID == "" {
return model.OidcClient{}, &common.OidcMissingClientCredentialsError{}
}
// Load the OIDC client's configuration
var client model.OidcClient
err := tx.
WithContext(ctx).
@@ -1193,10 +1229,16 @@ func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, clien
return model.OidcClient{}, err
}
if !client.IsPublic {
if err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret)); err != nil {
// If we have a client secret, we validate it
// 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 client, nil
} else if !client.IsPublic {
return model.OidcClient{}, &common.OidcMissingClientCredentialsError{}
}
return client, nil

View File

@@ -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) {
// If expires at is less than 15 minutes, use an 6 character token instead of 16
tokenLength := 16
if time.Until(expiresAt) <= 15*time.Minute {
tokenLength = 6
}
randomString, err := utils.GenerateRandomAlphanumericString(tokenLength)
oneTimeAccessToken, err := NewOneTimeAccessToken(userID, expiresAt)
if err != nil {
return "", err
}
oneTimeAccessToken := model.OneTimeAccessToken{
UserID: userID,
ExpiresAt: datatype.DateTime(expiresAt),
Token: randomString,
}
if err := tx.WithContext(ctx).Create(&oneTimeAccessToken).Error; err != nil {
if err := tx.WithContext(ctx).Create(oneTimeAccessToken).Error; err != nil {
return "", err
}
@@ -641,3 +629,24 @@ func (s *UserService) disableUserInternal(ctx context.Context, userID string, tx
Update("disabled", true).
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
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,12 +4,12 @@ services:
restart: unless-stopped
env_file: .env
ports:
- 3000:80
- 1411:1411
volumes:
- "./data:/app/backend/data"
- "./data:/app/data"
# Optional healthcheck
healthcheck:
test: "curl -f http://localhost/healthz"
test: "curl -f http://localhost:1411/healthz"
interval: 1m30s
timeout: 5s
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",
"tailwind": {
"config": "tailwind.config.ts",
@@ -8,7 +8,11 @@
},
"aliases": {
"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

@@ -49,7 +49,7 @@
"sign_in_to": "Přihlásit se k {name}",
"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:",
"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",
"view_your_email_address": "Zobrazit vaši e-mailovou adresu",
"profile": "Profil",
@@ -343,8 +343,8 @@
"callback_url_description": "URL poskytnuté klientem. Klientské zástupné znaky (*) jsou podporovány, ale raději se jim vyhýbejte, pro lepší bezpečnost.",
"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ší.",
"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"
"authorize_device": "Autorizovat zařízení",
"the_device_has_been_authorized": "Zařízení bylo autorizováno.",
"enter_code_displayed_in_previous_step": "Zadejte kód, který byl zobrazen v předchozím kroku.",
"authorize": "Autorizovat"
}

View File

@@ -49,7 +49,7 @@
"sign_in_to": "Bei {name} anmelden",
"client_not_found": "Client nicht gefunden",
"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",
"view_your_email_address": "Deine E-Mail-Adresse anzeigen",
"profile": "Profil",

View File

@@ -49,7 +49,7 @@
"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?",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Do you want to sign in to <b>{client}</b> with your {appName} account?",
"email": "Email",
"view_your_email_address": "View your email address",
"profile": "Profile",
@@ -61,7 +61,7 @@
"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>?",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Do you want to sign out of {appName} with the account <b>{username}</b>?",
"sign_in_to_appname": "Sign in to {appName}",
"please_try_to_sign_in_again": "Please try to sign in again.",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Authenticate yourself with your passkey to access the admin panel.",

View File

@@ -49,7 +49,7 @@
"sign_in_to": "Iniciar sesión en {name}",
"client_not_found": "Cliente no encontrado",
"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",
"view_your_email_address": "Ver su dirección de correo electrónico",
"profile": "Perfil",
@@ -122,12 +122,12 @@
"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}",
"added_on": "Añadido el",
"rename": "Renombrar",
"delete": "Borrar",
"are_you_sure_you_want_to_delete_this_passkey": "¿Está seguro de que desea eliminar esta passkey?",
"passkey_deleted_successfully": "Passkey eliminada con éxito",
"delete_passkey_name": "Borrar {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.",
@@ -139,21 +139,21 @@
"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",
"close": "Cerrar",
"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.",
"expires_at": "Expira el",
"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.",
"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",
"name_must_be_at_least_3_characters": "El nombre debe tener al menos 3 caracteres",
"name_cannot_exceed_50_characters": "El nombre no puede exceder los 50 caracteres",
"expiration_date_must_be_in_the_future": "La fecha de caducidad debe ser en el futuro",
"revoke_api_key": "Revoke API Key",
"never": "Never",
"never": "Nunca",
"revoke": "Revoke",
"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.",
"last_used": "Last Used",
"actions": "Actions",
"last_used": "Utilizado por última vez",
"actions": "Acciones",
"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.",
@@ -163,9 +163,9 @@
"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.",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "Tienes que guardar los cambios antes de enviar un correo electrónico de prueba. ¿Quieres guardar ahora?",
"save_and_send": "Guardar y enviar",
"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.",
"smtp_configuration": "SMTP Configuration",
"smtp_host": "SMTP Host",
@@ -175,22 +175,22 @@
"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.",
"skip_certificate_verification": "Omitir la verificación del certificado",
"this_can_be_useful_for_selfsigned_certificates": "Esto puede ser útil para certificados autofirmados.",
"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.",
"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": "Código de acceso solicitado por el usuario",
"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",
"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.",
"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": "Enviar correo de prueba",
"application_configuration_updated_successfully": "Configuración actualizada correctamente",
"application_name": "Nombre de la aplicación",
"session_duration": "Duración de la sesión",
"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",
"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",
"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",
@@ -208,8 +208,8 @@
"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",
"username_attribute": "Atributo Nombre de usuario",
"user_mail_attribute": "Atributo de Correo de Usuario",
"user_first_name_attribute": "User First Name Attribute",
"user_last_name_attribute": "User Last Name Attribute",
"user_profile_picture_attribute": "User Profile Picture Attribute",

View File

@@ -49,7 +49,7 @@
"sign_in_to": "Connexion à {name}",
"client_not_found": "Client introuvable",
"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",
"view_your_email_address": "Afficher votre e-mail",
"profile": "Profil",

View File

@@ -49,7 +49,7 @@
"sign_in_to": "Accedi a {name}",
"client_not_found": "Client non trovato",
"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",
"view_your_email_address": "Visualizza il tuo indirizzo email",
"profile": "Profilo",

View File

@@ -49,7 +49,7 @@
"sign_in_to": "Meld u aan bij {name}",
"client_not_found": "Client niet gevonden",
"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",
"view_your_email_address": "Bekijk uw e-mailadres",
"profile": "Profiel",

View File

@@ -0,0 +1,350 @@
{
"$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": "{name} logo",
"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": "Favicon",
"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-e podane przez twojego klienta. Wildcardy (*) są obsługiwane, ale najlepiej ich unikać dla lepszej bezpieczeństwa.",
"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"
}

View File

@@ -49,7 +49,7 @@
"sign_in_to": "Entrar em {name}",
"client_not_found": "Cliente não encontrado",
"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",
"view_your_email_address": "Ver seu endereço de e-mail",
"profile": "Profile",

View File

@@ -49,7 +49,7 @@
"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?",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Do you want to sign in to <b>{client}</b> with your {appName} account?",
"email": "Email",
"view_your_email_address": "View your email address",
"profile": "Profile",

View File

@@ -49,7 +49,7 @@
"sign_in_to": "Вход в {name}",
"client_not_found": "Клиент не найден",
"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": "Электронная почта",
"view_your_email_address": "Просмотр адреса электронной почты",
"profile": "Профиль",

View File

@@ -49,7 +49,7 @@
"sign_in_to": "登录到 {name}",
"client_not_found": "客户端未找到",
"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": "电子邮件",
"view_your_email_address": "查看您的电子邮件地址",
"profile": "个人资料",

11047
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,70 +1,199 @@
@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 {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(240 10% 3.9%);
--muted: hsl(240 4.8% 95.9%);
--muted-foreground: hsl(240 3.8% 46.1%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(240 10% 3.9%);
--border: hsl(240 5.9% 90%);
--input: hsl(240 5.9% 90%);
--primary: hsl(240 5.9% 10%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%);
--accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 10%);
--destructive: hsl(0 72.2% 50.6%);
--destructive-foreground: hsl(0 0% 98%);
--ring: hsl(240 10% 3.9%);
--radius: 0.5rem;
}
.dark {
--background: hsl(240 10% 3.9%);
--foreground: hsl(0 0% 98%);
--muted: hsl(240 3.7% 15.9%);
--muted-foreground: hsl(240 5% 64.9%);
--popover: hsl(240 10% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--card: hsl(240 10% 3.9%);
--card-foreground: hsl(0 0% 98%);
--border: hsl(240 3.7% 15.9%);
--input: hsl(240 3.7% 15.9%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(240 5.9% 10%);
--secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: hsl(0 0% 98%);
--accent: hsl(240 3.7% 15.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(0 0% 98%);
--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 {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 0 0% 98%;
--ring: 240 10% 3.9%;
--radius: 0.5rem;
* {
@apply border-border;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
body {
@apply bg-background text-foreground;
}
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
button {
@apply cursor-pointer;
}
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
@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');
}
}
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
@keyframes accordion-down {
from {
height: 0;
}
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
to {
height: var(--bits-accordion-content-height);
}
}
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
@keyframes accordion-up {
from {
height: var(--bits-accordion-content-height);
}
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
to {
height: 0;
}
}
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
@keyframes caret-blink {
0%,
70%,
100% {
opacity: 1;
}
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--ring: 240 4.9% 83.9%;
20%,
50% {
opacity: 0;
}
}
@@ -102,7 +231,6 @@
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 {
0%,
40% {
@@ -116,38 +244,3 @@
.animate-delayed-fade {
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 };
}

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

@@ -0,0 +1,31 @@
import { paraglideMiddleware } from '$lib/paraglide/server';
import type { Handle, HandleServerError } from '@sveltejs/kit';
import { AxiosError } from 'axios';
// Handle to use the paraglide middleware
const paraglideHandle: Handle = ({ event, resolve }) => {
return paraglideMiddleware(event.request, ({ locale }) => {
return resolve(event, {
transformPageChunk: ({ html }) => html.replace('%lang%', locale)
});
});
};
export const handle: Handle = paraglideHandle;
export const handleError: HandleServerError = async ({ error, message, status }) => {
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 { debounced } from '$lib/utils/debounce-util';
import { cn } from '$lib/utils/style';
import { ChevronDown } from 'lucide-svelte';
import { ChevronDown } from '@lucide/svelte';
import type { Snippet } from 'svelte';
import Button from './ui/button/button.svelte';
import { m } from '$lib/paraglide/messages';
@@ -96,7 +96,7 @@
)}
placeholder={m.search()}
type="text"
oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
oninput={(e: Event) => onSearch((e.currentTarget as HTMLInputElement).value)}
/>
{/if}
@@ -114,7 +114,7 @@
<Checkbox
disabled={selectionDisabled}
checked={allChecked}
onCheckedChange={(c) => onAllCheck(c as boolean)}
onCheckedChange={(c: boolean) => onAllCheck(c as boolean)}
/>
</Table.Head>
{/if}
@@ -124,7 +124,7 @@
<Button
variant="ghost"
class="flex items-center"
on:click={() =>
onclick={() =>
onSort(
column.sortColumn,
requestOptions.sort?.direction === 'desc' ? 'asc' : 'desc'
@@ -134,7 +134,7 @@
{#if requestOptions.sort?.column === column.sortColumn}
<ChevronDown
class={cn(
'ml-2 h-4 w-4',
'ml-2 size-4',
requestOptions.sort?.direction === 'asc' ? 'rotate-180' : ''
)}
/>
@@ -155,7 +155,7 @@
<Checkbox
disabled={selectionDisabled}
checked={selectedIds.includes(item.id)}
onCheckedChange={(c) => onCheck(c as boolean, item.id)}
onCheckedChange={(c: boolean) => onCheck(c, item.id)}
/>
</Table.Cell>
{/if}
@@ -169,18 +169,16 @@
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">{m.items_per_page()}</p>
<Select.Root
selected={{
label: items.pagination.itemsPerPage.toString(),
value: items.pagination.itemsPerPage
}}
onSelectedChange={(v) => onPageSizeChange(v?.value as number)}
type="single"
value={items.pagination.itemsPerPage.toString()}
onValueChange={(v) => onPageSizeChange(Number(v))}
>
<Select.Trigger class="h-9 w-[80px]">
<Select.Value>{items.pagination.itemsPerPage}</Select.Value>
{items.pagination.itemsPerPage}
</Select.Trigger>
<Select.Content>
{#each availablePageSizes as size}
<Select.Item value={size}>{size}</Select.Item>
<Select.Item value={size.toString()}>{size}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
@@ -191,25 +189,26 @@
perPage={items.pagination.itemsPerPage}
{onPageChange}
page={items.pagination.currentPage}
let:pages
>
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis' && page.value != 0}
<Pagination.Item>
<Pagination.Link {page} isActive={items.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{#snippet children({ pages })}
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis' && page.value != 0}
<Pagination.Item>
<Pagination.Link {page} isActive={items.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
{/if}

View File

@@ -37,7 +37,7 @@
: (auditLogs = await auditLogService.list(options))}
columns={[
{ label: m.time(), sortColumn: 'createdAt' },
...(isAdmin ? [{ label: 'Username' }] : []),
...(isAdmin ? [{ label: 'Username' }] : []),
{ label: m.event(), sortColumn: 'event' },
{ label: m.approximate_location(), sortColumn: 'city' },
{ label: m.ip_address(), sortColumn: 'ipAddress' },
@@ -58,7 +58,7 @@
</Table.Cell>
{/if}
<Table.Cell>
<Badge variant="outline">{toFriendlyEventString(item.event)}</Badge>
<Badge class="rounded-full" variant="outline">{toFriendlyEventString(item.event)}</Badge>
</Table.Cell>
<Table.Cell
>{item.city && item.country ? `${item.city}, ${item.country}` : m.unknown()}</Table.Cell

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,13 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import { LucideXCircle } from 'lucide-svelte';
import { LucideXCircle } from '@lucide/svelte';
let { message, showButton = true }: { message: string; showButton?: boolean } = $props();
</script>
<div class="mt-[20%] flex flex-col items-center">
<LucideXCircle class="h-12 w-12 text-muted-foreground" />
<LucideXCircle class="text-muted-foreground size-12" />
<h1 class="mt-3 text-2xl font-semibold">{m.something_went_wrong()}</h1>
<p class="text-muted-foreground">{message}</p>
{#if showButton}

View File

@@ -48,7 +48,7 @@
opacity: 0;
transform: translateY(10px);
animation-delay: calc(var(--animation-delay, 0ms) + 0.1s);
animation: fadeIn 0.8s ease-out forwards;
animation: fadeIn 0.3s ease-out forwards;
will-change: opacity, transform;
}
</style>

View File

@@ -75,15 +75,16 @@
onfocus={() => (isInputFocused = true)}
onblur={() => (isInputFocused = false)}
/>
<Popover.Root
open={isOpen}
disableFocusTrap
openFocus={() => {}}
closeOnOutsideClick={false}
closeOnEscape={false}
>
<Popover.Root open={isOpen}>
<Popover.Trigger tabindex={-1} class="h-0 w-full" aria-hidden />
<Popover.Content class="p-0" sideOffset={5} sameWidth>
<Popover.Content
sameWidth
class="p-0"
sideOffset={5}
trapFocus={false}
interactOutsideBehavior="ignore"
onCloseAutoFocus={(e) => e.preventDefault()}
>
{#each filteredSuggestions as suggestion, index}
<div
role="button"
@@ -92,7 +93,7 @@
onkeydown={(e) => {
if (e.key === 'Enter') handleSuggestionClick(suggestion);
}}
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 {selectedIndex ===
class="hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 {selectedIndex ===
index
? 'bg-accent text-accent-foreground'
: ''}"

View File

@@ -27,7 +27,7 @@
bind:checked
/>
<div class="grid gap-1.5 leading-none">
<Label for={id} class="mb-0 text-sm font-medium leading-none">
<Label for={id} class="mb-0 text-sm leading-none font-medium">
{label}
</Label>
{#if description}

View File

@@ -4,7 +4,7 @@
import { Input } from '$lib/components/ui/input';
import CustomClaimService from '$lib/services/custom-claim-service';
import type { CustomClaim } from '$lib/types/custom-claim.type';
import { LucideMinus, LucidePlus } from 'lucide-svelte';
import { LucideMinus, LucidePlus } from '@lucide/svelte';
import { onMount, type Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import AutoCompleteInput from './auto-complete-input.svelte';
@@ -51,25 +51,25 @@
variant="outline"
size="sm"
aria-label={m.remove_custom_claim()}
on:click={() => (customClaims = customClaims.filter((_, index) => index !== i))}
onclick={() => (customClaims = customClaims.filter((_, index) => index !== i))}
>
<LucideMinus class="h-4 w-4" />
<LucideMinus class="size-4" />
</Button>
</div>
{/each}
</div>
</FormInput>
{#if error}
<p class="mt-1 text-sm text-red-500">{error}</p>
<p class="text-destructive mt-1 text-xs">{error}</p>
{/if}
{#if customClaims.length < limit}
<Button
class="mt-2"
variant="secondary"
size="sm"
on:click={() => (customClaims = [...customClaims, { key: '', value: '' }])}
onclick={() => (customClaims = [...customClaims, { key: '', value: '' }])}
>
<LucidePlus class="mr-1 h-4 w-4" />
<LucidePlus class="mr-1 size-4" />
{customClaims.length === 0 ? m.add_custom_claim() : m.add_another()}
</Button>
{/if}

View File

@@ -11,24 +11,53 @@
getLocalTimeZone,
type DateValue
} from '@internationalized/date';
import CalendarIcon from 'lucide-svelte/icons/calendar';
import CalendarIcon from '@lucide/svelte/icons/calendar';
import type { HTMLAttributes } from 'svelte/elements';
let { value = $bindable(), ...restProps }: HTMLAttributes<HTMLButtonElement> & { value: Date } =
$props();
type Props = {
value?: Date;
id?: string;
} & HTMLAttributes<HTMLDivElement>;
let { value = $bindable(undefined), id, ...restProps }: Props = $props();
let calendarDisplayDate: CalendarDate | undefined = $state(
value ? dateToCalendarDate(value) : undefined
);
let date: CalendarDate = $state(dateToCalendarDate(value));
let open = $state(false);
function dateToCalendarDate(date: Date) {
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate());
function dateToCalendarDate(d: Date): CalendarDate {
return new CalendarDate(d.getFullYear(), d.getMonth() + 1, d.getDate());
}
function onValueChange(newDate?: DateValue) {
if (!newDate) return;
$effect(() => {
if (calendarDisplayDate) {
const newExternalDate = calendarDisplayDate.toDate(getLocalTimeZone());
if (!value || value.getTime() !== newExternalDate.getTime()) {
value = newExternalDate;
}
} else {
if (value !== undefined) {
value = undefined;
}
}
});
value = newDate.toDate(getLocalTimeZone());
date = newDate as CalendarDate;
$effect(() => {
if (value) {
const newInternalCalendarDate = dateToCalendarDate(value);
if (!calendarDisplayDate || calendarDisplayDate.compare(newInternalCalendarDate) !== 0) {
calendarDisplayDate = newInternalCalendarDate;
}
} else {
if (calendarDisplayDate !== undefined) {
calendarDisplayDate = undefined;
}
}
});
function handleCalendarInteraction(newDateValue?: DateValue) {
open = false;
}
@@ -37,19 +66,33 @@
});
</script>
<Popover.Root openFocus {open} onOpenChange={(o) => (open = o)}>
<Popover.Trigger asChild let:builder>
<Button
{...restProps}
variant="outline"
class={cn('w-full justify-start text-left font-normal', !value && 'text-muted-foreground')}
builders={[builder]}
>
<CalendarIcon class="mr-2 h-4 w-4" />
{date ? df.format(date.toDate(getLocalTimeZone())) : m.select_a_date()}
</Button>
</Popover.Trigger>
<Popover.Content class="w-auto p-0" align="start">
<Calendar bind:value={date} initialFocus {onValueChange} />
</Popover.Content>
</Popover.Root>
<div class="w-full" {...restProps}>
<Popover.Root bind:open>
<Popover.Trigger {id} class="w-full" >
{#snippet child({ props })}
<Button
{...props}
variant="outline"
class={cn(
'w-full justify-start text-left font-normal',
!value && 'text-muted-foreground'
)}
aria-label={m.select_a_date()}
>
<CalendarIcon class="mr-2 size-4" />
{calendarDisplayDate
? df.format(calendarDisplayDate.toDate(getLocalTimeZone()))
: m.select_a_date()}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-auto p-0" align="start">
<Calendar
type="single"
bind:value={calendarDisplayDate}
onValueChange={handleCalendarInteraction}
initialFocus
/>
</Popover.Content>
</Popover.Root>
</div>

View File

@@ -45,17 +45,18 @@
<DatePicker {id} bind:value={input.value as Date} />
{:else}
<Input
aria-invalid={!!input.error}
{id}
{placeholder}
{type}
bind:value={input.value}
{disabled}
on:input={(e) => onInput?.(e)}
oninput={(e) => onInput?.(e)}
/>
{/if}
{/if}
{#if input?.error}
<p class="mt-1 text-sm text-red-500">{input.error}</p>
<p class="text-destructive mt-1 text-xs">{input.error}</p>
{/if}
</div>
</div>

View File

@@ -4,7 +4,7 @@
import Button from '$lib/components/ui/button/button.svelte';
import { m } from '$lib/paraglide/messages';
import { getProfilePictureUrl } from '$lib/utils/profile-picture-util';
import { LucideLoader, LucideRefreshCw, LucideUpload } from 'lucide-svelte';
import { LucideLoader, LucideRefreshCw, LucideUpload } from '@lucide/svelte';
import { onMount } from 'svelte';
import { openConfirmDialog } from '../confirm-dialog';
@@ -65,7 +65,7 @@
<div class="flex flex-col items-center gap-6 sm:flex-row">
<div class="shrink-0">
{#if isLdapUser}
<Avatar.Root class="h-24 w-24">
<Avatar.Root class="size-24">
<Avatar.Image class="object-cover" src={imageDataURL} />
</Avatar.Root>
{:else}
@@ -75,7 +75,7 @@
accept="image/png, image/jpeg"
onchange={onImageChange}
>
<div class="group relative h-24 w-24 rounded-full">
<div class="group relative size-24 rounded-full">
<Avatar.Root class="h-full w-full transition-opacity duration-200">
<Avatar.Image
class="object-cover group-hover:opacity-30 {isLoading ? 'opacity-30' : ''}"
@@ -84,9 +84,9 @@
</Avatar.Root>
<div class="absolute inset-0 flex items-center justify-center">
{#if isLoading}
<LucideLoader class="h-5 w-5 animate-spin" />
<LucideLoader class="size-5 animate-spin" />
{:else}
<LucideUpload class="h-5 w-5 opacity-0 transition-opacity group-hover:opacity-100" />
<LucideUpload class="size-5 opacity-0 transition-opacity group-hover:opacity-100" />
{/if}
</div>
</div>
@@ -105,8 +105,8 @@
{m.click_profile_picture_to_upload_custom()}
</p>
<p class="text-muted-foreground mb-2 text-sm">{m.image_should_be_in_format()}</p>
<Button variant="outline" size="sm" on:click={onReset} disabled={isLoading || isLdapUser}>
<LucideRefreshCw class="mr-2 h-4 w-4" />
<Button variant="outline" size="sm" onclick={onReset} disabled={isLoading || isLdapUser}>
<LucideRefreshCw class="mr-2 size-4" />
{m.reset_to_default()}
</Button>
{/if}

View File

@@ -3,7 +3,7 @@
import * as Command from '$lib/components/ui/command';
import * as Popover from '$lib/components/ui/popover';
import { cn } from '$lib/utils/style';
import { LucideCheck, LucideChevronDown } from 'lucide-svelte';
import { LucideCheck, LucideChevronDown } from '@lucide/svelte';
import { tick } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
@@ -52,21 +52,19 @@
});
</script>
<Popover.Root bind:open let:ids>
<Popover.Trigger asChild let:builder>
<Popover.Root bind:open {...restProps}>
<Popover.Trigger class="w-full">
<Button
{...restProps}
builders={[builder]}
variant="outline"
role="combobox"
aria-expanded={open}
class={cn('justify-between', restProps.class)}
>
{items.find((item) => item.value === value)?.label || 'Select an option'}
<LucideChevronDown class="ml-2 h-4 w-4 shrink-0 opacity-50" />
<LucideChevronDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</Popover.Trigger>
<Popover.Content class="p-0" sameWidth>
<Popover.Content class="p-0">
<Command.Root shouldFilter={false}>
<Command.Input placeholder="Search..." oninput={(e: any) => filterItems(e.target.value)} />
<Command.Empty>No results found.</Command.Empty>
@@ -77,10 +75,11 @@
onSelect={() => {
value = item.value;
onSelect?.(item.value);
closeAndFocusTrigger(ids.trigger);
// If you need to focus the trigger, you may need to refactor to get the trigger id another way
closeAndFocusTrigger('popover-trigger');
}}
>
<LucideCheck class={cn('mr-2 h-4 w-4', value !== item.value && 'text-transparent')} />
<LucideCheck class={cn('mr-2 size-4', value !== item.value && 'text-transparent')} />
{item.label}
</Command.Item>
{/each}

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '$lib/components/ui/tooltip';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { m } from '$lib/paraglide/messages';
import { LucideCalendar, LucidePencil, LucideTrash, type Icon as IconType } from 'lucide-svelte';
import { LucideCalendar, LucidePencil, LucideTrash, type Icon as IconType } from '@lucide/svelte';
let {
icon,
@@ -24,7 +24,7 @@
<div class="flex items-start gap-3">
<div class="bg-primary/10 text-primary mt-1 rounded-lg p-2">
{#if icon}{@const Icon = icon}
<Icon class="h-5 w-5" />
<Icon class="size-5" />
{/if}
</div>
<div>
@@ -33,7 +33,7 @@
</div>
{#if description}
<div class="text-muted-foreground mt-1 flex items-center text-xs">
<LucideCalendar class="mr-1 h-3 w-3" />
<LucideCalendar class="mr-1 size-3" />
{description}
</div>
{/if}
@@ -41,35 +41,39 @@
</div>
<div class="flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<Button
on:click={onRename}
size="icon"
variant="ghost"
class="h-8 w-8"
aria-label={m.rename()}
>
<LucidePencil class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{m.rename()}</TooltipContent>
</Tooltip>
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
<Button
onclick={onRename}
size="icon"
variant="ghost"
class="size-8"
aria-label={m.rename()}
>
<LucidePencil class="size-4" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>{m.rename()}</Tooltip.Content>
</Tooltip.Root></Tooltip.Provider
>
<Tooltip>
<TooltipTrigger asChild>
<Button
on:click={onDelete}
size="icon"
variant="ghost"
class="hover:bg-destructive/10 hover:text-destructive h-8 w-8"
aria-label={m.delete()}
>
<LucideTrash class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{m.delete()}</TooltipContent>
</Tooltip>
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
<Button
onclick={onDelete}
size="icon"
variant="ghost"
class="hover:bg-destructive/10 hover:text-destructive size-8"
aria-label={m.delete()}
>
<LucideTrash class="size-4" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>{m.delete()}</Tooltip.Content>
</Tooltip.Root></Tooltip.Provider
>
</div>
</div>
</div>

View File

@@ -1,11 +1,12 @@
<script lang="ts">
import { goto } from '$app/navigation';
import * as Avatar from '$lib/components/ui/avatar';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { m } from '$lib/paraglide/messages';
import WebAuthnService from '$lib/services/webauthn-service';
import userStore from '$lib/stores/user-store';
import { getProfilePictureUrl } from '$lib/utils/profile-picture-util';
import { LucideLogOut, LucideUser } from 'lucide-svelte';
import { LucideLogOut, LucideUser } from '@lucide/svelte';
const webauthnService = new WebAuthnService();
@@ -17,14 +18,14 @@
<DropdownMenu.Root>
<DropdownMenu.Trigger
><Avatar.Root class="h-9 w-9">
><Avatar.Root class="size-9">
<Avatar.Image src={getProfilePictureUrl($userStore?.id)} />
</Avatar.Root></DropdownMenu.Trigger
>
<DropdownMenu.Content class="min-w-40" align="start">
<DropdownMenu.Content class="min-w-40" align="end">
<DropdownMenu.Label class="font-normal">
<div class="flex flex-col space-y-1">
<p class="text-sm font-medium leading-none">
<p class="text-sm leading-none font-medium">
{$userStore?.firstName}
{$userStore?.lastName}
</p>
@@ -33,11 +34,11 @@
</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item href="/settings/account"
><LucideUser class="mr-2 h-4 w-4" /> {m.my_account()}</DropdownMenu.Item
<DropdownMenu.Item onclick={() => goto('/settings/account')}
><LucideUser class="mr-2 size-4" /> {m.my_account()}</DropdownMenu.Item
>
<DropdownMenu.Item on:click={logout}
><LucideLogOut class="mr-2 h-4 w-4" /> {m.logout()}</DropdownMenu.Item
<DropdownMenu.Item onclick={logout}
><LucideLogOut class="mr-2 size-4" /> {m.logout()}</DropdownMenu.Item
>
</DropdownMenu.Group>
</DropdownMenu.Content>

View File

@@ -24,7 +24,7 @@
href="/settings/account"
class="flex items-center gap-3 transition-opacity hover:opacity-80"
>
<Logo class="h-8 w-8" />
<Logo class="size-8" />
<h1 class="text-lg font-semibold tracking-tight" data-testid="application-name">
{$appConfigStore.appName}
</h1>

View File

@@ -50,7 +50,7 @@
</div>
<!-- Background image with slide animation -->
<div class="{cn(animate && 'animate-slide-bg-container')} absolute bottom-0 right-0 top-0 z-0">
<div class="{cn(animate && 'animate-slide-bg-container')} absolute top-0 right-0 bottom-0 z-0">
<img
src="/api/application-configuration/background-image"
class="h-screen rounded-l-[60px] object-cover {animate ? 'w-full' : 'w-[calc(100vw-650px)]'}"

View File

@@ -5,7 +5,7 @@
let { ...props }: HTMLAttributes<HTMLImageElement> = $props();
const isDarkMode = $derived($mode === 'dark');
const isDarkMode = $derived(mode.current === 'dark');
</script>
<img {...props} src="/api/application-configuration/logo?light={!isDarkMode}" alt={m.logo()} />

View File

@@ -77,15 +77,12 @@
<div>
<Label for="expiration">{m.expiration()}</Label>
<Select.Root
selected={{
label: Object.keys(availableExpirations)[0],
value: Object.keys(availableExpirations)[0]
}}
onSelectedChange={(v) =>
(selectedExpiration = v!.value as keyof typeof availableExpirations)}
type="single"
value={Object.keys(availableExpirations)[0]}
onValueChange={(v) => (selectedExpiration = v! as keyof typeof availableExpirations)}
>
<Select.Trigger class="h-9 w-full">
<Select.Value>{selectedExpiration}</Select.Value>
<Select.Trigger id="expiration" class="h-9 w-full">
{selectedExpiration}
</Select.Trigger>
<Select.Content>
{#each Object.keys(availableExpirations) as key}
@@ -116,7 +113,7 @@
<div class="text-muted-foreground my-2 flex items-center justify-center gap-3">
<Separator />
<p class="text-nowrap text-xs">{m.or_visit()}</p>
<p class="text-xs text-nowrap">{m.or_visit()}</p>
<Separator />
</div>
@@ -124,8 +121,8 @@
class="mb-2"
value={oneTimeLink}
size={180}
color={$mode === 'dark' ? '#FFFFFF' : '#000000'}
backgroundColor={$mode === 'dark' ? '#000000' : '#FFFFFF'}
color={mode.current === 'dark' ? '#FFFFFF' : '#000000'}
backgroundColor={mode.current === 'dark' ? '#000000' : '#FFFFFF'}
/>
<CopyToClipboard value={oneTimeLink!}>
<p data-testId="login-code-link">{oneTimeLink!}</p>

View File

@@ -1,13 +1,20 @@
<script lang="ts">
export let icon: ConstructorOfATypedSvelteComponent;
export let name: string;
export let description: string;
import type { Icon as IconType } from '@lucide/svelte';
interface Props {
icon: typeof IconType;
name: string;
description: string;
}
let { icon, name, description }: Props = $props();
const SvelteComponent = $derived(icon);
</script>
<div class="flex items-center">
<div class="mr-5 rounded-lg bg-muted p-2"><svelte:component this={icon} /></div>
<div class="bg-muted mr-5 rounded-lg p-2"><SvelteComponent /></div>
<div class="text-start">
<h3 class="font-semibold">{name}</h3>
<p class="text-sm text-muted-foreground">{description}</p>
<p class="text-muted-foreground text-sm">{description}</p>
</div>
</div>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages';
import { LucideMail, LucideUser, LucideUsers } from 'lucide-svelte';
import { LucideMail, LucideUser, LucideUsers } from '@lucide/svelte';
import ScopeItem from './scope-item.svelte';
let { scope }: { scope: string } = $props();

View File

@@ -3,19 +3,16 @@
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils/style.js';
type $$Props = AlertDialogPrimitive.ActionProps;
type $$Events = AlertDialogPrimitive.ActionEvents;
let className: $$Props['class'] = undefined;
export { className as class };
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.ActionProps = $props();
</script>
<AlertDialogPrimitive.Action
bind:ref
data-slot="alert-dialog-action"
class={cn(buttonVariants(), className)}
{...$$restProps}
on:click
on:keydown
let:builder
>
<slot {builder} />
</AlertDialogPrimitive.Action>
{...restProps}
/>

View File

@@ -3,19 +3,16 @@
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils/style.js';
type $$Props = AlertDialogPrimitive.CancelProps;
type $$Events = AlertDialogPrimitive.CancelEvents;
let className: $$Props['class'] = undefined;
export { className as class };
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.CancelProps = $props();
</script>
<AlertDialogPrimitive.Cancel
class={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
{...$$restProps}
on:click
on:keydown
let:builder
>
<slot {builder} />
</AlertDialogPrimitive.Cancel>
bind:ref
data-slot="alert-dialog-cancel"
class={cn(buttonVariants({ variant: 'outline' }), className)}
{...restProps}
/>

View File

@@ -1,28 +1,27 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import * as AlertDialog from './index.js';
import { cn, flyAndScale } from '$lib/utils/style.js';
import AlertDialogOverlay from './alert-dialog-overlay.svelte';
import { cn, type WithoutChild, type WithoutChildrenOrChild } from '$lib/utils/style.js';
type $$Props = AlertDialogPrimitive.ContentProps;
export let transition: $$Props['transition'] = flyAndScale;
export let transitionConfig: $$Props['transitionConfig'] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
let {
ref = $bindable(null),
class: className,
portalProps,
...restProps
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<AlertDialogPrimitive.PortalProps>;
} = $props();
</script>
<AlertDialog.Portal>
<AlertDialog.Overlay />
<AlertDialogPrimitive.Portal {...portalProps}>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
{transition}
{transitionConfig}
bind:ref
data-slot="alert-dialog-content"
class={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg sm:rounded-lg md:w-full',
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
{...$$restProps}
>
<slot />
</AlertDialogPrimitive.Content>
</AlertDialog.Portal>
{...restProps}
/>
</AlertDialogPrimitive.Portal>

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