Compare commits

..

38 Commits

Author SHA1 Message Date
Elias Schneider
6e4d2a4a33 release: 1.7.0 2025-08-10 20:01:03 +02:00
Elias Schneider
6c65bd34cd chore(translations): update translations via Crowdin (#820) 2025-08-10 19:50:36 +02:00
Kyle Mendell
7bfe4834d0 chore: switch from npm to pnpm (#786)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-10 12:16:30 -05:00
Kyle Mendell
484c2f6ef2 feat: user application dashboard (#727)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-10 15:56:03 +00:00
Elias Schneider
87956ea725 chore(translations): update translations via Crowdin (#819) 2025-08-10 10:18:30 -05:00
Elias Schneider
32dd403038 chore(translations): update translations via Crowdin (#817) 2025-08-10 14:49:24 +02:00
Elias Schneider
4d59e72866 fix: custom claims input suggestions instantly close after opening 2025-08-08 15:11:44 +02:00
Elias Schneider
9ac5d51187 fix: authorization animation not working 2025-08-08 12:23:32 +02:00
Elias Schneider
5a031f5d1b refactor: use reflection to mark file based env variables (#815) 2025-08-07 20:41:00 +02:00
Alessandro (Ale) Segala
535bc9f46b chore: additional logs for database connections (#813) 2025-08-06 18:04:25 +02:00
Kyle Mendell
f0c144c51c fix: admins can not delete or disable their own account 2025-08-05 16:14:25 -05:00
Elias Schneider
61e4ea45fb chore(translations): update translations via Crowdin (#811) 2025-08-05 15:56:45 -05:00
Etienne
06e1656923 feat: add robots.txt to block indexing (#806) 2025-08-02 18:30:50 +00:00
Alessandro (Ale) Segala
0a3b1c6530 feat: support reading secret env vars from _FILE (#799)
Co-authored-by: Kyle Mendell <ksm@ofkm.us>
2025-07-30 11:59:25 -05:00
Kyle Mendell
d479817b6a feat: add support for code_challenge_methods_supported (#794) 2025-07-29 17:34:49 -05:00
Elias Schneider
01bf31d23d chore(translations): update translations via Crowdin (#791) 2025-07-27 20:21:37 -05:00
Alessandro (Ale) Segala
42a861d206 refactor: complete conversion of log calls to slog (#787) 2025-07-27 04:34:23 +00:00
Alessandro (Ale) Segala
78266e3e4c feat: Support OTel and JSON for logs (via log/slog) (#760)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-07-27 01:03:52 +00:00
Alessandro (Ale) Segala
c8478d75be fix: delete WebAuthn registration session after use (#783) 2025-07-26 18:45:54 -05:00
Elias Schneider
28d93b00a3 chore(translations): update translations via Crowdin (#785) 2025-07-26 16:37:42 -05:00
Kyle Mendell
12a7a6a5c5 chore: update Vietnamese display name 2025-07-26 15:33:36 -05:00
Elias Schneider
a6d5071724 chore(translations): update translations via Crowdin (#782) 2025-07-25 15:48:52 -05:00
Elias Schneider
cebe2242b9 chore(translations): update translations via Crowdin (#779) 2025-07-24 20:28:07 -05:00
Kyle Mendell
56ee7d946f chore: fix federated credentials type error 2025-07-24 20:22:34 -05:00
Kyle Mendell
f3c6521f2b chore: update dependencies and fix zod/4 import path 2025-07-24 20:16:17 -05:00
Kyle Mendell
ffed465f09 chore: update dependencies and fix zod/4 import path 2025-07-24 20:14:25 -05:00
Kyle Mendell
c359b5be06 chore: rename glass-row-item to passkey-row 2025-07-24 19:50:27 -05:00
Elias Schneider
e9a023bb71 chore(translations): update translations via Crowdin (#778)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-07-24 19:35:16 -05:00
Kyle Mendell
60f0b28076 chore(transaltions): add Vietnamese files 2025-07-24 10:11:01 -05:00
Alessandro (Ale) Segala
d541c9ab4a fix: set input type 'email' for email-based login (#776) 2025-07-23 12:39:50 -05:00
dependabot[bot]
024ed53022 chore(deps): bump axios from 1.10.0 to 1.11.0 in /frontend in the npm_and_yarn group across 1 directory (#777)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-23 12:38:00 -05:00
Elias Schneider
2c78bd1b46 chore(translations): update translations via Crowdin (#767) 2025-07-22 15:08:04 -05:00
dependabot[bot]
5602d79611 chore(deps): bump form-data from 4.0.1 to 4.0.4 in /frontend in the npm_and_yarn group across 1 directory (#771)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-22 15:07:43 -05:00
Kyle Mendell
51b73c9c31 chore(translations): add Ukrainian files 2025-07-21 15:56:59 -05:00
Elias Schneider
10f0580a43 chore(translations): update translations via Crowdin (#763) 2025-07-21 07:32:57 -05:00
ItalyPaleAle
a1488565ea release: 1.6.4 2025-07-21 07:44:25 +02:00
Alessandro (Ale) Segala
35d5f887ce fix: migration fails on postgres (#762) 2025-07-20 22:36:22 -07:00
Kyle Mendell
4c76de45ed chore: remove labels from issue templates 2025-07-20 22:51:02 -05:00
108 changed files with 6660 additions and 6323 deletions

View File

@@ -1,4 +1,4 @@
node_modules
**/node_modules
# Output
.output

View File

@@ -2,7 +2,6 @@ name: "🐛 Bug Report"
description: "Report something that is not working as expected"
title: "🐛 Bug Report: "
type: 'Bug'
labels: [bug]
body:
- type: markdown
attributes:

View File

@@ -1,7 +1,6 @@
name: 🚀 Feature
description: "Submit a proposal for a new feature"
title: "🚀 Feature: "
labels: [feature]
type: 'Feature'
body:
- type: textarea

View File

@@ -1,7 +1,6 @@
name: "🌐 Language request"
description: "You want to contribute to a language that isn't on Crowdin yet?"
title: "🌐 Language Request: <language name in english>"
labels: [language-request]
type: 'Language Request'
body:
- type: input

View File

@@ -21,17 +21,22 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: "backend/go.mod"
go-version-file: 'backend/go.mod'
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -54,12 +59,11 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
run: pnpm install --frozen-lockfile
- name: Build frontend
working-directory: frontend
run: npm run build
run: pnpm run build
- name: Build binaries
run: sh scripts/development/build-binaries.sh --docker-only
@@ -85,12 +89,12 @@ jobs:
- name: Container image attestation
uses: actions/attest-build-provenance@v2
with:
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
subject-name: '${{ env.DOCKER_IMAGE_NAME }}'
subject-digest: ${{ steps.build-push-image.outputs.digest }}
push-to-registry: true
- name: Container image attestation (distroless)
uses: actions/attest-build-provenance@v2
with:
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
subject-name: '${{ env.DOCKER_IMAGE_NAME }}'
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
push-to-registry: true

View File

@@ -3,15 +3,15 @@ on:
push:
branches: [main]
paths-ignore:
- "docs/**"
- "**.md"
- ".github/**"
- 'docs/**'
- '**.md'
- '.github/**'
pull_request:
branches: [main]
paths-ignore:
- "docs/**"
- "**.md"
- ".github/**"
- 'docs/**'
- '**.md'
- '.github/**'
jobs:
build:
@@ -54,18 +54,22 @@ jobs:
needs: build
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml
- name: Cache Playwright Browsers
uses: actions/cache@v3
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/package-lock.json') }}
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-
@@ -96,13 +100,12 @@ jobs:
run: docker load < /tmp/lldap-image.tar
- name: Install test dependencies
working-directory: ./tests
run: npm ci
run: pnpm --filter pocket-id-tests install --frozen-lockfile
- name: Install Playwright Browsers
working-directory: ./tests
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
run: pnpm dlx playwright install --with-deps chromium
- name: Run Docker Container with Sqlite DB and LDAP
working-directory: ./tests/setup
@@ -111,8 +114,8 @@ jobs:
docker compose logs -f pocket-id &> /tmp/backend.log &
- name: Run Playwright tests
working-directory: ./tests
run: npx playwright test
working-directory: tests
run: pnpm exec playwright test
- name: Upload Test Report
uses: actions/upload-artifact@v4
@@ -141,18 +144,22 @@ jobs:
needs: build
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml
- name: Cache Playwright Browsers
uses: actions/cache@v3
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/package-lock.json') }}
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-
@@ -200,13 +207,12 @@ jobs:
run: docker load -i /tmp/docker-image.tar
- name: Install test dependencies
working-directory: ./tests
run: npm ci
run: pnpm --filter pocket-id-tests install
- name: Install Playwright Browsers
working-directory: ./tests
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
run: pnpm dlx playwright install --with-deps chromium
- name: Run Docker Container with Postgres DB and LDAP
working-directory: ./tests/setup
@@ -216,7 +222,7 @@ jobs:
- name: Run Playwright tests
working-directory: ./tests
run: npx playwright test
run: pnpm exec playwright test
- name: Upload Test Report
uses: actions/upload-artifact@v4

View File

@@ -3,7 +3,7 @@ name: Release
on:
push:
tags:
- "v*.*.*"
- 'v*.*.*'
jobs:
build:
@@ -16,15 +16,19 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml
- uses: actions/setup-go@v5
with:
go-version-file: "backend/go.mod"
go-version-file: 'backend/go.mod'
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
@@ -62,13 +66,11 @@ jobs:
tags: |
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v
type=semver,pattern={{major}},prefix=v
type=semver,pattern={{major}},prefix=v
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
run: pnpm --filter pocket-id-frontend install --frozen-lockfile
- name: Build frontend
working-directory: frontend
run: npm run build
run: pnpm --filter pocket-id-frontend build
- name: Build binaries
run: sh scripts/development/build-binaries.sh
- name: Build and push container image
@@ -94,17 +96,17 @@ jobs:
- name: Binary attestation
uses: actions/attest-build-provenance@v2
with:
subject-path: "backend/.bin/pocket-id-**"
subject-path: 'backend/.bin/pocket-id-**'
- name: Container image attestation
uses: actions/attest-build-provenance@v2
with:
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
subject-name: '${{ env.DOCKER_IMAGE_NAME }}'
subject-digest: ${{ steps.container-build-push.outputs.digest }}
push-to-registry: true
- name: Container image attestation (distroless)
uses: actions/attest-build-provenance@v2
with:
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
subject-name: '${{ env.DOCKER_IMAGE_NAME }}'
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
push-to-registry: true
- name: Upload binaries to release
@@ -121,6 +123,6 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Mark release as published
run: gh release edit ${{ github.ref_name }} --draft=false

View File

@@ -4,21 +4,21 @@ on:
push:
branches: [main]
paths:
- "frontend/src/**"
- ".github/svelte-check-matcher.json"
- "frontend/package.json"
- "frontend/package-lock.json"
- "frontend/tsconfig.json"
- "frontend/svelte.config.js"
- 'frontend/src/**'
- '.github/svelte-check-matcher.json'
- 'frontend/package.json'
- 'frontend/package-lock.json'
- 'frontend/tsconfig.json'
- 'frontend/svelte.config.js'
pull_request:
branches: [main]
paths:
- "frontend/src/**"
- ".github/svelte-check-matcher.json"
- "frontend/package.json"
- "frontend/package-lock.json"
- "frontend/tsconfig.json"
- "frontend/svelte.config.js"
- 'frontend/src/**'
- '.github/svelte-check-matcher.json'
- 'frontend/package.json'
- 'frontend/package-lock.json'
- 'frontend/tsconfig.json'
- 'frontend/svelte.config.js'
workflow_dispatch:
jobs:
@@ -36,24 +36,28 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies
working-directory: frontend
run: npm ci
run: pnpm --filter pocket-id-frontend install --frozen-lockfile
- name: Build Pocket ID Frontend
working-directory: frontend
run: npm run build
run: pnpm --filter pocket-id-frontend build
- name: Add svelte-check problem matcher
run: echo "::add-matcher::.github/svelte-check-matcher.json"
- name: Run svelte-check
working-directory: frontend
run: npm run check
run: pnpm --filter pocket-id-frontend check

View File

@@ -1 +1 @@
1.6.3
1.7.0

View File

@@ -1,3 +1,28 @@
## [](https://github.com/pocket-id/pocket-id/compare/v1.6.4...v) (2025-08-10)
### Features
* add robots.txt to block indexing ([#806](https://github.com/pocket-id/pocket-id/issues/806)) ([06e1656](https://github.com/pocket-id/pocket-id/commit/06e1656923eb2f4531be497716f9147c09d60b65))
* add support for `code_challenge_methods_supported` ([#794](https://github.com/pocket-id/pocket-id/issues/794)) ([d479817](https://github.com/pocket-id/pocket-id/commit/d479817b6a7ca4807b5de500b3ba713d436b0770))
* Support OTel and JSON for logs (via log/slog) ([#760](https://github.com/pocket-id/pocket-id/issues/760)) ([78266e3](https://github.com/pocket-id/pocket-id/commit/78266e3e4cab2b23249c3baf20f4387d00eebd9e))
* support reading secret env vars from _FILE ([#799](https://github.com/pocket-id/pocket-id/issues/799)) ([0a3b1c6](https://github.com/pocket-id/pocket-id/commit/0a3b1c653050f2237d30ec437c5de88baa704a25))
* user application dashboard ([#727](https://github.com/pocket-id/pocket-id/issues/727)) ([484c2f6](https://github.com/pocket-id/pocket-id/commit/484c2f6ef20efc1fade1a41e2aeace54c7bb4f1b))
### Bug Fixes
* admins can not delete or disable their own account ([f0c144c](https://github.com/pocket-id/pocket-id/commit/f0c144c51c635bc348222a00d3bc88bc4e0711ef))
* authorization animation not working ([9ac5d51](https://github.com/pocket-id/pocket-id/commit/9ac5d5118710cad59c8c4ce7cef7ab09be3de664))
* custom claims input suggestions instantly close after opening ([4d59e72](https://github.com/pocket-id/pocket-id/commit/4d59e7286666480e20c728787a95e82513509240))
* delete WebAuthn registration session after use ([#783](https://github.com/pocket-id/pocket-id/issues/783)) ([c8478d7](https://github.com/pocket-id/pocket-id/commit/c8478d75bed7295625cd3cf62ef46fcd95902410))
* set input type 'email' for email-based login ([#776](https://github.com/pocket-id/pocket-id/issues/776)) ([d541c9a](https://github.com/pocket-id/pocket-id/commit/d541c9ab4af8d7283891a80f886dd5d4ebc52f53))
## [](https://github.com/pocket-id/pocket-id/compare/v1.6.3...v) (2025-07-21)
### Bug Fixes
* migration fails on postgres ([#762](https://github.com/pocket-id/pocket-id/issues/762)) ([35d5f88](https://github.com/pocket-id/pocket-id/commit/35d5f887ce7c88933d7e4c2f0acd2aeedd18c214))
## [](https://github.com/pocket-id/pocket-id/compare/v1.6.2...v) (2025-07-21)
### Bug Fixes

View File

@@ -28,7 +28,7 @@ Before you submit the pull request for review please ensure that
- **refactor** - code change that neither fixes a bug nor adds a feature
- Your pull request has a detailed description
- You run `npm run format` to format the code
- You run `pnpm format` to format the code
## Development Environment
@@ -69,10 +69,10 @@ The backend is built with [Gin](https://gin-gonic.com) and written in Go. To set
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.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`
1. Open the `pocket-id` project folder
2. Copy the `frontend/.env.development-example` file to `frontend/.env` and edit the variables as needed
3. Install the dependencies with `pnpm install`
4. Start the frontend with `pnpm dev`
You're all set! The application is now listening on `localhost:3000`. The backend gets proxied trough the frontend in development mode.
@@ -84,11 +84,13 @@ If you are contributing to a new feature please ensure that you add tests for it
The tests can be run like this:
1. Visit the setup folder by running `cd tests/setup`
1. Install the dependencies from the root of the project `pnpm install`
2. Start the test environment by running `docker compose up -d --build`
2. Visit the setup folder by running `cd tests/setup`
3. Go back to the test folder by running `cd ..`
4. Run the tests with `npx playwright test`
3. Start the test environment by running `docker compose up -d --build`
4. Go back to the test folder by running `cd ..`
5. Run the tests with `pnpm dlx playwright test` or from the root project folder `pnpm 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

@@ -5,11 +5,17 @@ ARG BUILD_TAGS=""
# Stage 1: Build Frontend
FROM node:22-alpine AS frontend-builder
RUN corepack enable
WORKDIR /build
COPY ./frontend/package*.json ./
RUN npm ci
COPY ./frontend ./
RUN BUILD_OUTPUT_PATH=dist npm run build
COPY pnpm-workspace.yaml pnpm-lock.yaml ./
COPY frontend/package.json ./frontend/
RUN pnpm --filter pocket-id-frontend install --frozen-lockfile
COPY ./frontend ./frontend/
RUN BUILD_OUTPUT_PATH=dist pnpm --filter pocket-id-frontend run build
# Stage 2: Build Backend
FROM golang:1.24-alpine AS backend-builder
@@ -19,7 +25,7 @@ COPY ./backend/go.mod ./backend/go.sum ./
RUN go mod download
COPY ./backend ./
COPY --from=frontend-builder /build/dist ./frontend/dist
COPY --from=frontend-builder /build/frontend/dist ./frontend/dist
COPY .version .version
WORKDIR /build/cmd
@@ -30,7 +36,7 @@ RUN VERSION=$(cat /build/.version) \
-tags "${BUILD_TAGS}" \
-ldflags="-X github.com/pocket-id/pocket-id/backend/internal/common.Version=${VERSION} -buildid=${VERSION}" \
-trimpath \
-o /build/pocket-id-backend \
-o /build/pocket-id \
.
# Stage 3: Production Image
@@ -39,7 +45,7 @@ WORKDIR /app
RUN apk add --no-cache curl su-exec
COPY --from=backend-builder /build/pocket-id-backend /app/pocket-id
COPY --from=backend-builder /build/pocket-id /app/pocket-id
COPY ./scripts/docker /app/docker
RUN chmod +x /app/pocket-id && \

View File

@@ -24,18 +24,25 @@ require (
github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2
github.com/lestrrat-go/jwx/v3 v3.0.1
github.com/lmittmann/tint v1.1.2
github.com/mattn/go-isatty v0.0.20
github.com/mileusna/useragent v1.3.5
github.com/orandin/slog-gorm v1.4.0
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
github.com/samber/slog-gin v1.15.1
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0
go.opentelemetry.io/otel v1.35.0
go.opentelemetry.io/otel/metric v1.35.0
go.opentelemetry.io/otel v1.37.0
go.opentelemetry.io/otel/log v0.13.0
go.opentelemetry.io/otel/metric v1.37.0
go.opentelemetry.io/otel/sdk v1.35.0
go.opentelemetry.io/otel/sdk/log v0.10.0
go.opentelemetry.io/otel/sdk/metric v1.35.0
go.opentelemetry.io/otel/trace v1.35.0
go.opentelemetry.io/otel/trace v1.37.0
golang.org/x/crypto v0.39.0
golang.org/x/image v0.24.0
golang.org/x/text v0.26.0
@@ -60,7 +67,7 @@ require (
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
@@ -86,7 +93,6 @@ require (
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
@@ -119,8 +125,6 @@ require (
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.10.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 // indirect
go.opentelemetry.io/otel/log v0.10.0 // indirect
go.opentelemetry.io/otel/sdk/log v0.10.0 // indirect
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

View File

@@ -73,8 +73,8 @@ github.com/go-co-op/gocron/v2 v2.15.0/go.mod h1:ZF70ZwEqz0OO4RBXE1sNxnANy/zvwLca
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -181,6 +181,8 @@ github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNB
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
@@ -208,6 +210,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsTUU=
github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2 h1:jG+FaCBv3h6GD5F+oenTfe3+0NmX8sCKjni5k3A5Dek=
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2/go.mod h1:rHaQJ5SjfCdL4sqCKa3FhklRcaXga2/qyvmQuA+ZJ6M=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
@@ -231,6 +235,8 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/slog-gin v1.15.1 h1:jsnfr+S5HQPlz9pFPA3tOmKW7wN/znyZiE6hncucrTM=
github.com/samber/slog-gin v1.15.1/go.mod h1:mPAEinK/g2jPLauuWO11m3Q0Ca7aG4k9XjXjXY8IhMQ=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
@@ -260,6 +266,8 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0 h1:lFM7SZo8Ce01RzRfnUFQZEYeWRf/MtOA3A5MobOqk2g=
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0/go.mod h1:Dw05mhFtrKAYu72Tkb3YBYeQpRUJ4quDgo2DQw3No5A=
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0 h1:HY2hJ7yn3KuEBBBsKxvF3ViSmzLwsgeNvD+0utRMgzc=
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0/go.mod h1:H4H7vs8766kwFnOZVEGMJFVF+phpBSmTckvvNRdJeDI=
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0 h1:dKhAFwh7SSoOw+gwMtSv+XLkUGTFAwAGMT3X3XSE4FA=
@@ -268,8 +276,8 @@ go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0/go.mod h1:ZvRTVaYYGypytG0zRp2A60lpj//cMq3ZnxYdZaljVBM=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.10.0 h1:5dTKu4I5Dn4P2hxyW3l3jTaZx9ACgg0ECos1eAVrheY=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.10.0/go.mod h1:P5HcUI8obLrCCmM3sbVBohZFH34iszk/+CPWuakZWL8=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.10.0 h1:q/heq5Zh8xV1+7GoMGJpTxM2Lhq5+bFxB29tshuRuw0=
@@ -292,18 +300,18 @@ go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 h1:PB3Zrjs1sG1GBX
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 h1:T0Ec2E+3YZf5bgTNQVet8iTDW7oIk03tXHq+wkwIDnE=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0/go.mod h1:30v2gqH+vYGJsesLWFov8u47EpYTcIQcBjKpI6pJThg=
go.opentelemetry.io/otel/log v0.10.0 h1:1CXmspaRITvFcjA4kyVszuG4HjA61fPDxMb7q3BuyF0=
go.opentelemetry.io/otel/log v0.10.0/go.mod h1:PbVdm9bXKku/gL0oFfUF4wwsQsOPlpo4VEqjvxih+FM=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/log v0.13.0 h1:yoxRoIZcohB6Xf0lNv9QIyCzQvrtGZklVbdCoyb7dls=
go.opentelemetry.io/otel/log v0.13.0/go.mod h1:INKfG4k1O9CL25BaM1qLe0zIedOpvlS5Z7XgSbmN83E=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/log v0.10.0 h1:lR4teQGWfeDVGoute6l0Ou+RpFqQ9vaPdrNJlST0bvw=
go.opentelemetry.io/otel/sdk/log v0.10.0/go.mod h1:A+V1UTWREhWAittaQEG4bYm4gAZa6xnvVu+xKrIRkzo=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=

View File

@@ -1,7 +1,7 @@
package bootstrap
import (
"log"
"fmt"
"os"
"path"
"strings"
@@ -12,17 +12,17 @@ import (
)
// initApplicationImages copies the images from the images directory to the application-images directory
func initApplicationImages() {
func initApplicationImages() error {
dirPath := common.EnvConfig.UploadPath + "/application-images"
sourceFiles, err := resources.FS.ReadDir("images")
if err != nil && !os.IsNotExist(err) {
log.Fatalf("Error reading directory: %v", err)
return fmt.Errorf("failed to read directory: %w", err)
}
destinationFiles, err := os.ReadDir(dirPath)
if err != nil && !os.IsNotExist(err) {
log.Fatalf("Error reading directory: %v", err)
return fmt.Errorf("failed to read directory: %w", err)
}
// Copy images from the images directory to the application-images directory if they don't already exist
@@ -35,9 +35,11 @@ func initApplicationImages() {
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
if err != nil {
log.Fatalf("Error copying file: %v", err)
return fmt.Errorf("failed to copy file: %w", err)
}
}
return nil
}
func imageAlreadyExists(fileName string, destinationFiles []os.DirEntry) bool {

View File

@@ -3,7 +3,7 @@ package bootstrap
import (
"context"
"fmt"
"log"
"log/slog"
"time"
_ "github.com/golang-migrate/migrate/v4/source/file"
@@ -14,16 +14,23 @@ import (
)
func Bootstrap(ctx context.Context) error {
initApplicationImages()
// Initialize the tracer and metrics exporter
shutdownFns, httpClient, err := initOtel(ctx, common.EnvConfig.MetricsEnabled, common.EnvConfig.TracingEnabled)
// Initialize the observability stack, including the logger, distributed tracing, and metrics
shutdownFns, httpClient, err := initObservability(ctx, common.EnvConfig.MetricsEnabled, common.EnvConfig.TracingEnabled)
if err != nil {
return fmt.Errorf("failed to initialize OpenTelemetry: %w", err)
}
slog.InfoContext(ctx, "Pocket ID is starting")
err = initApplicationImages()
if err != nil {
return fmt.Errorf("failed to initialize application images: %w", err)
}
// Connect to the database
db := NewDatabase()
db, err := NewDatabase()
if err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
// Create all services
svc, err := initServices(ctx, db, httpClient)
@@ -62,7 +69,7 @@ func Bootstrap(ctx context.Context) error {
NewServiceRunner(shutdownFns...).
Run(shutdownCtx) //nolint:contextcheck
if err != nil {
log.Printf("Error shutting down services: %v", err)
slog.Error("Error shutting down services", slog.Any("error", err))
}
return nil

View File

@@ -3,9 +3,8 @@ package bootstrap
import (
"errors"
"fmt"
"log"
"log/slog"
"net/url"
"os"
"strings"
"time"
@@ -15,23 +14,24 @@ import (
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"
slogGorm "github.com/orandin/slog-gorm"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
gormLogger "gorm.io/gorm/logger"
"github.com/pocket-id/pocket-id/backend/internal/common"
sqliteutil "github.com/pocket-id/pocket-id/backend/internal/utils/sqlite"
"github.com/pocket-id/pocket-id/backend/resources"
)
func NewDatabase() (db *gorm.DB) {
db, err := connectDatabase()
func NewDatabase() (db *gorm.DB, err error) {
db, err = connectDatabase()
if err != nil {
log.Fatalf("failed to connect to database: %v", err)
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
sqlDb, err := db.DB()
if err != nil {
log.Fatalf("failed to get sql.DB: %v", err)
return nil, fmt.Errorf("failed to get sql.DB: %w", err)
}
// Choose the correct driver for the database provider
@@ -43,18 +43,18 @@ func NewDatabase() (db *gorm.DB) {
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
default:
// Should never happen at this point
log.Fatalf("unsupported database provider: %s", common.EnvConfig.DbProvider)
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
}
if err != nil {
log.Fatalf("failed to create migration driver: %v", err)
return nil, fmt.Errorf("failed to create migration driver: %w", err)
}
// Run migrations
if err := migrateDatabase(driver); err != nil {
log.Fatalf("failed to run migrations: %v", err)
return nil, fmt.Errorf("failed to run migrations: %w", err)
}
return db
return db, nil
}
func migrateDatabase(driver database.Driver) error {
@@ -107,16 +107,19 @@ func connectDatabase() (db *gorm.DB, err error) {
for i := 1; i <= 3; i++ {
db, err = gorm.Open(dialector, &gorm.Config{
TranslateError: true,
Logger: getLogger(),
Logger: getGormLogger(),
})
if err == nil {
slog.Info("Connected to database", slog.String("provider", string(common.EnvConfig.DbProvider)))
return db, nil
}
log.Printf("Attempt %d: Failed to initialize database. Retrying...", i)
slog.Warn("Failed to connect to database, will retry in 3s", slog.Int("attempt", i), slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
time.Sleep(3 * time.Second)
}
slog.Error("Failed to connect to database after 3 attempts", slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
return nil, err
}
@@ -164,24 +167,25 @@ func parseSqliteConnectionString(connString string) (string, error) {
return connStringUrl.String(), nil
}
func getLogger() logger.Interface {
isProduction := common.EnvConfig.AppEnv == "production"
func getGormLogger() gormLogger.Interface {
loggerOpts := make([]slogGorm.Option, 0, 5)
loggerOpts = append(loggerOpts,
slogGorm.WithSlowThreshold(200*time.Millisecond),
slogGorm.WithErrorField("error"),
)
var logLevel logger.LogLevel
if isProduction {
logLevel = logger.Error
if common.EnvConfig.AppEnv == "production" {
loggerOpts = append(loggerOpts,
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelWarn),
slogGorm.WithIgnoreTrace(),
)
} else {
logLevel = logger.Info
loggerOpts = append(loggerOpts,
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelDebug),
slogGorm.WithRecordNotFoundError(),
slogGorm.WithTraceAll(),
)
}
return logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logLevel,
IgnoreRecordNotFoundError: isProduction,
ParameterizedQueries: isProduction,
Colorful: !isProduction,
},
)
return slogGorm.New(loggerOpts...)
}

View File

@@ -3,7 +3,8 @@
package bootstrap
import (
"log"
"log/slog"
"os"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -18,7 +19,8 @@ func init() {
func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService)
if err != nil {
log.Fatalf("failed to initialize test service: %v", err)
slog.Error("Failed to initialize test service", slog.Any("error", err))
os.Exit(1)
return
}

View File

@@ -0,0 +1,210 @@
package bootstrap
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"time"
"github.com/lmittmann/tint"
"github.com/mattn/go-isatty"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/contrib/exporters/autoexport"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel"
globallog "go.opentelemetry.io/otel/log/global"
metricnoop "go.opentelemetry.io/otel/metric/noop"
"go.opentelemetry.io/otel/propagation"
sdklog "go.opentelemetry.io/otel/sdk/log"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.30.0"
tracenoop "go.opentelemetry.io/otel/trace/noop"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
func defaultResource() (*resource.Resource, error) {
return resource.Merge(
resource.Default(),
resource.NewSchemaless(
semconv.ServiceName(common.Name),
semconv.ServiceVersion(common.Version),
),
)
}
func initObservability(ctx context.Context, metrics, traces bool) (shutdownFns []utils.Service, httpClient *http.Client, err error) {
resource, err := defaultResource()
if err != nil {
return nil, nil, fmt.Errorf("failed to create OpenTelemetry resource: %w", err)
}
shutdownFns = make([]utils.Service, 0, 2)
httpClient = &http.Client{}
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
// Indicates a development-time error
panic("Default transport is not of type *http.Transport")
}
httpClient.Transport = defaultTransport.Clone()
// Logging
err = initOtelLogging(ctx, resource)
if err != nil {
return nil, nil, err
}
// Tracing
tracingShutdownFn, err := initOtelTracing(ctx, traces, resource, httpClient)
if err != nil {
return nil, nil, err
} else if tracingShutdownFn != nil {
shutdownFns = append(shutdownFns, tracingShutdownFn)
}
// Metrics
metricsShutdownFn, err := initOtelMetrics(ctx, metrics, resource)
if err != nil {
return nil, nil, err
} else if metricsShutdownFn != nil {
shutdownFns = append(shutdownFns, metricsShutdownFn)
}
return shutdownFns, httpClient, nil
}
func initOtelLogging(ctx context.Context, resource *resource.Resource) error {
// If the env var OTEL_LOGS_EXPORTER is empty, we set it to "none", for autoexport to work
if os.Getenv("OTEL_LOGS_EXPORTER") == "" {
os.Setenv("OTEL_LOGS_EXPORTER", "none")
}
exp, err := autoexport.NewLogExporter(ctx)
if err != nil {
return fmt.Errorf("failed to initialize OpenTelemetry log exporter: %w", err)
}
level := slog.LevelDebug
if common.EnvConfig.AppEnv == "production" {
level = slog.LevelInfo
}
// Create the handler
var handler slog.Handler
switch {
case common.EnvConfig.LogJSON:
// Log as JSON if configured
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
})
case isatty.IsTerminal(os.Stdout.Fd()):
// Enable colors if we have a TTY
handler = tint.NewHandler(os.Stdout, &tint.Options{
TimeFormat: time.StampMilli,
Level: level,
})
default:
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
})
}
// Create the logger provider
provider := sdklog.NewLoggerProvider(
sdklog.WithProcessor(
sdklog.NewBatchProcessor(exp),
),
sdklog.WithResource(resource),
)
// Set the logger provider globally
globallog.SetLoggerProvider(provider)
// Wrap the handler in a "fanout" one
handler = utils.LogFanoutHandler{
handler,
otelslog.NewHandler(common.Name, otelslog.WithLoggerProvider(provider)),
}
// Set the default slog to send logs to OTel and add the app name
log := slog.New(handler).
With(slog.String("app", common.Name)).
With(slog.String("version", common.Version))
slog.SetDefault(log)
return nil
}
func initOtelTracing(ctx context.Context, traces bool, resource *resource.Resource, httpClient *http.Client) (shutdownFn utils.Service, err error) {
if !traces {
otel.SetTracerProvider(tracenoop.NewTracerProvider())
return nil, nil
}
tr, err := autoexport.NewSpanExporter(ctx)
if err != nil {
return nil, fmt.Errorf("failed to initialize OpenTelemetry span exporter: %w", err)
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithResource(resource),
sdktrace.WithBatcher(tr),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(
propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
),
)
shutdownFn = func(shutdownCtx context.Context) error { //nolint:contextcheck
tpCtx, tpCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
defer tpCancel()
shutdownErr := tp.Shutdown(tpCtx)
if shutdownErr != nil {
return fmt.Errorf("failed to gracefully shut down traces exporter: %w", shutdownErr)
}
return nil
}
// Add tracing to the HTTP client
httpClient.Transport = otelhttp.NewTransport(httpClient.Transport)
return shutdownFn, nil
}
func initOtelMetrics(ctx context.Context, metrics bool, resource *resource.Resource) (shutdownFn utils.Service, err error) {
if !metrics {
otel.SetMeterProvider(metricnoop.NewMeterProvider())
return nil, nil
}
mr, err := autoexport.NewMetricReader(ctx)
if err != nil {
return nil, fmt.Errorf("failed to initialize OpenTelemetry metric reader: %w", err)
}
mp := metric.NewMeterProvider(
metric.WithResource(resource),
metric.WithReader(mr),
)
otel.SetMeterProvider(mp)
shutdownFn = func(shutdownCtx context.Context) error { //nolint:contextcheck
mpCtx, mpCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
defer mpCancel()
shutdownErr := mp.Shutdown(mpCtx)
if shutdownErr != nil {
return fmt.Errorf("failed to gracefully shut down metrics exporter: %w", shutdownErr)
}
return nil
}
return shutdownFn, nil
}

View File

@@ -1,107 +0,0 @@
package bootstrap
import (
"context"
"fmt"
"net/http"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"go.opentelemetry.io/contrib/exporters/autoexport"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel"
metricnoop "go.opentelemetry.io/otel/metric/noop"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.30.0"
tracenoop "go.opentelemetry.io/otel/trace/noop"
)
func defaultResource() (*resource.Resource, error) {
return resource.Merge(
resource.Default(),
resource.NewSchemaless(
semconv.ServiceName("pocket-id-backend"),
semconv.ServiceVersion(common.Version),
),
)
}
func initOtel(ctx context.Context, metrics, traces bool) (shutdownFns []utils.Service, httpClient *http.Client, err error) {
resource, err := defaultResource()
if err != nil {
return nil, nil, fmt.Errorf("failed to create OpenTelemetry resource: %w", err)
}
shutdownFns = make([]utils.Service, 0, 2)
httpClient = &http.Client{}
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
// Indicates a development-time error
panic("Default transport is not of type *http.Transport")
}
httpClient.Transport = defaultTransport.Clone()
if traces {
tr, err := autoexport.NewSpanExporter(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to initialize OpenTelemetry span exporter: %w", err)
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithResource(resource),
sdktrace.WithBatcher(tr),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(
propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
),
)
shutdownFns = append(shutdownFns, func(shutdownCtx context.Context) error { //nolint:contextcheck
tpCtx, tpCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
defer tpCancel()
shutdownErr := tp.Shutdown(tpCtx)
if shutdownErr != nil {
return fmt.Errorf("failed to gracefully shut down traces exporter: %w", shutdownErr)
}
return nil
})
httpClient.Transport = otelhttp.NewTransport(httpClient.Transport)
} else {
otel.SetTracerProvider(tracenoop.NewTracerProvider())
}
if metrics {
mr, err := autoexport.NewMetricReader(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to initialize OpenTelemetry metric reader: %w", err)
}
mp := metric.NewMeterProvider(
metric.WithResource(resource),
metric.WithReader(mr),
)
otel.SetMeterProvider(mp)
shutdownFns = append(shutdownFns, func(shutdownCtx context.Context) error { //nolint:contextcheck
mpCtx, mpCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
defer mpCancel()
shutdownErr := mp.Shutdown(mpCtx)
if shutdownErr != nil {
return fmt.Errorf("failed to gracefully shut down metrics exporter: %w", shutdownErr)
}
return nil
})
} else {
otel.SetMeterProvider(metricnoop.NewMeterProvider())
}
return shutdownFns, httpClient, nil
}

View File

@@ -4,7 +4,7 @@ import (
"context"
"errors"
"fmt"
"log"
"log/slog"
"net"
"net/http"
"os"
@@ -12,13 +12,13 @@ import (
"strings"
"time"
"github.com/pocket-id/pocket-id/backend/frontend"
"github.com/gin-gonic/gin"
sloggin "github.com/samber/slog-gin"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
"golang.org/x/time/rate"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/frontend"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/controller"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
@@ -32,7 +32,8 @@ var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *
func initRouter(db *gorm.DB, svc *services) utils.Service {
runner, err := initRouterInternal(db, svc)
if err != nil {
log.Fatalf("failed to init router: %v", err)
slog.Error("Failed to init router", "error", err)
os.Exit(1)
}
return runner
}
@@ -60,21 +61,25 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
}
r := gin.New()
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{Skip: func(c *gin.Context) bool {
for _, prefix := range loggerSkipPathsPrefix {
if strings.HasPrefix(c.Request.Method+" "+c.Request.URL.String(), prefix) {
r.Use(sloggin.NewWithConfig(slog.Default(), sloggin.Config{
Filters: []sloggin.Filter{
func(c *gin.Context) bool {
for _, prefix := range loggerSkipPathsPrefix {
if strings.HasPrefix(c.Request.Method+" "+c.Request.URL.String(), prefix) {
return false
}
}
return true
}
}
return false
}}))
},
},
}))
if !common.EnvConfig.TrustProxy {
_ = r.SetTrustedProxies(nil)
}
if common.EnvConfig.TracingEnabled {
r.Use(otelgin.Middleware("pocket-id-backend"))
r.Use(otelgin.Middleware(common.Name))
}
rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60)
@@ -85,7 +90,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
err := frontend.RegisterFrontend(r)
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
log.Println("Frontend is not included in the build. Skipping frontend registration.")
slog.Warn("Frontend is not included in the build. Skipping frontend registration.")
} else if err != nil {
return nil, fmt.Errorf("failed to register frontend: %w", err)
}
@@ -154,7 +159,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
// Service runner function
runFn := func(ctx context.Context) error {
log.Printf("Server listening on %s", addr)
slog.Info("Server listening", slog.String("addr", addr))
// Start the server in a background goroutine
go func() {
@@ -163,7 +168,8 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
// Next call blocks until the server is shut down
srvErr := srv.Serve(listener)
if srvErr != http.ErrServerClosed {
log.Fatalf("Error starting app server: %v", srvErr)
slog.Error("Error starting app server", "error", srvErr)
os.Exit(1)
}
}()
@@ -171,7 +177,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
err = systemd.SdNotifyReady()
if err != nil {
// Log the error only
log.Printf("[WARN] Unable to notify systemd that the service is ready: %v", err)
slog.Warn("Unable to notify systemd that the service is ready", "error", err)
}
// Block until the context is canceled
@@ -184,7 +190,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
shutdownCancel()
if shutdownErr != nil {
// Log the error only (could be context canceled)
log.Printf("[WARN] App server shutdown error: %v", shutdownErr)
slog.Warn("App server shutdown error", "error", shutdownErr)
}
return nil

View File

@@ -29,7 +29,10 @@ type services struct {
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (svc *services, err error) {
svc = &services{}
svc.appConfigService = service.NewAppConfigService(ctx, db)
svc.appConfigService, err = service.NewAppConfigService(ctx, db)
if err != nil {
return nil, fmt.Errorf("failed to create app config service: %w", err)
}
svc.emailService, err = service.NewEmailService(db, svc.appConfigService)
if err != nil {
@@ -38,7 +41,11 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (sv
svc.geoLiteService = service.NewGeoLiteService(httpClient)
svc.auditLogService = service.NewAuditLogService(db, svc.appConfigService, svc.emailService, svc.geoLiteService)
svc.jwtService = service.NewJwtService(db, svc.appConfigService)
svc.jwtService, err = service.NewJwtService(db, svc.appConfigService)
if err != nil {
return nil, fmt.Errorf("failed to create JWT service: %w", err)
}
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)
svc.customClaimService = service.NewCustomClaimService(db)
@@ -50,7 +57,11 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (sv
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService)
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
svc.webauthnService = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
svc.webauthnService, err = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
if err != nil {
return nil, fmt.Errorf("failed to create WebAuthn service: %w", err)
}
return svc, nil
}

View File

@@ -30,7 +30,10 @@ func init() {
Use: "key-rotate",
Short: "Generates a new token signing key and replaces the current one",
RunE: func(cmd *cobra.Command, args []string) error {
db := bootstrap.NewDatabase()
db, err := bootstrap.NewDatabase()
if err != nil {
return err
}
return keyRotate(cmd.Context(), flags, db, &common.EnvConfig)
},
@@ -80,7 +83,10 @@ func keyRotate(ctx context.Context, flags keyRotateFlags, db *gorm.DB, envConfig
}
// Init the services we need
appConfigService := service.NewAppConfigService(ctx, db)
appConfigService, err := service.NewAppConfigService(ctx, db)
if err != nil {
return fmt.Errorf("failed to create app config service: %w", err)
}
// Get the key provider
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, appConfigService.GetDbConfig().InstanceID.Value)

View File

@@ -97,7 +97,8 @@ func testKeyRotateWithFileStorage(t *testing.T, flags keyRotateFlags, wantErr bo
db := testingutils.NewDatabaseForTest(t)
// Initialize app config service and create instance
appConfigService := service.NewAppConfigService(t.Context(), db)
appConfigService, err := service.NewAppConfigService(t.Context(), db)
require.NoError(t, err)
instanceID := appConfigService.GetDbConfig().InstanceID.Value
// Check if key exists before rotation
@@ -140,14 +141,15 @@ func testKeyRotateWithDatabaseStorage(t *testing.T, flags keyRotateFlags, wantEr
// Set up database storage config
envConfig := &common.EnvConfigSchema{
KeysStorage: "database",
EncryptionKey: "test-encryption-key-characters-long",
EncryptionKey: []byte("test-encryption-key-characters-long"),
}
// Create test database
db := testingutils.NewDatabaseForTest(t)
// Initialize app config service and create instance
appConfigService := service.NewAppConfigService(t.Context(), db)
appConfigService, err := service.NewAppConfigService(t.Context(), db)
require.NoError(t, err)
instanceID := appConfigService.GetDbConfig().InstanceID.Value
// Get key provider

View File

@@ -24,11 +24,14 @@ var oneTimeAccessTokenCmd = &cobra.Command{
userArg := args[0]
// Connect to the database
db := bootstrap.NewDatabase()
db, err := bootstrap.NewDatabase()
if err != nil {
return err
}
// Create the access token
var oneTimeAccessToken *model.OneTimeAccessToken
err := db.Transaction(func(tx *gorm.DB) error {
err = db.Transaction(func(tx *gorm.DB) error {
// Load the user to retrieve the user ID
var user model.User
queryCtx, queryCancel := context.WithTimeout(cmd.Context(), 10*time.Second)

View File

@@ -3,8 +3,11 @@ package common
import (
"errors"
"fmt"
"log"
"log/slog"
"net/url"
"os"
"reflect"
"strings"
"github.com/caarlos0/env/v11"
_ "github.com/joho/godotenv/autoload"
@@ -30,23 +33,23 @@ type EnvConfigSchema struct {
AppEnv string `env:"APP_ENV"`
AppURL string `env:"APP_URL"`
DbProvider DbProvider `env:"DB_PROVIDER"`
DbConnectionString string `env:"DB_CONNECTION_STRING"`
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
UploadPath string `env:"UPLOAD_PATH"`
KeysPath string `env:"KEYS_PATH"`
KeysStorage string `env:"KEYS_STORAGE"`
EncryptionKey string `env:"ENCRYPTION_KEY"`
EncryptionKeyFile string `env:"ENCRYPTION_KEY_FILE"`
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
Port string `env:"PORT"`
Host string `env:"HOST"`
UnixSocket string `env:"UNIX_SOCKET"`
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
MetricsEnabled bool `env:"METRICS_ENABLED"`
TracingEnabled bool `env:"TRACING_ENABLED"`
LogJSON bool `env:"LOG_JSON"`
TrustProxy bool `env:"TRUST_PROXY"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
}
@@ -56,7 +59,8 @@ var EnvConfig = defaultConfig()
func init() {
err := parseEnvConfig()
if err != nil {
log.Fatalf("Configuration error: %v", err)
slog.Error("Configuration error", slog.Any("error", err))
os.Exit(1)
}
}
@@ -68,7 +72,7 @@ func defaultConfig() EnvConfigSchema {
UploadPath: "data/uploads",
KeysPath: "data/keys",
KeysStorage: "", // "database" or "file"
EncryptionKey: "",
EncryptionKey: nil,
AppURL: "http://localhost:1411",
Port: "1411",
Host: "0.0.0.0",
@@ -87,11 +91,24 @@ func defaultConfig() EnvConfigSchema {
}
func parseEnvConfig() error {
err := env.ParseWithOptions(&EnvConfig, env.Options{})
parsers := map[reflect.Type]env.ParserFunc{
reflect.TypeOf([]byte{}): func(value string) (interface{}, error) {
return []byte(value), nil
},
}
err := env.ParseWithOptions(&EnvConfig, env.Options{
FuncMap: parsers,
})
if err != nil {
return fmt.Errorf("error parsing env config: %w", err)
}
err = resolveFileBasedEnvVariables(&EnvConfig)
if err != nil {
return err
}
// Validate the environment variables
switch EnvConfig.DbProvider {
case DbProviderSqlite:
@@ -119,9 +136,8 @@ func parseEnvConfig() error {
case "":
EnvConfig.KeysStorage = "file"
case "database":
// If KeysStorage is "database", a key must be specified
if EnvConfig.EncryptionKey == "" && EnvConfig.EncryptionKeyFile == "" {
return errors.New("ENCRYPTION_KEY or ENCRYPTION_KEY_FILE must be non-empty when KEYS_STORAGE is database")
if EnvConfig.EncryptionKey == nil {
return errors.New("ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database")
}
case "file":
// All good, these are valid values
@@ -131,3 +147,58 @@ func parseEnvConfig() error {
return nil
}
// resolveFileBasedEnvVariables uses reflection to automatically resolve file-based secrets
func resolveFileBasedEnvVariables(config *EnvConfigSchema) error {
val := reflect.ValueOf(config).Elem()
typ := val.Type()
for i := 0; i < val.NumField(); i++ {
field := val.Field(i)
fieldType := typ.Field(i)
// Only process string and []byte fields
isString := field.Kind() == reflect.String
isByteSlice := field.Kind() == reflect.Slice && field.Type().Elem().Kind() == reflect.Uint8
if !isString && !isByteSlice {
continue
}
// Only process fields with the "options" tag set to "file"
optionsTag := fieldType.Tag.Get("options")
if optionsTag != "file" {
continue
}
// Only process fields with the "env" tag
envTag := fieldType.Tag.Get("env")
if envTag == "" {
continue
}
envVarName := envTag
if commaIndex := len(envTag); commaIndex > 0 {
envVarName = envTag[:commaIndex]
}
// If the file environment variable is not set, skip
envVarFileName := envVarName + "_FILE"
envVarFileValue := os.Getenv(envVarFileName)
if envVarFileValue == "" {
continue
}
fileContent, err := os.ReadFile(envVarFileValue)
if err != nil {
return fmt.Errorf("failed to read file for env var %s: %w", envVarFileName, err)
}
if isString {
field.SetString(strings.TrimSpace(string(fileContent)))
} else {
field.SetBytes(fileContent)
}
}
return nil
}

View File

@@ -1,6 +1,7 @@
package common
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
@@ -110,7 +111,7 @@ func TestParseEnvConfig(t *testing.T) {
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "ENCRYPTION_KEY or ENCRYPTION_KEY_FILE must be non-empty")
assert.ErrorContains(t, err, "ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database")
})
t.Run("should accept valid KEYS_STORAGE values", func(t *testing.T) {
@@ -186,3 +187,119 @@ func TestParseEnvConfig(t *testing.T) {
assert.Equal(t, "127.0.0.1", EnvConfig.Host)
})
}
func TestResolveFileBasedEnvVariables(t *testing.T) {
// Create temporary directory for test files
tempDir := t.TempDir()
// Create test files
encryptionKeyFile := tempDir + "/encryption_key.txt"
encryptionKeyContent := "test-encryption-key-123"
err := os.WriteFile(encryptionKeyFile, []byte(encryptionKeyContent), 0600)
require.NoError(t, err)
dbConnFile := tempDir + "/db_connection.txt"
dbConnContent := "postgres://user:pass@localhost/testdb"
err = os.WriteFile(dbConnFile, []byte(dbConnContent), 0600)
require.NoError(t, err)
// Create a binary file for testing binary data handling
binaryKeyFile := tempDir + "/binary_key.bin"
binaryKeyContent := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10}
err = os.WriteFile(binaryKeyFile, binaryKeyContent, 0600)
require.NoError(t, err)
t.Run("should read file content for fields with options:file tag", func(t *testing.T) {
config := defaultConfig()
// Set environment variables pointing to files
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// Verify file contents were read correctly
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, dbConnContent, config.DbConnectionString)
})
t.Run("should skip fields without options:file tag", func(t *testing.T) {
config := defaultConfig()
originalAppURL := config.AppURL
// Set a file for a field that doesn't have options:file tag
t.Setenv("APP_URL_FILE", "/tmp/nonexistent.txt")
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// AppURL should remain unchanged
assert.Equal(t, originalAppURL, config.AppURL)
})
t.Run("should skip non-string fields", func(t *testing.T) {
// This test verifies that non-string fields are skipped
// We test this indirectly by ensuring the function doesn't error
// when processing the actual EnvConfigSchema which has bool fields
config := defaultConfig()
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
})
t.Run("should skip when _FILE environment variable is not set", func(t *testing.T) {
config := defaultConfig()
originalEncryptionKey := config.EncryptionKey
// Don't set ENCRYPTION_KEY_FILE environment variable
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// EncryptionKey should remain unchanged
assert.Equal(t, originalEncryptionKey, config.EncryptionKey)
})
t.Run("should handle multiple file-based variables simultaneously", func(t *testing.T) {
config := defaultConfig()
// Set multiple file environment variables
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// All should be resolved correctly
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, dbConnContent, config.DbConnectionString)
})
t.Run("should handle mixed file and non-file environment variables", func(t *testing.T) {
config := defaultConfig()
// Set both file and non-file environment variables
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// File-based should be resolved, others should remain as set by env parser
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, "http://localhost:1411", config.AppURL)
})
t.Run("should handle binary data correctly", func(t *testing.T) {
config := defaultConfig()
// Set environment variable pointing to binary file
t.Setenv("ENCRYPTION_KEY_FILE", binaryKeyFile)
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// Verify binary data was read correctly without corruption
assert.Equal(t, binaryKeyContent, config.EncryptionKey)
})
}

View File

@@ -1,5 +1,8 @@
package common
// Name is the name of the application
const Name = "pocket-id"
// Version contains the Pocket ID version.
//
// It can be set at build time using -ldflags.

View File

@@ -40,7 +40,7 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
return
}
if err := tc.TestService.ResetApplicationImages(); err != nil {
if err := tc.TestService.ResetApplicationImages(c.Request.Context()); err != nil {
_ = c.Error(err)
return
}

View File

@@ -2,7 +2,7 @@ package controller
import (
"errors"
"log"
"log/slog"
"net/http"
"net/url"
"strings"
@@ -57,6 +57,9 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler)
group.GET("/oidc/users/:id/clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler)
group.DELETE("/oidc/users/me/clients/:clientId", authMiddleware.WithAdminNotRequired().Add(), oc.revokeOwnClientAuthorizationHandler)
}
type OidcController struct {
@@ -257,7 +260,7 @@ func (oc *OidcController) EndSessionHandler(c *gin.Context) {
callbackURL, err := oc.oidcService.ValidateEndSession(c.Request.Context(), input, c.GetString("userID"))
if err != nil {
// If the validation fails, the user has to confirm the logout manually and doesn't get redirected
log.Printf("Error getting logout callback URL, the user has to confirm the logout manually: %v", err)
slog.WarnContext(c.Request.Context(), "Error getting logout callback URL, the user has to confirm the logout manually", "error", err)
c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout")
return
}
@@ -704,6 +707,27 @@ func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
})
}
// revokeOwnClientAuthorizationHandler godoc
// @Summary Revoke authorization for an OIDC client
// @Description Revoke the authorization for a specific OIDC client for the current user
// @Tags OIDC
// @Param clientId path string true "Client ID to revoke authorization for"
// @Success 204 "No Content"
// @Router /api/oidc/users/me/clients/{clientId} [delete]
func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) {
clientID := c.Param("clientId")
userID := c.GetString("userID")
err := oc.oidcService.RevokeAuthorizedClient(c.Request.Context(), userID, clientID)
if err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
userCode := c.Query("code")
if userCode == "" {

View File

@@ -3,8 +3,9 @@ package controller
import (
"encoding/json"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"github.com/gin-gonic/gin"
@@ -23,7 +24,9 @@ func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtServi
var err error
wkc.oidcConfig, err = wkc.computeOIDCConfiguration()
if err != nil {
log.Fatalf("Failed to pre-compute OpenID Connect configuration document: %v", err)
slog.Error("Failed to pre-compute OpenID Connect configuration document", slog.Any("error", err))
os.Exit(1)
return
}
group.GET("/.well-known/jwks.json", wkc.jwksHandler)
@@ -84,6 +87,7 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{alg.String()},
"authorization_response_iss_parameter_supported": true,
"code_challenge_methods_supported": []string{"plain", "S256"},
}
return json.Marshal(config)
}

View File

@@ -6,14 +6,14 @@ import (
type ApiKeyCreateDto struct {
Name string `json:"name" binding:"required,min=3,max=50" unorm:"nfc"`
Description string `json:"description" unorm:"nfc"`
Description *string `json:"description" unorm:"nfc"`
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
}
type ApiKeyDto struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Description *string `json:"description"`
ExpiresAt datatype.DateTime `json:"expiresAt"`
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
CreatedAt datatype.DateTime `json:"createdAt"`

View File

@@ -1,9 +1,12 @@
package dto
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
type OidcClientMetaDataDto struct {
ID string `json:"id"`
Name string `json:"name"`
HasLogo bool `json:"hasLogo"`
ID string `json:"id"`
Name string `json:"name"`
HasLogo bool `json:"hasLogo"`
LaunchURL *string `json:"launchURL"`
}
type OidcClientDto struct {
@@ -32,6 +35,7 @@ type OidcClientCreateDto struct {
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
Credentials OidcClientCredentialsDto `json:"credentials"`
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
}
type OidcClientCredentialsDto struct {
@@ -145,8 +149,9 @@ type DeviceCodeInfoDto struct {
}
type AuthorizedOidcClientDto struct {
Scope string `json:"scope"`
Client OidcClientMetaDataDto `json:"client"`
Scope string `json:"scope"`
Client OidcClientMetaDataDto `json:"client"`
LastUsedAt datatype.DateTime `json:"lastUsedAt"`
}
type OidcClientPreviewDto struct {

View File

@@ -1,7 +1,8 @@
package dto
import (
"log"
"log/slog"
"os"
"regexp"
"github.com/gin-gonic/gin/binding"
@@ -18,9 +19,11 @@ var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
}
func init() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("username", validateUsername); err != nil {
log.Fatalf("Failed to register custom validation: %v", err)
}
v, _ := binding.Validator.Engine().(*validator.Validate)
err := v.RegisterValidation("username", validateUsername)
if err != nil {
slog.Error("Failed to register custom validation", slog.Any("error", err))
os.Exit(1)
return
}
}

View File

@@ -178,7 +178,7 @@ type AppConfigKeyNotFoundError struct {
}
func (e AppConfigKeyNotFoundError) Error() string {
return fmt.Sprintf("cannot find config key '%s'", e.field)
return "cannot find config key '" + e.field + "'"
}
func (e AppConfigKeyNotFoundError) Is(target error) bool {
@@ -192,7 +192,7 @@ type AppConfigInternalForbiddenError struct {
}
func (e AppConfigInternalForbiddenError) Error() string {
return fmt.Sprintf("field '%s' is internal and can't be updated", e.field)
return "field '" + e.field + "' is internal and can't be updated"
}
func (e AppConfigInternalForbiddenError) Is(target error) bool {

View File

@@ -11,7 +11,9 @@ import (
)
type UserAuthorizedOidcClient struct {
Scope string
Scope string
LastUsedAt datatype.DateTime `sortable:"true"`
UserID string `gorm:"primary_key;"`
User User
@@ -47,6 +49,7 @@ type OidcClient struct {
IsPublic bool
PkceEnabled bool
Credentials OidcClientCredentials
LaunchURL *string
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
CreatedByID string

View File

@@ -55,8 +55,8 @@ func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input d
apiKey := model.ApiKey{
Name: input.Name,
Key: utils.CreateSha256Hash(token), // Hash the token for storage
Description: &input.Description,
ExpiresAt: datatype.DateTime(input.ExpiresAt),
Description: input.Description,
ExpiresAt: input.ExpiresAt,
UserID: userID,
}

View File

@@ -4,17 +4,14 @@ import (
"context"
"errors"
"fmt"
"log"
"mime/multipart"
"os"
"reflect"
"slices"
"strings"
"sync/atomic"
"time"
"github.com/hashicorp/go-uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -29,22 +26,22 @@ type AppConfigService struct {
db *gorm.DB
}
func NewAppConfigService(ctx context.Context, db *gorm.DB) *AppConfigService {
func NewAppConfigService(ctx context.Context, db *gorm.DB) (*AppConfigService, error) {
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(ctx)
if err != nil {
log.Fatalf("Failed to initialize app config service: %v", err)
return nil, fmt.Errorf("failed to initialize app config service: %w", err)
}
err = service.initInstanceID(ctx)
if err != nil {
log.Fatalf("Failed to initialize instance ID: %v", err)
return nil, fmt.Errorf("failed to initialize instance ID: %w", err)
}
return service
return service, nil
}
// GetDbConfig returns the application configuration.
@@ -414,12 +411,10 @@ func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB)
field := rt.Field(i)
// Get the key and internal tag values
tagValue := strings.Split(field.Tag.Get("key"), ",")
key := tagValue[0]
isInternal := slices.Contains(tagValue, "internal")
key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",")
// Internal fields are loaded from the database as they can't be set from the environment
if isInternal {
if attrs == "internal" {
var value string
err := tx.WithContext(ctx).
Model(&model.AppConfigVariable{}).
@@ -438,6 +433,20 @@ func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB)
value, ok := os.LookupEnv(envVarName)
if ok {
rv.Field(i).FieldByName("Value").SetString(value)
continue
}
// If it's sensitive, we also allow reading from file
if attrs == "sensitive" {
fileName := os.Getenv(envVarName + "_FILE")
if fileName != "" {
b, err := os.ReadFile(fileName)
if err != nil {
return nil, fmt.Errorf("failed to read secret '%s' from file '%s': %w", envVarName, fileName, err)
}
rv.Field(i).FieldByName("Value").SetString(string(b))
continue
}
}
}

View File

@@ -3,7 +3,6 @@ package service
import (
"context"
"fmt"
"log"
"log/slog"
userAgentParser "github.com/mileusna/useragent"
@@ -11,6 +10,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm"
)
@@ -22,7 +22,12 @@ type AuditLogService struct {
}
func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService, geoliteService *GeoLiteService) *AuditLogService {
return &AuditLogService{db: db, appConfigService: appConfigService, emailService: emailService, geoliteService: geoliteService}
return &AuditLogService{
db: db,
appConfigService: appConfigService,
emailService: emailService,
geoliteService: geoliteService,
}
}
// Create creates a new audit log entry in the database
@@ -82,7 +87,7 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
}
err := stmt.Count(&count).Error
if err != nil {
log.Printf("Failed to count audit logs: %v", err)
slog.ErrorContext(ctx, "Failed to count audit logs", slog.Any("error", err))
return createdAuditLog
}
@@ -91,7 +96,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
// We use a background context here as this is running in a goroutine
//nolint:contextcheck
go func() {
innerCtx := context.Background()
span := trace.SpanFromContext(ctx)
innerCtx := trace.ContextWithSpan(context.Background(), span)
// Note we don't use the transaction here because this is running in background
var user model.User
@@ -101,7 +107,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
First(&user).
Error
if innerErr != nil {
log.Printf("Failed to load user: %v", innerErr)
slog.ErrorContext(innerCtx, "Failed to load user from database to send notification email", slog.Any("error", innerErr))
return
}
innerErr = SendEmail(innerCtx, s.emailService, email.Address{
@@ -115,7 +122,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
DateTime: createdAuditLog.CreatedAt.UTC(),
})
if innerErr != nil {
log.Printf("Failed to send email to '%s': %v", user.Email, innerErr)
slog.ErrorContext(innerCtx, "Failed to send notification email", slog.Any("error", innerErr), slog.String("address", user.Email))
return
}
}()
}

View File

@@ -10,7 +10,7 @@ import (
"crypto/x509"
"encoding/base64"
"fmt"
"log"
"log/slog"
"os"
"path/filepath"
"time"
@@ -154,6 +154,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
},
Name: "Nextcloud",
LaunchURL: utils.Ptr("https://nextcloud.local"),
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"},
LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"},
@@ -172,6 +173,16 @@ func (s *TestService) SeedDatabase(baseURL string) error {
userGroups[1],
},
},
{
Base: model.Base{
ID: "7c21a609-96b5-4011-9900-272b8d31a9d1",
},
Name: "Tailscale",
Secret: "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a", // n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo
CallbackURLs: model.UrlList{"http://tailscale/auth/callback"},
LogoutCallbackURLs: model.UrlList{"http://tailscale/auth/logout/callback"},
CreatedByID: users[0].ID,
},
{
Base: model.Base{
ID: "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
@@ -245,14 +256,22 @@ func (s *TestService) SeedDatabase(baseURL string) error {
userAuthorizedClients := []model.UserAuthorizedOidcClient{
{
Scope: "openid profile email",
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
Scope: "openid profile email",
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 1, 13, 0, 0, 0, time.UTC)),
},
{
Scope: "openid profile email",
UserID: users[1].ID,
ClientID: oidcClients[2].ID,
Scope: "openid profile email",
UserID: users[0].ID,
ClientID: oidcClients[2].ID,
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 10, 14, 0, 0, 0, time.UTC)),
},
{
Scope: "openid profile email",
UserID: users[1].ID,
ClientID: oidcClients[3].ID,
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 12, 12, 0, 0, 0, time.UTC)),
},
}
for _, userAuthorizedClient := range userAuthorizedClients {
@@ -402,9 +421,9 @@ func (s *TestService) ResetDatabase() error {
return err
}
func (s *TestService) ResetApplicationImages() error {
func (s *TestService) ResetApplicationImages(ctx context.Context) error {
if err := os.RemoveAll(common.EnvConfig.UploadPath); err != nil {
log.Printf("Error removing directory: %v", err)
slog.ErrorContext(ctx, "Error removing directory", slog.Any("error", err))
return err
}

View File

@@ -7,7 +7,7 @@ import (
"errors"
"fmt"
"io"
"log"
"log/slog"
"net"
"net/http"
"net/netip"
@@ -52,13 +52,14 @@ func NewGeoLiteService(httpClient *http.Client) *GeoLiteService {
if common.EnvConfig.MaxMindLicenseKey == "" && common.EnvConfig.GeoLiteDBUrl == common.MaxMindGeoLiteCityUrl {
// Warn the user, and disable the periodic updater
log.Println("MAXMIND_LICENSE_KEY environment variable is empty. The GeoLite2 City database won't be updated.")
slog.Warn("MAXMIND_LICENSE_KEY environment variable is empty: the GeoLite2 City database won't be updated")
service.disableUpdater = true
}
// Initialize IPv6 local ranges
if err := service.initializeIPv6LocalRanges(); err != nil {
log.Printf("Warning: Failed to initialize IPv6 local ranges: %v", err)
err := service.initializeIPv6LocalRanges()
if err != nil {
slog.Warn("Failed to initialize IPv6 local ranges", slog.Any("error", err))
}
return service
@@ -96,7 +97,7 @@ func (s *GeoLiteService) initializeIPv6LocalRanges() error {
s.localIPv6Ranges = localRanges
if len(localRanges) > 0 {
log.Printf("Initialized %d IPv6 local ranges", len(localRanges))
slog.Info("Initialized IPv6 local ranges", slog.Int("count", len(localRanges)))
}
return nil
}
@@ -186,11 +187,11 @@ func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string
// UpdateDatabase checks the age of the database and updates it if it's older than 14 days.
func (s *GeoLiteService) UpdateDatabase(parentCtx context.Context) error {
if s.isDatabaseUpToDate() {
log.Println("GeoLite2 City database is up-to-date")
slog.Info("GeoLite2 City database is up-to-date")
return nil
}
log.Println("Updating GeoLite2 City database")
slog.Info("Updating GeoLite2 City database")
downloadUrl := fmt.Sprintf(common.EnvConfig.GeoLiteDBUrl, common.EnvConfig.MaxMindLicenseKey)
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Minute)
@@ -217,7 +218,7 @@ func (s *GeoLiteService) UpdateDatabase(parentCtx context.Context) error {
return fmt.Errorf("failed to extract database: %w", err)
}
log.Println("GeoLite2 City database successfully updated.")
slog.Info("GeoLite2 City database successfully updated.")
return nil
}

View File

@@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"time"
"github.com/lestrrat-go/jwx/v3/jwa"
@@ -64,16 +63,16 @@ type JwtService struct {
jwksEncoded []byte
}
func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) *JwtService {
func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) (*JwtService, error) {
service := &JwtService{}
// Ensure keys are generated or loaded
err := service.init(db, appConfigService, &common.EnvConfig)
if err != nil {
log.Fatalf("Failed to initialize jwt service: %v", err)
return nil, err
}
return service
return service, nil
}
func (s *JwtService) init(db *gorm.DB, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) {

View File

@@ -8,7 +8,7 @@ import (
"errors"
"fmt"
"io"
"log"
"log/slog"
"net/http"
"net/url"
"strings"
@@ -130,7 +130,7 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
// Skip groups without a valid LDAP ID
if ldapId == "" {
log.Printf("Skipping LDAP group without a valid unique identifier (attribute: %s)", dbConfig.LdapAttributeGroupUniqueIdentifier.Value)
slog.Warn("Skipping LDAP group without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
continue
}
@@ -168,13 +168,13 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
userResult, err := client.Search(userSearchReq)
if err != nil || len(userResult.Entries) == 0 {
log.Printf("Could not resolve group member DN '%s': %v", member, err)
slog.WarnContext(ctx, "Could not resolve group member DN", slog.String("member", member), slog.Any("error", err))
continue
}
username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)
if username == "" {
log.Printf("Could not extract username from group member DN '%s'", member)
slog.WarnContext(ctx, "Could not extract username from group member DN", slog.String("member", member))
continue
}
}
@@ -250,7 +250,7 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
return fmt.Errorf("failed to delete group '%s': %w", group.Name, err)
}
log.Printf("Deleted group '%s'", group.Name)
slog.Info("Deleted group", slog.String("group", group.Name))
}
return nil
@@ -295,7 +295,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
// Skip users without a valid LDAP ID
if ldapId == "" {
log.Printf("Skipping LDAP user without a valid unique identifier (attribute: %s)", dbConfig.LdapAttributeUserUniqueIdentifier.Value)
slog.Warn("Skipping LDAP user without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeUserUniqueIdentifier.Value))
continue
}
@@ -350,7 +350,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
if databaseUser.ID == "" {
_, err = s.userService.createUserInternal(ctx, newUser, true, tx)
if errors.Is(err, &common.AlreadyInUseError{}) {
log.Printf("Skipping creating LDAP user '%s': %v", newUser.Username, err)
slog.Warn("Skipping creating LDAP user", slog.String("username", newUser.Username), slog.Any("error", err))
continue
} else if err != nil {
return fmt.Errorf("error creating user '%s': %w", newUser.Username, err)
@@ -358,7 +358,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
} else {
_, err = s.userService.updateUserInternal(ctx, databaseUser.ID, newUser, false, true, tx)
if errors.Is(err, &common.AlreadyInUseError{}) {
log.Printf("Skipping updating LDAP user '%s': %v", newUser.Username, err)
slog.Warn("Skipping updating LDAP user", slog.String("username", newUser.Username), slog.Any("error", err))
continue
} else if err != nil {
return fmt.Errorf("error updating user '%s': %w", newUser.Username, err)
@@ -371,7 +371,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
err = s.saveProfilePicture(ctx, databaseUser.ID, pictureString)
if err != nil {
// This is not a fatal error
log.Printf("Error saving profile picture for user %s: %v", newUser.Username, err)
slog.Warn("Error saving profile picture for user", slog.String("username", newUser.Username), slog.Any("error", err))
}
}
}
@@ -400,7 +400,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
return fmt.Errorf("failed to disable user %s: %w", user.Username, err)
}
log.Printf("Disabled user '%s'", user.Username)
slog.Info("Disabled user", slog.String("username", user.Username))
} else {
err = s.userService.deleteUserInternal(ctx, user.ID, true, tx)
target := &common.LdapUserUpdateError{}
@@ -410,7 +410,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
return fmt.Errorf("failed to delete user %s: %w", user.Username, err)
}
log.Printf("Deleted user '%s'", user.Username)
slog.Info("Deleted user", slog.String("username", user.Username))
}
}

View File

@@ -8,7 +8,6 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"log/slog"
"mime/multipart"
"net/http"
@@ -150,20 +149,11 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
return "", "", &common.OidcAccessDeniedError{}
}
// Check if the user has already authorized the client with the given scope
hasAuthorizedClient, err := s.hasAuthorizedClientInternal(ctx, input.ClientID, userID, input.Scope, tx)
hasAlreadyAuthorizedClient, err := s.createAuthorizedClientInternal(ctx, userID, input.ClientID, input.Scope, tx)
if err != nil {
return "", "", err
}
// If the user has not authorized the client, create a new authorization in the database
if !hasAuthorizedClient {
err := s.createAuthorizedClientInternal(ctx, userID, input.ClientID, input.Scope, tx)
if err != nil {
return "", "", err
}
}
// Create the authorization code
code, err := s.createAuthorizationCode(ctx, input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod, tx)
if err != nil {
@@ -171,7 +161,7 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
}
// Log the authorization event
if hasAuthorizedClient {
if hasAlreadyAuthorizedClient {
s.auditLogService.Create(ctx, model.AuditLogEventClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name}, tx)
} else {
s.auditLogService.Create(ctx, model.AuditLogEventNewClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name}, tx)
@@ -725,6 +715,7 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien
client.IsPublic = input.IsPublic
// PKCE is required for public clients
client.PkceEnabled = input.IsPublic || input.PkceEnabled
client.LaunchURL = input.LaunchURL
// Credentials
if len(input.Credentials.FederatedIdentities) > 0 {
@@ -1180,9 +1171,13 @@ func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, use
}()
var deviceAuth model.OidcDeviceCode
if err := tx.WithContext(ctx).Preload("Client.AllowedUserGroups").First(&deviceAuth, "user_code = ?", userCode).Error; err != nil {
log.Printf("Error finding device code with user_code %s: %v", userCode, err)
return err
err := tx.
WithContext(ctx).
Preload("Client.AllowedUserGroups").
First(&deviceAuth, "user_code = ?", userCode).
Error
if err != nil {
return fmt.Errorf("error finding device code: %w", err)
}
if time.Now().After(deviceAuth.ExpiresAt.ToTime()) {
@@ -1191,17 +1186,26 @@ func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, use
// Check if the user group is allowed to authorize the client
var user model.User
if err := tx.WithContext(ctx).Preload("UserGroups").First(&user, "id = ?", userID).Error; err != nil {
return err
err = tx.
WithContext(ctx).
Preload("UserGroups").
First(&user, "id = ?", userID).
Error
if err != nil {
return fmt.Errorf("error finding user groups: %w", err)
}
if !s.IsUserGroupAllowedToAuthorize(user, deviceAuth.Client) {
return &common.OidcAccessDeniedError{}
}
if err := tx.WithContext(ctx).Preload("Client").First(&deviceAuth, "user_code = ?", userCode).Error; err != nil {
log.Printf("Error finding device code with user_code %s: %v", userCode, err)
return err
err = tx.
WithContext(ctx).
Preload("Client").
First(&deviceAuth, "user_code = ?", userCode).
Error
if err != nil {
return fmt.Errorf("error finding device code: %w", err)
}
if time.Now().After(deviceAuth.ExpiresAt.ToTime()) {
@@ -1211,33 +1215,24 @@ func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, use
deviceAuth.UserID = &userID
deviceAuth.IsAuthorized = true
if err := tx.WithContext(ctx).Save(&deviceAuth).Error; err != nil {
log.Printf("Error saving device auth: %v", err)
return err
err = tx.
WithContext(ctx).
Save(&deviceAuth).
Error
if err != nil {
return fmt.Errorf("error saving device auth: %w", err)
}
// Verify the update was successful
var verifiedAuth model.OidcDeviceCode
if err := tx.WithContext(ctx).First(&verifiedAuth, "device_code = ?", deviceAuth.DeviceCode).Error; err != nil {
log.Printf("Error verifying update: %v", err)
return err
}
// Create user authorization if needed
hasAuthorizedClient, err := s.hasAuthorizedClientInternal(ctx, deviceAuth.ClientID, userID, deviceAuth.Scope, tx)
hasAlreadyAuthorizedClient, err := s.createAuthorizedClientInternal(ctx, userID, deviceAuth.ClientID, deviceAuth.Scope, tx)
if err != nil {
return err
}
if !hasAuthorizedClient {
err := s.createAuthorizedClientInternal(ctx, userID, deviceAuth.ClientID, deviceAuth.Scope, tx)
if err != nil {
return err
}
s.auditLogService.Create(ctx, model.AuditLogEventNewDeviceCodeAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": deviceAuth.Client.Name}, tx)
auditLogData := model.AuditLogData{"clientName": deviceAuth.Client.Name}
if hasAlreadyAuthorizedClient {
s.auditLogService.Create(ctx, model.AuditLogEventDeviceCodeAuthorization, ipAddress, userAgent, userID, auditLogData, tx)
} else {
s.auditLogService.Create(ctx, model.AuditLogEventDeviceCodeAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": deviceAuth.Client.Name}, tx)
s.auditLogService.Create(ctx, model.AuditLogEventNewDeviceCodeAuthorization, ipAddress, userAgent, userID, auditLogData, tx)
}
return tx.Commit().Error
@@ -1313,6 +1308,34 @@ func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string,
return authorizedClients, response, err
}
func (s *OidcService) RevokeAuthorizedClient(ctx context.Context, userID string, clientID string) error {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var authorizedClient model.UserAuthorizedOidcClient
err := tx.
WithContext(ctx).
Where("user_id = ? AND client_id = ?", userID, clientID).
First(&authorizedClient).Error
if err != nil {
return err
}
err = tx.WithContext(ctx).Delete(&authorizedClient).Error
if err != nil {
return err
}
err = tx.Commit().Error
if err != nil {
return err
}
return nil
}
func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, tx *gorm.DB) (string, error) {
refreshToken, err := utils.GenerateRandomAlphanumericString(40)
if err != nil {
@@ -1348,14 +1371,37 @@ func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, u
return signed, nil
}
func (s *OidcService) createAuthorizedClientInternal(ctx context.Context, userID string, clientID string, scope string, tx *gorm.DB) error {
userAuthorizedClient := model.UserAuthorizedOidcClient{
UserID: userID,
ClientID: clientID,
Scope: scope,
func (s *OidcService) createAuthorizedClientInternal(ctx context.Context, userID string, clientID string, scope string, tx *gorm.DB) (hasAlreadyAuthorizedClient bool, err error) {
// Check if the user has already authorized the client with the given scope
hasAlreadyAuthorizedClient, err = s.hasAuthorizedClientInternal(ctx, clientID, userID, scope, tx)
if err != nil {
return false, err
}
err := tx.WithContext(ctx).
if hasAlreadyAuthorizedClient {
err = tx.
WithContext(ctx).
Model(&model.UserAuthorizedOidcClient{}).
Where("user_id = ? AND client_id = ?", userID, clientID).
Update("last_used_at", datatype.DateTime(time.Now())).
Error
if err != nil {
return hasAlreadyAuthorizedClient, err
}
return hasAlreadyAuthorizedClient, nil
}
userAuthorizedClient := model.UserAuthorizedOidcClient{
UserID: userID,
ClientID: clientID,
Scope: scope,
LastUsedAt: datatype.DateTime(time.Now()),
}
err = tx.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "client_id"}},
DoUpdates: clause.AssignmentColumns([]string{"scope"}),
@@ -1363,7 +1409,7 @@ func (s *OidcService) createAuthorizedClientInternal(ctx context.Context, userID
Create(&userAuthorizedClient).
Error
return err
return hasAlreadyAuthorizedClient, err
}
type ClientAuthCredentials struct {
@@ -1428,7 +1474,7 @@ func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *g
case isClientAssertion:
err = s.verifyClientAssertionFromFederatedIdentities(ctx, client, input)
if err != nil {
log.Printf("Invalid assertion for client '%s': %v", client.ID, err)
slog.WarnContext(ctx, "Invalid assertion for client", slog.String("client", client.ID), slog.Any("error", err))
return nil, &common.OidcClientAssertionInvalidError{}
}
return client, nil
@@ -1695,3 +1741,19 @@ func (s *OidcService) getUserClaimsFromAuthorizedClient(ctx context.Context, aut
return claims, nil
}
func (s *OidcService) IsClientAccessibleToUser(ctx context.Context, clientID string, userID string) (bool, error) {
var user model.User
err := s.db.WithContext(ctx).Preload("UserGroups").First(&user, "id = ?", userID).Error
if err != nil {
return false, err
}
var client model.OidcClient
err = s.db.WithContext(ctx).Preload("AllowedUserGroups").First(&client, "id = ?", clientID).Error
if err != nil {
return false, err
}
return s.IsUserGroupAllowedToAuthorize(user, client), nil
}

View File

@@ -6,13 +6,14 @@ import (
"errors"
"fmt"
"io"
"log"
"log/slog"
"net/url"
"os"
"strings"
"time"
"github.com/google/uuid"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
@@ -33,7 +34,13 @@ type UserService struct {
}
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService) *UserService {
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService, emailService: emailService, appConfigService: appConfigService}
return &UserService{
db: db,
jwtService: jwtService,
auditLogService: auditLogService,
emailService: emailService,
appConfigService: appConfigService,
}
}
func (s *UserService) ListUsers(ctx context.Context, searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) {
@@ -45,7 +52,8 @@ func (s *UserService) ListUsers(ctx context.Context, searchTerm string, sortedPa
if searchTerm != "" {
searchPattern := "%" + searchTerm + "%"
query = query.Where("email LIKE ? OR first_name LIKE ? OR last_name LIKE ? OR username LIKE ?",
query = query.Where(
"email LIKE ? OR first_name LIKE ? OR last_name LIKE ? OR username LIKE ?",
searchPattern, searchPattern, searchPattern, searchPattern)
}
@@ -118,13 +126,14 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.
defaultPictureBytes := defaultPicture.Bytes()
go func() {
// Ensure the directory exists
err = os.MkdirAll(defaultProfilePicturesDir, os.ModePerm)
if err != nil {
log.Printf("Failed to create directory for default profile picture: %v", err)
errInternal := os.MkdirAll(defaultProfilePicturesDir, os.ModePerm)
if errInternal != nil {
slog.Error("Failed to create directory for default profile picture", slog.Any("error", errInternal))
return
}
if err := utils.SaveFileStream(bytes.NewReader(defaultPictureBytes), defaultPicturePath); err != nil {
log.Printf("Failed to cache default profile picture for initials %s: %v", user.Initials(), err)
errInternal = utils.SaveFileStream(bytes.NewReader(defaultPictureBytes), defaultPicturePath)
if errInternal != nil {
slog.Error("Failed to cache default profile picture for initials", slog.String("initials", user.Initials()), slog.Any("error", errInternal))
}
}()
@@ -393,7 +402,8 @@ func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, use
// We use a background context here as this is running in a goroutine
//nolint:contextcheck
go func() {
innerCtx := context.Background()
span := trace.SpanFromContext(ctx)
innerCtx := trace.ContextWithSpan(context.Background(), span)
link := common.EnvConfig.AppURL + "/lc"
linkWithCode := link + "/" + oneTimeAccessToken
@@ -414,7 +424,8 @@ func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, use
ExpirationString: utils.DurationToString(time.Until(expiration).Round(time.Second)),
})
if errInternal != nil {
log.Printf("Failed to send email to '%s': %v\n", user.Email, errInternal)
slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", errInternal), slog.String("address", user.Email))
return
}
}()

View File

@@ -9,6 +9,7 @@ import (
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
@@ -24,8 +25,8 @@ type WebAuthnService struct {
appConfigService *AppConfigService
}
func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) *WebAuthnService {
webauthnConfig := &webauthn.Config{
func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) (*WebAuthnService, error) {
wa, err := webauthn.New(&webauthn.Config{
RPDisplayName: appConfigService.GetDbConfig().AppName.Value,
RPID: utils.GetHostnameFromURL(common.EnvConfig.AppURL),
RPOrigins: []string{common.EnvConfig.AppURL},
@@ -44,15 +45,18 @@ func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *Au
TimeoutUVD: time.Second * 60,
},
},
})
if err != nil {
return nil, fmt.Errorf("failed to init webauthn object: %w", err)
}
wa, _ := webauthn.New(webauthnConfig)
return &WebAuthnService{
db: db,
webAuthn: wa,
jwtService: jwtService,
auditLogService: auditLogService,
appConfigService: appConfigService,
}
}, nil
}
func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID string) (*model.PublicKeyCredentialCreationOptions, error) {
@@ -70,8 +74,7 @@ func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID string)
Find(&user, "id = ?", userID).
Error
if err != nil {
tx.Rollback()
return nil, err
return nil, fmt.Errorf("failed to load user: %w", err)
}
options, session, err := s.webAuthn.BeginRegistration(
@@ -80,7 +83,7 @@ func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID string)
webauthn.WithExclusions(user.WebAuthnCredentialDescriptors()),
)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to begin WebAuthn registration: %w", err)
}
sessionToStore := &model.WebauthnSession{
@@ -94,12 +97,12 @@ func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID string)
Create(&sessionToStore).
Error
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to save WebAuthn session: %w", err)
}
err = tx.Commit().Error
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to commit transaction: %w", err)
}
return &model.PublicKeyCredentialCreationOptions{
@@ -115,13 +118,15 @@ func (s *WebAuthnService) VerifyRegistration(ctx context.Context, sessionID, use
tx.Rollback()
}()
// Load & delete the session row
var storedSession model.WebauthnSession
err := tx.
WithContext(ctx).
First(&storedSession, "id = ?", sessionID).
Clauses(clause.Returning{}).
Delete(&storedSession, "id = ?", sessionID).
Error
if err != nil {
return model.WebauthnCredential{}, err
return model.WebauthnCredential{}, fmt.Errorf("failed to load WebAuthn session: %w", err)
}
session := webauthn.SessionData{
@@ -136,12 +141,12 @@ func (s *WebAuthnService) VerifyRegistration(ctx context.Context, sessionID, use
Find(&user, "id = ?", userID).
Error
if err != nil {
return model.WebauthnCredential{}, err
return model.WebauthnCredential{}, fmt.Errorf("failed to load user: %w", err)
}
credential, err := s.webAuthn.FinishRegistration(&user, session, r)
if err != nil {
return model.WebauthnCredential{}, err
return model.WebauthnCredential{}, fmt.Errorf("failed to finish WebAuthn registration: %w", err)
}
// Determine passkey name using AAGUID and User-Agent
@@ -162,12 +167,12 @@ func (s *WebAuthnService) VerifyRegistration(ctx context.Context, sessionID, use
Create(&credentialToStore).
Error
if err != nil {
return model.WebauthnCredential{}, err
return model.WebauthnCredential{}, fmt.Errorf("failed to store WebAuthn credential: %w", err)
}
err = tx.Commit().Error
if err != nil {
return model.WebauthnCredential{}, err
return model.WebauthnCredential{}, fmt.Errorf("failed to commit transaction: %w", err)
}
return credentialToStore, nil

View File

@@ -4,7 +4,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"log"
"log/slog"
"sync"
"github.com/pocket-id/pocket-id/backend/resources"
@@ -57,12 +57,13 @@ func loadAAGUIDsFromFile() {
// Read from embedded file system
data, err := resources.FS.ReadFile("aaguids.json")
if err != nil {
log.Printf("Error reading embedded AAGUID file: %v", err)
slog.Error("Error reading embedded AAGUID file", slog.Any("error", err))
return
}
if err := json.Unmarshal(data, &aaguidMap); err != nil {
log.Printf("Error unmarshalling AAGUID data: %v", err)
err = json.Unmarshal(data, &aaguidMap)
if err != nil {
slog.Error("Error unmarshalling AAGUID data", slog.Any("error", err))
return
}
}

View File

@@ -29,9 +29,9 @@ func CreateProfilePicture(file io.Reader) (io.Reader, error) {
pr, pw := io.Pipe()
go func() {
err = imaging.Encode(pw, img, imaging.PNG)
if err != nil {
_ = pw.CloseWithError(fmt.Errorf("failed to encode image: %w", err))
innerErr := imaging.Encode(pw, img, imaging.PNG)
if innerErr != nil {
_ = pw.CloseWithError(fmt.Errorf("failed to encode image: %w", innerErr))
return
}
pw.Close()

View File

@@ -15,7 +15,6 @@ import (
"fmt"
"hash"
"io"
"os"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
@@ -47,26 +46,15 @@ func EncodeJWKBytes(key jwk.Key) ([]byte, error) {
// LoadKeyEncryptionKey loads the key encryption key for JWKs
func LoadKeyEncryptionKey(envConfig *common.EnvConfigSchema, instanceID string) (kek []byte, err error) {
// Try getting the key from the env var as string
kekInput := []byte(envConfig.EncryptionKey)
// If there's nothing in the env, try loading from file
if len(kekInput) == 0 && envConfig.EncryptionKeyFile != "" {
kekInput, err = os.ReadFile(envConfig.EncryptionKeyFile)
if err != nil {
return nil, fmt.Errorf("failed to read key file '%s': %w", envConfig.EncryptionKeyFile, err)
}
}
// If there's still no key, return
if len(kekInput) == 0 {
// If there's no key, return
if len(envConfig.EncryptionKey) == 0 {
return nil, nil
}
// We need a 256-bit key for encryption with AES-GCM-256
// We use HMAC with SHA3-256 here to derive the key from the one passed as input
// The key is tied to a specific instance of Pocket ID
h := hmac.New(func() hash.Hash { return sha3.New256() }, kekInput)
h := hmac.New(func() hash.Hash { return sha3.New256() }, []byte(envConfig.EncryptionKey))
fmt.Fprint(h, "pocketid/"+instanceID+"/jwk-kek")
kek = h.Sum(nil)

View File

@@ -2,7 +2,7 @@ package signals
import (
"context"
"log"
"log/slog"
"os"
"os/signal"
"syscall"
@@ -28,11 +28,11 @@ func SignalContext(parentCtx context.Context) context.Context {
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigCh
log.Println("Received interrupt signal. Shutting down…")
slog.Info("Received interrupt signal. Shutting down…")
cancel()
<-sigCh
log.Println("Received a second interrupt signal. Forcing an immediate shutdown.")
slog.Warn("Received a second interrupt signal. Forcing an immediate shutdown.")
os.Exit(1)
}()

View File

@@ -0,0 +1,85 @@
package utils
import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
)
// This file contains code adapted from https://github.com/samber/slog-multi
// Source: https://github.com/samber/slog-multi/blob/ced84707f45ec9848138349ed58de178eedaa6f2/pipe.go
// Copyright (C) 2023 Samuel Berthe
// License: MIT (https://github.com/samber/slog-multi/blob/ced84707f45ec9848138349ed58de178eedaa6f2/LICENSE)
// LogFanoutHandler is a slog.Handler that sends logs to multiple destinations
type LogFanoutHandler []slog.Handler
// Implements slog.Handler
func (h LogFanoutHandler) Enabled(ctx context.Context, l slog.Level) bool {
for i := range h {
if h[i].Enabled(ctx, l) {
return true
}
}
return false
}
// Implements slog.Handler
func (h LogFanoutHandler) Handle(ctx context.Context, r slog.Record) error {
errs := make([]error, 0)
for i := range h {
if h[i].Enabled(ctx, r.Level) {
err := try(func() error {
return h[i].Handle(ctx, r.Clone())
})
if err != nil {
errs = append(errs, err)
}
}
}
return errors.Join(errs...)
}
// Implements slog.Handler
func (h LogFanoutHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
res := make(LogFanoutHandler, len(h))
for i, v := range h {
res[i] = v.WithAttrs(slices.Clone(attrs))
}
return res
}
// Implements slog.Handler
func (h LogFanoutHandler) WithGroup(name string) slog.Handler {
// https://cs.opensource.google/go/x/exp/+/46b07846:slog/handler.go;l=247
if name == "" {
return h
}
res := make(LogFanoutHandler, len(h))
for i, v := range h {
res[i] = v.WithGroup(name)
}
return res
}
func try(callback func() error) (err error) {
defer func() {
r := recover()
if r != nil {
if e, ok := r.(error); ok {
err = e
} else {
err = fmt.Errorf("unexpected error: %+v", r)
}
}
}()
err = callback()
return
}

View File

@@ -1,25 +1,34 @@
-- Normalize (form NFC) all existing values in the database
UPDATE api_keys SET
name = normalize(name, 'nfc'),
description = normalize(description, 'nfc');
DO $$
BEGIN
-- This function is available only if the server's encoding is UTF8
IF current_setting('server_encoding') = 'UTF8' THEN
UPDATE api_keys SET
name = normalize(name, NFC),
description = normalize(description, NFC);
UPDATE app_config_variables SET
"value" = normalize("value", 'nfc')
WHERE "key" = 'appName';
UPDATE app_config_variables SET
"value" = normalize("value", NFC)
WHERE "key" = 'appName';
UPDATE custom_claims SET
"key" = normalize("key", 'nfc'),
"value" = normalize("value", 'nfc');
UPDATE custom_claims SET
"key" = normalize("key", NFC),
"value" = normalize("value", NFC);
UPDATE oidc_clients SET
name = normalize(name, 'nfc');
UPDATE oidc_clients SET
name = normalize(name, NFC);
UPDATE users SET
username = normalize(username, 'nfc'),
email = normalize(email, 'nfc'),
first_name = normalize(first_name, 'nfc'),
last_name = normalize(last_name, 'nfc');
UPDATE users SET
username = normalize(username, NFC),
email = normalize(email, NFC),
first_name = normalize(first_name, NFC),
last_name = normalize(last_name, NFC);
UPDATE user_groups SET
friendly_name = normalize(friendly_name, 'nfc'),
"name" = normalize("name", 'nfc');
UPDATE user_groups SET
friendly_name = normalize(friendly_name, NFC),
"name" = normalize("name", NFC);
ELSE
RAISE NOTICE 'Skipping normalization: server_encoding is %', current_setting('server_encoding');
END IF;
END;
$$ LANGUAGE plpgsql;

View File

@@ -0,0 +1,3 @@
ALTER TABLE oidc_clients DROP COLUMN launch_url;
ALTER TABLE user_authorized_oidc_clients DROP COLUMN last_used_at;

View File

@@ -0,0 +1,3 @@
ALTER TABLE oidc_clients ADD COLUMN launch_url TEXT;
ALTER TABLE user_authorized_oidc_clients ADD COLUMN last_used_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp;

View File

@@ -0,0 +1,3 @@
ALTER TABLE oidc_clients DROP COLUMN launch_url;
ALTER TABLE user_authorized_oidc_clients DROP COLUMN created_at;

View File

@@ -0,0 +1,16 @@
ALTER TABLE oidc_clients ADD COLUMN launch_url TEXT;
CREATE TABLE user_authorized_oidc_clients_new
(
scope TEXT,
user_id TEXT,
client_id TEXT REFERENCES oidc_clients,
last_used_at DATETIME NOT NULL,
PRIMARY KEY (user_id, client_id)
);
INSERT INTO user_authorized_oidc_clients_new (scope, user_id, client_id, last_used_at)
SELECT scope, user_id, client_id, unixepoch() FROM user_authorized_oidc_clients;
DROP TABLE user_authorized_oidc_clients;
ALTER TABLE user_authorized_oidc_clients_new RENAME TO user_authorized_oidc_clients;

View File

@@ -419,5 +419,15 @@
"signup_open_description": "Kdokoli si může vytvořit nový účet bez omezení.",
"of": "z",
"skip_passkey_setup": "Přeskočit nastavení přístupového klíče",
"skip_passkey_setup_description": "Je důrazně doporučeno nastavit přístupový klíč, bez něho se nebudete moci přihlásit, jakmile aktuální relace vyprší."
"skip_passkey_setup_description": "Je důrazně doporučeno nastavit přístupový klíč, bez něho se nebudete moci přihlásit, jakmile aktuální relace vyprší.",
"my_apps": "Moje aplikace",
"no_apps_available": "Žádné aplikace nejsou k dispozici",
"contact_your_administrator_for_app_access": "Kontaktujte svého správce, abyste získali přístup k aplikacím.",
"launch": "Spuštění",
"client_launch_url": "URL spuštění klienta",
"client_launch_url_description": "URL adresa, která se otevře, když uživatel spustí aplikaci ze stránky Moje aplikace.",
"client_name_description": "Název klienta, který se zobrazuje v uživatelském rozhraní Pocket ID.",
"revoke_access": "Zrušit přístup",
"revoke_access_description": "Zrušit přístup k <b>{clientName}</b>. <b>{clientName}</b> už nebude mít přístup k informacím o vašem účtu.",
"revoke_access_successful": "Přístup k {clientName} byl úspěšně zrušen."
}

View File

@@ -419,5 +419,15 @@
"signup_open_description": "Alle kan oprette en ny konto uden begrænsninger.",
"of": "af",
"skip_passkey_setup": "Spring Passkey-opsætning over",
"skip_passkey_setup_description": "Det anbefales stærkt at oprette en adgangskode, da du ellers bliver låst ude af din konto, så snart sessionen udløber."
"skip_passkey_setup_description": "Det anbefales stærkt at oprette en adgangskode, da du ellers bliver låst ude af din konto, så snart sessionen udløber.",
"my_apps": "Mine apps",
"no_apps_available": "Ingen apps tilgængelige",
"contact_your_administrator_for_app_access": "Kontakt din administrator for at få adgang til applikationer.",
"launch": "Start",
"client_launch_url": "Kundens lancerings-URL",
"client_launch_url_description": "Den URL, der åbnes, når en bruger starter appen fra siden Mine apps.",
"client_name_description": "Navnet på den klient, der vises i Pocket ID-brugergrænsefladen.",
"revoke_access": "Tilbagekald adgang",
"revoke_access_description": "Tilbagekald adgang til <b>{clientName}</b>. <b>{clientName}</b> vil ikke længere kunne få adgang til dine kontooplysninger.",
"revoke_access_successful": "Adgangen til {clientName} er blevet ophævet."
}

View File

@@ -419,5 +419,15 @@
"signup_open_description": "Jeder kann ohne Einschränkungen ein neues Konto erstellen.",
"of": "von",
"skip_passkey_setup": "Passwort-Einrichtung überspringen",
"skip_passkey_setup_description": "Es wird dringend empfohlen, einen Passkey einzurichten, da du sonst nach Ablauf der Sitzung aus deinem Konto ausgesperrt wirst."
"skip_passkey_setup_description": "Es wird dringend empfohlen, einen Passkey einzurichten, da du sonst nach Ablauf der Sitzung aus deinem Konto ausgesperrt wirst.",
"my_apps": "Meine Apps",
"no_apps_available": "Keine Apps verfügbar",
"contact_your_administrator_for_app_access": "Frag deinen Administrator, wie du Zugriff auf die Anwendungen bekommst.",
"launch": "Start",
"client_launch_url": "Client-Start-URL",
"client_launch_url_description": "Die URL, die geöffnet wird, wenn jemand die App von der Seite „Meine Apps“ startet.",
"client_name_description": "Der Name des Clients, der in der Pocket ID-Benutzeroberfläche angezeigt wird.",
"revoke_access": "Zugriff widerrufen",
"revoke_access_description": "Zugriff widerrufen <b>{clientName}</b>. <b>{clientName}</b> kann nicht mehr auf deine Kontoinfos zugreifen.",
"revoke_access_successful": "Der Zugriff auf „ {clientName} “ wurde erfolgreich gesperrt."
}

View File

@@ -419,5 +419,15 @@
"signup_open_description": "Anyone can create a new account without restrictions.",
"of": "of",
"skip_passkey_setup": "Skip Passkey Setup",
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires.",
"my_apps": "My Apps",
"no_apps_available": "No apps available",
"contact_your_administrator_for_app_access": "Contact your administrator to get access to applications.",
"launch": "Launch",
"client_launch_url": "Client Launch URL",
"client_launch_url_description": "The URL that will be opened when a user launches the app from the My Apps page.",
"client_name_description": "The name of the client that shows in the Pocket ID UI.",
"revoke_access": "Revoke Access",
"revoke_access_description": "Revoke access to <b>{clientName}</b>. <b>{clientName}</b> will no longer be able to access your account information.",
"revoke_access_successful": "The access to {clientName} has been successfully revoked."
}

View File

@@ -419,5 +419,15 @@
"signup_open_description": "Cualquiera puede crear una nueva cuenta sin restricciones.",
"of": "de",
"skip_passkey_setup": "Omitir la configuración de la clave de acceso",
"skip_passkey_setup_description": "Es muy recomendable configurar una contraseña maestra, ya que sin ella no podrás acceder a tu cuenta una vez que expire la sesión."
"skip_passkey_setup_description": "Es muy recomendable configurar una contraseña maestra, ya que sin ella no podrás acceder a tu cuenta una vez que expire la sesión.",
"my_apps": "Mis aplicaciones",
"no_apps_available": "No hay aplicaciones disponibles",
"contact_your_administrator_for_app_access": "Ponte en contacto con tu administrador para obtener acceso a las aplicaciones.",
"launch": "Lanzamiento",
"client_launch_url": "URL de inicio del cliente",
"client_launch_url_description": "La URL que se abrirá cuando un usuario inicie la aplicación desde la página Mis aplicaciones.",
"client_name_description": "El nombre del cliente que aparece en la interfaz de usuario de Pocket ID.",
"revoke_access": "Revocar acceso",
"revoke_access_description": "Revocar el acceso a <b>{clientName}</b>. <b>{clientName}</b> ya no podrás acceder a la información de tu cuenta.",
"revoke_access_successful": "El acceso a {clientName} ha sido revocado correctamente."
}

View File

@@ -419,5 +419,15 @@
"signup_open_description": "Toute personne peut créer un nouveau compte sans restriction.",
"of": "sur",
"skip_passkey_setup": "Ignorer la configuration de la clé d'accès",
"skip_passkey_setup_description": "Il est fortement recommandé de configurer une clé d'accès, car sans elle, vous serez verrouillé hors de votre compte dès l'expiration de la session."
"skip_passkey_setup_description": "Il est fortement recommandé de configurer une clé d'accès, car sans elle, vous serez verrouillé hors de votre compte dès l'expiration de la session.",
"my_apps": "Mes applications",
"no_apps_available": "Aucune appli disponible",
"contact_your_administrator_for_app_access": "Contacte ton administrateur pour avoir accès aux applications.",
"launch": "Lancement",
"client_launch_url": "URL de lancement du client",
"client_launch_url_description": "L'URL qui s'ouvrira quand quelqu'un lancera l'appli depuis la page Mes applis.",
"client_name_description": "Le nom du client qui apparaît dans l'interface utilisateur Pocket ID.",
"revoke_access": "Supprimer l'accès",
"revoke_access_description": "Supprimer l'accès à <b>{clientName}</b>. <b>{clientName}</b> ne pourra plus accéder aux infos de ton compte.",
"revoke_access_successful": "L'accès à {clientName} a été supprimé."
}

View File

@@ -419,5 +419,15 @@
"signup_open_description": "Chiunque può creare un nuovo account senza restrizioni.",
"of": "di",
"skip_passkey_setup": "Salta Impostazione Passkey",
"skip_passkey_setup_description": "Si consiglia vivamente di impostare una passkey perché senza di essa, sarai tagliato fuori dal tuo account non appena scadrà la sessione."
"skip_passkey_setup_description": "Si consiglia vivamente di impostare una passkey perché senza di essa, sarai tagliato fuori dal tuo account non appena scadrà la sessione.",
"my_apps": "Le mie app",
"no_apps_available": "Non ci sono app disponibili",
"contact_your_administrator_for_app_access": "Contatta il tuo amministratore per accedere alle app.",
"launch": "Lancio",
"client_launch_url": "URL di avvio del cliente",
"client_launch_url_description": "L'URL che si aprirà quando qualcuno avvia l'app dalla pagina Le mie app.",
"client_name_description": "Il nome del cliente che appare nell'interfaccia utente Pocket ID.",
"revoke_access": "Revoca accesso",
"revoke_access_description": "Revoca l'accesso a <b>{clientName}</b>. <b>{clientName}</b> non potrà più accedere alle informazioni del tuo account.",
"revoke_access_successful": "L'accesso a {clientName} è stato revocato con successo."
}

View File

@@ -419,5 +419,15 @@
"signup_open_description": "Iedereen kan zonder beperkingen een nieuw account aanmaken.",
"of": "van",
"skip_passkey_setup": "Pas de instellingen voor de toegangssleutel over",
"skip_passkey_setup_description": "Het is echt een aanrader om een wachtwoord in te stellen, want zonder dat word je uit je account gegooid zodra de sessie afloopt."
"skip_passkey_setup_description": "Het is echt een aanrader om een wachtwoord in te stellen, want zonder dat word je uit je account gegooid zodra de sessie afloopt.",
"my_apps": "Mijn apps",
"no_apps_available": "Geen apps beschikbaar",
"contact_your_administrator_for_app_access": "Neem contact op met je beheerder om toegang te krijgen tot applicaties.",
"launch": "Lancering",
"client_launch_url": "URL voor lancering door klant",
"client_launch_url_description": "De URL die wordt geopend als iemand de app start vanaf de pagina Mijn apps.",
"client_name_description": "De naam van de klant die je in de Pocket ID-UI ziet.",
"revoke_access": "Toegang intrekken",
"revoke_access_description": "Toegang intrekken tot <b>{clientName}</b>. <b>{clientName}</b> kan je accountgegevens niet meer bekijken.",
"revoke_access_successful": "De toegang tot {clientName} is nu echt geblokkeerd."
}

View File

@@ -419,5 +419,15 @@
"signup_open_description": "Każdy może utworzyć nowe konto bez żadnych ograniczeń.",
"of": "z",
"skip_passkey_setup": "Pomiń konfigurację klucza dostępu",
"skip_passkey_setup_description": "Zdecydowanie zalecamy skonfigurowanie klucza dostępu, ponieważ bez niego utracisz dostęp do konta zaraz po wygaśnięciu sesji."
"skip_passkey_setup_description": "Zdecydowanie zalecamy skonfigurowanie klucza dostępu, ponieważ bez niego utracisz dostęp do konta zaraz po wygaśnięciu sesji.",
"my_apps": "Moje aplikacje",
"no_apps_available": "Brak dostępnych aplikacji",
"contact_your_administrator_for_app_access": "Skontaktuj się z administratorem, aby uzyskać dostęp do aplikacji.",
"launch": "Uruchomienie",
"client_launch_url": "Adres URL uruchomienia klienta",
"client_launch_url_description": "Adres URL, który zostanie otwarty po uruchomieniu aplikacji przez użytkownika ze strony Moje aplikacje.",
"client_name_description": "Nazwa klienta wyświetlana w interfejsie użytkownika Pocket ID.",
"revoke_access": "Cofnij dostęp",
"revoke_access_description": "Cofnij dostęp do <b>{clientName}</b>. <b>{clientName}</b> nie będzie już mógł uzyskać dostępu do informacji o Twoim koncie.",
"revoke_access_successful": "Dostęp do strony {clientName} został pomyślnie cofnięty."
}

View File

@@ -419,5 +419,15 @@
"signup_open_description": "Qualquer pessoa pode criar uma conta nova sem restrições.",
"of": "de",
"skip_passkey_setup": "Pular configuração da chave de acesso",
"skip_passkey_setup_description": "É super recomendável criar uma senha de acesso, porque sem ela você vai ficar sem poder entrar na sua conta assim que a sessão acabar."
"skip_passkey_setup_description": "É super recomendável criar uma senha de acesso, porque sem ela você vai ficar sem poder entrar na sua conta assim que a sessão acabar.",
"my_apps": "Meus aplicativos",
"no_apps_available": "Não tem aplicativos disponíveis",
"contact_your_administrator_for_app_access": "Fala com o seu administrador pra conseguir acesso aos aplicativos.",
"launch": "Lançamento",
"client_launch_url": "URL de lançamento do cliente",
"client_launch_url_description": "A URL que vai abrir quando alguém abrir o aplicativo na página Meus aplicativos.",
"client_name_description": "O nome do cliente que aparece na interface do Pocket ID.",
"revoke_access": "Revogar acesso",
"revoke_access_description": "Revogar acesso a <b>{clientName}</b>. <b>{clientName}</b> não vai mais conseguir acessar as informações da sua conta.",
"revoke_access_successful": "O acesso a {clientName} foi revogado com sucesso."
}

View File

@@ -23,11 +23,11 @@
"click_to_copy": "Нажмите, чтобы скопировать",
"something_went_wrong": "Что-то пошло не так",
"go_back_to_home": "Вернуться на главную",
"dont_have_access_to_your_passkey": "Нет доступа к вашему passkey?",
"dont_have_access_to_your_passkey": "Нет доступа к вашему пасскею?",
"login_background": "Фон страницы входа",
"logo": "Логотип",
"login_code": "Код входа",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Создайте код входа, с которым пользователь сможет войти без passkey один раз.",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Создайте код входа, с которым пользователь сможет войти без пасскея один раз.",
"one_hour": "1 час",
"twelve_hours": "12 часов",
"one_day": "1 день",
@@ -37,13 +37,13 @@
"generate_code": "Сгенерировать код",
"name": "Имя",
"browser_unsupported": "Браузер не поддерживается",
"this_browser_does_not_support_passkeys": "Этот браузер не поддерживает ключи доступа. Пожалуйста, воспользуйтесь альтернативным способом входа.",
"this_browser_does_not_support_passkeys": "Этот браузер не поддерживает пасскеи. Пожалуйста, воспользуйтесь альтернативным способом входа.",
"an_unknown_error_occurred": "Произошла неизвестная ошибка",
"authentication_process_was_aborted": "Процесс аутентификации был прерван",
"error_occurred_with_authenticator": "С аутентификатором произошла ошибка",
"authenticator_does_not_support_discoverable_credentials": "Аутентификатор не поддерживает discoverable credentials",
"authenticator_does_not_support_resident_keys": "Аутентификатор не поддерживает resident keys",
"passkey_was_previously_registered": "Этот passkey был ранее зарегистрирован",
"passkey_was_previously_registered": "Этот пасскей был ранее зарегистрирован",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Аутентификатор не поддерживает ни один из запрошенных алгоритмов",
"authenticator_timed_out": "Время ожидания аутентификатора истекло",
"critical_error_occurred_contact_administrator": "Произошла критическая ошибка. Обратитесь к администратору.",
@@ -62,16 +62,16 @@
"try_again": "Попробовать снова",
"client_logo": "Логотип клиента",
"sign_out": "Выйти",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Вы хотите выйти из Pocket ID с учетной записью <b>{username}</b>?",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Вы хотите выйти из {appName} с учетной записью <b>{username}</b>?",
"sign_in_to_appname": "Вход в {appName}",
"please_try_to_sign_in_again": "Пожалуйста, попробуйте войти снова.",
"authenticate_with_passkey_to_access_account": "Авторизуйтесь с использованием passkey для доступа к вашей учетной записи.",
"authenticate_with_passkey_to_access_account": "Авторизуйтесь с использованием пасскея для доступа к вашей учетной записи.",
"authenticate": "Авторизоваться",
"please_try_again": "Пожалуйста, повторите попытку.",
"continue": "Продолжить",
"alternative_sign_in": "Альтернативный вход",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Если у вас нет доступа к вашему passkey, вы можете войти одним из следующих способов.",
"use_your_passkey_instead": "Воспользоваться passkey вместо этого?",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Если у вас нет доступа к вашему пасскею, вы можете войти одним из следующих способов.",
"use_your_passkey_instead": "Воспользоваться пасскеем вместо этого?",
"email_login": "Вход через электронную почту",
"enter_a_login_code_to_sign_in": "Введите предварительно созданный код входа.",
"request_a_login_code_via_email": "Запросить код входа на электронную почту.",
@@ -104,14 +104,14 @@
"account_details_updated_successfully": "Данные учетной записи успешно обновлены",
"profile_picture_updated_successfully": "Изображение профиля успешно обновлено. Обновление может занять несколько минут.",
"account_settings": "Настройки учетной записи",
"passkey_missing": "Passkey отсутствует",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Пожалуйста, добавьте passkey, чтобы избежать утери доступа к вашей учетной записи.",
"single_passkey_configured": "Настроен один passkey",
"it_is_recommended_to_add_more_than_one_passkey": "Рекомендуется добавить более одного ключа доступа во избежание потери доступа к вашей учетной записи.",
"passkey_missing": "Пасскей отсутствует",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Пожалуйста, добавьте пасскей, чтобы избежать утери доступа к вашей учетной записи.",
"single_passkey_configured": "Настроен один пасскей",
"it_is_recommended_to_add_more_than_one_passkey": "Рекомендуется добавить более одного пасскея во избежание потери доступа к вашей учетной записи.",
"account_details": "Детали учетной записи",
"passkeys": "Ключи доступа",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Управляйте ключами доступа, которые вы можете использовать для аутентификации.",
"add_passkey": "Добавить ключ",
"passkeys": "Пасскеи",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Управляйте пасскеями, которые вы можете использовать для аутентификации себя.",
"add_passkey": "Добавить пасскей",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Создайте одноразовый код входа, чтобы войти с другого устройства без passkey.",
"create": "Создать",
"first_name": "Имя",
@@ -124,12 +124,12 @@
"added_on": "Добавлен",
"rename": "Переименовать",
"delete": "Удалить",
"are_you_sure_you_want_to_delete_this_passkey": "Вы уверены, что хотите удалить этот passkey?",
"passkey_deleted_successfully": "Passkey успешно удален",
"are_you_sure_you_want_to_delete_this_passkey": "Вы уверены, что хотите удалить этот пасскей?",
"passkey_deleted_successfully": "Пасскей успешно удален",
"delete_passkey_name": "Удалить {passkeyName}",
"passkey_name_updated_successfully": "Имя passkey успешно обновлено",
"name_passkey": "Имя Passkey",
"name_your_passkey_to_easily_identify_it_later": "Назовите ваш passkey, чтобы легко идентифицировать его позже.",
"passkey_name_updated_successfully": "Имя пасскея успешно обновлено",
"name_passkey": "Имя пасскея",
"name_your_passkey_to_easily_identify_it_later": "Назовите ваш пасскей, чтобы легко идентифицировать его позже.",
"create_api_key": "Создать API ключ",
"add_a_new_api_key_for_programmatic_access": "Добавить новый API ключ для программного доступа.",
"add_api_key": "Добавить API ключ",
@@ -178,7 +178,7 @@
"email_login_notification": "Уведомление о логине по электронной почте",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Отправлять пользователю письмо при входе с нового устройства.",
"emai_login_code_requested_by_user": "Код входа по электронной почте, запрошенный пользователем",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Позволяет пользователям обходить вход через ключи доступа, запросив код входа, отправляемый на их электронную почту. Это значительно снижает безопасность, так как любой человек, имеющий доступ к электронной почте пользователя, может получить доступ.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Позволяет пользователям обходить вход через пасскей, запросив код входа, отправляемый на их электронную почту. Это значительно снижает безопасность так как любой человек, имеющий доступ к электронной почте пользователя, может получить доступ.",
"email_login_code_from_admin": "Код входа по электронной почте от администратора",
"allows_an_admin_to_send_a_login_code_to_the_user": "Позволяет администратору отправлять код входа пользователю по электронной почте.",
"send_test_email": "Отправить тестовое письмо",
@@ -268,12 +268,12 @@
"add_oidc_client": "Добавить OIDC клиент",
"manage_oidc_clients": "Управление OIDC клиентами",
"one_time_link": "Одноразовая ссылка",
"use_this_link_to_sign_in_once": "Используйте эту ссылку, чтобы войти единожды. Это необходимо для пользователей, которые ещё не добавили passkey или потеряли его.",
"use_this_link_to_sign_in_once": "Используйте эту ссылку, чтобы войти единожды. Это необходимо для пользователей, которые ещё не добавили пасскей или потеряли его.",
"add": "Добавить",
"callback_urls": "Callback URLs",
"logout_callback_urls": "Logout Callback URLs",
"public_client": "Публичный клиент",
"public_clients_description": "Публичные клиенты не имеют клиентского секрета и вместо этого используют PKCE. Включите, если ваш клиент является SPA или мобильным приложением.",
"public_clients_description": "Публичные клиенты не имеют клиентского секрета. Они предназначены для мобильных, SPA и нативных приложений, где секретные данные нельзя надежно хранить.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange — это функция безопасности для предотвращения атак CSRF и перехвата кода авторизации.",
"name_logo": "Логотип {name}",
@@ -346,7 +346,7 @@
"authorize_device": "Авторизовать устройство",
"the_device_has_been_authorized": "Устройство авторизовано.",
"enter_code_displayed_in_previous_step": "Введите код, который был отображен на предыдущем шаге.",
"authorize": "Авторизируйте",
"authorize": "Авторизовать",
"federated_client_credentials": "Федеративные учетные данные клиента",
"federated_client_credentials_description": "Используя федеративные учетные данные клиента, вы можете авторизовывать OIDC клиентов, используя JWT токены, выпущенные третьими сторонами.",
"add_federated_client_credential": "Добавить федеративные учетные данные клиента",
@@ -390,9 +390,9 @@
"go_to_login": "Перейти ко входу",
"signup_to_appname": "Зарегистрироваться в {appName}",
"create_your_account_to_get_started": "Создайте свою учетную запись, чтобы начать.",
"initial_account_creation_description": "Пожалуйста, создайте свою учетную запись, чтобы начать. Вы сможете настроить passkey позже.",
"setup_your_passkey": "Настроить passkey",
"create_a_passkey_to_securely_access_your_account": "Создайте passkey для безопасного доступа к учетной записи. Это будет ваш основной способ входа.",
"initial_account_creation_description": "Пожалуйста, создайте свою учетную запись, чтобы начать. Вы сможете настроить пасскей позже.",
"setup_your_passkey": "Настройте ваш пасскей",
"create_a_passkey_to_securely_access_your_account": "Создайте пасскей для безопасного доступа к учетной записи. Это будет ваш основной способ входа.",
"skip_for_now": "Пока пропустить",
"account_created": "Учетная запись создана",
"enable_user_signups": "Включить регистрацию пользователей",
@@ -418,6 +418,16 @@
"signup_open": "Открытая регистрация",
"signup_open_description": "Любой может создать новую учетную запись без ограничений.",
"of": "из",
"skip_passkey_setup": "Пропустить настройку passkey",
"skip_passkey_setup_description": "Настоятельно рекомендуется настроить passkey, так как без него вы более не сможете войти в учетную запись после истечения сессии."
"skip_passkey_setup": "Пропустить настройку пасскея",
"skip_passkey_setup_description": "Настоятельно рекомендуется настроить passkey, так как без него вы более не сможете войти в учетную запись после истечения сессии.",
"my_apps": "Мои приложения",
"no_apps_available": "Нет доступных приложений",
"contact_your_administrator_for_app_access": "Свяжись с администратором, чтобы получить доступ к приложениям.",
"launch": "Запуск",
"client_launch_url": "URL запуска клиента",
"client_launch_url_description": "URL-адрес, который откроется, когда кто-то запустит приложение со страницы «Мои приложения».",
"client_name_description": "Имя клиента, которое показывается в интерфейсе Pocket ID.",
"revoke_access": "Отменить доступ",
"revoke_access_description": "Отменить доступ к <b>{clientName}</b>. <b>{clientName}</b> больше не сможет заходить в твою учетную запись.",
"revoke_access_successful": "Доступ к {clientName} был успешно заблокирован."
}

433
frontend/messages/uk.json Normal file
View File

@@ -0,0 +1,433 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "Мій обліковий запис",
"logout": "Вийти",
"confirm": "Підтвердити",
"docs": "Документація",
"key": "Ключ",
"value": "Значення",
"remove_custom_claim": "Видалити власний атрибут",
"add_custom_claim": "Додати власний атрибут",
"add_another": "Додати ще",
"select_a_date": "Обрати дату",
"select_file": "Обрати файл",
"profile_picture": "Фотографія профілю",
"profile_picture_is_managed_by_ldap_server": "Фотографія профілю управляється сервером LDAP і не може бути змінена тут.",
"click_profile_picture_to_upload_custom": "Натисніть на зображення профілю, щоб завантажити власне зображення.",
"image_should_be_in_format": "Зображення повинно бути у форматі PNG або JPEG.",
"items_per_page": "Елементів на сторінці",
"no_items_found": "Нічого не знайдено",
"search": "Пошук...",
"expand_card": "Розгорнути картку",
"copied": "Скопійовано",
"click_to_copy": "Натисніть, щоб скопіювати",
"something_went_wrong": "Щось пішло не так",
"go_back_to_home": "Повернутися на головну сторінку",
"dont_have_access_to_your_passkey": "Не маєте доступу до свого ключа доступу?",
"login_background": "Фон сторінки входу",
"logo": "Логотип",
"login_code": "Код входу",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Створіть код входу, який користувач може використовувати для входу без ключа доступу одноразово.",
"one_hour": "1 година",
"twelve_hours": "12 годин",
"one_day": "1 день",
"one_week": "1 тиждень",
"one_month": "1 місяць",
"expiration": "Термін дії",
"generate_code": "Генерувати код",
"name": "Назва",
"browser_unsupported": "Браузер не підтримується",
"this_browser_does_not_support_passkeys": "Цей браузер не підтримує ключі доступу. Будь ласка, скористайтеся іншим способом входу.",
"an_unknown_error_occurred": "Сталася невідома помилка",
"authentication_process_was_aborted": "Процес автентифікації було перервано",
"error_occurred_with_authenticator": "Сталася помилка з автентифікатором",
"authenticator_does_not_support_discoverable_credentials": "Автентифікатор не підтримує виявлені облікові дані",
"authenticator_does_not_support_resident_keys": "Автентифікатор не підтримує локальні ключі",
"passkey_was_previously_registered": "Цей ключ доступу був раніше зареєстрований",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Автентифікатор не підтримує жоден із запитаних алгоритмів",
"authenticator_timed_out": "Час очікування автентифікатора вичерпано",
"critical_error_occurred_contact_administrator": "Виникла критична помилка. Будь ласка, зверніться до адміністратора.",
"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> за допомогою облікового запису {appName}?",
"email": "Електронна пошта",
"view_your_email_address": "Переглянути адресу електронної пошти",
"profile": "Профіль",
"view_your_profile_information": "Переглянути інформацію про свій профіль",
"groups": "Групи",
"view_the_groups_you_are_a_member_of": "Переглянути групи, учасником яких ви є",
"cancel": "Скасувати",
"sign_in": "Увійти",
"try_again": "Спробувати знову",
"client_logo": "Логотип клієнта",
"sign_out": "Вийти",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Ви хочете вийти з {appName} під обліковим записом <b>{username}</b>?",
"sign_in_to_appname": "Увійти в {appName}",
"please_try_to_sign_in_again": "Будь ласка, спробуйте увійти знову.",
"authenticate_with_passkey_to_access_account": "Автентифікуйтеся за допомогою ключа доступу, щоб отримати доступ до свого облікового запису.",
"authenticate": "Автентифікуватися",
"please_try_again": "Будь ласка, спробуйте ще раз.",
"continue": "Продовжити",
"alternative_sign_in": "Альтернативний вхід",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Якщо у вас немає доступу до ключа доступу, ви можете увійти, використавши один із наступних способів.",
"use_your_passkey_instead": "Використати ключ доступу натомість?",
"email_login": "Вхід за електронною поштою",
"enter_a_login_code_to_sign_in": "Введіть код для входу, щоб увійти.",
"request_a_login_code_via_email": "Запросити код для входу електронною поштою.",
"go_back": "Назад",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Електронний лист було надіслано на вказану електронну адресу, якщо вона існує в системі.",
"enter_code": "Введіть код",
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Введіть вашу електронну адресу, щоб отримати лист із кодом для входу.",
"your_email": "Ваша електронна адреса",
"submit": "Надіслати",
"enter_the_code_you_received_to_sign_in": "Введіть отриманий код, щоб увійти.",
"code": "Код",
"invalid_redirect_url": "Неправильна URL-адреса перенаправлення",
"audit_log": "Журнал авдиту",
"users": "Користувачі",
"user_groups": "Групи користувачів",
"oidc_clients": "Клієнти OIDC",
"api_keys": "Ключі API",
"application_configuration": "Конфігурація застосунку",
"settings": "Налаштування",
"update_pocket_id": "Оновити Pocket ID",
"powered_by": "Працює на базі",
"see_your_account_activities_from_the_last_3_months": "Перегляньте активність вашого облікового запису за останні 3 місяці.",
"time": "Час",
"event": "Подія",
"approximate_location": "Приблизне місцеперебування",
"ip_address": "IP-адреса",
"device": "Пристрій",
"client": "Клієнт",
"unknown": "Невідомий",
"account_details_updated_successfully": "Дані облікового запису успішно оновлені",
"profile_picture_updated_successfully": "Зображення профілю успішно оновлено. Оновлення може зайняти кілька хвилин.",
"account_settings": "Налаштування облікового запису",
"passkey_missing": "Ключ доступу відсутній",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Будь ласка, додайте ключ доступу для запобігання втрати доступу до вашого облікового запису.",
"single_passkey_configured": "Налаштовано єдиний ключ доступу",
"it_is_recommended_to_add_more_than_one_passkey": "Рекомендується додати більше одного ключа доступу, щоб уникнути втрати доступу до вашого облікового запису.",
"account_details": "Дані облікового запису",
"passkeys": "Ключі доступу",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Керуйте своїми ключами доступу, які ви можете використовувати для автентифікації.",
"add_passkey": "Додати ключ доступу",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Створіть одноразовий код для входу, щоб увійти з іншого пристрою без ключа доступу.",
"create": "Створити",
"first_name": "Ім'я",
"last_name": "Прізвище",
"username": "Ім’я користувача",
"save": "Зберегти",
"username_can_only_contain": "Ім’я користувача може містити лише малі літери, цифри, підкреслення, крапки, дефіси та символ '@'",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Увійдіть, використовуючи наступний код. Код дійсний протягом 15 хвилин.",
"or_visit": "або відвідайте",
"added_on": "Додано",
"rename": "Перейменувати",
"delete": "Видалити",
"are_you_sure_you_want_to_delete_this_passkey": "Ви впевнені, що хочете видалити цей ключ доступу?",
"passkey_deleted_successfully": "Ключ доступу успішно видалено",
"delete_passkey_name": "Видалити {passkeyName}",
"passkey_name_updated_successfully": "Назва ключа доступу успішно оновлено",
"name_passkey": "Назва ключа доступу",
"name_your_passkey_to_easily_identify_it_later": "Назвіть свій ключ доступу, щоб легко пізнати його пізніше.",
"create_api_key": "Створити API-ключ",
"add_a_new_api_key_for_programmatic_access": "Додайте новий API-ключ для програмного доступу.",
"add_api_key": "Додати API-ключ",
"manage_api_keys": "Керувати ключами API",
"api_key_created": "Створено API-ключ",
"for_security_reasons_this_key_will_only_be_shown_once": "З міркувань безпеки цей ключ буде показано лише один раз. Будь ласка, збережіть його в безпечному місці.",
"description": "Опис",
"api_key": "API-ключ",
"close": "Закрити",
"name_to_identify_this_api_key": "Назва для ідентифікації цього API-ключа.",
"expires_at": "Дійсно до",
"when_this_api_key_will_expire": "Коли спливе термін дії цього API-ключа.",
"optional_description_to_help_identify_this_keys_purpose": "Додатковий опис для допомоги в ідентифікації призначення цього ключа (необов’язково).",
"expiration_date_must_be_in_the_future": "Дата закінчення терміну дії повинна бути в майбутньому",
"revoke_api_key": "Анулювати API-ключ",
"never": "Ніколи",
"revoke": "Анулювати",
"api_key_revoked_successfully": "API-ключ успішно анульовано",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Ви впевнені, що хочете анулювати API-ключ «{apiKeyName}»? Це призведе до зупинки всіх інтеграцій, які використовують цей ключ.",
"last_used": "Останнє використання",
"actions": "Дії",
"images_updated_successfully": "Зображення успішно оновлено",
"general": "Загальне",
"configure_smtp_to_send_emails": "Увімкніть сповіщення електронною поштою, щоб повідомляти користувачів про вхід з нового пристрою або місцеперебування.",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Налаштуйте параметри LDAP для синхронізації користувачів і груп із LDAP-сервера.",
"images": "Зображення",
"update": "Оновити",
"email_configuration_updated_successfully": "Конфігурацію електронної пошти успішно оновлено",
"save_changes_question": "Зберегти зміни?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "Ви повинні зберегти зміни перед надсиланням тестового листа. Зберегти зараз?",
"save_and_send": "Зберегти та надіслати",
"test_email_sent_successfully": "Тестовий лист успішно відправлено на вашу електронну адресу.",
"failed_to_send_test_email": "Не вдалося надіслати тестовий лист. Перевірте журнали сервера для отримання додаткової інформації.",
"smtp_configuration": "Налаштування SMTP",
"smtp_host": "SMTP хост",
"smtp_port": "SMTP порт",
"smtp_user": "SMTP користувач",
"smtp_password": "SMTP пароль",
"smtp_from": "Відправник",
"smtp_tls_option": "Тип SMTP TLS",
"email_tls_option": "TLS налаштування електронної пошти",
"skip_certificate_verification": "Пропустити перевірку сертифіката",
"this_can_be_useful_for_selfsigned_certificates": "Ця опція може бути корисною для спопідписних сертифікатів.",
"enabled_emails": "Увімкнені електронні листи",
"email_login_notification": "Сповіщення електронною поштою про вхід",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Надіслати електронний лист користувачеві після входу з нового пристрою.",
"emai_login_code_requested_by_user": "Надіслати коду входу, згенерований користувачем, електронною поштою",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Дозволяє користувачам обходити ключі доступу шляхом запиту коду для входу, який був відправлений на їх електронну пошту. Це суттєво зменшує безпеку, оскільки будь-хто, хто має доступ до електронної пошти користувача, може отримати доступ.",
"email_login_code_from_admin": "Надіслати коду входу, згенерований адміністратором, електронною поштою",
"allows_an_admin_to_send_a_login_code_to_the_user": "Дозволяє адміністратору надсилати код для входу користувачеві електронною поштою.",
"send_test_email": "Відправити тестового листа",
"application_configuration_updated_successfully": "Налаштування додатку успішно оновлено",
"application_name": "Назва додатку",
"session_duration": "Тривалість сеансу",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Тривалість сесії у хвилинах до повторного входу користувача.",
"enable_self_account_editing": "Увімкнути редагування власного облікового запису",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Чи повинні користувачі мати можливість редагувати власні дані облікового запису.",
"emails_verified": "Підтверджена електронна пошта",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Чи слід позначати електронну пошту користувача як підтверджену для OIDC клієнтів.",
"ldap_configuration_updated_successfully": "Налаштування LDAP успішно оновлено",
"ldap_disabled_successfully": "LDAP успішно вимкнено",
"ldap_sync_finished": "Синхронізація LDAP завершена",
"client_configuration": "Налаштування клієнтів",
"ldap_url": "URL-адреса LDAP",
"ldap_bind_dn": "LDAP Bind DN",
"ldap_bind_password": "Пароль прив’язки LDAP",
"ldap_base_dn": "LDAP Base DN",
"user_search_filter": "Фільтр пошуку користувачів",
"the_search_filter_to_use_to_search_or_sync_users": "Фільтр пошуку для пошуку/синхронізації користувачів.",
"groups_search_filter": "Фільтр пошуку груп",
"the_search_filter_to_use_to_search_or_sync_groups": "Фільтр пошуку для пошуку/синхронізації груп.",
"attribute_mapping": "Зіставлення атрибутів",
"user_unique_identifier_attribute": "Атрибут для унікального ідентифікатора користувача",
"the_value_of_this_attribute_should_never_change": "Значення цього атрибуту ніколи не повинно змінюватися.",
"username_attribute": "Атрибут «Ім’я користувача»",
"user_mail_attribute": "Атрибут \"Електронна пошта\" користувача",
"user_first_name_attribute": "Атрибут \"Ім'я\" користувача",
"user_last_name_attribute": "Атрибут \"Прізвище\" користувача",
"user_profile_picture_attribute": "Атрибут \"Зображення профілю\" користувача",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "Значення цього атрибута може бути URL, бінарним файлом або зображенням, закодованим у форматі base64.",
"group_members_attribute": "Атрибут \"Учасник груп\"",
"the_attribute_to_use_for_querying_members_of_a_group": "Атрибут, який використовується для запиту учасників групи.",
"group_unique_identifier_attribute": "Атрибут \"Унікальний ідентифікатор групи\"",
"group_name_attribute": "Атрибут \"Назва групи\"",
"admin_group_name": "Атрибут \"Назва групи адміністратора\"",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Учасники цієї групи матимуть адміністративні права в Pocket ID.",
"disable": "Вимкнути",
"sync_now": "Синхронізувати",
"enable": "Увімкнути",
"user_created_successfully": "Користувача успішно створено",
"create_user": "Створити користувача",
"add_a_new_user_to_appname": "Додати нового користувача до {appName}",
"add_user": "Додати користувача",
"manage_users": "Керування користувачами",
"admin_privileges": "Права адміністратора",
"admins_have_full_access_to_the_admin_panel": "Адміністратори мають повний доступ до панелі адміністратора.",
"delete_firstname_lastname": "Видалити {firstName} {lastName}",
"are_you_sure_you_want_to_delete_this_user": "Ви впевнені, що хочете видалити цього користувача?",
"user_deleted_successfully": "Користувача успішно видалено",
"role": "Роль",
"source": "Джерело",
"admin": "Адміністратор",
"user": "Користувач",
"local": "Локальний",
"toggle_menu": "Відкрити меню",
"edit": "Редагувати",
"user_groups_updated_successfully": "Групи користувачів успішно оновлено",
"user_updated_successfully": "Користувача успішно оновлено",
"custom_claims_updated_successfully": "Власні атрибути успішно оновлено",
"back": "Назад",
"user_details_firstname_lastname": "Дані користувача {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Керування групами, до яких належить цей користувач.",
"custom_claims": "Власні атрибути",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Власні атрибути — це пари ключ-значення, які використовуються для зберігання додаткової інформації про користувача. Ці атрибути будуть включені до ID-токена при запиті області 'profile'.",
"user_group_created_successfully": "Групи користувачів успішно створено",
"create_user_group": "Створити групу користувачів",
"create_a_new_group_that_can_be_assigned_to_users": "Створіть нову групу, яку можна призначити користувачам.",
"add_group": "Створити групу",
"manage_user_groups": "Керування групами користувачів",
"friendly_name": "Зручна назва",
"name_that_will_be_displayed_in_the_ui": "Ім'я, яке буде показуватися в інтерфейсі користувача",
"name_that_will_be_in_the_groups_claim": "Ім'я, яке буде в атрибуті \"groups\"",
"delete_name": "Видалити {name}",
"are_you_sure_you_want_to_delete_this_user_group": "Ви впевнені, що хочете видалити цю групу користувачів?",
"user_group_deleted_successfully": "Групу користувачів успішно видалено",
"user_count": "Кількість користувачів",
"user_group_updated_successfully": "Групу користувачів успішно оновлено",
"users_updated_successfully": "Користувачі успішно оновлені",
"user_group_details_name": "Деталі групи користувачів {name}",
"assign_users_to_this_group": "Призначити користувачів до цієї групи.",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Власні атрибути — це пари ключ-значення, які використовуються для зберігання додаткової інформації про користувача. Ці атрибути будуть включені до ID-токена при запиті області 'profile'. Якщо виникають конфлікти, пріоритет мають власні атрибути, визначені для конкретного користувача.",
"oidc_client_created_successfully": "OIDC-клієнт успішно створений",
"create_oidc_client": "Створити OIDC-клієнт",
"add_a_new_oidc_client_to_appname": "Додати новий OIDC-клієнт до {appName}.",
"add_oidc_client": "Додати OIDC-клієнт",
"manage_oidc_clients": "Керувати OIDC-клієнтами",
"one_time_link": "Одноразове посилання",
"use_this_link_to_sign_in_once": "Використайте це посилання для одноразового входу. Це потрібно для користувачів, які ще не додали ключ доступу або втратили його.",
"add": "Додати",
"callback_urls": "URL-адреси зворотного виклику",
"logout_callback_urls": "URL-адреси зворотного виклику для виходу",
"public_client": "Публічний клієнт",
"public_clients_description": "Публічні клієнти не мають секретного ключа. Вони призначені для мобільних, веб та нативних додатків, де секретний ключ не може надійно зберігатись.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange — це функція безпеки, що запобігає атакам типу CSRF та перехопленню коду авторизації.",
"name_logo": "Логотип {name}",
"change_logo": "Змінити логотип",
"upload_logo": "Вивантажити логотип",
"remove_logo": "Видалити логотип",
"are_you_sure_you_want_to_delete_this_oidc_client": "Ви дійсно хочете видалити цей OIDC-клієнт?",
"oidc_client_deleted_successfully": "OIDC-клієнт успішно видалений",
"authorization_url": "URL-адреса Authorization",
"oidc_discovery_url": "URL-адреса ODC Discovery",
"token_url": "URL-адреса Token",
"userinfo_url": "URL-адреса Userinfo",
"logout_url": "URL-адреса Logout",
"certificate_url": "URL-адреса Certificate",
"enabled": "Увімкнено",
"disabled": "Вимкнено",
"oidc_client_updated_successfully": "OIDC-клієнт успішно оновлений",
"create_new_client_secret": "Створити новий секретний ключ клієнта",
"are_you_sure_you_want_to_create_a_new_client_secret": "Ви впевнені, що хочете створити новий секретний ключ клієнта? Старий стане недійсним.",
"generate": "Згенерувати",
"new_client_secret_created_successfully": "Новий секретний ключ клієнта успішно створено",
"allowed_user_groups_updated_successfully": "Дозволені групи користувачів успішно оновлено",
"oidc_client_name": "OIDC-клієнт {name}",
"client_id": "ID клієнта",
"client_secret": "Секретний ключ клієнта",
"show_more_details": "Показати подробиці",
"allowed_user_groups": "Дозволені групи користувачів",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Додайте групи користувачів до цього клієнта, щоб обмежити доступ користувачам лише з цих груп. Якщо жодна група користувачів не обрана, усі користувачі матимуть доступ до цього клієнта.",
"favicon": "Фавікон",
"light_mode_logo": "Логотип світлого режиму",
"dark_mode_logo": "Логотип темного режиму",
"background_image": "Фонове зображення",
"language": "Мова",
"reset_profile_picture_question": "Скинути зображення профілю?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "Це видалить завантажене зображення та скине фото профілю на стандартне. Продовжити?",
"reset": "Скинути",
"reset_to_default": "Відновити налаштування за замовчуванням",
"profile_picture_has_been_reset": "Фотографію профілю скинуто. Оновлення може зайняти кілька хвилин.",
"select_the_language_you_want_to_use": "Виберіть мову, яку бажаєте використовувати. Зверніть увагу, що деякий текст може бути автоматично перекладений і може містити неточності.",
"contribute_to_translation": "Якщо ви знайдете помилку, ви можете долучитися до перекладу на <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
"personal": "Особисте",
"global": "Загальні",
"all_users": "Усі користувачі",
"all_events": "Усі події",
"all_clients": "Усі клієнти",
"all_locations": "Усі місця розташування",
"global_audit_log": "Глобальний журнал авдиту",
"see_all_account_activities_from_the_last_3_months": "Переглянути всю активність користувача за останні 3 місяці.",
"token_sign_in": "Вхід за допомогою токена",
"client_authorization": "Авторизація клієнта",
"new_client_authorization": "Нова авторизація клієнта",
"disable_animations": "Вимкнути анімацію",
"turn_off_ui_animations": "Вимкнути анімації у всьому інтерфейсі.",
"user_disabled": "Обліковий запис вимкнено",
"disabled_users_cannot_log_in_or_use_services": "Вимкнені користувачі не можуть увійти в систему або користуватися послугами.",
"user_disabled_successfully": "Користувача успішно деактивовано.",
"user_enabled_successfully": "Користувача успішно активовано.",
"status": "Статус",
"disable_firstname_lastname": "Деактивувати {firstName} {lastName}",
"are_you_sure_you_want_to_disable_this_user": "Ви впевнені, що хочете вимкнути цього користувача? Він не зможе увійти в систему або користуватися будь-якими послугами.",
"ldap_soft_delete_users": "Зберігати вимкнених користувачів із LDAP.",
"ldap_soft_delete_users_description": "Якщо увімкнено, користувачів, видалених з LDAP, буде вимкнено, а не видалено з системи.",
"login_code_email_success": "Код для входу було надіслано користувачеві.",
"send_email": "Надіслати електронного листа",
"show_code": "Показати код",
"callback_url_description": "URL-адреси надані вашим клієнтом. Якщо поле залишити порожнім, вони будуть додані автоматично. Підтримуються символи-замінники (*), але краще їх уникати для підвищення безпеки.",
"logout_callback_url_description": "URL-адреси надані вашим клієнтом для виходу з системи. Якщо поле залишити порожнім, вони будуть додані автоматично. Підтримуються символи-замінники (*), але краще їх уникати для підвищення безпеки.",
"api_key_expiration": "Термін дії API-ключа",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Надіслати користувачу електронного листа, коли термін дії його API-ключа наближається до завершення.",
"authorize_device": "Авторизувати пристрій",
"the_device_has_been_authorized": "Пристрій авторизовано.",
"enter_code_displayed_in_previous_step": "Введіть код, який було показано на попередньому кроці.",
"authorize": "Авторизувати",
"federated_client_credentials": "Федеративні облікові дані клієнта",
"federated_client_credentials_description": "За допомогою федеративних облікових даних клієнта ви можете автентифікувати клієнтів OIDC за допомогою токенів JWT, виданих третіми сторонами.",
"add_federated_client_credential": "Додати федеративний обліковий запис клієнта",
"add_another_federated_client_credential": "Додати ще один федеративний обліковий запис клієнта",
"oidc_allowed_group_count": "Кількість дозволених груп",
"unrestricted": "Не обмежено",
"show_advanced_options": "Показати розширені параметри",
"hide_advanced_options": "Приховати розширені параметри",
"oidc_data_preview": "Попередній перегляд даних OIDC",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Переглянути дані OIDC, які будуть надіслані для різних користувачів",
"id_token": "ID-токен",
"access_token": "Токен доступу",
"userinfo": "Userinfo",
"id_token_payload": "Вміст ID-токена",
"access_token_payload": "Вміст токена доступу",
"userinfo_endpoint_response": "Відповідь сервісу Userinfo",
"copy": "Копіювати",
"no_preview_data_available": "Попередній перегляд даних недоступний",
"copy_all": "Скопіювати все",
"preview": "Попередній перегляд",
"preview_for_user": "Попередній перегляд для {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Попередній перегляд OIDC-даних для цього користувача",
"show": "Показати",
"select_an_option": "Обрати варіант",
"select_user": "Обрати користувача",
"error": "Помилка",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Оберіть колір підсвічування, щоб налаштувати зовнішній вигляд Pocket ID.",
"accent_color": "Колір підсвічування",
"custom_accent_color": "Власний колір підсвічування",
"custom_accent_color_description": "Введіть власний колір, використовуючи допустимі формати кольорів CSS (наприклад, hex, rgb, hsl).",
"color_value": "Значення кольору",
"apply": "Застосувати",
"signup_token": "Токен реєстрації",
"create_a_signup_token_to_allow_new_user_registration": "Створити токен реєстрації, щоб дозволити реєстрацію нових користувачів.",
"usage_limit": "Створити токен для реєстрації нового користувача",
"number_of_times_token_can_be_used": "Скільки разів можна використовувати реєстраційний токен.",
"expires": "Термін дії",
"signup": "Зареєструватися",
"signup_requires_valid_token": "Для створення облікового запису потрібен дійсний токен реєстрації",
"validating_signup_token": "Перевірка токена реєстрації",
"go_to_login": "Перейти до входу",
"signup_to_appname": "Зареєструватися в {appName}",
"create_your_account_to_get_started": "Створіть свій обліковий запис, щоб розпочати.",
"initial_account_creation_description": "Будь ласка, створіть свій обліковий запис, щоб почати. Ви зможете налаштувати ключ доступу пізніше.",
"setup_your_passkey": "Налаштуйте свій ключ доступу",
"create_a_passkey_to_securely_access_your_account": "Створіть ключ доступу для безпечного входу до свого облікового запису. Це буде ваш основний спосіб увійти.",
"skip_for_now": "Пропустити наразі",
"account_created": "Обліковий запис створено",
"enable_user_signups": "Дозволити реєстрацію користувачів",
"enable_user_signups_description": "Чи слід увімкнути можливість реєстрації користувачів.",
"user_signups_are_disabled": "Реєстрація користувачів наразі вимкнена",
"create_signup_token": "Створити токен реєстрації",
"view_active_signup_tokens": "Переглянути активні токени реєстрації",
"manage_signup_tokens": "Керування токенами реєстрації",
"view_and_manage_active_signup_tokens": "Перегляд та керування активними токенами реєстрації.",
"signup_token_deleted_successfully": "Токен реєстрації успішно видалено.",
"expired": "Закінчився",
"used_up": "Використаний",
"active": "Активний",
"usage": "Використано",
"created": "Створено",
"token": "Токен",
"loading": "Завантаження",
"delete_signup_token": "Видалити токен реєстрації",
"are_you_sure_you_want_to_delete_this_signup_token": "Ви впевнені, що хочете видалити цей токен реєстрації? Цю дію неможливо скасувати.",
"signup_disabled_description": "Реєстрація користувачів повністю вимкнена. Нові облікові записи можуть створювати лише адміністратори.",
"signup_with_token": "Зареєструватися за допомогою токену",
"signup_with_token_description": "Користувачі можуть зареєструватися лише за допомогою дійсного токена реєстрації, створеного адміністратором.",
"signup_open": "Відкрита реєстрація",
"signup_open_description": "Будь-хто може створити новий обліковий запис без обмежень.",
"of": "з",
"skip_passkey_setup": "Пропустити налаштування ключа доступу",
"skip_passkey_setup_description": "Рекомендується налаштувати ключ доступу, оскільки без нього ви не зможете увійти у свій обліковий запис після закінчення сеансу.",
"my_apps": "Мої програми",
"no_apps_available": "Немає доступних додатків",
"contact_your_administrator_for_app_access": "Зверніться до адміністратора, щоб отримати доступ до додатків.",
"launch": "Запуск",
"client_launch_url": "URL-адреса запуску клієнта",
"client_launch_url_description": "URL-адреса, яка відкриється, коли користувач запустить програму зі сторінки «Мої програми».",
"client_name_description": "Ім'я клієнта, яке відображається в інтерфейсі Pocket ID.",
"revoke_access": "Скасувати доступ",
"revoke_access_description": "Скасувати доступ до <b>{clientName}</b>. <b>{clientName}</b> більше не зможе отримати доступ до інформації вашого облікового запису.",
"revoke_access_successful": "Доступ до {clientName} було успішно скасовано."
}

433
frontend/messages/vi.json Normal file
View File

@@ -0,0 +1,433 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "Tài khoản",
"logout": "Đăng xuất",
"confirm": "Xác nhận",
"docs": "Tài liệu",
"key": "Key",
"value": "Value",
"remove_custom_claim": "Xóa yêu cầu tùy chỉnh",
"add_custom_claim": "Thêm yêu cầu tùy chỉnh",
"add_another": "Thêm khác",
"select_a_date": "Chọn ngày",
"select_file": "Chọn tập tin",
"profile_picture": "Ảnh đại diện",
"profile_picture_is_managed_by_ldap_server": "Hình đại diện được quản lý bởi máy chủ LDAP và không thể thay đổi tại đây.",
"click_profile_picture_to_upload_custom": "Nhấp vào hình ảnh hồ sơ để tải lên hình ảnh tùy chỉnh.",
"image_should_be_in_format": "Hình ảnh phải ở định dạng PNG hoặc JPEG.",
"items_per_page": "Số kết quả mỗi trang",
"no_items_found": "Không tìm thấy kết quả nào",
"search": "Tìm kiếm...",
"expand_card": "Mở rộng thẻ",
"copied": "Đã sao chép",
"click_to_copy": "Nhấn để sao chép",
"something_went_wrong": "Đã xảy ra lỗi",
"go_back_to_home": "Quay lại trang chủ",
"dont_have_access_to_your_passkey": "Không có passkey?",
"login_background": "Hình nền đăng nhập",
"logo": "Logo",
"login_code": "Mã đăng nhập",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Tạo một mã đăng nhập mà người dùng có thể sử dụng để đăng nhập mà không cần passkey (một lần).",
"one_hour": "1 giờ",
"twelve_hours": "12 giờ",
"one_day": "1 ngày",
"one_week": "1 tuần",
"one_month": "1 tháng",
"expiration": "Hết hiệu lực",
"generate_code": "Tạo mã",
"name": "Tên",
"browser_unsupported": "Trình duyệt không được hỗ trợ",
"this_browser_does_not_support_passkeys": "Trình duyệt này không hỗ trợ passkeys. Vui lòng sử dụng phương thức đăng nhập khác.",
"an_unknown_error_occurred": "Một lỗi không rõ đã xảy ra",
"authentication_process_was_aborted": "Quá trình xác thực đã bị hủy bỏ",
"error_occurred_with_authenticator": "Đã xảy ra lỗi với trình xác thực",
"authenticator_does_not_support_discoverable_credentials": "Thiết bị xác thực không hỗ trợ discoverable credentials",
"authenticator_does_not_support_resident_keys": "Thiết bị xác thực không hỗ trợ khóa lưu trữ",
"passkey_was_previously_registered": "Passkey này đã được đăng ký trước đó",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Thiết bị xác thực không hỗ trợ bất kỳ thuật toán nào trong số các thuật toán được yêu cầu",
"authenticator_timed_out": "Thời gian chờ của trình xác thực đã hết hạn",
"critical_error_occurred_contact_administrator": "Đã xảy ra lỗi nghiêm trọng. Vui lòng liên hệ với quản trị viên.",
"sign_in_to": "Đăng nhập {name}",
"client_not_found": "Không tìm thấy client.",
"client_wants_to_access_the_following_information": "<b>{client}</b> muốn truy cập các thông tin sau:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Bạn có muốn đăng nhập vào <b>{client}</b> với tài khoản {appName} của bạn?",
"email": "Email",
"view_your_email_address": "Xem địa chỉ email của bạn",
"profile": "Hồ sơ",
"view_your_profile_information": "Xem thông tin hồ sơ của bạn",
"groups": "Nhóm",
"view_the_groups_you_are_a_member_of": "Xem các nhóm mà bạn là thành viên",
"cancel": "Hủy",
"sign_in": "Đăng nhập",
"try_again": "Thử lại",
"client_logo": "Logo của client",
"sign_out": "Đăng xuất",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Bạn có muốn đăng xuất khỏi {appName} bằng tài khoản <b>{username}</b>?",
"sign_in_to_appname": "Đăng nhập vào {appName}",
"please_try_to_sign_in_again": "Vui lòng đăng nhập lại.",
"authenticate_with_passkey_to_access_account": "Xác thực tài khoản của bạn bằng passkey để truy cập tài khoản.",
"authenticate": "Xác thực",
"please_try_again": "Vui lòng thử lại.",
"continue": "Tiếp tục",
"alternative_sign_in": "Đăng nhập bằng cách khác",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Nếu bạn không thể sử dụng passkey, bạn có thể đăng nhập bằng một trong các phương thức sau.",
"use_your_passkey_instead": "Sử dụng passkey?",
"email_login": "Đăng nhập bằng email",
"enter_a_login_code_to_sign_in": "Nhập mã đăng nhập để đăng nhập.",
"request_a_login_code_via_email": "Yêu cầu mã đăng nhập qua email.",
"go_back": "Quay lại",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Một email đã được gửi đến địa chỉ email đã cung cấp, nếu địa chỉ đó tồn tại trong hệ thống.",
"enter_code": "Nhập mã",
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Nhập địa chỉ email của bạn để nhận email có mã đăng nhập.",
"your_email": "Email của bạn",
"submit": "Gửi",
"enter_the_code_you_received_to_sign_in": "Nhập mã bạn đã nhận để đăng nhập.",
"code": "Mã",
"invalid_redirect_url": "URL chuyển hướng không hợp lệ",
"audit_log": "Nhật ký hoạt động",
"users": "Người dùng",
"user_groups": "Nhóm người dùng",
"oidc_clients": "OIDC Clients",
"api_keys": "Khóa API",
"application_configuration": "Cấu hình ứng dụng",
"settings": "Cài đặt",
"update_pocket_id": "Cập nhật Pocket ID",
"powered_by": "Được cung cấp bởi",
"see_your_account_activities_from_the_last_3_months": "Xem các hoạt động tài khoản của bạn trong 3 tháng qua.",
"time": "Thời gian",
"event": "Sự kiện",
"approximate_location": "Vị Trí Ước Tính",
"ip_address": "Địa chỉ IP",
"device": "Thiết bị",
"client": "Client.",
"unknown": "Không xác định",
"account_details_updated_successfully": "Cập nhật tài khoản thành công",
"profile_picture_updated_successfully": "Hình đại diện đã được cập nhật thành công. Việc cập nhật có thể mất vài phút.",
"account_settings": "Cài Đặt Tài Khoản",
"passkey_missing": "Passkey bị thiếu",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Vui lòng thêm passkey để tránh mất quyền truy cập vào tài khoản của bạn.",
"single_passkey_configured": "Đã cài đặt passkey duy nhất",
"it_is_recommended_to_add_more_than_one_passkey": "Nên có hơn một passkey cập để tránh mất quyền truy cập vào tài khoản của bạn.",
"account_details": "Thông Tin Tài Khoản",
"passkeys": "Passkeys",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Quản lý các passkeys mà bạn có thể sử dụng để xác thực danh tính của mình.",
"add_passkey": "Thêm Passkey",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Tạo mã đăng nhập một lần để đăng nhập từ thiết bị khác mà không cần passkeys.",
"create": "Tạo",
"first_name": "Tên",
"last_name": "Họ",
"username": "Tên đăng nhập",
"save": "Lưu",
"username_can_only_contain": "Tên người dùng chỉ có thể chứa các ký tự chữ thường, số, dấu gạch dưới, dấu chấm, dấu gạch ngang và ký hiệu '@'.",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Đăng nhập bằng mã sau. Mã này sẽ hết hạn trong 15 phút.",
"or_visit": "hoặc truy cập",
"added_on": "Đã được thêm vào",
"rename": "Đổi tên",
"delete": "Xóa",
"are_you_sure_you_want_to_delete_this_passkey": "Bạn có chắc chắn muốn xóa passkey này không?",
"passkey_deleted_successfully": "Passkey đã được xóa thành công",
"delete_passkey_name": "Xóa {passkeyName}",
"passkey_name_updated_successfully": "Tên passkey đã được cập nhật thành công",
"name_passkey": "Tên Passkey",
"name_your_passkey_to_easily_identify_it_later": "Đặt tên cho passkey để dễ dàng nhận diện sau này.",
"create_api_key": "Tạo API Key",
"add_a_new_api_key_for_programmatic_access": "Thêm API Key mới cho truy cập lập trình.",
"add_api_key": "Thêm API Key",
"manage_api_keys": "Quản lý API keys",
"api_key_created": "API Key đã được tạo",
"for_security_reasons_this_key_will_only_be_shown_once": "Vì lý do bảo mật, khóa này chỉ được hiển thị một lần. Vui lòng lưu trữ nó một cách an toàn.",
"description": "Mô tả",
"api_key": "API Key",
"close": "Đóng",
"name_to_identify_this_api_key": "Tên để xác API key này.",
"expires_at": "Hết hạn vào",
"when_this_api_key_will_expire": "Thời điểm API Key này hết hạn.",
"optional_description_to_help_identify_this_keys_purpose": "Mô tả tùy chọn để giúp xác định mục đích của key này.",
"expiration_date_must_be_in_the_future": "Ngày hết hạn phải nằm trong tương lai",
"revoke_api_key": "Thu Hồi API Key",
"never": "Không bao giờ",
"revoke": "Thu hồi",
"api_key_revoked_successfully": "API key đã được thu hồi thành công",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Bạn có chắc chắn muốn hủy API Key \"{apiKeyName}\" không? Việc này sẽ làm gián đoạn tất cả các tích hợp sử dụng khóa này.",
"last_used": "Lần Sử Dụng Cuối",
"actions": "Hành động",
"images_updated_successfully": "Đã cập nhật hình ảnh thành công",
"general": "Tổng quan",
"configure_smtp_to_send_emails": "Bật thông báo email để thông báo cho người dùng khi phát hiện đăng nhập từ thiết bị hoặc vị trí mới.",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Cấu hình cài đặt LDAP để đồng bộ hóa người dùng và nhóm từ máy chủ LDAP.",
"images": "Hình ảnh",
"update": "Cập nhật",
"email_configuration_updated_successfully": "Cấu hình email đã được cập nhật thành công",
"save_changes_question": "Lưu thay đổi?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "Bạn phải lưu các thay đổi trước khi gửi email thử nghiệm. Bạn có muốn lưu ngay bây giờ không?",
"save_and_send": "Lưu và gửi",
"test_email_sent_successfully": "Email thử nghiệm đã được gửi thành công đến địa chỉ email của bạn.",
"failed_to_send_test_email": "Không thể gửi email thử nghiệm. Vui lòng kiểm tra nhật ký máy chủ để biết thêm thông tin.",
"smtp_configuration": "Cấu hình SMTP",
"smtp_host": "Máy Chủ SMTP",
"smtp_port": "Cổng SMTP",
"smtp_user": "Người Dùng SMTP",
"smtp_password": "Mật Khẩu SMTP",
"smtp_from": "SMTP Gửi Từ Địa Chỉ Email",
"smtp_tls_option": "Tùy Chọn TLS Cho SMTP",
"email_tls_option": "Tùy Chọn TLS Cho email",
"skip_certificate_verification": "Bỏ Qua Xác Minh Chứng Chỉ (Certificate Verification)",
"this_can_be_useful_for_selfsigned_certificates": "Điều này có thể hữu ích cho chứng chỉ tự ký.",
"enabled_emails": "Email đã bật",
"email_login_notification": "Thông Báo Đăng Nhập Email",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Gửi email cho người dùng khi họ đăng nhập từ một thiết bị mới.",
"emai_login_code_requested_by_user": "Mã Đăng Nhập Email Được Yêu Cầu Bởi Người Dùng",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Cho phép người dùng bỏ qua passkeys bằng cách yêu cầu mã đăng nhập được gửi đến email của họ. Điều này làm giảm đáng kể tính bảo mật vì bất kỳ ai có quyền truy cập vào email của người dùng đều có thể truy cập vào hệ thống.",
"email_login_code_from_admin": "Mã Đăng Nhập Email Từ Quản Trị Viên",
"allows_an_admin_to_send_a_login_code_to_the_user": "Cho phép quản trị viên gửi mã đăng nhập cho người dùng qua email.",
"send_test_email": "Gửi email thử nghiệm",
"application_configuration_updated_successfully": "Cấu hình ứng dụng đã được cập nhật thành công",
"application_name": "Tên Ứng Dụng",
"session_duration": "Thời Lượng Phiên",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Thời gian của một phiên (tính bằng phút) trước khi người dùng phải đăng nhập lại.",
"enable_self_account_editing": "Cho Phép Chỉnh Sửa Tài Khoản Cá Nhân",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Người dùng có nên được phép chỉnh sửa thông tin tài khoản của mình không?",
"emails_verified": "Xác Minh Email",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Có nên đánh dấu email của người dùng là đã xác minh cho các OIDC clients hay không.",
"ldap_configuration_updated_successfully": "Cấu hình LDAP đã được cập nhật thành công",
"ldap_disabled_successfully": "Tắt LDAP thành công",
"ldap_sync_finished": "Quá trình đồng bộ hóa LDAP đã hoàn tất",
"client_configuration": "Cấu Hình Client",
"ldap_url": "URL Của LDAP",
"ldap_bind_dn": "DN Của LDAP Bind",
"ldap_bind_password": "Mật Khẩu Của LDAP Bind",
"ldap_base_dn": "Base DN Của LDAP",
"user_search_filter": "Bộ Lọc Tìm Kiếm Người Dùng",
"the_search_filter_to_use_to_search_or_sync_users": "Bộ lọc tìm kiếm để sử dụng khi tìm kiếm hoặc đồng bộ hóa người dùng.",
"groups_search_filter": "Bộ lọc tìm kiếm nhóm",
"the_search_filter_to_use_to_search_or_sync_groups": "Bộ lọc tìm kiếm để sử dụng khi tìm kiếm hoặc đồng bộ hóa nhóm.",
"attribute_mapping": "Map Thuộc Tính",
"user_unique_identifier_attribute": "Thuộc tính định danh duy nhất của người dùng",
"the_value_of_this_attribute_should_never_change": "Giá trị của thuộc tính này không bao giờ được thay đổi.",
"username_attribute": "Thuộc tính tên đăng nhập",
"user_mail_attribute": "Thuộc tính email của người dùng",
"user_first_name_attribute": "Thuộc tính tên của người dùng",
"user_last_name_attribute": "Thuộc tính họ của người dùng",
"user_profile_picture_attribute": "Thuộc tính hình ảnh hồ sơ người dùng",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "Giá trị của thuộc tính này có thể là một URL, một tệp nhị phân hoặc một hình ảnh được mã hóa base64.",
"group_members_attribute": "Thuộc tính thành viên nhóm",
"the_attribute_to_use_for_querying_members_of_a_group": "Thuộc tính được sử dụng để truy vấn các thành viên của một nhóm.",
"group_unique_identifier_attribute": "Thuộc tính định danh duy nhất của người dùng",
"group_name_attribute": "Thuộc tính tên nhóm",
"admin_group_name": "Tên nhóm quản trị",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Các thành viên của nhóm này sẽ có quyền quản trị trong Pocket ID.",
"disable": "Tắt",
"sync_now": "Đồng bộ hóa ngay",
"enable": "Bật",
"user_created_successfully": "Đã tạo người dùng thành công",
"create_user": "Tạo người dùng",
"add_a_new_user_to_appname": "Thêm người dùng mới vào {appName}",
"add_user": "Thêm Người Dùng",
"manage_users": "Quản Lý Người Dùng",
"admin_privileges": "Quyền quản trị viên",
"admins_have_full_access_to_the_admin_panel": "Quản trị viên có quyền truy cập đầy đủ vào bảng điều khiển quản trị.",
"delete_firstname_lastname": "Xóa {firstName} {lastName}",
"are_you_sure_you_want_to_delete_this_user": "Bạn có chắc chắn muốn xóa người này?",
"user_deleted_successfully": "Đã xóa người dùng",
"role": "Vai trò",
"source": "Nguồn",
"admin": "Quản trị viên",
"user": "Người dùng",
"local": "Cục Bộ",
"toggle_menu": "Bật/tắt menu",
"edit": "Chỉnh sửa",
"user_groups_updated_successfully": "Nhóm người dùng đã được cập nhật thành công",
"user_updated_successfully": "Đã cập nhật người dùng thành công",
"custom_claims_updated_successfully": "Custom claims đã được cập nhật thành công",
"back": "Quay Lại",
"user_details_firstname_lastname": "Thông tin người dùng {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Quản lý các nhóm mà người dùng này thuộc về.",
"custom_claims": "Custom Claims",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Custom claims là các cặp key-value có thể được sử dụng để lưu trữ thông tin bổ sung về một người dùng. Các claims này sẽ được bao gồm trong token ID nếu phạm vi 'profile' được yêu cầu.",
"user_group_created_successfully": "Nhóm người dùng đã được tạo thành công",
"create_user_group": "Tạo Nhóm Người Dùng",
"create_a_new_group_that_can_be_assigned_to_users": "Tạo một nhóm mới có thể được gán cho người dùng.",
"add_group": "Thêm Nhóm",
"manage_user_groups": "Quản lý Nhóm Người dùng",
"friendly_name": "Tên thân thiện",
"name_that_will_be_displayed_in_the_ui": "Tên sẽ hiển thị trong giao diện người dùng",
"name_that_will_be_in_the_groups_claim": "Tên sẽ hiển thị trong claim của phần \"nhóm\"",
"delete_name": "Xoá {name}",
"are_you_sure_you_want_to_delete_this_user_group": "Bạn có chắc chắn muốn xoá nhóm người dùng không?",
"user_group_deleted_successfully": "Nhóm người dùng đã được xóa thành công",
"user_count": "Số người dùng",
"user_group_updated_successfully": "Nhóm người dùng đã được cập nhật thành công",
"users_updated_successfully": "Đã cập nhật người dùng thành công",
"user_group_details_name": "Chi tiết nhóm người dùng {name}",
"assign_users_to_this_group": "Gán người dùng vào nhóm này.",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Custom claims là các cặp key-value trị có thể được sử dụng để lưu trữ thông tin bổ sung về người dùng. Các yêu cầu này sẽ được bao gồm trong token ID nếu phạm vi 'profile' được yêu cầu. Các custom claims được định nghĩa trên người dùng sẽ được ưu tiên nếu có xung đột.",
"oidc_client_created_successfully": "OIDC client đã được tạo thành công",
"create_oidc_client": "Tạo OIDC client",
"add_a_new_oidc_client_to_appname": "Thêm một OIDC client mới vào {appName}.",
"add_oidc_client": "Thêm OIDC Client",
"manage_oidc_clients": "Quản Lý OIDC Client",
"one_time_link": "Liên kết sử dụng một lần",
"use_this_link_to_sign_in_once": "Sử dụng liên kết này để đăng nhập một lần. Điều này là cần thiết cho người dùng chưa thêm passkey hoặc đã mất passkey.",
"add": "Thêm",
"callback_urls": "Callback URL",
"logout_callback_urls": "Callback URL khi đăng xuất",
"public_client": "Public Client",
"public_clients_description": "Public client không có client secret. Chúng được thiết kế cho các ứng dụng di động, web và ứng dụng gốc, nơi các khóa bí mật không thể được lưu trữ một cách an toàn.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange là một tính năng bảo mật nhằm ngăn chặn các cuộc tấn công CSRF và đánh cắp mã xác thực.",
"name_logo": "{name} logo",
"change_logo": "Đổi Logo",
"upload_logo": "Tải logo",
"remove_logo": "Xóa logo",
"are_you_sure_you_want_to_delete_this_oidc_client": "Bạn có chắc chắn muốn xóa OIDC client này không?",
"oidc_client_deleted_successfully": "OIDC client đã được xóa thành công",
"authorization_url": "Authorization URL",
"oidc_discovery_url": "OIDC Discovery URL",
"token_url": "Token URL",
"userinfo_url": "Userinfo URL",
"logout_url": "URL đăng xuất",
"certificate_url": "Certificate URL",
"enabled": "Bật",
"disabled": "Tắt",
"oidc_client_updated_successfully": "OIDC client đã được cập nhật thành công",
"create_new_client_secret": "Tạo client secret mới",
"are_you_sure_you_want_to_create_a_new_client_secret": "Bạn có chắc chắn muốn tạo một client secret? Client secret cũ sẽ bị vô hiệu hóa.",
"generate": "Tạo ra",
"new_client_secret_created_successfully": "Client secret mới đã được tạo thành công",
"allowed_user_groups_updated_successfully": "Các nhóm người dùng được phép đã được cập nhật thành công",
"oidc_client_name": "OIDC Client {name}",
"client_id": "Client ID",
"client_secret": "Client secret",
"show_more_details": "Hiển thị thêm thông tin",
"allowed_user_groups": "Nhóm người dùng được phép",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Thêm nhóm người dùng vào khách hàng này để hạn chế quyền truy cập cho người dùng trong các nhóm này. Nếu không chọn nhóm người dùng nào, tất cả người dùng sẽ có quyền truy cập vào client này.",
"favicon": "Biểu tượng favicon",
"light_mode_logo": "Logo cho Light Mode",
"dark_mode_logo": "Logo cho Dark Mode",
"background_image": "Ảnh nền",
"language": "Ngôn ngữ",
"reset_profile_picture_question": "Đặt lại ảnh đại diện?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "Điều này sẽ xóa hình ảnh đã tải lên và đặt lại ảnh đại diện về mặc định. Bạn có muốn tiếp tục không?",
"reset": "Khôi phục",
"reset_to_default": "Khôi phục về mặc định",
"profile_picture_has_been_reset": "Hình đại diện đã được đặt lại. Việc cập nhật có thể mất vài phút.",
"select_the_language_you_want_to_use": "Chọn ngôn ngữ bạn muốn sử dụng. Lưu ý rằng một số văn bản có thể được dịch tự động và có thể không chính xác.",
"contribute_to_translation": "Nếu bạn phát hiện vấn đề, bạn có thể đóng góp vào việc dịch trên <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
"personal": "Cá nhân",
"global": "Toàn cầu",
"all_users": "Tất cả người dùng",
"all_events": "Tất cả các sự kiện",
"all_clients": "Tất cả clients",
"all_locations": "Tất cả địa điểm",
"global_audit_log": "Nhật ký kiểm tra toàn cầu",
"see_all_account_activities_from_the_last_3_months": "Xem tất cả hoạt động của người dùng trong 3 tháng qua.",
"token_sign_in": "Đăng nhập bằng token",
"client_authorization": "Xác thực client",
"new_client_authorization": "Xác thực client mới",
"disable_animations": "Tắt hiệu ứng động",
"turn_off_ui_animations": "Tắt các hiệu ứng động trong giao diện người dùng.",
"user_disabled": "Tài khoản bị vô hiệu hóa",
"disabled_users_cannot_log_in_or_use_services": "Người dùng bị vô hiệu hóa không thể đăng nhập hoặc sử dụng dịch vụ.",
"user_disabled_successfully": "Người dùng đã bị vô hiệu hóa thành công.",
"user_enabled_successfully": "Người dùng đã được kích hoạt thành công.",
"status": "Trạng thái",
"disable_firstname_lastname": "Vô hiệu hóa ( {firstName} {lastName})",
"are_you_sure_you_want_to_disable_this_user": "Bạn có chắc chắn muốn vô hiệu hóa người dùng này không? Họ sẽ không thể đăng nhập hoặc truy cập bất kỳ dịch vụ nào.",
"ldap_soft_delete_users": "Giữ người dùng bị vô hiệu hóa khỏi LDAP.",
"ldap_soft_delete_users_description": "Khi được kích hoạt, người dùng bị xóa khỏi LDAP sẽ bị vô hiệu hóa thay vì bị xóa khỏi hệ thống.",
"login_code_email_success": "Mã đăng nhập đã được gửi đến người dùng.",
"send_email": "Gửi Email",
"show_code": "Hiện Thị Mã",
"callback_url_description": "URL do client của bạn cung cấp. Sẽ được tự động thêm nếu để trống. Hỗ trợ ký tự đại diện (*), nhưng nên tránh sử dụng để đảm bảo an toàn.",
"logout_callback_url_description": "URL do client của bạn cung cấp để đăng xuất. Ký tự đại diện (*) được hỗ trợ, nhưng nên tránh sử dụng để đảm bảo an toàn.",
"api_key_expiration": "Hạn sử dụng API Key",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Gửi email cho người dùng khi API key của họ sắp hết hạn.",
"authorize_device": "Thiết Bị Được Cho Phép",
"the_device_has_been_authorized": "Thiết bị đã được cấp quyền.",
"enter_code_displayed_in_previous_step": "Nhập mã đã hiển thị ở bước trước.",
"authorize": "Cho phép",
"federated_client_credentials": "Thông Tin Xác Thực Của Federated Clients",
"federated_client_credentials_description": "Sử dụng thông tin xác thực của federated client, bạn có thể xác thực các client OIDC bằng cách sử dụng token JWT được cấp bởi các bên thứ ba.",
"add_federated_client_credential": "Thêm thông tin xác thực cho federated clients",
"add_another_federated_client_credential": "Thêm một thông tin xác thực cho federated clients khác",
"oidc_allowed_group_count": "Số lượng nhóm được phép",
"unrestricted": "Không hạn chế",
"show_advanced_options": "Hiển thị tuỳ chọn nâng cao",
"hide_advanced_options": "Ẩn tuỳ chọn nâng cao",
"oidc_data_preview": "Xem trước dữ liệu OIDC",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Xem trước dữ liệu OIDC sẽ được gửi cho các người dùng khác nhau",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Sao chép",
"no_preview_data_available": "No preview data available",
"copy_all": "Sao chép tất cả",
"preview": "Xem trước",
"preview_for_user": "Xem trước cho {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Xem trước dữ liệu OIDC sẽ được gửi cho người dùng này",
"show": "Hiển thị",
"select_an_option": "Chọn một tùy chọn",
"select_user": "Chọn người dùng",
"error": "Lỗi",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Chọn màu nhấn để tùy chỉnh giao diện của Pocket ID.",
"accent_color": "Màu nhấn",
"custom_accent_color": "Tuỳ chỉnh màu sắc",
"custom_accent_color_description": "Nhập màu tùy chỉnh bằng định dạng màu CSS hợp lệ (ví dụ: hex, rgb, hsl).",
"color_value": "Giá trị màu",
"apply": "Áp dụng",
"signup_token": "Token đăng ký",
"create_a_signup_token_to_allow_new_user_registration": "Tạo token đăng ký để cho phép người dùng mới đăng ký.",
"usage_limit": "Giới hạn sử dụng",
"number_of_times_token_can_be_used": "Số lần token đăng ký có thể được sử dụng.",
"expires": "Hết hạn",
"signup": "Đăng ký",
"signup_requires_valid_token": "Yêu cầu mã đăng ký hợp lệ để tạo tài khoản",
"validating_signup_token": "Xác thực token đăng ký",
"go_to_login": "Đi tới đăng nhập",
"signup_to_appname": "Đăng ký vào {appName}",
"create_your_account_to_get_started": "Tạo tài khoản của bạn để bắt đầu.",
"initial_account_creation_description": "Vui lòng tạo tài khoản của bạn để bắt đầu. Bạn có thể thiết lập passkey sau này.",
"setup_your_passkey": "Thiết lập passkey của bạn",
"create_a_passkey_to_securely_access_your_account": "Tạo passkey để truy cập tài khoản của bạn một cách an toàn. Đây sẽ là phương thức chính để bạn đăng nhập.",
"skip_for_now": "Tạm thời bỏ qua",
"account_created": "Tài khoản đã được tạo",
"enable_user_signups": "Bật đăng ký người dùng",
"enable_user_signups_description": "Có nên kích hoạt tính năng Đăng ký người dùng hay không.",
"user_signups_are_disabled": "Đăng ký người dùng hiện đang bị vô hiệu hóa",
"create_signup_token": "Tạo Signup Token",
"view_active_signup_tokens": "Xem các Signup Tokens đang hoạt động",
"manage_signup_tokens": "Quản lý Signup Tokén",
"view_and_manage_active_signup_tokens": "Xem và quản lý các Signup Tokén đang hoạt động.",
"signup_token_deleted_successfully": "Signup token đã bị xóa thành công.",
"expired": "Đã hết hạn",
"used_up": "Đã Dùng Hết",
"active": "Hoạt động",
"usage": "Độ sử dụng",
"created": "Ngày tạo",
"token": "Token",
"loading": "Đang tải",
"delete_signup_token": "Xóa mã Signup Token",
"are_you_sure_you_want_to_delete_this_signup_token": "Bạn có chắc muốn xoá signup token này không? Thao tác này không thể được hoàn lại.",
"signup_disabled_description": "Đăng ký người dùng đã bị vô hiệu hóa hoàn toàn. Chỉ quản trị viên mới có thể tạo tài khoản người dùng mới.",
"signup_with_token": "Đăng ký bằng token",
"signup_with_token_description": "Người dùng chỉ có thể đăng ký bằng mã đăng ký hợp lệ do quản trị viên tạo ra.",
"signup_open": "Mở Đăng Ký",
"signup_open_description": "Bất kỳ ai cũng có thể tạo tài khoản mới mà không có bất kỳ hạn chế nào.",
"of": "của",
"skip_passkey_setup": "Bỏ qua thiết lập Passkey",
"skip_passkey_setup_description": "Bạn nên thiết lập khóa truy cập (passkey) vì nếu không có, bạn sẽ bị khóa khỏi tài khoản ngay khi phiên làm việc hết hạn.",
"my_apps": "Ứng dụng của tôi",
"no_apps_available": "Không có ứng dụng nào có sẵn.",
"contact_your_administrator_for_app_access": "Liên hệ với quản trị viên của bạn để được cấp quyền truy cập vào các ứng dụng.",
"launch": "Ra mắt",
"client_launch_url": "URL khởi chạy của khách hàng",
"client_launch_url_description": "Đường dẫn URL sẽ được mở khi người dùng khởi chạy ứng dụng từ trang Ứng dụng của tôi.",
"client_name_description": "Tên của khách hàng hiển thị trong giao diện Pocket ID.",
"revoke_access": "Hủy quyền truy cập",
"revoke_access_description": "Hủy quyền truy cập vào <b>{clientName}</b>. <b>{clientName}</b> sẽ không còn có thể truy cập thông tin tài khoản của bạn.",
"revoke_access_successful": "Quyền truy cập vào {clientName} đã bị thu hồi thành công."
}

View File

@@ -8,7 +8,7 @@
"value": "键值",
"remove_custom_claim": "删除自定义声明",
"add_custom_claim": "添加自定义声明",
"add_another": "添加一个",
"add_another": "添加一个",
"select_a_date": "选择日期",
"select_file": "选择上传文件",
"profile_picture": "头像",
@@ -43,7 +43,7 @@
"error_occurred_with_authenticator": "认证器发生错误",
"authenticator_does_not_support_discoverable_credentials": "认证器不支持可发现的凭据",
"authenticator_does_not_support_resident_keys": "认证器不支持常驻密钥",
"passkey_was_previously_registered": "此通行密钥之前已注册",
"passkey_was_previously_registered": "此通行密钥曾被注册",
"authenticator_does_not_support_any_of_the_requested_algorithms": "认证器不支持任何请求的算法",
"authenticator_timed_out": "认证器超时",
"critical_error_occurred_contact_administrator": "发生严重错误。请联系您的管理员。",
@@ -92,7 +92,7 @@
"application_configuration": "设置",
"settings": "设置",
"update_pocket_id": "更新 Pocket ID",
"powered_by": "",
"powered_by": "",
"see_your_account_activities_from_the_last_3_months": "查看过去 3 个月的账户活动。",
"time": "时间",
"event": "事件",
@@ -143,7 +143,7 @@
"expires_at": "到期时间",
"when_this_api_key_will_expire": "此 API 密钥的到期时间。",
"optional_description_to_help_identify_this_keys_purpose": "可选描述,用于帮助识别此密钥的用途。",
"expiration_date_must_be_in_the_future": "到期日期必须设定为未来的日期",
"expiration_date_must_be_in_the_future": "到期日期必须在未来",
"revoke_api_key": "撤销 API 密钥",
"never": "永不",
"revoke": "撤销",
@@ -173,7 +173,7 @@
"smtp_tls_option": "SMTP TLS 选项",
"email_tls_option": "电子邮件 TLS 选项",
"skip_certificate_verification": "跳过证书验证",
"this_can_be_useful_for_selfsigned_certificates": "这对于自签证书很有用。",
"this_can_be_useful_for_selfsigned_certificates": "这对于自签证书很有用。",
"enabled_emails": "启用的电子邮件",
"email_login_notification": "登录时的电子邮件通知",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "用户通过新设备登录时发送一封电子邮件通知。",
@@ -195,9 +195,9 @@
"ldap_sync_finished": "LDAP 同步完成",
"client_configuration": "客户端配置",
"ldap_url": "LDAP 地址",
"ldap_bind_dn": "LDAP 绑定用户专有名称",
"ldap_bind_dn": "LDAP 绑定 DN",
"ldap_bind_password": "LDAP 绑定密码",
"ldap_base_dn": "LDAP 基础用户专有名称",
"ldap_base_dn": "LDAP 基础 DN",
"user_search_filter": "用户搜索过滤器",
"the_search_filter_to_use_to_search_or_sync_users": "用于搜索或同步用户的筛选器。",
"groups_search_filter": "群组搜索过滤器",
@@ -270,11 +270,11 @@
"one_time_link": "一次性链接",
"use_this_link_to_sign_in_once": "使用此链接进行一次性登录。这对尚未添加或已丢失通行密钥的用户来说非常必要。",
"add": "添加",
"callback_urls": "Callback URL",
"logout_callback_urls": "Logout Callback URL",
"callback_urls": "回调 URL",
"logout_callback_urls": "登出回调URL",
"public_client": "公共客户端",
"public_clients_description": "公共客户端没有客户端密钥。它们用于无法安全存储密钥的移动端、Web端和原生应用程序。",
"pkce": "PKCE",
"pkce": "公钥代码交换",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "公钥代码交换是一种安全功能,可防止 CSRF 和授权代码拦截攻击。",
"name_logo": "{name} Logo",
"change_logo": "更改 Logo",
@@ -350,7 +350,7 @@
"federated_client_credentials": "联合身份",
"federated_client_credentials_description": "您可以使用联合身份,通过第三方授权机构签发的 JWT 令牌,对 OIDC 客户端进行认证。",
"add_federated_client_credential": "添加联合身份",
"add_another_federated_client_credential": "添加一个联合身份",
"add_another_federated_client_credential": "添加一个联合身份",
"oidc_allowed_group_count": "允许的群组数量",
"unrestricted": "不受限制",
"show_advanced_options": "显示高级选项",
@@ -419,5 +419,15 @@
"signup_open_description": "任何人都可以无限制地注册新账户。",
"of": "中的",
"skip_passkey_setup": "跳过设置通行密钥",
"skip_passkey_setup_description": "强烈建议设置一个通行密钥,否则您在此会话结束后将无法访问您的账户。"
"skip_passkey_setup_description": "强烈建议设置一个通行密钥,否则您在此会话结束后将无法访问您的账户。",
"my_apps": "我的应用程序",
"no_apps_available": "没有可用应用程序",
"contact_your_administrator_for_app_access": "请联系您的管理员以获取应用程序的访问权限。",
"launch": "启动",
"client_launch_url": "客户发布链接",
"client_launch_url_description": "当用户从“我的应用”页面启动应用时将打开的 URL。",
"client_name_description": "在Pocket ID用户界面中显示的客户端名称。",
"revoke_access": "撤销访问权限",
"revoke_access_description": "撤销对 <b>{clientName}</b>. <b>{clientName}</b>将无法再访问您的账户信息。",
"revoke_access_successful": "对 {clientName} 的访问权限已成功撤销。"
}

View File

@@ -419,5 +419,15 @@
"signup_open_description": "任何人都可以不受限制地建立新帳號。",
"of": "的",
"skip_passkey_setup": "跳過密碼金鑰設定",
"skip_passkey_setup_description": "我們強烈建議您設定密碼金鑰,因為如果沒有密碼金鑰,當工作階段到期時,您就會被鎖住。"
"skip_passkey_setup_description": "我們強烈建議您設定密碼金鑰,因為如果沒有密碼金鑰,當工作階段到期時,您就會被鎖住。",
"my_apps": "我的應用程式",
"no_apps_available": "沒有可用的應用程式",
"contact_your_administrator_for_app_access": "聯絡您的管理員以取得應用程式的存取權。",
"launch": "啟動",
"client_launch_url": "用戶端啟動 URL",
"client_launch_url_description": "當使用者從「我的應用程式」頁面啟動應用程式時將會開啟的 URL。",
"client_name_description": "顯示在 Pocket ID UI 中的用戶端名稱。",
"revoke_access": "撤銷存取權",
"revoke_access_description": "撤銷存取 <b>{clientName}</b>. <b>{clientName}</b>將無法再存取您的帳戶資訊。",
"revoke_access_successful": "{clientName} 的存取權已成功取消。"
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
{
"name": "pocket-id-frontend",
"version": "1.6.3",
"version": "1.7.0",
"private": true,
"type": "module",
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "vite dev --port 3000",
"build": "vite build",
"preview": "vite preview --port 3000",
@@ -13,15 +14,15 @@
"format": "prettier --write ."
},
"dependencies": {
"@simplewebauthn/browser": "^13.1.0",
"@simplewebauthn/browser": "^13.1.2",
"@tailwindcss/vite": "^4.1.11",
"axios": "^1.10.0",
"axios": "^1.11.0",
"clsx": "^2.1.1",
"jose": "^5.9.6",
"jose": "^5.10.0",
"qrcode": "^1.5.4",
"sveltekit-superforms": "^2.23.1",
"tailwind-merge": "^3.3.0",
"zod": "^3.25.55"
"sveltekit-superforms": "^2.27.1",
"tailwind-merge": "^3.3.1",
"zod": "^4.0.9"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.2.0",
@@ -29,32 +30,31 @@
"@inlang/plugin-message-format": "^4.0.0",
"@internationalized/date": "^3.8.2",
"@lucide/svelte": "^0.525.0",
"@playwright/test": "^1.54.1",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.23.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@sveltejs/kit": "^2.26.0",
"@sveltejs/vite-plugin-svelte": "^6.1.0",
"@types/eslint": "^9.6.1",
"@types/node": "^22.10.10",
"@types/node": "^22.16.5",
"@types/qrcode": "^1.5.5",
"bits-ui": "^2.8.11",
"eslint": "^9.31.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-svelte": "^3.10.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.11.0",
"formsnap": "^2.0.1",
"globals": "^16.3.0",
"mode-watcher": "^1.1.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.6.14",
"svelte": "^5.36.0",
"svelte-check": "^4.2.2",
"svelte": "^5.36.16",
"svelte-check": "^4.3.0",
"svelte-sonner": "^1.0.5",
"tailwind-variants": "^1.0.0",
"tailwindcss": "^4.1.11",
"tslib": "^2.8.1",
"tw-animate-css": "^1.3.0",
"tw-animate-css": "^1.3.5",
"typescript": "^5.8.3",
"typescript-eslint": "^8.37.0",
"vite": "^7.0.4"
"typescript-eslint": "^8.38.0",
"vite": "^7.0.6"
}
}

View File

@@ -13,6 +13,8 @@
"pl",
"pt-BR",
"ru",
"uk",
"vi",
"zh-CN",
"zh-TW"
],

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { confirmDialogStore } from '.';
import FormattedMessage from '../formatted-message.svelte';
import Button from '../ui/button/button.svelte';
</script>
@@ -9,7 +10,7 @@
<AlertDialog.Header>
<AlertDialog.Title>{$confirmDialogStore.title}</AlertDialog.Title>
<AlertDialog.Description>
{$confirmDialogStore.message}
<FormattedMessage m={$confirmDialogStore.message} />
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>

View File

@@ -85,7 +85,8 @@
sideOffset={5}
trapFocus={false}
interactOutsideBehavior="ignore"
onCloseAutoFocus={(e) => e.preventDefault()}
onOpenAutoFocus={(e: Event) => e.preventDefault()}
onCloseAutoFocus={(e: Event) => e.preventDefault()}
avoidCollisions={false}
strategy="absolute"
>

View File

@@ -8,53 +8,66 @@
} = $props();
interface MessagePart {
type: 'text' | 'link';
type: 'text' | 'link' | 'bold';
content: string;
href?: string;
}
// Extracts attribute value from a tag's attribute string
function getAttr(attrs: string, name: string): string | undefined {
const re = new RegExp(`\\b${name}\\s*=\\s*(["'])(.*?)\\1`, 'i');
const m = re.exec(attrs ?? '');
return m?.[2];
}
const handlers: Record<string, (attrs: string, inner: string) => MessagePart | null> = {
link: (attrs, inner) => {
const href = getAttr(attrs, 'href');
if (!href) return { type: 'text', content: inner };
return { type: 'link', content: inner, href };
},
b: (_attrs, inner) => ({ type: 'bold', content: inner })
};
function buildTokenRegex(): RegExp {
const keys = Object.keys(handlers).join('|');
// Matches: <tag attrs>inner</tag> for allowed tags only
return new RegExp(`<(${keys})\\b([^>]*)>(.*?)<\\/\\1>`, 'g');
}
function parseMessage(content: string): MessagePart[] | string {
// Regex to match only <link href="url">text</link> format
const linkRegex = /<link\s+href=(['"])(.*?)\1>(.*?)<\/link>/g;
if (!linkRegex.test(content)) {
return content;
}
// Reset regex lastIndex for reuse
linkRegex.lastIndex = 0;
const tokenRegex = buildTokenRegex();
if (!tokenRegex.test(content)) return content;
// Reset lastIndex for reuse
tokenRegex.lastIndex = 0;
const parts: MessagePart[] = [];
let lastIndex = 0;
let match;
let match: RegExpExecArray | null;
while ((match = linkRegex.exec(content)) !== null) {
// Add text before the link
while ((match = tokenRegex.exec(content)) !== null) {
// Add text before the matched token
if (match.index > lastIndex) {
const textContent = content.slice(lastIndex, match.index);
if (textContent) {
parts.push({ type: 'text', content: textContent });
}
if (textContent) parts.push({ type: 'text', content: textContent });
}
const href = match[2];
const linkText = match[3];
parts.push({
type: 'link',
content: linkText,
href: href
});
const tag = match[1];
const attrs = match[2] ?? '';
const inner = match[3] ?? '';
const handler = handlers[tag];
const part: MessagePart | null = handler
? handler(attrs, inner)
: { type: 'text', content: inner };
if (part) parts.push(part);
lastIndex = match.index + match[0].length;
}
// Add remaining text after the last link
// Add remaining text after the last token
if (lastIndex < content.length) {
const remainingText = content.slice(lastIndex);
if (remainingText) {
parts.push({ type: 'text', content: remainingText });
}
if (remainingText) parts.push({ type: 'text', content: remainingText });
}
return parts;
@@ -69,6 +82,10 @@
{#each parsedContent as part}
{#if part.type === 'text'}
{part.content}
{:else if part.type === 'bold'}
<b>
{part.content}
</b>
{:else if part.type === 'link'}
<a
class="text-black underline dark:text-white"

View File

@@ -6,7 +6,7 @@
import WebAuthnService from '$lib/services/webauthn-service';
import userStore from '$lib/stores/user-store';
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
import { LucideLogOut, LucideUser } from '@lucide/svelte';
import { LayoutDashboard, LucideLogOut, LucideUser } from '@lucide/svelte';
const webauthnService = new WebAuthnService();
@@ -34,6 +34,9 @@
</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item onclick={() => goto('/settings/apps')}
><LayoutDashboard class="mr-2 size-4" /> {m.my_apps()}</DropdownMenu.Item
>
<DropdownMenu.Item onclick={() => goto('/settings/account')}
><LucideUser class="mr-2 size-4" /> {m.my_account()}</DropdownMenu.Item
>

View File

@@ -1,4 +1,5 @@
import type {
AuthorizedOidcClient,
AuthorizeResponse,
OidcClient,
OidcClientCreate,
@@ -113,6 +114,24 @@ class OidcService extends APIService {
});
return response.data;
}
async listAuthorizedClients(options?: SearchPaginationSortRequest) {
const res = await this.api.get('/oidc/users/me/clients', {
params: options
});
return res.data as Paginated<AuthorizedOidcClient>;
}
async listAuthorizedClientsForUser(userId: string, options?: SearchPaginationSortRequest) {
const res = await this.api.get(`/oidc/users/${userId}/clients`, {
params: options
});
return res.data as Paginated<AuthorizedOidcClient>;
}
async revokeOwnAuthorizedClient(clientId: string) {
await this.api.delete(`/oidc/users/me/clients/${clientId}`);
}
}
export default OidcService;

View File

@@ -4,13 +4,14 @@ export type OidcClientMetaData = {
id: string;
name: string;
hasLogo: boolean;
launchURL?: string;
};
export type OidcClientFederatedIdentity = {
issuer: string;
subject?: string;
audience?: string;
jwks: string | undefined;
jwks?: string | undefined;
};
export type OidcClientCredentials = {
@@ -23,6 +24,7 @@ export type OidcClient = OidcClientMetaData & {
isPublic: boolean;
pkceEnabled: boolean;
credentials?: OidcClientCredentials;
launchURL?: string;
};
export type OidcClientWithAllowedUserGroups = OidcClient & {
@@ -50,3 +52,8 @@ export type AuthorizeResponse = {
callbackURL: string;
issuer: string;
};
export type AuthorizedOidcClient = {
scope: string;
client: OidcClientMetaData;
};

View File

@@ -54,6 +54,13 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
inputs[input as keyof z.infer<T>].error = null;
}
}
// Update the input values with the parsed data
for (const key in result.data) {
if (Object.prototype.hasOwnProperty.call(inputs, key)) {
inputs[key as keyof z.infer<T>].value = result.data[key];
}
}
return inputs;
});
return success ? data() : null;

View File

@@ -2,7 +2,7 @@ import { setLocale as setParaglideLocale, type Locale } from '$lib/paraglide/run
import { z } from 'zod/v4';
export function setLocale(locale: Locale, reload = true) {
import(`../../../node_modules/zod/dist/esm/v4/locales/${locale}.js`)
import(`../../../node_modules/zod/v4/locales/${locale}.js`)
.then((zodLocale) => z.config(zodLocale.default()))
.finally(() => {
setParaglideLocale(locale, { reload });

View File

@@ -0,0 +1,11 @@
import z from 'zod/v4';
export const optionalString = z
.string()
.transform((v) => (v === '' ? undefined : v))
.optional();
export const optionalUrl = z
.url()
.optional()
.or(z.literal('').transform(() => undefined));

View File

@@ -57,4 +57,4 @@
}}
/>
<ConfirmDialog />
<ModeWatcher />
<ModeWatcher disableTransitions={false} />

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import FormattedMessage from '$lib/components/formatted-message.svelte';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import ScopeItem from '$lib/components/scope-item.svelte';
import { Button } from '$lib/components/ui/button';
@@ -98,17 +99,21 @@
{/if}
{#if !authorizationRequired && !errorMessage}
<p class="text-muted-foreground mt-2 mb-10">
{@html m.do_you_want_to_sign_in_to_client_with_your_app_name_account({
client: client.name,
appName: $appConfigStore.appName
})}
<FormattedMessage
m={m.do_you_want_to_sign_in_to_client_with_your_app_name_account({
client: client.name,
appName: $appConfigStore.appName
})}
/>
</p>
{:else if authorizationRequired}
<div class="w-full max-w-[450px]" transition:slide={{ duration: 300 }}>
<Card.Root class="mt-6 mb-10">
<Card.Header>
<p class="text-muted-foreground text-start">
{@html m.client_wants_to_access_the_following_information({ client: client.name })}
<FormattedMessage
m={m.client_wants_to_access_the_following_information({ client: client.name })}
/>
</p>
</Card.Header>
<Card.Content data-testid="scopes">

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import FormattedMessage from '$lib/components/formatted-message.svelte';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import ScopeList from '$lib/components/scope-list.svelte';
import { Button } from '$lib/components/ui/button';
@@ -94,9 +95,11 @@
<Card.Root class="mt-6">
<Card.Header class="pb-5">
<p class="text-muted-foreground text-start">
{@html m.client_wants_to_access_the_following_information({
client: deviceInfo!.client.name
})}
<FormattedMessage
m={m.client_wants_to_access_the_following_information({
client: deviceInfo!.client.name
})}
/>
</p>
</Card.Header>
<Card.Content data-testid="scopes">

View File

@@ -61,7 +61,7 @@
<p class="text-muted-foreground mt-2">{m.enter_the_code_you_received_to_sign_in()}</p>
{/if}
<form onsubmit={preventDefault(authenticate)} class="w-full max-w-[450px]">
<Input id="Email" class="mt-7" placeholder={m.code()} bind:value={code} type="text" />
<Input id="Code" class="mt-7" placeholder={m.code()} bind:value={code} type="text" />
<div class="mt-8 flex justify-between gap-2">
<Button variant="secondary" class="flex-1" href={'/login/alternative' + page.url.search}
>{m.go_back()}</Button

View File

@@ -63,7 +63,7 @@
<p class="text-muted-foreground mt-2" in:fade>
{m.enter_your_email_address_to_receive_an_email_with_a_login_code()}
</p>
<Input id="Email" class="mt-7" placeholder={m.your_email()} bind:value={email} />
<Input id="Email" class="mt-7" placeholder={m.your_email()} bind:value={email} type="email" />
<div class="mt-8 flex justify-between gap-2">
<Button variant="secondary" class="flex-1" href={'/login/alternative' + page.url.search}
>{m.go_back()}</Button

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import FormattedMessage from '$lib/components/formatted-message.svelte';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button';
@@ -36,10 +37,12 @@
<h1 class="font-playfair mt-5 text-4xl font-bold">{m.sign_out()}</h1>
<p class="text-muted-foreground mt-2">
{@html m.do_you_want_to_sign_out_of_pocketid_with_the_account({
username: $userStore?.username ?? '',
appName: $appConfigStore.appName
})}
<FormattedMessage
m={m.do_you_want_to_sign_out_of_pocketid_with_the_account({
username: $userStore?.username ?? '',
appName: $appConfigStore.appName
})}
/>
</p>
<div class="mt-10 flex w-full justify-stretch gap-2">
<Button class="flex-1" variant="secondary" onclick={() => history.back()}>{m.cancel()}</Button>

View File

@@ -25,6 +25,8 @@
{ href: '/settings/audit-log', label: m.audit_log() }
];
const nonAdminLinks = [{ href: '/settings/apps', label: m.my_apps() }];
const adminLinks = [
{ href: '/settings/admin/users', label: m.users() },
{ href: '/settings/admin/user-groups', label: m.user_groups() },
@@ -35,6 +37,8 @@
if (user?.isAdmin || $userStore?.isAdmin) {
links.push(...adminLinks);
} else {
links.push(...nonAdminLinks);
}
</script>

View File

@@ -20,6 +20,8 @@
pl: 'Polski',
'pt-BR': 'Português brasileiro',
ru: 'Русский',
uk: 'Українська',
vi: 'Tiếng Việt',
'zh-CN': '简体中文',
'zh-TW': '繁體中文(臺灣)'
};

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
import GlassRowItem from '$lib/components/glass-row-item.svelte';
import GlassRowItem from '$lib/components/passkey-row.svelte';
import { m } from '$lib/paraglide/messages';
import WebauthnService from '$lib/services/webauthn-service';
import type { Passkey } from '$lib/types/passkey.type';

View File

@@ -5,6 +5,7 @@
import type { ApiKeyCreate } from '$lib/types/api-key.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { optionalString } from '$lib/utils/zod-util';
import { z } from 'zod/v4';
let {
@@ -27,7 +28,7 @@
const formSchema = z.object({
name: z.string().min(3).max(50),
description: z.string().default(''),
description: optionalString,
expiresAt: z.date().min(new Date(), m.expiration_date_must_be_in_the_future())
});

View File

@@ -16,6 +16,7 @@
import { z } from 'zod/v4';
import FederatedIdentitiesInput from './federated-identities-input.svelte';
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
import { optionalUrl } from '$lib/utils/zod-util';
let {
callback,
@@ -38,6 +39,7 @@
logoutCallbackURLs: existingClient?.logoutCallbackURLs || [],
isPublic: existingClient?.isPublic || false,
pkceEnabled: existingClient?.pkceEnabled || false,
launchURL: existingClient?.launchURL || '',
credentials: {
federatedIdentities: existingClient?.credentials?.federatedIdentities || []
}
@@ -49,6 +51,7 @@
logoutCallbackURLs: z.array(z.string().nonempty()),
isPublic: z.boolean(),
pkceEnabled: z.boolean(),
launchURL: optionalUrl,
credentials: z.object({
federatedIdentities: z.array(
z.object({
@@ -106,8 +109,18 @@
<form onsubmit={preventDefault(onSubmit)}>
<div class="grid grid-cols-1 gap-x-3 gap-y-7 sm:flex-row md:grid-cols-2">
<FormInput label={m.name()} class="w-full" bind:input={$inputs.name} />
<div></div>
<FormInput
label={m.name()}
class="w-full"
description={m.client_name_description()}
bind:input={$inputs.name}
/>
<FormInput
label={m.client_launch_url()}
description={m.client_launch_url_description()}
class="w-full"
bind:input={$inputs.launchURL}
/>
<OidcCallbackUrlInput
label={m.callback_urls()}
description={m.callback_url_description()}

View File

@@ -10,6 +10,7 @@
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { User } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util';
@@ -145,11 +146,15 @@
>
{#if !item.ldapId || !$appConfigStore.ldapEnabled}
{#if item.disabled}
<DropdownMenu.Item onclick={() => enableUser(item)}
<DropdownMenu.Item
disabled={item.id === $userStore?.id}
onclick={() => enableUser(item)}
><LucideUserCheck class="mr-2 size-4" />{m.enable()}</DropdownMenu.Item
>
{:else}
<DropdownMenu.Item onclick={() => disableUser(item)}
<DropdownMenu.Item
disabled={item.id === $userStore?.id}
onclick={() => disableUser(item)}
><LucideUserX class="mr-2 size-4" />{m.disable()}</DropdownMenu.Item
>
{/if}
@@ -157,6 +162,7 @@
{#if !item.ldapId || (item.ldapId && item.disabled)}
<DropdownMenu.Item
class="text-red-500 focus:!text-red-700"
disabled={item.id === $userStore?.id}
onclick={() => deleteUser(item)}
><LucideTrash class="mr-2 size-4" />{m.delete()}</DropdownMenu.Item
>

View File

@@ -0,0 +1,121 @@
<script lang="ts">
import { openConfirmDialog } from '$lib/components/confirm-dialog';
import * as Pagination from '$lib/components/ui/pagination';
import { m } from '$lib/paraglide/messages';
import OIDCService from '$lib/services/oidc-service';
import type { AuthorizedOidcClient, OidcClientMetaData } from '$lib/types/oidc.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LayoutDashboard } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { default as AuthorizedOidcClientCard } from './authorized-oidc-client-card.svelte';
let { data } = $props();
let authorizedClients: Paginated<AuthorizedOidcClient> = $state(data.authorizedClients);
let requestOptions: SearchPaginationSortRequest = $state(data.appRequestOptions);
const oidcService = new OIDCService();
async function onRefresh(options: SearchPaginationSortRequest) {
authorizedClients = await oidcService.listAuthorizedClients(options);
}
async function onPageChange(page: number) {
requestOptions.pagination = { limit: authorizedClients.pagination.itemsPerPage, page };
onRefresh(requestOptions);
}
async function revokeAuthorizedClient(client: OidcClientMetaData) {
openConfirmDialog({
title: m.revoke_access(),
message: m.revoke_access_description({
clientName: client.name
}),
confirm: {
label: m.revoke(),
destructive: true,
action: async () => {
try {
await oidcService.revokeOwnAuthorizedClient(client.id);
onRefresh(requestOptions);
toast.success(
m.revoke_access_successful({
clientName: client.name
})
);
} catch (e) {
axiosErrorToast(e);
}
}
}
});
}
</script>
<svelte:head>
<title>{m.my_apps()}</title>
</svelte:head>
<div class="space-y-6">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold">
<LayoutDashboard class="text-primary/80 size-6" />
{m.my_apps()}
</h1>
</div>
{#if authorizedClients.data.length === 0}
<div class="py-16 text-center">
<LayoutDashboard class="text-muted-foreground mx-auto mb-4 size-16" />
<h3 class="text-muted-foreground mb-2 text-lg font-medium">
{m.no_apps_available()}
</h3>
<p class="text-muted-foreground mx-auto max-w-md text-sm">
{m.contact_your_administrator_for_app_access()}
</p>
</div>
{:else}
<div class="space-y-8">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{#each authorizedClients.data as authorizedClient}
<AuthorizedOidcClientCard {authorizedClient} onRevoke={revokeAuthorizedClient} />
{/each}
</div>
{#if authorizedClients.pagination.totalPages > 1}
<div class="border-border flex items-center justify-center border-t pt-3">
<Pagination.Root
class="mx-0 w-auto"
count={authorizedClients.pagination.totalItems}
perPage={authorizedClients.pagination.itemsPerPage}
{onPageChange}
page={authorizedClients.pagination.currentPage}
>
{#snippet children({ pages })}
<Pagination.Content class="flex justify-center">
<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={authorizedClients.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}
</div>
{/if}
</div>

View File

@@ -0,0 +1,22 @@
import OIDCService from '$lib/services/oidc-service';
import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { PageLoad } from './$types';
export const load: PageLoad = async () => {
const oidcService = new OIDCService();
const appRequestOptions: SearchPaginationSortRequest = {
pagination: {
page: 1,
limit: 20
},
sort: {
column: 'lastUsedAt',
direction: 'desc'
}
};
const authorizedClients = await oidcService.listAuthorizedClients(appRequestOptions);
return { authorizedClients, appRequestOptions };
};

View File

@@ -0,0 +1,102 @@
<script lang="ts">
import { goto } from '$app/navigation';
import ImageBox from '$lib/components/image-box.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { m } from '$lib/paraglide/messages';
import userStore from '$lib/stores/user-store';
import type { AuthorizedOidcClient, OidcClientMetaData } from '$lib/types/oidc.type';
import { cachedApplicationLogo, cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import {
LucideBan,
LucideEllipsisVertical,
LucideExternalLink,
LucidePencil
} from '@lucide/svelte';
import { mode } from 'mode-watcher';
let {
authorizedClient,
onRevoke
}: {
authorizedClient: AuthorizedOidcClient;
onRevoke: (client: OidcClientMetaData) => Promise<void>;
} = $props();
const isLightMode = $derived(mode.current === 'light');
</script>
<Card.Root
class="border-muted group h-[140px] p-5 transition-all duration-200 hover:shadow-md"
data-testid="authorized-oidc-client-card"
>
<Card.Content class=" p-0">
<div class="flex gap-3">
<div class="aspect-square h-[56px]">
<ImageBox
class="grow rounded-lg object-contain"
src={authorizedClient.client.hasLogo
? cachedOidcClientLogo.getUrl(authorizedClient.client.id)
: cachedApplicationLogo.getUrl(isLightMode)}
alt={m.name_logo({ name: authorizedClient.client.name })}
/>
</div>
<div class="flex w-full justify-between gap-3">
<div>
<div class="mb-1 flex items-start gap-2">
<h3
class="text-foreground line-clamp-2 leading-tight font-semibold break-words break-all text-ellipsis"
>
{authorizedClient.client.name}
</h3>
</div>
{#if authorizedClient.client.launchURL}
<p
class="text-muted-foreground line-clamp-1 text-xs break-words break-all text-ellipsis"
>
{new URL(authorizedClient.client.launchURL).hostname}
</p>
{/if}
</div>
<div>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<LucideEllipsisVertical class="size-4" />
<span class="sr-only">{m.toggle_menu()}</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() => goto(`/settings/admin/oidc-clients/${authorizedClient.client.id}`)}
><LucidePencil class="mr-2 size-4" /> {m.edit()}</DropdownMenu.Item
>
{#if $userStore?.isAdmin}
<DropdownMenu.Item
class="text-red-500 focus:!text-red-700"
onclick={() => onRevoke(authorizedClient.client)}
><LucideBan class="mr-2 size-4" />{m.revoke()}</DropdownMenu.Item
>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</div>
</div>
<div class="mt-2 flex justify-end">
<Button
href={authorizedClient.client.launchURL}
target="_blank"
size="sm"
class="h-8 text-xs"
disabled={!authorizedClient.client.launchURL}
>
{m.launch()}
<LucideExternalLink class="ml-1 size-3" />
</Button>
</div>
</Card.Content>
</Card.Root>
<style>
</style>

View File

@@ -0,0 +1,3 @@
User-agent: *
Disallow: /
Noindex: /

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