mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-14 17:23:02 +03:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0de4b55dc4 | ||
|
|
78c88f5339 | ||
|
|
60e7dafa01 | ||
|
|
2ccabf835c | ||
|
|
590cb02f6c | ||
|
|
8c96ab9574 | ||
|
|
3484daf870 | ||
|
|
cfbc0d6d35 | ||
|
|
939601b6a4 | ||
|
|
b9daa5d757 | ||
|
|
8304065652 | ||
|
|
7bfc3f43a5 | ||
|
|
c056089c60 | ||
|
|
3350398abc | ||
|
|
0b0a6781ff | ||
|
|
735dc70d5f | ||
|
|
47e164b4b5 | ||
|
|
18c5103c20 | ||
|
|
5565f60d6d | ||
|
|
bd4f87b2d2 | ||
|
|
6560fd9279 | ||
|
|
29d632c151 | ||
|
|
2092007752 | ||
|
|
0aff6181c9 | ||
|
|
824c5cb4f3 | ||
|
|
3a300a2b51 | ||
|
|
a1985ce1b2 | ||
|
|
b39bc4f79a | ||
|
|
0a07344139 | ||
|
|
f3f0e1d56d | ||
|
|
70ad0b4f39 | ||
|
|
2587058ded | ||
|
|
ff06bf0b34 | ||
|
|
11ed661f86 | ||
|
|
29748cc6c7 | ||
|
|
edfb99d221 | ||
|
|
282ff82b0c | ||
|
|
9d5f83da78 | ||
|
|
896da812a3 | ||
|
|
d2b3b7647d | ||
|
|
025378d14e |
18
.dockerignore
Normal file
18
.dockerignore
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
/frontend/.svelte-kit
|
||||||
|
/frontend/build
|
||||||
|
/backend/bin
|
||||||
|
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
|
||||||
|
# Application specific
|
||||||
|
data
|
||||||
|
/scripts/development
|
||||||
@@ -23,6 +23,9 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Download GeoLite2 City database
|
||||||
|
run: MAXMIND_LICENSE_KEY=${{ secrets.MAXMIND_LICENSE_KEY }} sh scripts/download-ip-database.sh
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
11
.github/workflows/e2e-tests.yml
vendored
11
.github/workflows/e2e-tests.yml
vendored
@@ -16,8 +16,12 @@ jobs:
|
|||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Create dummy GeoLite2 City database
|
||||||
|
run: touch ./backend/GeoLite2-City.mmdb
|
||||||
|
|
||||||
- name: Build Docker Image
|
- name: Build Docker Image
|
||||||
run: docker build -t stonith404/pocket-id .
|
run: docker build -t stonith404/pocket-id .
|
||||||
|
|
||||||
- name: Run Docker Container
|
- name: Run Docker Container
|
||||||
run: docker run -d --name pocket-id -p 80:80 --env-file .env.test stonith404/pocket-id
|
run: docker run -d --name pocket-id -p 80:80 --env-file .env.test stonith404/pocket-id
|
||||||
|
|
||||||
@@ -33,13 +37,10 @@ jobs:
|
|||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
run: npx playwright test
|
run: npx playwright test
|
||||||
|
|
||||||
- name: Get container logs
|
|
||||||
if: always()
|
|
||||||
run: docker logs pocket-id
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
path: frontend/tests/.output
|
path: frontend/tests/.report
|
||||||
|
include-hidden-files: true
|
||||||
retention-days: 15
|
retention-days: 15
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -34,4 +34,6 @@ vite.config.ts.timestamp-*
|
|||||||
# Application specific
|
# Application specific
|
||||||
data
|
data
|
||||||
/frontend/tests/.auth
|
/frontend/tests/.auth
|
||||||
|
/frontend/tests/.report
|
||||||
pocket-id-backend
|
pocket-id-backend
|
||||||
|
/backend/GeoLite2-City.mmdb
|
||||||
71
CHANGELOG.md
71
CHANGELOG.md
@@ -1,3 +1,74 @@
|
|||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.12.0...v) (2024-10-31)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add ability to define expiration of one time link ([2ccabf8](https://github.com/stonith404/pocket-id/commit/2ccabf835c2c923d6986d9cafb4e878f5110b91a))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.11.0...v) (2024-10-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add option to disable self-account editing ([8304065](https://github.com/stonith404/pocket-id/commit/83040656525cf7b6c8f2acf416c5f8f3288f3d48))
|
||||||
|
* add validation to custom claim input ([7bfc3f4](https://github.com/stonith404/pocket-id/commit/7bfc3f43a591287c038187ed5e782de6b9dd738b))
|
||||||
|
* custom claims ([#53](https://github.com/stonith404/pocket-id/issues/53)) ([c056089](https://github.com/stonith404/pocket-id/commit/c056089c6043a825aaaaecf0c57454892a108f1d))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.10.0...v) (2024-10-25)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add `email_verified` claim ([5565f60](https://github.com/stonith404/pocket-id/commit/5565f60d6d62ca24bedea337e21effc13e5853a5))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* powered by link text color in light mode ([18c5103](https://github.com/stonith404/pocket-id/commit/18c5103c20ce79abdc0f724cdedd642c09269e78))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.9.0...v) (2024-10-23)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add script for creating one time access token ([a1985ce](https://github.com/stonith404/pocket-id/commit/a1985ce1b200550e91c5cb42a8d19899dcec831e))
|
||||||
|
* add version information to footer and update link if new update is available ([70ad0b4](https://github.com/stonith404/pocket-id/commit/70ad0b4f39699fd81ffdfd5c8d6839f49348be78))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* cache version information for 3 hours ([29d632c](https://github.com/stonith404/pocket-id/commit/29d632c1514d6edacdfebe6deae4c95fc5a0f621))
|
||||||
|
* improve text for initial admin account setup ([0a07344](https://github.com/stonith404/pocket-id/commit/0a0734413943b1fff27d8f4ccf07587e207e2189))
|
||||||
|
* increase callback url count ([f3f0e1d](https://github.com/stonith404/pocket-id/commit/f3f0e1d56d7656bdabbd745a4eaf967f63193b6c))
|
||||||
|
* no DTO was returned from exchange one time access token endpoint ([824c5cb](https://github.com/stonith404/pocket-id/commit/824c5cb4f3d6be7f940c1758112fbe9322df5768))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.8.1...v) (2024-10-18)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add environment variable to change the caddy port in Docker ([ff06bf0](https://github.com/stonith404/pocket-id/commit/ff06bf0b34496ce472ba6d3ebd4ea249f21c0ec3))
|
||||||
|
* use improve table for users and audit logs ([11ed661](https://github.com/stonith404/pocket-id/commit/11ed661f86a512f78f66d604a10c1d47d39f2c39))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* allow copy to clipboard for client secret ([29748cc](https://github.com/stonith404/pocket-id/commit/29748cc6c7b7e5a6b54bfe837e0b1a98fa1ad594))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.8.0...v) (2024-10-11)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add key id to JWK ([282ff82](https://github.com/stonith404/pocket-id/commit/282ff82b0c7e2414b3528c8ca325758245b8ae61))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.7.1...v) (2024-10-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add location based on ip to the audit log ([025378d](https://github.com/stonith404/pocket-id/commit/025378d14edd2d72da76e90799a0ccdd42cf672c))
|
||||||
|
|
||||||
## [](https://github.com/stonith404/pocket-id/compare/v0.7.0...v) (2024-10-03)
|
## [](https://github.com/stonith404/pocket-id/compare/v0.7.0...v) (2024-10-03)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,12 +31,14 @@ COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json
|
|||||||
|
|
||||||
COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend
|
COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend
|
||||||
COPY --from=backend-builder /app/backend/migrations ./backend/migrations
|
COPY --from=backend-builder /app/backend/migrations ./backend/migrations
|
||||||
|
COPY --from=backend-builder /app/backend/GeoLite2-City.mmdb ./backend/GeoLite2-City.mmdb
|
||||||
COPY --from=backend-builder /app/backend/email-templates ./backend/email-templates
|
COPY --from=backend-builder /app/backend/email-templates ./backend/email-templates
|
||||||
COPY --from=backend-builder /app/backend/images ./backend/images
|
COPY --from=backend-builder /app/backend/images ./backend/images
|
||||||
|
|
||||||
COPY ./scripts ./scripts
|
COPY ./scripts ./scripts
|
||||||
|
RUN chmod +x ./scripts/*.sh
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 80
|
||||||
ENV APP_ENV=production
|
ENV APP_ENV=production
|
||||||
|
|
||||||
# Use a shell form to run both the frontend and backend
|
# Use a shell form to run both the frontend and backend
|
||||||
|
|||||||
36
README.md
36
README.md
@@ -68,6 +68,10 @@ Required tools:
|
|||||||
cd ..
|
cd ..
|
||||||
pm2 start pocket-id-backend --name pocket-id-backend
|
pm2 start pocket-id-backend --name pocket-id-backend
|
||||||
|
|
||||||
|
# Optional: Download the GeoLite2 city database.
|
||||||
|
# If not downloaded the ip location in the audit log will be empty.
|
||||||
|
MAXMIND_LICENSE_KEY=<your-key> sh scripts/download-ip-database.sh
|
||||||
|
|
||||||
# Start the frontend
|
# Start the frontend
|
||||||
cd ../frontend
|
cd ../frontend
|
||||||
npm install
|
npm install
|
||||||
@@ -81,29 +85,23 @@ Required tools:
|
|||||||
|
|
||||||
You can now sign in with the admin account on `http://localhost/login/setup`.
|
You can now sign in with the admin account on `http://localhost/login/setup`.
|
||||||
|
|
||||||
### Add Pocket ID as an OIDC provider
|
### Nginx Reverse Proxy
|
||||||
|
|
||||||
You can add a new OIDC client on `https://<your-domain>/settings/admin/oidc-clients`
|
To use Nginx in front of Pocket ID, add the following configuration to increase the header buffer size because, as SvelteKit generates larger headers.
|
||||||
|
|
||||||
After you have added the client, you can obtain the client ID and client secret.
|
```nginx
|
||||||
|
proxy_busy_buffers_size 512k;
|
||||||
|
proxy_buffers 4 512k;
|
||||||
|
proxy_buffer_size 256k;
|
||||||
|
```
|
||||||
|
|
||||||
You may need the following information:
|
## Proxy Services with Pocket ID
|
||||||
|
|
||||||
- **Authorization URL**: `https://<your-domain>/authorize`
|
|
||||||
- **Token URL**: `https://<your-domain>/api/oidc/token`
|
|
||||||
- **Userinfo URL**: `https://<your-domain>/api/oidc/userinfo`
|
|
||||||
- **Certificate URL**: `https://<your-domain>/.well-known/jwks.json`
|
|
||||||
- **OIDC Discovery URL**: `https://<your-domain>/.well-known/openid-configuration`
|
|
||||||
- **PKCE**: `false` as this is not supported yet.
|
|
||||||
- **Scopes**: At least `openid email`. Optionally you can add `profile` and `groups`.
|
|
||||||
|
|
||||||
### Proxy Services with Pocket ID
|
|
||||||
|
|
||||||
As the goal of Pocket ID is to stay simple, we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/) to add authentication to your services that don't support OIDC.
|
As the goal of Pocket ID is to stay simple, we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/) to add authentication to your services that don't support OIDC.
|
||||||
|
|
||||||
See the [guide](docs/proxy-services.md) for more information.
|
See the [guide](docs/proxy-services.md) for more information.
|
||||||
|
|
||||||
### Update
|
## Update
|
||||||
|
|
||||||
#### Docker
|
#### Docker
|
||||||
|
|
||||||
@@ -132,6 +130,9 @@ docker compose up -d
|
|||||||
cd ..
|
cd ..
|
||||||
pm2 start pocket-id-backend --name pocket-id-backend
|
pm2 start pocket-id-backend --name pocket-id-backend
|
||||||
|
|
||||||
|
# Optional: Update the GeoLite2 city database
|
||||||
|
MAXMIND_LICENSE_KEY=<your-key> sh scripts/download-ip-database.sh
|
||||||
|
|
||||||
# Start the frontend
|
# Start the frontend
|
||||||
cd ../frontend
|
cd ../frontend
|
||||||
npm install
|
npm install
|
||||||
@@ -143,15 +144,16 @@ docker compose up -d
|
|||||||
pm2 start caddy --name pocket-id-caddy -- run --config Caddyfile
|
pm2 start caddy --name pocket-id-caddy -- run --config Caddyfile
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment variables
|
## Environment variables
|
||||||
|
|
||||||
| Variable | Default Value | Recommended to change | Description |
|
| Variable | Default Value | Recommended to change | Description |
|
||||||
| ---------------------- | ----------------------- | --------------------- | --------------------------------------------- |
|
| ---------------------- | ----------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. |
|
| `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. |
|
||||||
| `TRUST_PROXY` | `false` | yes | Whether the app is behind a reverse proxy. |
|
| `TRUST_PROXY` | `false` | yes | Whether the app is behind a reverse proxy. |
|
||||||
| `DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. |
|
| `DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. |
|
||||||
| `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. |
|
| `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. |
|
||||||
| `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. |
|
| `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. |
|
||||||
|
| `CADDY_PORT` | `80` | no | The port on which Caddy should listen. Caddy is only active inside the Docker container. If you want to change the exposed port of the container then you sould change this variable. |
|
||||||
| `PORT` | `3000` | no | The port on which the frontend should listen. |
|
| `PORT` | `3000` | no | The port on which the frontend should listen. |
|
||||||
| `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. |
|
| `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. |
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,15 @@
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
<h2>New Sign-In Detected</h2>
|
<h2>New Sign-In Detected</h2>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
|
{{ if and .Data.City .Data.Country }}
|
||||||
|
<div>
|
||||||
|
<p class="label">Approximate Location</p>
|
||||||
|
<p>{{ .Data.City }}, {{ .Data.Country }}</p>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
<div>
|
<div>
|
||||||
<p class="label">IP Address</p>
|
<p class="label">IP Address</p>
|
||||||
<p>{{ .Data.IPAddress}}</p>
|
<p>{{ .Data.IPAddress }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="label">Device</p>
|
<p class="label">Device</p>
|
||||||
@@ -19,7 +25,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="label">Sign-In Time</p>
|
<p class="label">Sign-In Time</p>
|
||||||
<p>{{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC"}}</p>
|
<p>{{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC" }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="message">
|
<p class="message">
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
New Sign-In Detected
|
New Sign-In Detected
|
||||||
====================
|
====================
|
||||||
|
|
||||||
|
{{ if and .Data.City .Data.Country }}
|
||||||
|
Approximate Location: {{ .Data.City }}, {{ .Data.Country }}
|
||||||
|
{{ end }}
|
||||||
IP Address: {{ .Data.IPAddress }}
|
IP Address: {{ .Data.IPAddress }}
|
||||||
Device: {{ .Data.Device }}
|
Device: {{ .Data.Device }}
|
||||||
Time: {{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC"}}
|
Time: {{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC"}}
|
||||||
|
|||||||
@@ -7,22 +7,23 @@ require (
|
|||||||
github.com/fxamacker/cbor/v2 v2.7.0
|
github.com/fxamacker/cbor/v2 v2.7.0
|
||||||
github.com/gin-contrib/cors v1.7.2
|
github.com/gin-contrib/cors v1.7.2
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
github.com/go-co-op/gocron/v2 v2.11.0
|
github.com/go-co-op/gocron/v2 v2.12.1
|
||||||
github.com/go-playground/validator/v10 v10.22.0
|
github.com/go-playground/validator/v10 v10.22.1
|
||||||
github.com/go-webauthn/webauthn v0.11.1
|
github.com/go-webauthn/webauthn v0.11.2
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
github.com/golang-migrate/migrate/v4 v4.17.1
|
github.com/golang-migrate/migrate/v4 v4.18.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/mileusna/useragent v1.3.4
|
github.com/mileusna/useragent v1.3.5
|
||||||
golang.org/x/crypto v0.26.0
|
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1
|
||||||
|
golang.org/x/crypto v0.27.0
|
||||||
golang.org/x/time v0.6.0
|
golang.org/x/time v0.6.0
|
||||||
gorm.io/driver/sqlite v1.5.6
|
gorm.io/driver/sqlite v1.5.6
|
||||||
gorm.io/gorm v1.25.11
|
gorm.io/gorm v1.25.12
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.12.1 // indirect
|
github.com/bytedance/sonic v1.12.3 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.2.0 // indirect
|
github.com/bytedance/sonic/loader v0.2.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
@@ -30,7 +31,7 @@ require (
|
|||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-webauthn/x v0.1.12 // indirect
|
github.com/go-webauthn/x v0.1.14 // indirect
|
||||||
github.com/goccy/go-json v0.10.3 // indirect
|
github.com/goccy/go-json v0.10.3 // indirect
|
||||||
github.com/google/go-tpm v0.9.1 // indirect
|
github.com/google/go-tpm v0.9.1 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
@@ -43,22 +44,21 @@ require (
|
|||||||
github.com/kr/pretty v0.3.1 // indirect
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
github.com/mattn/go-sqlite3 v1.14.23 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.11.0 // indirect
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
golang.org/x/arch v0.9.0 // indirect
|
golang.org/x/arch v0.10.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
|
||||||
golang.org/x/net v0.27.0 // indirect
|
golang.org/x/net v0.29.0 // indirect
|
||||||
golang.org/x/sys v0.23.0 // indirect
|
golang.org/x/sys v0.25.0 // indirect
|
||||||
golang.org/x/text v0.17.0 // indirect
|
golang.org/x/text v0.18.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.2 // indirect
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
github.com/bytedance/sonic v1.12.1 h1:jWl5Qz1fy7X1ioY74WqO0KjAMtAGQs4sYnjiEBiyX24=
|
github.com/bytedance/sonic v1.12.3 h1:W2MGa7RCU1QTeYRTPE3+88mVC0yXmsRQRChiyVocVjU=
|
||||||
github.com/bytedance/sonic v1.12.1/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
|
github.com/bytedance/sonic v1.12.3/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM=
|
github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM=
|
||||||
github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
@@ -23,26 +23,26 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
|
|||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
github.com/go-co-op/gocron/v2 v2.11.0 h1:IOowNA6SzwdRFnD4/Ol3Kj6G2xKfsoiiGq2Jhhm9bvE=
|
github.com/go-co-op/gocron/v2 v2.12.1 h1:dCIIBFbzhWKdgXeEifBjHPzgQ1hoWhjS4289Hjjy1uw=
|
||||||
github.com/go-co-op/gocron/v2 v2.11.0/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
|
github.com/go-co-op/gocron/v2 v2.12.1/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
|
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
|
||||||
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||||
github.com/go-webauthn/webauthn v0.11.1 h1:5G/+dg91/VcaJHTtJUfwIlNJkLwbJCcnUc4W8VtkpzA=
|
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
|
||||||
github.com/go-webauthn/webauthn v0.11.1/go.mod h1:YXRm1WG0OtUyDFaVAgB5KG7kVqW+6dYCJ7FTQH4SxEE=
|
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
|
||||||
github.com/go-webauthn/x v0.1.12 h1:RjQ5cvApzyU/xLCiP+rub0PE4HBZsLggbxGR5ZpUf/A=
|
github.com/go-webauthn/x v0.1.14 h1:1wrB8jzXAofojJPAaRxnZhRgagvLGnLjhCAwg3kTpT0=
|
||||||
github.com/go-webauthn/x v0.1.12/go.mod h1:XlRcGkNH8PT45TfeJYc6gqpOtiOendHhVmnOxh+5yHs=
|
github.com/go-webauthn/x v0.1.14/go.mod h1:UuVvFZ8/NbOnkDz3y1NaxtUN87pmtpC1PQ+/5BBQRdc=
|
||||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4=
|
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
|
||||||
github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM=
|
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM=
|
github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM=
|
||||||
@@ -79,10 +79,10 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
|||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
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-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/mileusna/useragent v1.3.4 h1:MiuRRuvGjEie1+yZHO88UBYg8YBC/ddF6T7F56i3PCk=
|
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
|
||||||
github.com/mileusna/useragent v1.3.4/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
|
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
|
||||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -90,26 +90,26 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1 h1:UihPOz+oIJ5X0JsO7wEkL50fheCODsoZ9r86mJWfNMc=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1/go.mod h1:vPpFrres6g9B5+meBwAd9xnp335KFcLEFW7EqJxBHy0=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
@@ -122,20 +122,20 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
|||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
golang.org/x/arch v0.9.0 h1:ub9TgUInamJ8mrZIGlBG6/4TqWeMszd4N8lNorbrr6k=
|
golang.org/x/arch v0.10.0 h1:S3huipmSclq3PJMNe76NGwkBR504WFkQ5dhzWzP8ZW8=
|
||||||
golang.org/x/arch v0.9.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
golang.org/x/arch v0.10.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
|
||||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
||||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
|
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||||
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
||||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||||
@@ -148,6 +148,6 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
|
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
|
||||||
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||||
gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
|
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||||
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ func newDatabase() (db *gorm.DB) {
|
|||||||
log.Fatalf("failed to connect to database: %v", err)
|
log.Fatalf("failed to connect to database: %v", err)
|
||||||
}
|
}
|
||||||
sqlDb, err := db.DB()
|
sqlDb, err := db.DB()
|
||||||
|
sqlDb.SetMaxOpenConns(1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to get sql.DB: %v", err)
|
log.Fatalf("failed to get sql.DB: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,11 +39,13 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
|||||||
jwtService := service.NewJwtService(appConfigService)
|
jwtService := service.NewJwtService(appConfigService)
|
||||||
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
|
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
|
||||||
userService := service.NewUserService(db, jwtService)
|
userService := service.NewUserService(db, jwtService)
|
||||||
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService)
|
customClaimService := service.NewCustomClaimService(db)
|
||||||
|
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
|
||||||
testService := service.NewTestService(db, appConfigService)
|
testService := service.NewTestService(db, appConfigService)
|
||||||
userGroupService := service.NewUserGroupService(db)
|
userGroupService := service.NewUserGroupService(db)
|
||||||
|
|
||||||
r.Use(middleware.NewCorsMiddleware().Add())
|
r.Use(middleware.NewCorsMiddleware().Add())
|
||||||
|
r.Use(middleware.NewErrorHandlerMiddleware().Add())
|
||||||
r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60))
|
r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60))
|
||||||
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
|
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
|
||||||
|
|
||||||
@@ -55,10 +57,11 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
|||||||
apiGroup := r.Group("/api")
|
apiGroup := r.Group("/api")
|
||||||
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService)
|
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService)
|
||||||
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
|
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
|
||||||
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService)
|
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
|
||||||
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService)
|
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService)
|
||||||
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
|
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
|
||||||
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
|
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
|
||||||
|
controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)
|
||||||
|
|
||||||
// Add test controller in non-production environments
|
// Add test controller in non-production environments
|
||||||
if common.EnvConfig.AppEnv != "production" {
|
if common.EnvConfig.AppEnv != "production" {
|
||||||
|
|||||||
@@ -1,19 +1,148 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import "errors"
|
import (
|
||||||
|
"fmt"
|
||||||
var (
|
"net/http"
|
||||||
ErrUsernameTaken = errors.New("username is already taken")
|
|
||||||
ErrEmailTaken = errors.New("email is already taken")
|
|
||||||
ErrSetupAlreadyCompleted = errors.New("setup already completed")
|
|
||||||
ErrTokenInvalidOrExpired = errors.New("token is invalid or expired")
|
|
||||||
ErrOidcMissingAuthorization = errors.New("missing authorization")
|
|
||||||
ErrOidcGrantTypeNotSupported = errors.New("grant type not supported")
|
|
||||||
ErrOidcMissingClientCredentials = errors.New("client id or secret not provided")
|
|
||||||
ErrOidcClientSecretInvalid = errors.New("invalid client secret")
|
|
||||||
ErrOidcInvalidAuthorizationCode = errors.New("invalid authorization code")
|
|
||||||
ErrOidcInvalidCallbackURL = errors.New("invalid callback URL")
|
|
||||||
ErrFileTypeNotSupported = errors.New("file type not supported")
|
|
||||||
ErrInvalidCredentials = errors.New("no user found with provided credentials")
|
|
||||||
ErrNameAlreadyInUse = errors.New("name is already in use")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type AppError interface {
|
||||||
|
error
|
||||||
|
HttpStatusCode() int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom error types for various conditions
|
||||||
|
|
||||||
|
type AlreadyInUseError struct {
|
||||||
|
Property string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *AlreadyInUseError) Error() string {
|
||||||
|
return fmt.Sprintf("%s is already in use", e.Property)
|
||||||
|
}
|
||||||
|
func (e *AlreadyInUseError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type SetupAlreadyCompletedError struct{}
|
||||||
|
|
||||||
|
func (e *SetupAlreadyCompletedError) Error() string { return "setup already completed" }
|
||||||
|
func (e *SetupAlreadyCompletedError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type TokenInvalidOrExpiredError struct{}
|
||||||
|
|
||||||
|
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
|
||||||
|
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type OidcMissingAuthorizationError struct{}
|
||||||
|
|
||||||
|
func (e *OidcMissingAuthorizationError) Error() string { return "missing authorization" }
|
||||||
|
func (e *OidcMissingAuthorizationError) HttpStatusCode() int { return http.StatusForbidden }
|
||||||
|
|
||||||
|
type OidcGrantTypeNotSupportedError struct{}
|
||||||
|
|
||||||
|
func (e *OidcGrantTypeNotSupportedError) Error() string { return "grant type not supported" }
|
||||||
|
func (e *OidcGrantTypeNotSupportedError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type OidcMissingClientCredentialsError struct{}
|
||||||
|
|
||||||
|
func (e *OidcMissingClientCredentialsError) Error() string { return "client id or secret not provided" }
|
||||||
|
func (e *OidcMissingClientCredentialsError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type OidcClientSecretInvalidError struct{}
|
||||||
|
|
||||||
|
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
|
||||||
|
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type OidcInvalidAuthorizationCodeError struct{}
|
||||||
|
|
||||||
|
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
|
||||||
|
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type OidcInvalidCallbackURLError struct{}
|
||||||
|
|
||||||
|
func (e *OidcInvalidCallbackURLError) Error() string { return "invalid callback URL" }
|
||||||
|
func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type FileTypeNotSupportedError struct{}
|
||||||
|
|
||||||
|
func (e *FileTypeNotSupportedError) Error() string { return "file type not supported" }
|
||||||
|
func (e *FileTypeNotSupportedError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type InvalidCredentialsError struct{}
|
||||||
|
|
||||||
|
func (e *InvalidCredentialsError) Error() string { return "no user found with provided credentials" }
|
||||||
|
func (e *InvalidCredentialsError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type FileTooLargeError struct {
|
||||||
|
MaxSize string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *FileTooLargeError) Error() string {
|
||||||
|
return fmt.Sprintf("The file can't be larger than %s", e.MaxSize)
|
||||||
|
}
|
||||||
|
func (e *FileTooLargeError) HttpStatusCode() int { return http.StatusRequestEntityTooLarge }
|
||||||
|
|
||||||
|
type NotSignedInError struct{}
|
||||||
|
|
||||||
|
func (e *NotSignedInError) Error() string { return "You are not signed in" }
|
||||||
|
func (e *NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||||
|
|
||||||
|
type MissingPermissionError struct{}
|
||||||
|
|
||||||
|
func (e *MissingPermissionError) Error() string {
|
||||||
|
return "You don't have permission to perform this action"
|
||||||
|
}
|
||||||
|
func (e *MissingPermissionError) HttpStatusCode() int { return http.StatusForbidden }
|
||||||
|
|
||||||
|
type TooManyRequestsError struct{}
|
||||||
|
|
||||||
|
func (e *TooManyRequestsError) Error() string {
|
||||||
|
return "Too many requests. Please wait a while before trying again."
|
||||||
|
}
|
||||||
|
func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
|
||||||
|
|
||||||
|
type ClientIdOrSecretNotProvidedError struct{}
|
||||||
|
|
||||||
|
func (e *ClientIdOrSecretNotProvidedError) Error() string {
|
||||||
|
return "Client id and secret not provided"
|
||||||
|
}
|
||||||
|
func (e *ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type WrongFileTypeError struct {
|
||||||
|
ExpectedFileType string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *WrongFileTypeError) Error() string {
|
||||||
|
return fmt.Sprintf("File must be of type %s", e.ExpectedFileType)
|
||||||
|
}
|
||||||
|
func (e *WrongFileTypeError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type MissingSessionIdError struct{}
|
||||||
|
|
||||||
|
func (e *MissingSessionIdError) Error() string {
|
||||||
|
return "Missing session id"
|
||||||
|
}
|
||||||
|
func (e *MissingSessionIdError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type ReservedClaimError struct {
|
||||||
|
Key string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ReservedClaimError) Error() string {
|
||||||
|
return fmt.Sprintf("Claim %s is reserved and can't be used", e.Key)
|
||||||
|
}
|
||||||
|
func (e *ReservedClaimError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type DuplicateClaimError struct {
|
||||||
|
Key string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *DuplicateClaimError) Error() string {
|
||||||
|
return fmt.Sprintf("Claim %s is already defined", e.Key)
|
||||||
|
}
|
||||||
|
func (e *DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type AccountEditNotAllowedError struct{}
|
||||||
|
|
||||||
|
func (e *AccountEditNotAllowedError) Error() string {
|
||||||
|
return "You are not allowed to edit your account"
|
||||||
|
}
|
||||||
|
func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
@@ -39,13 +38,13 @@ type AppConfigController struct {
|
|||||||
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
||||||
configuration, err := acc.appConfigService.ListAppConfig(false)
|
configuration, err := acc.appConfigService.ListAppConfig(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var configVariablesDto []dto.PublicAppConfigVariableDto
|
var configVariablesDto []dto.PublicAppConfigVariableDto
|
||||||
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,13 +54,13 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
|||||||
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
||||||
configuration, err := acc.appConfigService.ListAppConfig(true)
|
configuration, err := acc.appConfigService.ListAppConfig(true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var configVariablesDto []dto.AppConfigVariableDto
|
var configVariablesDto []dto.AppConfigVariableDto
|
||||||
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,19 +70,19 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
|||||||
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
|
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
|
||||||
var input dto.AppConfigUpdateDto
|
var input dto.AppConfigUpdateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(input)
|
savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var configVariablesDto []dto.AppConfigVariableDto
|
var configVariablesDto []dto.AppConfigVariableDto
|
||||||
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
|
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,13 +135,13 @@ func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
|
|||||||
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
|
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
|
||||||
file, err := c.FormFile("file")
|
file, err := c.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fileType := utils.GetFileExtension(file.Filename)
|
fileType := utils.GetFileExtension(file.Filename)
|
||||||
if fileType != "ico" {
|
if fileType != "ico" {
|
||||||
utils.CustomControllerError(c, http.StatusBadRequest, "File must be of type .ico")
|
c.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
acc.updateImage(c, "favicon", "ico")
|
acc.updateImage(c, "favicon", "ico")
|
||||||
@@ -164,17 +163,13 @@ func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType
|
|||||||
func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) {
|
func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) {
|
||||||
file, err := c.FormFile("file")
|
file, err := c.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = acc.appConfigService.UpdateImage(file, imageName, oldImageType)
|
err = acc.appConfigService.UpdateImage(file, imageName, oldImageType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrFileTypeNotSupported) {
|
c.Error(err)
|
||||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, jwtAuthMiddleware *middleware.JwtAuthMiddleware) {
|
func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, jwtAuthMiddleware *middleware.JwtAuthMiddleware) {
|
||||||
@@ -31,7 +30,7 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
|||||||
// Fetch audit logs for the user
|
// Fetch audit logs for the user
|
||||||
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, page, pageSize)
|
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +38,7 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
|||||||
var logsDtos []dto.AuditLogDto
|
var logsDtos []dto.AuditLogDto
|
||||||
err = dto.MapStructList(logs, &logsDtos)
|
err = dto.MapStructList(logs, &logsDtos)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
78
backend/internal/controller/custom_claim_controller.go
Normal file
78
backend/internal/controller/custom_claim_controller.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewCustomClaimController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, customClaimService *service.CustomClaimService) {
|
||||||
|
wkc := &CustomClaimController{customClaimService: customClaimService}
|
||||||
|
group.GET("/custom-claims/suggestions", jwtAuthMiddleware.Add(true), wkc.getSuggestionsHandler)
|
||||||
|
group.PUT("/custom-claims/user/:userId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserHandler)
|
||||||
|
group.PUT("/custom-claims/user-group/:userGroupId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserGroupHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomClaimController struct {
|
||||||
|
customClaimService *service.CustomClaimService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
|
||||||
|
claims, err := ccc.customClaimService.GetSuggestions()
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
|
||||||
|
var input []dto.CustomClaimCreateDto
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId := c.Param("userId")
|
||||||
|
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(userId, input)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var customClaimsDto []dto.CustomClaimDto
|
||||||
|
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, customClaimsDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
|
||||||
|
var input []dto.CustomClaimCreateDto
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId := c.Param("userGroupId")
|
||||||
|
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(userId, input)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var customClaimsDto []dto.CustomClaimDto
|
||||||
|
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, customClaimsDto)
|
||||||
|
}
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,7 +16,7 @@ func NewOidcController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
|
|||||||
|
|
||||||
group.POST("/oidc/authorize", jwtAuthMiddleware.Add(false), oc.authorizeHandler)
|
group.POST("/oidc/authorize", jwtAuthMiddleware.Add(false), oc.authorizeHandler)
|
||||||
group.POST("/oidc/authorize/new-client", jwtAuthMiddleware.Add(false), oc.authorizeNewClientHandler)
|
group.POST("/oidc/authorize/new-client", jwtAuthMiddleware.Add(false), oc.authorizeNewClientHandler)
|
||||||
group.POST("/oidc/token", oc.createIDTokenHandler)
|
group.POST("/oidc/token", oc.createTokensHandler)
|
||||||
group.GET("/oidc/userinfo", oc.userInfoHandler)
|
group.GET("/oidc/userinfo", oc.userInfoHandler)
|
||||||
|
|
||||||
group.GET("/oidc/clients", jwtAuthMiddleware.Add(true), oc.listClientsHandler)
|
group.GET("/oidc/clients", jwtAuthMiddleware.Add(true), oc.listClientsHandler)
|
||||||
@@ -42,19 +40,13 @@ type OidcController struct {
|
|||||||
func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
||||||
var input dto.AuthorizeOidcClientRequestDto
|
var input dto.AuthorizeOidcClientRequestDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
|
code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrOidcMissingAuthorization) {
|
c.Error(err)
|
||||||
utils.CustomControllerError(c, http.StatusForbidden, err.Error())
|
|
||||||
} else if errors.Is(err, common.ErrOidcInvalidCallbackURL) {
|
|
||||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,17 +61,13 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
|||||||
func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
|
func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
|
||||||
var input dto.AuthorizeOidcClientRequestDto
|
var input dto.AuthorizeOidcClientRequestDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
code, callbackURL, err := oc.oidcService.AuthorizeNewClient(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
|
code, callbackURL, err := oc.oidcService.AuthorizeNewClient(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrOidcInvalidCallbackURL) {
|
c.Error(err)
|
||||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,11 +79,11 @@ func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, response)
|
c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (oc *OidcController) createIDTokenHandler(c *gin.Context) {
|
func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
||||||
var input dto.OidcIdTokenDto
|
var input dto.OidcIdTokenDto
|
||||||
|
|
||||||
if err := c.ShouldBind(&input); err != nil {
|
if err := c.ShouldBind(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,21 +95,14 @@ func (oc *OidcController) createIDTokenHandler(c *gin.Context) {
|
|||||||
var ok bool
|
var ok bool
|
||||||
clientID, clientSecret, ok = c.Request.BasicAuth()
|
clientID, clientSecret, ok = c.Request.BasicAuth()
|
||||||
if !ok {
|
if !ok {
|
||||||
utils.CustomControllerError(c, http.StatusBadRequest, "Client id and secret not provided")
|
c.Error(&common.ClientIdOrSecretNotProvidedError{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret)
|
idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrOidcGrantTypeNotSupported) ||
|
c.Error(err)
|
||||||
errors.Is(err, common.ErrOidcMissingClientCredentials) ||
|
|
||||||
errors.Is(err, common.ErrOidcClientSecretInvalid) ||
|
|
||||||
errors.Is(err, common.ErrOidcInvalidAuthorizationCode) {
|
|
||||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,14 +113,14 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
|
|||||||
token := strings.Split(c.GetHeader("Authorization"), " ")[1]
|
token := strings.Split(c.GetHeader("Authorization"), " ")[1]
|
||||||
jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token)
|
jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.CustomControllerError(c, http.StatusUnauthorized, common.ErrTokenInvalidOrExpired.Error())
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID := jwtClaims.Subject
|
userID := jwtClaims.Subject
|
||||||
clientId := jwtClaims.Audience[0]
|
clientId := jwtClaims.Audience[0]
|
||||||
claims, err := oc.oidcService.GetUserClaimsForClient(userID, clientId)
|
claims, err := oc.oidcService.GetUserClaimsForClient(userID, clientId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +131,7 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
|
|||||||
clientId := c.Param("id")
|
clientId := c.Param("id")
|
||||||
client, err := oc.oidcService.GetClient(clientId)
|
client, err := oc.oidcService.GetClient(clientId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +152,7 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
||||||
@@ -181,13 +162,13 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
|||||||
|
|
||||||
clients, pagination, err := oc.oidcService.ListClients(searchTerm, page, pageSize)
|
clients, pagination, err := oc.oidcService.ListClients(searchTerm, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var clientsDto []dto.OidcClientDto
|
var clientsDto []dto.OidcClientDto
|
||||||
if err := dto.MapStructList(clients, &clientsDto); err != nil {
|
if err := dto.MapStructList(clients, &clientsDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,19 +181,19 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
|||||||
func (oc *OidcController) createClientHandler(c *gin.Context) {
|
func (oc *OidcController) createClientHandler(c *gin.Context) {
|
||||||
var input dto.OidcClientCreateDto
|
var input dto.OidcClientCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := oc.oidcService.CreateClient(input, c.GetString("userID"))
|
client, err := oc.oidcService.CreateClient(input, c.GetString("userID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var clientDto dto.OidcClientDto
|
var clientDto dto.OidcClientDto
|
||||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +203,7 @@ func (oc *OidcController) createClientHandler(c *gin.Context) {
|
|||||||
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
||||||
err := oc.oidcService.DeleteClient(c.Param("id"))
|
err := oc.oidcService.DeleteClient(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,19 +213,19 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
|||||||
func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
||||||
var input dto.OidcClientCreateDto
|
var input dto.OidcClientCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := oc.oidcService.UpdateClient(c.Param("id"), input)
|
client, err := oc.oidcService.UpdateClient(c.Param("id"), input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var clientDto dto.OidcClientDto
|
var clientDto dto.OidcClientDto
|
||||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +235,7 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
|||||||
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
||||||
secret, err := oc.oidcService.CreateClientSecret(c.Param("id"))
|
secret, err := oc.oidcService.CreateClientSecret(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +245,7 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
|||||||
func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
||||||
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Param("id"))
|
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,17 +256,13 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
|||||||
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
||||||
file, err := c.FormFile("file")
|
file, err := c.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = oc.oidcService.UpdateClientLogo(c.Param("id"), file)
|
err = oc.oidcService.UpdateClientLogo(c.Param("id"), file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrFileTypeNotSupported) {
|
c.Error(err)
|
||||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +272,7 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
|||||||
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
|
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
|
||||||
err := oc.oidcService.DeleteClientLogo(c.Param("id"))
|
err := oc.oidcService.DeleteClientLogo(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,17 +18,22 @@ type TestController struct {
|
|||||||
|
|
||||||
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
||||||
if err := tc.TestService.ResetDatabase(); err != nil {
|
if err := tc.TestService.ResetDatabase(); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tc.TestService.ResetApplicationImages(); err != nil {
|
if err := tc.TestService.ResetApplicationImages(); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tc.TestService.SeedDatabase(); err != nil {
|
if err := tc.TestService.SeedDatabase(); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tc.TestService.ResetAppConfig(); err != nil {
|
||||||
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService) {
|
func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) {
|
||||||
uc := UserController{
|
uc := UserController{
|
||||||
UserService: userService,
|
UserService: userService,
|
||||||
|
AppConfigService: appConfigService,
|
||||||
}
|
}
|
||||||
|
|
||||||
group.GET("/users", jwtAuthMiddleware.Add(true), uc.listUsersHandler)
|
group.GET("/users", jwtAuthMiddleware.Add(true), uc.listUsersHandler)
|
||||||
@@ -34,6 +33,7 @@ func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
|
|||||||
|
|
||||||
type UserController struct {
|
type UserController struct {
|
||||||
UserService *service.UserService
|
UserService *service.UserService
|
||||||
|
AppConfigService *service.AppConfigService
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uc *UserController) listUsersHandler(c *gin.Context) {
|
func (uc *UserController) listUsersHandler(c *gin.Context) {
|
||||||
@@ -43,13 +43,13 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
|
|||||||
|
|
||||||
users, pagination, err := uc.UserService.ListUsers(searchTerm, page, pageSize)
|
users, pagination, err := uc.UserService.ListUsers(searchTerm, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var usersDto []dto.UserDto
|
var usersDto []dto.UserDto
|
||||||
if err := dto.MapStructList(users, &usersDto); err != nil {
|
if err := dto.MapStructList(users, &usersDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,13 +62,13 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
|
|||||||
func (uc *UserController) getUserHandler(c *gin.Context) {
|
func (uc *UserController) getUserHandler(c *gin.Context) {
|
||||||
user, err := uc.UserService.GetUser(c.Param("id"))
|
user, err := uc.UserService.GetUser(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var userDto dto.UserDto
|
var userDto dto.UserDto
|
||||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,13 +78,13 @@ func (uc *UserController) getUserHandler(c *gin.Context) {
|
|||||||
func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
|
func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
|
||||||
user, err := uc.UserService.GetUser(c.GetString("userID"))
|
user, err := uc.UserService.GetUser(c.GetString("userID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var userDto dto.UserDto
|
var userDto dto.UserDto
|
||||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
|
|||||||
|
|
||||||
func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
||||||
if err := uc.UserService.DeleteUser(c.Param("id")); err != nil {
|
if err := uc.UserService.DeleteUser(c.Param("id")); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,23 +103,19 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
|||||||
func (uc *UserController) createUserHandler(c *gin.Context) {
|
func (uc *UserController) createUserHandler(c *gin.Context) {
|
||||||
var input dto.UserCreateDto
|
var input dto.UserCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := uc.UserService.CreateUser(input)
|
user, err := uc.UserService.CreateUser(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrEmailTaken) || errors.Is(err, common.ErrUsernameTaken) {
|
c.Error(err)
|
||||||
utils.CustomControllerError(c, http.StatusConflict, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var userDto dto.UserDto
|
var userDto dto.UserDto
|
||||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,19 +127,23 @@ func (uc *UserController) updateUserHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
|
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
|
||||||
|
if uc.AppConfigService.DbConfig.AllowOwnAccountEdit.Value != "true" {
|
||||||
|
c.Error(&common.AccountEditNotAllowedError{})
|
||||||
|
return
|
||||||
|
}
|
||||||
uc.updateUser(c, true)
|
uc.updateUser(c, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
|
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
|
||||||
var input dto.OneTimeAccessTokenCreateDto
|
var input dto.OneTimeAccessTokenCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
|
token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,32 +153,30 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
|
|||||||
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
||||||
user, token, err := uc.UserService.ExchangeOneTimeAccessToken(c.Param("token"))
|
user, token, err := uc.UserService.ExchangeOneTimeAccessToken(c.Param("token"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrTokenInvalidOrExpired) {
|
c.Error(err)
|
||||||
utils.CustomControllerError(c, http.StatusUnauthorized, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
|
|
||||||
c.JSON(http.StatusOK, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
|
|
||||||
user, token, err := uc.UserService.SetupInitialAdmin()
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, common.ErrSetupAlreadyCompleted) {
|
|
||||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var userDto dto.UserDto
|
var userDto dto.UserDto
|
||||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
|
||||||
|
c.JSON(http.StatusOK, userDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
|
||||||
|
user, token, err := uc.UserService.SetupInitialAdmin()
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var userDto dto.UserDto
|
||||||
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +187,7 @@ func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
|
|||||||
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||||
var input dto.UserCreateDto
|
var input dto.UserCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,17 +200,13 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
|||||||
|
|
||||||
user, err := uc.UserService.UpdateUser(userID, input, updateOwnUser)
|
user, err := uc.UserService.UpdateUser(userID, input, updateOwnUser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrEmailTaken) || errors.Is(err, common.ErrUsernameTaken) {
|
c.Error(err)
|
||||||
utils.CustomControllerError(c, http.StatusConflict, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var userDto dto.UserDto
|
var userDto dto.UserDto
|
||||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
|
||||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewUserGroupController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, userGroupService *service.UserGroupService) {
|
func NewUserGroupController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, userGroupService *service.UserGroupService) {
|
||||||
@@ -37,7 +34,7 @@ func (ugc *UserGroupController) list(c *gin.Context) {
|
|||||||
|
|
||||||
groups, pagination, err := ugc.UserGroupService.List(searchTerm, page, pageSize)
|
groups, pagination, err := ugc.UserGroupService.List(searchTerm, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,12 +42,12 @@ func (ugc *UserGroupController) list(c *gin.Context) {
|
|||||||
for i, group := range groups {
|
for i, group := range groups {
|
||||||
var groupDto dto.UserGroupDtoWithUserCount
|
var groupDto dto.UserGroupDtoWithUserCount
|
||||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(group.ID)
|
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(group.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
groupsDto[i] = groupDto
|
groupsDto[i] = groupDto
|
||||||
@@ -65,13 +62,13 @@ func (ugc *UserGroupController) list(c *gin.Context) {
|
|||||||
func (ugc *UserGroupController) get(c *gin.Context) {
|
func (ugc *UserGroupController) get(c *gin.Context) {
|
||||||
group, err := ugc.UserGroupService.Get(c.Param("id"))
|
group, err := ugc.UserGroupService.Get(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var groupDto dto.UserGroupDtoWithUsers
|
var groupDto dto.UserGroupDtoWithUsers
|
||||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,23 +78,19 @@ func (ugc *UserGroupController) get(c *gin.Context) {
|
|||||||
func (ugc *UserGroupController) create(c *gin.Context) {
|
func (ugc *UserGroupController) create(c *gin.Context) {
|
||||||
var input dto.UserGroupCreateDto
|
var input dto.UserGroupCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
group, err := ugc.UserGroupService.Create(input)
|
group, err := ugc.UserGroupService.Create(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrNameAlreadyInUse) {
|
c.Error(err)
|
||||||
utils.CustomControllerError(c, http.StatusConflict, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var groupDto dto.UserGroupDtoWithUsers
|
var groupDto dto.UserGroupDtoWithUsers
|
||||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,23 +100,19 @@ func (ugc *UserGroupController) create(c *gin.Context) {
|
|||||||
func (ugc *UserGroupController) update(c *gin.Context) {
|
func (ugc *UserGroupController) update(c *gin.Context) {
|
||||||
var input dto.UserGroupCreateDto
|
var input dto.UserGroupCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
group, err := ugc.UserGroupService.Update(c.Param("id"), input)
|
group, err := ugc.UserGroupService.Update(c.Param("id"), input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrNameAlreadyInUse) {
|
c.Error(err)
|
||||||
utils.CustomControllerError(c, http.StatusConflict, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var groupDto dto.UserGroupDtoWithUsers
|
var groupDto dto.UserGroupDtoWithUsers
|
||||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +121,7 @@ func (ugc *UserGroupController) update(c *gin.Context) {
|
|||||||
|
|
||||||
func (ugc *UserGroupController) delete(c *gin.Context) {
|
func (ugc *UserGroupController) delete(c *gin.Context) {
|
||||||
if err := ugc.UserGroupService.Delete(c.Param("id")); err != nil {
|
if err := ugc.UserGroupService.Delete(c.Param("id")); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,19 +131,19 @@ func (ugc *UserGroupController) delete(c *gin.Context) {
|
|||||||
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
|
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
|
||||||
var input dto.UserGroupUpdateUsersDto
|
var input dto.UserGroupUpdateUsersDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input)
|
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var groupDto dto.UserGroupDtoWithUsers
|
var groupDto dto.UserGroupDtoWithUsers
|
||||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"github.com/go-webauthn/webauthn/protocol"
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
|
||||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,7 +36,7 @@ func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
|
|||||||
userID := c.GetString("userID")
|
userID := c.GetString("userID")
|
||||||
options, err := wc.webAuthnService.BeginRegistration(userID)
|
options, err := wc.webAuthnService.BeginRegistration(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,20 +47,20 @@ func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
|
|||||||
func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
|
func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
|
||||||
sessionID, err := c.Cookie("session_id")
|
sessionID, err := c.Cookie("session_id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.CustomControllerError(c, http.StatusBadRequest, "Session ID missing")
|
c.Error(&common.MissingSessionIdError{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID := c.GetString("userID")
|
userID := c.GetString("userID")
|
||||||
credential, err := wc.webAuthnService.VerifyRegistration(sessionID, userID, c.Request)
|
credential, err := wc.webAuthnService.VerifyRegistration(sessionID, userID, c.Request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var credentialDto dto.WebauthnCredentialDto
|
var credentialDto dto.WebauthnCredentialDto
|
||||||
if err := dto.MapStruct(credential, &credentialDto); err != nil {
|
if err := dto.MapStruct(credential, &credentialDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +70,7 @@ func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
|
|||||||
func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
|
func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
|
||||||
options, err := wc.webAuthnService.BeginLogin()
|
options, err := wc.webAuthnService.BeginLogin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,13 +81,13 @@ func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
|
|||||||
func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
|
func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
|
||||||
sessionID, err := c.Cookie("session_id")
|
sessionID, err := c.Cookie("session_id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.CustomControllerError(c, http.StatusBadRequest, "Session ID missing")
|
c.Error(&common.MissingSessionIdError{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
|
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,17 +95,13 @@ func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
|
|||||||
|
|
||||||
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, userID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
|
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, userID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrInvalidCredentials) {
|
c.Error(err)
|
||||||
utils.CustomControllerError(c, http.StatusUnauthorized, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var userDto dto.UserDto
|
var userDto dto.UserDto
|
||||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,13 +113,13 @@ func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) {
|
|||||||
userID := c.GetString("userID")
|
userID := c.GetString("userID")
|
||||||
credentials, err := wc.webAuthnService.ListCredentials(userID)
|
credentials, err := wc.webAuthnService.ListCredentials(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var credentialDtos []dto.WebauthnCredentialDto
|
var credentialDtos []dto.WebauthnCredentialDto
|
||||||
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
|
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +132,7 @@ func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
|
|||||||
|
|
||||||
err := wc.webAuthnService.DeleteCredential(userID, credentialID)
|
err := wc.webAuthnService.DeleteCredential(userID, credentialID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,19 +145,19 @@ func (wc *WebauthnController) updateCredentialHandler(c *gin.Context) {
|
|||||||
|
|
||||||
var input dto.WebauthnCredentialUpdateDto
|
var input dto.WebauthnCredentialUpdateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
credential, err := wc.webAuthnService.UpdateCredential(userID, credentialID, input.Name)
|
credential, err := wc.webAuthnService.UpdateCredential(userID, credentialID, input.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var credentialDto dto.WebauthnCredentialDto
|
var credentialDto dto.WebauthnCredentialDto
|
||||||
if err := dto.MapStruct(credential, &credentialDto); err != nil {
|
if err := dto.MapStruct(credential, &credentialDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,7 +20,7 @@ type WellKnownController struct {
|
|||||||
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
|
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
|
||||||
jwk, err := wkc.jwtService.GetJWK()
|
jwk, err := wkc.jwtService.GetJWK()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +36,7 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
|
|||||||
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
|
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
|
||||||
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
||||||
"scopes_supported": []string{"openid", "profile", "email"},
|
"scopes_supported": []string{"openid", "profile", "email"},
|
||||||
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "preferred_username"},
|
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username"},
|
||||||
"response_types_supported": []string{"code", "id_token"},
|
"response_types_supported": []string{"code", "id_token"},
|
||||||
"subject_types_supported": []string{"public"},
|
"subject_types_supported": []string{"public"},
|
||||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ type AppConfigVariableDto struct {
|
|||||||
type AppConfigUpdateDto struct {
|
type AppConfigUpdateDto struct {
|
||||||
AppName string `json:"appName" binding:"required,min=1,max=30"`
|
AppName string `json:"appName" binding:"required,min=1,max=30"`
|
||||||
SessionDuration string `json:"sessionDuration" binding:"required"`
|
SessionDuration string `json:"sessionDuration" binding:"required"`
|
||||||
|
EmailsVerified string `json:"emailsVerified" binding:"required"`
|
||||||
|
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
|
||||||
EmailEnabled string `json:"emailEnabled" binding:"required"`
|
EmailEnabled string `json:"emailEnabled" binding:"required"`
|
||||||
SmtHost string `json:"smtpHost"`
|
SmtHost string `json:"smtpHost"`
|
||||||
SmtpPort string `json:"smtpPort"`
|
SmtpPort string `json:"smtpPort"`
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ type AuditLogDto struct {
|
|||||||
|
|
||||||
Event model.AuditLogEvent `json:"event"`
|
Event model.AuditLogEvent `json:"event"`
|
||||||
IpAddress string `json:"ipAddress"`
|
IpAddress string `json:"ipAddress"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
City string `json:"city"`
|
||||||
Device string `json:"device"`
|
Device string `json:"device"`
|
||||||
UserID string `json:"userID"`
|
UserID string `json:"userID"`
|
||||||
Data model.AuditLogData `json:"data"`
|
Data model.AuditLogData `json:"data"`
|
||||||
|
|||||||
11
backend/internal/dto/custom_claim_dto.go
Normal file
11
backend/internal/dto/custom_claim_dto.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
type CustomClaimDto struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomClaimCreateDto struct {
|
||||||
|
Key string `json:"key" binding:"required,claimKey"`
|
||||||
|
Value string `json:"value" binding:"required"`
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ package dto
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MapStructList maps a list of source structs to a list of destination structs
|
// MapStructList maps a list of source structs to a list of destination structs
|
||||||
@@ -95,9 +97,20 @@ func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
|
|||||||
if err := mapStructInternal(sourceField, destField); err != nil {
|
if err := mapStructInternal(sourceField, destField); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Type switch for specific type conversions
|
||||||
|
switch sourceField.Interface().(type) {
|
||||||
|
case datatype.DateTime:
|
||||||
|
// Convert datatype.DateTime to time.Time
|
||||||
|
if sourceField.Type() == reflect.TypeOf(datatype.DateTime{}) && destField.Type() == reflect.TypeOf(time.Time{}) {
|
||||||
|
dateValue := sourceField.Interface().(datatype.DateTime)
|
||||||
|
destField.Set(reflect.ValueOf(dateValue.ToTime()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type UserDto struct {
|
|||||||
FirstName string `json:"firstName"`
|
FirstName string `json:"firstName"`
|
||||||
LastName string `json:"lastName"`
|
LastName string `json:"lastName"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
|
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserCreateDto struct {
|
type UserCreateDto struct {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ type UserGroupDtoWithUsers struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
FriendlyName string `json:"friendlyName"`
|
FriendlyName string `json:"friendlyName"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||||
Users []UserDto `json:"users"`
|
Users []UserDto `json:"users"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
@@ -14,6 +15,7 @@ type UserGroupDtoWithUserCount struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
FriendlyName string `json:"friendlyName"`
|
FriendlyName string `json:"friendlyName"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||||
UserCount int64 `json:"userCount"`
|
UserCount int64 `json:"userCount"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,15 @@ var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var validateUserGroupName validator.Func = func(fl validator.FieldLevel) bool {
|
var validateUserGroupName validator.Func = func(fl validator.FieldLevel) bool {
|
||||||
// [a-z0-9_] : The group name can only contain lowercase letters, numbers, and underscores
|
// The string can only contain lowercase letters, numbers, and underscores
|
||||||
regex := "^[a-z0-9_]+$"
|
regex := "^[a-z0-9_]*$"
|
||||||
|
matched, _ := regexp.MatchString(regex, fl.Field().String())
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
|
var validateClaimKey validator.Func = func(fl validator.FieldLevel) bool {
|
||||||
|
// The string can only contain letters and numbers
|
||||||
|
regex := "^[A-Za-z0-9]*$"
|
||||||
matched, _ := regexp.MatchString(regex, fl.Field().String())
|
matched, _ := regexp.MatchString(regex, fl.Field().String())
|
||||||
return matched
|
return matched
|
||||||
}
|
}
|
||||||
@@ -52,4 +59,10 @@ func init() {
|
|||||||
log.Fatalf("Failed to register custom validation: %v", err)
|
log.Fatalf("Failed to register custom validation: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||||
|
if err := v.RegisterValidation("claimKey", validateClaimKey); err != nil {
|
||||||
|
log.Fatalf("Failed to register custom validation: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"github.com/go-co-op/gocron/v2"
|
"github.com/go-co-op/gocron/v2"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
@@ -30,22 +29,22 @@ type Jobs struct {
|
|||||||
|
|
||||||
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
|
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
|
||||||
func (j *Jobs) clearWebauthnSessions() error {
|
func (j *Jobs) clearWebauthnSessions() error {
|
||||||
return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", utils.FormatDateForDb(time.Now())).Error
|
return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", time.Now().Unix()).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
|
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
|
||||||
func (j *Jobs) clearOneTimeAccessTokens() error {
|
func (j *Jobs) clearOneTimeAccessTokens() error {
|
||||||
return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", utils.FormatDateForDb(time.Now())).Error
|
return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", time.Now().Unix()).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
||||||
func (j *Jobs) clearOidcAuthorizationCodes() error {
|
func (j *Jobs) clearOidcAuthorizationCodes() error {
|
||||||
return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", utils.FormatDateForDb(time.Now())).Error
|
return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", time.Now().Unix()).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearAuditLogs deletes audit logs older than 90 days
|
// ClearAuditLogs deletes audit logs older than 90 days
|
||||||
func (j *Jobs) clearAuditLogs() error {
|
func (j *Jobs) clearAuditLogs() error {
|
||||||
return j.db.Delete(&model.AuditLog{}, "created_at < ?", utils.FormatDateForDb(time.Now().AddDate(0, 0, -90))).Error
|
return j.db.Delete(&model.AuditLog{}, "created_at < ?", time.Now().AddDate(0, 0, -90).Unix()).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {
|
func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {
|
||||||
|
|||||||
@@ -1,23 +1,31 @@
|
|||||||
package utils
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gin-gonic/gin/binding"
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
import (
|
type ErrorHandlerMiddleware struct{}
|
||||||
"fmt"
|
|
||||||
)
|
func NewErrorHandlerMiddleware() *ErrorHandlerMiddleware {
|
||||||
|
return &ErrorHandlerMiddleware{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ErrorHandlerMiddleware) Add() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Next()
|
||||||
|
for _, err := range c.Errors {
|
||||||
|
|
||||||
func ControllerError(c *gin.Context, err error) {
|
|
||||||
// Check for record not found errors
|
// Check for record not found errors
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
CustomControllerError(c, http.StatusNotFound, "Record not found")
|
errorResponse(c, http.StatusNotFound, "Record not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,13 +33,35 @@ func ControllerError(c *gin.Context, err error) {
|
|||||||
var validationErrors validator.ValidationErrors
|
var validationErrors validator.ValidationErrors
|
||||||
if errors.As(err, &validationErrors) {
|
if errors.As(err, &validationErrors) {
|
||||||
message := handleValidationError(validationErrors)
|
message := handleValidationError(validationErrors)
|
||||||
CustomControllerError(c, http.StatusBadRequest, message)
|
errorResponse(c, http.StatusBadRequest, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for slice validation errors
|
||||||
|
var sliceValidationErrors binding.SliceValidationError
|
||||||
|
if errors.As(err, &sliceValidationErrors) {
|
||||||
|
if errors.As(sliceValidationErrors[0], &validationErrors) {
|
||||||
|
message := handleValidationError(validationErrors)
|
||||||
|
errorResponse(c, http.StatusBadRequest, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var appErr common.AppError
|
||||||
|
if errors.As(err, &appErr) {
|
||||||
|
errorResponse(c, appErr.HttpStatusCode(), appErr.Error())
|
||||||
return
|
return
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println(err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorResponse(c *gin.Context, statusCode int, message string) {
|
||||||
|
// Capitalize the first letter of the message
|
||||||
|
message = strings.ToUpper(message[:1]) + message[1:]
|
||||||
|
c.JSON(statusCode, gin.H{"error": message})
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleValidationError(validationErrors validator.ValidationErrors) string {
|
func handleValidationError(validationErrors validator.ValidationErrors) string {
|
||||||
@@ -67,9 +97,3 @@ func handleValidationError(validationErrors validator.ValidationErrors) string {
|
|||||||
|
|
||||||
return combinedErrors
|
return combinedErrors
|
||||||
}
|
}
|
||||||
|
|
||||||
func CustomControllerError(c *gin.Context, statusCode int, message string) {
|
|
||||||
// Capitalize the first letter of the message
|
|
||||||
message = strings.ToUpper(message[:1]) + message[1:]
|
|
||||||
c.JSON(statusCode, gin.H{"error": message})
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,8 +17,8 @@ func (m *FileSizeLimitMiddleware) Add(maxSize int64) gin.HandlerFunc {
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
|
||||||
if err := c.Request.ParseMultipartForm(maxSize); err != nil {
|
if err := c.Request.ParseMultipartForm(maxSize); err != nil {
|
||||||
utils.CustomControllerError(c, http.StatusRequestEntityTooLarge, fmt.Sprintf("The file can't be larger than %s bytes", formatFileSize(maxSize)))
|
err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)}
|
||||||
c.Abort()
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,8 +28,7 @@ func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
|
|||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
utils.CustomControllerError(c, http.StatusUnauthorized, "You're not signed in")
|
c.Error(&common.NotSignedInError{})
|
||||||
c.Abort()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,14 +38,14 @@ func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
|
|||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
utils.CustomControllerError(c, http.StatusUnauthorized, "You're not signed in")
|
c.Error(&common.NotSignedInError{})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the user is an admin
|
// Check if the user is an admin
|
||||||
if adminOnly && !claims.IsAdmin {
|
if adminOnly && !claims.IsAdmin {
|
||||||
utils.CustomControllerError(c, http.StatusForbidden, "You don't have permission to access this resource")
|
c.Error(&common.MissingPermissionError{})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -33,8 +31,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
|
|||||||
|
|
||||||
limiter := getLimiter(ip, limit, burst)
|
limiter := getLimiter(ip, limit, burst)
|
||||||
if !limiter.Allow() {
|
if !limiter.Allow() {
|
||||||
utils.CustomControllerError(c, http.StatusTooManyRequests, "Too many requests. Please wait a while before trying again.")
|
c.Error(&common.TooManyRequestsError{})
|
||||||
c.Abort()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,18 @@ type AppConfigVariable struct {
|
|||||||
IsPublic bool
|
IsPublic bool
|
||||||
IsInternal bool
|
IsInternal bool
|
||||||
Value string
|
Value string
|
||||||
|
DefaultValue string
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
AppName AppConfigVariable
|
AppName AppConfigVariable
|
||||||
|
SessionDuration AppConfigVariable
|
||||||
|
EmailsVerified AppConfigVariable
|
||||||
|
AllowOwnAccountEdit AppConfigVariable
|
||||||
|
|
||||||
BackgroundImageType AppConfigVariable
|
BackgroundImageType AppConfigVariable
|
||||||
LogoLightImageType AppConfigVariable
|
LogoLightImageType AppConfigVariable
|
||||||
LogoDarkImageType AppConfigVariable
|
LogoDarkImageType AppConfigVariable
|
||||||
SessionDuration AppConfigVariable
|
|
||||||
|
|
||||||
EmailEnabled AppConfigVariable
|
EmailEnabled AppConfigVariable
|
||||||
SmtpHost AppConfigVariable
|
SmtpHost AppConfigVariable
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ type AuditLog struct {
|
|||||||
|
|
||||||
Event AuditLogEvent
|
Event AuditLogEvent
|
||||||
IpAddress string
|
IpAddress string
|
||||||
|
Country string
|
||||||
|
City string
|
||||||
UserAgent string
|
UserAgent string
|
||||||
UserID string
|
UserID string
|
||||||
Data AuditLogData
|
Data AuditLogData
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package model
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
model "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -9,12 +10,13 @@ import (
|
|||||||
// Base contains common columns for all tables.
|
// Base contains common columns for all tables.
|
||||||
type Base struct {
|
type Base struct {
|
||||||
ID string `gorm:"primaryKey;not null"`
|
ID string `gorm:"primaryKey;not null"`
|
||||||
CreatedAt time.Time
|
CreatedAt model.DateTime
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Base) BeforeCreate(_ *gorm.DB) (err error) {
|
func (b *Base) BeforeCreate(_ *gorm.DB) (err error) {
|
||||||
if b.ID == "" {
|
if b.ID == "" {
|
||||||
b.ID = uuid.New().String()
|
b.ID = uuid.New().String()
|
||||||
}
|
}
|
||||||
|
b.CreatedAt = model.DateTime(time.Now())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
11
backend/internal/model/custom_claim.go
Normal file
11
backend/internal/model/custom_claim.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type CustomClaim struct {
|
||||||
|
Base
|
||||||
|
|
||||||
|
Key string
|
||||||
|
Value string
|
||||||
|
|
||||||
|
UserID *string
|
||||||
|
UserGroupID *string
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"database/sql/driver"
|
"database/sql/driver"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserAuthorizedOidcClient struct {
|
type UserAuthorizedOidcClient struct {
|
||||||
@@ -23,7 +23,7 @@ type OidcAuthorizationCode struct {
|
|||||||
Code string
|
Code string
|
||||||
Scope string
|
Scope string
|
||||||
Nonce string
|
Nonce string
|
||||||
ExpiresAt time.Time
|
ExpiresAt datatype.DateTime
|
||||||
|
|
||||||
UserID string
|
UserID string
|
||||||
User User
|
User User
|
||||||
|
|||||||
47
backend/internal/model/types/date_time.go
Normal file
47
backend/internal/model/types/date_time.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package datatype
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DateTime custom type for time.Time to store date as unix timestamp in the database
|
||||||
|
type DateTime time.Time
|
||||||
|
|
||||||
|
func (date *DateTime) Scan(value interface{}) (err error) {
|
||||||
|
*date = DateTime(value.(time.Time))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (date DateTime) Value() (driver.Value, error) {
|
||||||
|
return time.Time(date).Unix(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (date DateTime) UTC() time.Time {
|
||||||
|
return time.Time(date).UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (date DateTime) ToTime() time.Time {
|
||||||
|
return time.Time(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GormDataType gorm common data type
|
||||||
|
func (date DateTime) GormDataType() string {
|
||||||
|
return "date"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (date DateTime) GobEncode() ([]byte, error) {
|
||||||
|
return time.Time(date).GobEncode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (date *DateTime) GobDecode(b []byte) error {
|
||||||
|
return (*time.Time)(date).GobDecode(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (date DateTime) MarshalJSON() ([]byte, error) {
|
||||||
|
return time.Time(date).MarshalJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (date *DateTime) UnmarshalJSON(b []byte) error {
|
||||||
|
return (*time.Time)(date).UnmarshalJSON(b)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package model
|
|||||||
import (
|
import (
|
||||||
"github.com/go-webauthn/webauthn/protocol"
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
"github.com/go-webauthn/webauthn/webauthn"
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
"time"
|
"github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
@@ -15,6 +15,7 @@ type User struct {
|
|||||||
LastName string
|
LastName string
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
|
|
||||||
|
CustomClaims []CustomClaim
|
||||||
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
||||||
Credentials []WebauthnCredential
|
Credentials []WebauthnCredential
|
||||||
}
|
}
|
||||||
@@ -61,7 +62,7 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
|
|||||||
type OneTimeAccessToken struct {
|
type OneTimeAccessToken struct {
|
||||||
Base
|
Base
|
||||||
Token string
|
Token string
|
||||||
ExpiresAt time.Time
|
ExpiresAt datatype.DateTime
|
||||||
|
|
||||||
UserID string
|
UserID string
|
||||||
User User
|
User User
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ type UserGroup struct {
|
|||||||
FriendlyName string
|
FriendlyName string
|
||||||
Name string `gorm:"unique"`
|
Name string `gorm:"unique"`
|
||||||
Users []User `gorm:"many2many:user_groups_users;"`
|
Users []User `gorm:"many2many:user_groups_users;"`
|
||||||
|
CustomClaims []CustomClaim
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,35 +34,46 @@ var defaultDbConfig = model.AppConfig{
|
|||||||
Key: "appName",
|
Key: "appName",
|
||||||
Type: "string",
|
Type: "string",
|
||||||
IsPublic: true,
|
IsPublic: true,
|
||||||
Value: "Pocket ID",
|
DefaultValue: "Pocket ID",
|
||||||
},
|
},
|
||||||
SessionDuration: model.AppConfigVariable{
|
SessionDuration: model.AppConfigVariable{
|
||||||
Key: "sessionDuration",
|
Key: "sessionDuration",
|
||||||
Type: "number",
|
Type: "number",
|
||||||
Value: "60",
|
DefaultValue: "60",
|
||||||
|
},
|
||||||
|
EmailsVerified: model.AppConfigVariable{
|
||||||
|
Key: "emailsVerified",
|
||||||
|
Type: "bool",
|
||||||
|
DefaultValue: "false",
|
||||||
|
},
|
||||||
|
AllowOwnAccountEdit: model.AppConfigVariable{
|
||||||
|
Key: "allowOwnAccountEdit",
|
||||||
|
Type: "bool",
|
||||||
|
IsPublic: true,
|
||||||
|
DefaultValue: "true",
|
||||||
},
|
},
|
||||||
BackgroundImageType: model.AppConfigVariable{
|
BackgroundImageType: model.AppConfigVariable{
|
||||||
Key: "backgroundImageType",
|
Key: "backgroundImageType",
|
||||||
Type: "string",
|
Type: "string",
|
||||||
IsInternal: true,
|
IsInternal: true,
|
||||||
Value: "jpg",
|
DefaultValue: "jpg",
|
||||||
},
|
},
|
||||||
LogoLightImageType: model.AppConfigVariable{
|
LogoLightImageType: model.AppConfigVariable{
|
||||||
Key: "logoLightImageType",
|
Key: "logoLightImageType",
|
||||||
Type: "string",
|
Type: "string",
|
||||||
IsInternal: true,
|
IsInternal: true,
|
||||||
Value: "svg",
|
DefaultValue: "svg",
|
||||||
},
|
},
|
||||||
LogoDarkImageType: model.AppConfigVariable{
|
LogoDarkImageType: model.AppConfigVariable{
|
||||||
Key: "logoDarkImageType",
|
Key: "logoDarkImageType",
|
||||||
Type: "string",
|
Type: "string",
|
||||||
IsInternal: true,
|
IsInternal: true,
|
||||||
Value: "svg",
|
DefaultValue: "svg",
|
||||||
},
|
},
|
||||||
EmailEnabled: model.AppConfigVariable{
|
EmailEnabled: model.AppConfigVariable{
|
||||||
Key: "emailEnabled",
|
Key: "emailEnabled",
|
||||||
Type: "bool",
|
Type: "bool",
|
||||||
Value: "false",
|
DefaultValue: "false",
|
||||||
},
|
},
|
||||||
SmtpHost: model.AppConfigVariable{
|
SmtpHost: model.AppConfigVariable{
|
||||||
Key: "smtpHost",
|
Key: "smtpHost",
|
||||||
@@ -115,7 +126,7 @@ func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]mode
|
|||||||
|
|
||||||
tx.Commit()
|
tx.Commit()
|
||||||
|
|
||||||
if err := s.loadDbConfigFromDb(); err != nil {
|
if err := s.LoadDbConfigFromDb(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +140,7 @@ func (s *AppConfigService) UpdateImageType(imageName string, fileType string) er
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.loadDbConfigFromDb()
|
return s.LoadDbConfigFromDb()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AppConfigService) ListAppConfig(showAll bool) ([]model.AppConfigVariable, error) {
|
func (s *AppConfigService) ListAppConfig(showAll bool) ([]model.AppConfigVariable, error) {
|
||||||
@@ -146,6 +157,13 @@ func (s *AppConfigService) ListAppConfig(showAll bool) ([]model.AppConfigVariabl
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set the value to the default value if it is empty
|
||||||
|
for i := range configuration {
|
||||||
|
if configuration[i].Value == "" && configuration[i].DefaultValue != "" {
|
||||||
|
configuration[i].Value = configuration[i].DefaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return configuration, nil
|
return configuration, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +171,7 @@ func (s *AppConfigService) UpdateImage(uploadedFile *multipart.FileHeader, image
|
|||||||
fileType := utils.GetFileExtension(uploadedFile.Filename)
|
fileType := utils.GetFileExtension(uploadedFile.Filename)
|
||||||
mimeType := utils.GetImageMimeType(fileType)
|
mimeType := utils.GetImageMimeType(fileType)
|
||||||
if mimeType == "" {
|
if mimeType == "" {
|
||||||
return common.ErrFileTypeNotSupported
|
return &common.FileTypeNotSupportedError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the old image if it has a different file type
|
// Delete the old image if it has a different file type
|
||||||
@@ -201,10 +219,11 @@ func (s *AppConfigService) InitDbConfig() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update existing configuration if it differs from the default
|
// Update existing configuration if it differs from the default
|
||||||
if storedConfigVar.Type != defaultConfigVar.Type || storedConfigVar.IsPublic != defaultConfigVar.IsPublic || storedConfigVar.IsInternal != defaultConfigVar.IsInternal {
|
if storedConfigVar.Type != defaultConfigVar.Type || storedConfigVar.IsPublic != defaultConfigVar.IsPublic || storedConfigVar.IsInternal != defaultConfigVar.IsInternal || storedConfigVar.DefaultValue != defaultConfigVar.DefaultValue {
|
||||||
storedConfigVar.Type = defaultConfigVar.Type
|
storedConfigVar.Type = defaultConfigVar.Type
|
||||||
storedConfigVar.IsPublic = defaultConfigVar.IsPublic
|
storedConfigVar.IsPublic = defaultConfigVar.IsPublic
|
||||||
storedConfigVar.IsInternal = defaultConfigVar.IsInternal
|
storedConfigVar.IsInternal = defaultConfigVar.IsInternal
|
||||||
|
storedConfigVar.DefaultValue = defaultConfigVar.DefaultValue
|
||||||
if err := s.db.Save(&storedConfigVar).Error; err != nil {
|
if err := s.db.Save(&storedConfigVar).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -224,10 +243,11 @@ func (s *AppConfigService) InitDbConfig() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s.loadDbConfigFromDb()
|
return s.LoadDbConfigFromDb()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AppConfigService) loadDbConfigFromDb() error {
|
// LoadDbConfigFromDb loads the configuration values from the database into the DbConfig struct.
|
||||||
|
func (s *AppConfigService) LoadDbConfigFromDb() error {
|
||||||
dbConfigReflectValue := reflect.ValueOf(s.DbConfig).Elem()
|
dbConfigReflectValue := reflect.ValueOf(s.DbConfig).Elem()
|
||||||
|
|
||||||
for i := 0; i < dbConfigReflectValue.NumField(); i++ {
|
for i := 0; i < dbConfigReflectValue.NumField(); i++ {
|
||||||
@@ -238,6 +258,10 @@ func (s *AppConfigService) loadDbConfigFromDb() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if storedConfigVar.Value == "" && storedConfigVar.DefaultValue != "" {
|
||||||
|
storedConfigVar.Value = storedConfigVar.DefaultValue
|
||||||
|
}
|
||||||
|
|
||||||
dbConfigField.Set(reflect.ValueOf(storedConfigVar))
|
dbConfigField.Set(reflect.ValueOf(storedConfigVar))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
userAgentParser "github.com/mileusna/useragent"
|
userAgentParser "github.com/mileusna/useragent"
|
||||||
|
"github.com/oschwald/maxminddb-golang/v2"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"log"
|
"log"
|
||||||
|
"net/netip"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuditLogService struct {
|
type AuditLogService struct {
|
||||||
@@ -21,9 +23,16 @@ func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailSe
|
|||||||
|
|
||||||
// Create creates a new audit log entry in the database
|
// Create creates a new audit log entry in the database
|
||||||
func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog {
|
func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog {
|
||||||
|
country, city, err := s.GetIpLocation(ipAddress)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to get IP location: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
auditLog := model.AuditLog{
|
auditLog := model.AuditLog{
|
||||||
Event: event,
|
Event: event,
|
||||||
IpAddress: ipAddress,
|
IpAddress: ipAddress,
|
||||||
|
Country: country,
|
||||||
|
City: city,
|
||||||
UserAgent: userAgent,
|
UserAgent: userAgent,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Data: data,
|
Data: data,
|
||||||
@@ -61,6 +70,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID
|
|||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
}, NewLoginTemplate, &NewLoginTemplateData{
|
}, NewLoginTemplate, &NewLoginTemplateData{
|
||||||
IPAddress: ipAddress,
|
IPAddress: ipAddress,
|
||||||
|
Country: createdAuditLog.Country,
|
||||||
|
City: createdAuditLog.City,
|
||||||
Device: s.DeviceStringFromUserAgent(userAgent),
|
Device: s.DeviceStringFromUserAgent(userAgent),
|
||||||
DateTime: createdAuditLog.CreatedAt.UTC(),
|
DateTime: createdAuditLog.CreatedAt.UTC(),
|
||||||
})
|
})
|
||||||
@@ -86,3 +97,29 @@ func (s *AuditLogService) DeviceStringFromUserAgent(userAgent string) string {
|
|||||||
ua := userAgentParser.Parse(userAgent)
|
ua := userAgentParser.Parse(userAgent)
|
||||||
return ua.Name + " on " + ua.OS + " " + ua.OSVersion
|
return ua.Name + " on " + ua.OS + " " + ua.OSVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AuditLogService) GetIpLocation(ipAddress string) (country, city string, err error) {
|
||||||
|
db, err := maxminddb.Open("GeoLite2-City.mmdb")
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
addr := netip.MustParseAddr(ipAddress)
|
||||||
|
|
||||||
|
var record struct {
|
||||||
|
City struct {
|
||||||
|
Names map[string]string `maxminddb:"names"`
|
||||||
|
} `maxminddb:"city"`
|
||||||
|
Country struct {
|
||||||
|
Names map[string]string `maxminddb:"names"`
|
||||||
|
} `maxminddb:"country"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Lookup(addr).Decode(&record)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return record.Country.Names["en"], record.City.Names["en"], nil
|
||||||
|
}
|
||||||
|
|||||||
197
backend/internal/service/custom_claim_service.go
Normal file
197
backend/internal/service/custom_claim_service.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reserved claims
|
||||||
|
var reservedClaims = map[string]struct{}{
|
||||||
|
"given_name": {},
|
||||||
|
"family_name": {},
|
||||||
|
"name": {},
|
||||||
|
"email": {},
|
||||||
|
"preferred_username": {},
|
||||||
|
"groups": {},
|
||||||
|
"sub": {},
|
||||||
|
"iss": {},
|
||||||
|
"aud": {},
|
||||||
|
"exp": {},
|
||||||
|
"iat": {},
|
||||||
|
"auth_time": {},
|
||||||
|
"nonce": {},
|
||||||
|
"acr": {},
|
||||||
|
"amr": {},
|
||||||
|
"azp": {},
|
||||||
|
"nbf": {},
|
||||||
|
"jti": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomClaimService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCustomClaimService(db *gorm.DB) *CustomClaimService {
|
||||||
|
return &CustomClaimService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isReservedClaim checks if a claim key is reserved e.g. email, preferred_username
|
||||||
|
func isReservedClaim(key string) bool {
|
||||||
|
_, ok := reservedClaims[key]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// idType is the type of the id used to identify the user or user group
|
||||||
|
type idType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
UserID idType = "user_id"
|
||||||
|
UserGroupID idType = "user_group_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpdateCustomClaimsForUser updates the custom claims for a user
|
||||||
|
func (s *CustomClaimService) UpdateCustomClaimsForUser(userID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
||||||
|
return s.updateCustomClaims(UserID, userID, claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCustomClaimsForUserGroup updates the custom claims for a user group
|
||||||
|
func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(userGroupID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
||||||
|
return s.updateCustomClaims(UserGroupID, userGroupID, claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateCustomClaims updates the custom claims for a user or user group
|
||||||
|
func (s *CustomClaimService) updateCustomClaims(idType idType, value string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
||||||
|
// Check for duplicate keys in the claims slice
|
||||||
|
seenKeys := make(map[string]bool)
|
||||||
|
for _, claim := range claims {
|
||||||
|
if seenKeys[claim.Key] {
|
||||||
|
return nil, &common.DuplicateClaimError{Key: claim.Key}
|
||||||
|
}
|
||||||
|
seenKeys[claim.Key] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingClaims []model.CustomClaim
|
||||||
|
err := s.db.Where(string(idType), value).Find(&existingClaims).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete claims that are not in the new list
|
||||||
|
for _, existingClaim := range existingClaims {
|
||||||
|
found := false
|
||||||
|
for _, claim := range claims {
|
||||||
|
if claim.Key == existingClaim.Key {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
err = s.db.Delete(&existingClaim).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add or update claims
|
||||||
|
for _, claim := range claims {
|
||||||
|
if isReservedClaim(claim.Key) {
|
||||||
|
return nil, &common.ReservedClaimError{Key: claim.Key}
|
||||||
|
}
|
||||||
|
customClaim := model.CustomClaim{
|
||||||
|
Key: claim.Key,
|
||||||
|
Value: claim.Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
if idType == UserID {
|
||||||
|
customClaim.UserID = &value
|
||||||
|
} else if idType == UserGroupID {
|
||||||
|
customClaim.UserGroupID = &value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the claim if it already exists or create a new one
|
||||||
|
err = s.db.Where(string(idType)+" = ? AND key = ?", value, claim.Key).Assign(&customClaim).FirstOrCreate(&model.CustomClaim{}).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the updated claims
|
||||||
|
var updatedClaims []model.CustomClaim
|
||||||
|
err = s.db.Where(string(idType)+" = ?", value).Find(&updatedClaims).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedClaims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomClaimService) GetCustomClaimsForUser(userID string) ([]model.CustomClaim, error) {
|
||||||
|
var customClaims []model.CustomClaim
|
||||||
|
err := s.db.Where("user_id = ?", userID).Find(&customClaims).Error
|
||||||
|
return customClaims, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomClaimService) GetCustomClaimsForUserGroup(userGroupID string) ([]model.CustomClaim, error) {
|
||||||
|
var customClaims []model.CustomClaim
|
||||||
|
err := s.db.Where("user_group_id = ?", userGroupID).Find(&customClaims).Error
|
||||||
|
return customClaims, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCustomClaimsForUserWithUserGroups returns the custom claims of a user and all user groups the user is a member of,
|
||||||
|
// prioritizing the user's claims over user group claims with the same key.
|
||||||
|
func (s *CustomClaimService) GetCustomClaimsForUserWithUserGroups(userID string) ([]model.CustomClaim, error) {
|
||||||
|
// Get the custom claims of the user
|
||||||
|
customClaims, err := s.GetCustomClaimsForUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store user's claims in a map to prioritize and prevent duplicates
|
||||||
|
claimsMap := make(map[string]model.CustomClaim)
|
||||||
|
for _, claim := range customClaims {
|
||||||
|
claimsMap[claim.Key] = claim
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all user groups of the user
|
||||||
|
var userGroupsOfUser []model.UserGroup
|
||||||
|
err = s.db.Preload("CustomClaims").
|
||||||
|
Joins("JOIN user_groups_users ON user_groups_users.user_group_id = user_groups.id").
|
||||||
|
Where("user_groups_users.user_id = ?", userID).
|
||||||
|
Find(&userGroupsOfUser).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add only non-duplicate custom claims from user groups
|
||||||
|
for _, userGroup := range userGroupsOfUser {
|
||||||
|
for _, groupClaim := range userGroup.CustomClaims {
|
||||||
|
// Only add claim if it does not exist in the user's claims
|
||||||
|
if _, exists := claimsMap[groupClaim.Key]; !exists {
|
||||||
|
claimsMap[groupClaim.Key] = groupClaim
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the claimsMap back to a slice
|
||||||
|
finalClaims := make([]model.CustomClaim, 0, len(claimsMap))
|
||||||
|
for _, claim := range claimsMap {
|
||||||
|
finalClaims = append(finalClaims, claim)
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalClaims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSuggestions returns a list of custom claim keys that have been used before
|
||||||
|
func (s *CustomClaimService) GetSuggestions() ([]string, error) {
|
||||||
|
var customClaimsKeys []string
|
||||||
|
|
||||||
|
err := s.db.Model(&model.CustomClaim{}).
|
||||||
|
Group("key").
|
||||||
|
Order("COUNT(*) DESC").
|
||||||
|
Pluck("key", &customClaimsKeys).Error
|
||||||
|
|
||||||
|
return customClaimsKeys, err
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ var NewLoginTemplate = email.Template[NewLoginTemplateData]{
|
|||||||
|
|
||||||
type NewLoginTemplateData struct {
|
type NewLoginTemplateData struct {
|
||||||
IPAddress string
|
IPAddress string
|
||||||
|
Country string
|
||||||
|
City string
|
||||||
Device string
|
Device string
|
||||||
DateTime time.Time
|
DateTime time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
@@ -51,6 +52,7 @@ type AccessTokenJWTClaims struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type JWK struct {
|
type JWK struct {
|
||||||
|
Kid string `json:"kid"`
|
||||||
Kty string `json:"kty"`
|
Kty string `json:"kty"`
|
||||||
Use string `json:"use"`
|
Use string `json:"use"`
|
||||||
Alg string `json:"alg"`
|
Alg string `json:"alg"`
|
||||||
@@ -98,7 +100,15 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
|
|||||||
},
|
},
|
||||||
IsAdmin: user.IsAdmin,
|
IsAdmin: user.IsAdmin,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kid, err := s.generateKeyID(s.publicKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("failed to generate key ID: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
||||||
|
token.Header["kid"] = kid
|
||||||
|
|
||||||
return token.SignedString(s.privateKey)
|
return token.SignedString(s.privateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,9 +147,17 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID
|
|||||||
claims["nonce"] = nonce
|
claims["nonce"] = nonce
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kid, err := s.generateKeyID(s.publicKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("failed to generate key ID: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||||
|
token.Header["kid"] = kid
|
||||||
|
|
||||||
return token.SignedString(s.privateKey)
|
return token.SignedString(s.privateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
|
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
|
||||||
claim := jwt.RegisteredClaims{
|
claim := jwt.RegisteredClaims{
|
||||||
Subject: user.ID,
|
Subject: user.ID,
|
||||||
@@ -148,7 +166,15 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
|
|||||||
Audience: jwt.ClaimStrings{clientID},
|
Audience: jwt.ClaimStrings{clientID},
|
||||||
Issuer: common.EnvConfig.AppURL,
|
Issuer: common.EnvConfig.AppURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kid, err := s.generateKeyID(s.publicKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("failed to generate key ID: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
||||||
|
token.Header["kid"] = kid
|
||||||
|
|
||||||
return token.SignedString(s.privateKey)
|
return token.SignedString(s.privateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +200,13 @@ func (s *JwtService) GetJWK() (JWK, error) {
|
|||||||
return JWK{}, errors.New("public key is not initialized")
|
return JWK{}, errors.New("public key is not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kid, err := s.generateKeyID(s.publicKey)
|
||||||
|
if err != nil {
|
||||||
|
return JWK{}, err
|
||||||
|
}
|
||||||
|
|
||||||
jwk := JWK{
|
jwk := JWK{
|
||||||
|
Kid: kid,
|
||||||
Kty: "RSA",
|
Kty: "RSA",
|
||||||
Use: "sig",
|
Use: "sig",
|
||||||
Alg: "RS256",
|
Alg: "RS256",
|
||||||
@@ -185,6 +217,25 @@ func (s *JwtService) GetJWK() (JWK, error) {
|
|||||||
return jwk, nil
|
return jwk, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GenerateKeyID generates a Key ID for the public key using the first 8 bytes of the SHA-256 hash of the public key.
|
||||||
|
func (s *JwtService) generateKeyID(publicKey *rsa.PublicKey) (string, error) {
|
||||||
|
pubASN1, err := x509.MarshalPKIXPublicKey(publicKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("failed to marshal public key: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute SHA-256 hash of the public key
|
||||||
|
hash := sha256.New()
|
||||||
|
hash.Write(pubASN1)
|
||||||
|
hashed := hash.Sum(nil)
|
||||||
|
|
||||||
|
// Truncate the hash to the first 8 bytes for a shorter Key ID
|
||||||
|
shortHash := hashed[:8]
|
||||||
|
|
||||||
|
// Return Base64 encoded truncated hash as Key ID
|
||||||
|
return base64.RawURLEncoding.EncodeToString(shortHash), nil
|
||||||
|
}
|
||||||
|
|
||||||
// generateKeys generates a new RSA key pair and saves them to the specified paths.
|
// generateKeys generates a new RSA key pair and saves them to the specified paths.
|
||||||
func (s *JwtService) generateKeys() error {
|
func (s *JwtService) generateKeys() error {
|
||||||
if err := os.MkdirAll(filepath.Dir(privateKeyPath), 0700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(privateKeyPath), 0700); err != nil {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||||
|
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -21,14 +22,16 @@ type OidcService struct {
|
|||||||
jwtService *JwtService
|
jwtService *JwtService
|
||||||
appConfigService *AppConfigService
|
appConfigService *AppConfigService
|
||||||
auditLogService *AuditLogService
|
auditLogService *AuditLogService
|
||||||
|
customClaimService *CustomClaimService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppConfigService, auditLogService *AuditLogService) *OidcService {
|
func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppConfigService, auditLogService *AuditLogService, customClaimService *CustomClaimService) *OidcService {
|
||||||
return &OidcService{
|
return &OidcService{
|
||||||
db: db,
|
db: db,
|
||||||
jwtService: jwtService,
|
jwtService: jwtService,
|
||||||
appConfigService: appConfigService,
|
appConfigService: appConfigService,
|
||||||
auditLogService: auditLogService,
|
auditLogService: auditLogService,
|
||||||
|
customClaimService: customClaimService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +40,7 @@ func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID,
|
|||||||
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
|
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
|
||||||
|
|
||||||
if userAuthorizedOIDCClient.Scope != input.Scope {
|
if userAuthorizedOIDCClient.Scope != input.Scope {
|
||||||
return "", "", common.ErrOidcMissingAuthorization
|
return "", "", &common.OidcMissingAuthorizationError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
callbackURL, err := getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
|
callbackURL, err := getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
|
||||||
@@ -92,11 +95,11 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
|
|||||||
|
|
||||||
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) {
|
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) {
|
||||||
if grantType != "authorization_code" {
|
if grantType != "authorization_code" {
|
||||||
return "", "", common.ErrOidcGrantTypeNotSupported
|
return "", "", &common.OidcGrantTypeNotSupportedError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
if clientID == "" || clientSecret == "" {
|
if clientID == "" || clientSecret == "" {
|
||||||
return "", "", common.ErrOidcMissingClientCredentials
|
return "", "", &common.OidcMissingClientCredentialsError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
var client model.OidcClient
|
var client model.OidcClient
|
||||||
@@ -106,17 +109,17 @@ func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret strin
|
|||||||
|
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
|
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", common.ErrOidcClientSecretInvalid
|
return "", "", &common.OidcClientSecretInvalidError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
var authorizationCodeMetaData model.OidcAuthorizationCode
|
var authorizationCodeMetaData model.OidcAuthorizationCode
|
||||||
err = s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error
|
err = s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", common.ErrOidcInvalidAuthorizationCode
|
return "", "", &common.OidcInvalidAuthorizationCodeError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.Before(time.Now()) {
|
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
|
||||||
return "", "", common.ErrOidcInvalidAuthorizationCode
|
return "", "", &common.OidcInvalidAuthorizationCodeError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
userClaims, err := s.GetUserClaimsForClient(authorizationCodeMetaData.UserID, clientID)
|
userClaims, err := s.GetUserClaimsForClient(authorizationCodeMetaData.UserID, clientID)
|
||||||
@@ -248,7 +251,7 @@ func (s *OidcService) GetClientLogo(clientID string) (string, string, error) {
|
|||||||
func (s *OidcService) UpdateClientLogo(clientID string, file *multipart.FileHeader) error {
|
func (s *OidcService) UpdateClientLogo(clientID string, file *multipart.FileHeader) error {
|
||||||
fileType := utils.GetFileExtension(file.Filename)
|
fileType := utils.GetFileExtension(file.Filename)
|
||||||
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
|
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
|
||||||
return common.ErrFileTypeNotSupported
|
return &common.FileTypeNotSupportedError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, clientID, fileType)
|
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, clientID, fileType)
|
||||||
@@ -314,6 +317,7 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
|
|||||||
|
|
||||||
if strings.Contains(scope, "email") {
|
if strings.Contains(scope, "email") {
|
||||||
claims["email"] = user.Email
|
claims["email"] = user.Email
|
||||||
|
claims["email_verified"] = s.appConfigService.DbConfig.EmailsVerified.Value == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(scope, "groups") {
|
if strings.Contains(scope, "groups") {
|
||||||
@@ -332,9 +336,20 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
|
|||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(scope, "profile") {
|
if strings.Contains(scope, "profile") {
|
||||||
|
// Add profile claims
|
||||||
for k, v := range profileClaims {
|
for k, v := range profileClaims {
|
||||||
claims[k] = v
|
claims[k] = v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add custom claims
|
||||||
|
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, customClaim := range customClaims {
|
||||||
|
claims[customClaim.Key] = customClaim.Value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if strings.Contains(scope, "email") {
|
if strings.Contains(scope, "email") {
|
||||||
claims["email"] = user.Email
|
claims["email"] = user.Email
|
||||||
@@ -350,7 +365,7 @@ func (s *OidcService) createAuthorizationCode(clientID string, userID string, sc
|
|||||||
}
|
}
|
||||||
|
|
||||||
oidcAuthorizationCode := model.OidcAuthorizationCode{
|
oidcAuthorizationCode := model.OidcAuthorizationCode{
|
||||||
ExpiresAt: time.Now().Add(15 * time.Minute),
|
ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)),
|
||||||
Code: randomString,
|
Code: randomString,
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
@@ -373,5 +388,5 @@ func getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackU
|
|||||||
return inputCallbackURL, nil
|
return inputCallbackURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", common.ErrOidcInvalidCallbackURL
|
return "", &common.OidcInvalidCallbackURLError{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
@@ -111,7 +112,7 @@ func (s *TestService) SeedDatabase() error {
|
|||||||
Code: "auth-code",
|
Code: "auth-code",
|
||||||
Scope: "openid profile",
|
Scope: "openid profile",
|
||||||
Nonce: "nonce",
|
Nonce: "nonce",
|
||||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||||
UserID: users[0].ID,
|
UserID: users[0].ID,
|
||||||
ClientID: oidcClients[0].ID,
|
ClientID: oidcClients[0].ID,
|
||||||
}
|
}
|
||||||
@@ -121,7 +122,7 @@ func (s *TestService) SeedDatabase() error {
|
|||||||
|
|
||||||
accessToken := model.OneTimeAccessToken{
|
accessToken := model.OneTimeAccessToken{
|
||||||
Token: "one-time-token",
|
Token: "one-time-token",
|
||||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||||
UserID: users[0].ID,
|
UserID: users[0].ID,
|
||||||
}
|
}
|
||||||
if err := tx.Create(&accessToken).Error; err != nil {
|
if err := tx.Create(&accessToken).Error; err != nil {
|
||||||
@@ -137,8 +138,8 @@ func (s *TestService) SeedDatabase() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
publicKey1, err := getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==")
|
publicKey1, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==")
|
||||||
publicKey2, err := getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESq/wR8QbBu3dKnpaw/v0mDxFFDwnJ/L5XHSg2tAmq5x1BpSMmIr3+DxCbybVvGRmWGh8kKhy7SMnK91M6rFHTA==")
|
publicKey2, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESq/wR8QbBu3dKnpaw/v0mDxFFDwnJ/L5XHSg2tAmq5x1BpSMmIr3+DxCbybVvGRmWGh8kKhy7SMnK91M6rFHTA==")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -186,17 +187,16 @@ func (s *TestService) ResetDatabase() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete all rows from all tables
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
if err := tx.Exec("DELETE FROM " + table).Error; err != nil {
|
if err := tx.Exec("DELETE FROM " + table).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = s.appConfigService.InitDbConfig()
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,8 +214,23 @@ func (s *TestService) ResetApplicationImages() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *TestService) ResetAppConfig() error {
|
||||||
|
// Reseed the config variables
|
||||||
|
if err := s.appConfigService.InitDbConfig(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset all app config variables to their default values
|
||||||
|
if err := s.db.Session(&gorm.Session{AllowGlobalUpdate: true}).Model(&model.AppConfigVariable{}).Update("value", "").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload the app config from the database after resetting the values
|
||||||
|
return s.appConfigService.LoadDbConfigFromDb()
|
||||||
|
}
|
||||||
|
|
||||||
// getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key
|
// getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key
|
||||||
func getCborPublicKey(base64PublicKey string) ([]byte, error) {
|
func (s *TestService) getCborPublicKey(base64PublicKey string) ([]byte, error) {
|
||||||
decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey)
|
decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to decode base64 key: %w", err)
|
return nil, fmt.Errorf("failed to decode base64 key: %w", err)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func NewUserGroupService(db *gorm.DB) *UserGroupService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserGroupService) List(name string, page int, pageSize int) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
|
func (s *UserGroupService) List(name string, page int, pageSize int) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
|
||||||
query := s.db.Model(&model.UserGroup{})
|
query := s.db.Preload("CustomClaims").Model(&model.UserGroup{})
|
||||||
|
|
||||||
if name != "" {
|
if name != "" {
|
||||||
query = query.Where("name LIKE ?", "%"+name+"%")
|
query = query.Where("name LIKE ?", "%"+name+"%")
|
||||||
@@ -29,7 +29,7 @@ func (s *UserGroupService) List(name string, page int, pageSize int) (groups []m
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserGroupService) Get(id string) (group model.UserGroup, err error) {
|
func (s *UserGroupService) Get(id string) (group model.UserGroup, err error) {
|
||||||
err = s.db.Where("id = ?", id).Preload("Users").First(&group).Error
|
err = s.db.Where("id = ?", id).Preload("CustomClaims").Preload("Users").First(&group).Error
|
||||||
return group, err
|
return group, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ func (s *UserGroupService) Create(input dto.UserGroupCreateDto) (group model.Use
|
|||||||
|
|
||||||
if err := s.db.Preload("Users").Create(&group).Error; err != nil {
|
if err := s.db.Preload("Users").Create(&group).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||||
return model.UserGroup{}, common.ErrNameAlreadyInUse
|
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
|
||||||
}
|
}
|
||||||
return model.UserGroup{}, err
|
return model.UserGroup{}, err
|
||||||
}
|
}
|
||||||
@@ -68,7 +68,7 @@ func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto) (grou
|
|||||||
|
|
||||||
if err := s.db.Preload("Users").Save(&group).Error; err != nil {
|
if err := s.db.Preload("Users").Save(&group).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||||
return model.UserGroup{}, common.ErrNameAlreadyInUse
|
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
|
||||||
}
|
}
|
||||||
return model.UserGroup{}, err
|
return model.UserGroup{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"time"
|
"time"
|
||||||
@@ -34,7 +35,7 @@ func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]mo
|
|||||||
|
|
||||||
func (s *UserService) GetUser(userID string) (model.User, error) {
|
func (s *UserService) GetUser(userID string) (model.User, error) {
|
||||||
var user model.User
|
var user model.User
|
||||||
err := s.db.Where("id = ?", userID).First(&user).Error
|
err := s.db.Preload("CustomClaims").Where("id = ?", userID).First(&user).Error
|
||||||
return user, err
|
return user, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +96,7 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
|
|||||||
|
|
||||||
oneTimeAccessToken := model.OneTimeAccessToken{
|
oneTimeAccessToken := model.OneTimeAccessToken{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ExpiresAt: expiresAt,
|
ExpiresAt: datatype.DateTime(expiresAt),
|
||||||
Token: randomString,
|
Token: randomString,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,9 +109,9 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
|
|||||||
|
|
||||||
func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, string, error) {
|
func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, string, error) {
|
||||||
var oneTimeAccessToken model.OneTimeAccessToken
|
var oneTimeAccessToken model.OneTimeAccessToken
|
||||||
if err := s.db.Where("token = ? AND expires_at > ?", token, utils.FormatDateForDb(time.Now())).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
|
if err := s.db.Where("token = ? AND expires_at > ?", token, time.Now().Unix()).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return model.User{}, "", common.ErrTokenInvalidOrExpired
|
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||||
}
|
}
|
||||||
return model.User{}, "", err
|
return model.User{}, "", err
|
||||||
}
|
}
|
||||||
@@ -132,7 +133,7 @@ func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
|
|||||||
return model.User{}, "", err
|
return model.User{}, "", err
|
||||||
}
|
}
|
||||||
if userCount > 1 {
|
if userCount > 1 {
|
||||||
return model.User{}, "", common.ErrSetupAlreadyCompleted
|
return model.User{}, "", &common.SetupAlreadyCompletedError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
user := model.User{
|
user := model.User{
|
||||||
@@ -148,7 +149,7 @@ func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(user.Credentials) > 0 {
|
if len(user.Credentials) > 0 {
|
||||||
return model.User{}, "", common.ErrSetupAlreadyCompleted
|
return model.User{}, "", &common.SetupAlreadyCompletedError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := s.jwtService.GenerateAccessToken(user)
|
token, err := s.jwtService.GenerateAccessToken(user)
|
||||||
@@ -162,11 +163,11 @@ func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
|
|||||||
func (s *UserService) checkDuplicatedFields(user model.User) error {
|
func (s *UserService) checkDuplicatedFields(user model.User) error {
|
||||||
var existingUser model.User
|
var existingUser model.User
|
||||||
if s.db.Where("id != ? AND email = ?", user.ID, user.Email).First(&existingUser).Error == nil {
|
if s.db.Where("id != ? AND email = ?", user.ID, user.Email).First(&existingUser).Error == nil {
|
||||||
return common.ErrEmailTaken
|
return &common.AlreadyInUseError{Property: "email"}
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.db.Where("id != ? AND username = ?", user.ID, user.Username).First(&existingUser).Error == nil {
|
if s.db.Where("id != ? AND username = ?", user.ID, user.Username).First(&existingUser).Error == nil {
|
||||||
return common.ErrUsernameTaken
|
return &common.AlreadyInUseError{Property: "username"}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
func FormatDateForDb(time time.Time) string {
|
|
||||||
const layout = "2006-01-02 15:04:05.000-07:00"
|
|
||||||
return time.Format(layout)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE audit_logs DROP COLUMN country;
|
||||||
|
ALTER TABLE audit_logs DROP COLUMN city;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE audit_logs ADD COLUMN country TEXT;
|
||||||
|
ALTER TABLE audit_logs ADD COLUMN city TEXT;
|
||||||
28
backend/migrations/20241023072742_unix-timestamps.down.sql
Normal file
28
backend/migrations/20241023072742_unix-timestamps.down.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- Convert the Unix timestamps back to DATETIME format
|
||||||
|
|
||||||
|
UPDATE user_groups
|
||||||
|
SET created_at = datetime(created_at, 'unixepoch');
|
||||||
|
|
||||||
|
UPDATE users
|
||||||
|
SET created_at = datetime(created_at, 'unixepoch');
|
||||||
|
|
||||||
|
UPDATE audit_logs
|
||||||
|
SET created_at = datetime(created_at, 'unixepoch');
|
||||||
|
|
||||||
|
UPDATE oidc_authorization_codes
|
||||||
|
SET created_at = datetime(created_at, 'unixepoch'),
|
||||||
|
expires_at = datetime(expires_at, 'unixepoch');
|
||||||
|
|
||||||
|
UPDATE oidc_clients
|
||||||
|
SET created_at = datetime(created_at, 'unixepoch');
|
||||||
|
|
||||||
|
UPDATE one_time_access_tokens
|
||||||
|
SET created_at = datetime(created_at, 'unixepoch'),
|
||||||
|
expires_at = datetime(expires_at, 'unixepoch');
|
||||||
|
|
||||||
|
UPDATE webauthn_credentials
|
||||||
|
SET created_at = datetime(created_at, 'unixepoch');
|
||||||
|
|
||||||
|
UPDATE webauthn_sessions
|
||||||
|
SET created_at = datetime(created_at, 'unixepoch'),
|
||||||
|
expires_at = datetime(expires_at, 'unixepoch');
|
||||||
27
backend/migrations/20241023072742_unix-timestamps.up.sql
Normal file
27
backend/migrations/20241023072742_unix-timestamps.up.sql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
-- Convert the DATETIME fields to Unix timestamps (in seconds)
|
||||||
|
UPDATE user_groups
|
||||||
|
SET created_at = strftime('%s', created_at);
|
||||||
|
|
||||||
|
UPDATE users
|
||||||
|
SET created_at = strftime('%s', created_at);
|
||||||
|
|
||||||
|
UPDATE audit_logs
|
||||||
|
SET created_at = strftime('%s', created_at);
|
||||||
|
|
||||||
|
UPDATE oidc_authorization_codes
|
||||||
|
SET created_at = strftime('%s', created_at),
|
||||||
|
expires_at = strftime('%s', expires_at);
|
||||||
|
|
||||||
|
UPDATE oidc_clients
|
||||||
|
SET created_at = strftime('%s', created_at);
|
||||||
|
|
||||||
|
UPDATE one_time_access_tokens
|
||||||
|
SET created_at = strftime('%s', created_at),
|
||||||
|
expires_at = strftime('%s', expires_at);
|
||||||
|
|
||||||
|
UPDATE webauthn_credentials
|
||||||
|
SET created_at = strftime('%s', created_at);
|
||||||
|
|
||||||
|
UPDATE webauthn_sessions
|
||||||
|
SET created_at = strftime('%s', created_at),
|
||||||
|
expires_at = strftime('%s', expires_at);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE app_config_variables DROP COLUMN default_value;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE app_config_variables ADD COLUMN default_value TEXT;
|
||||||
1
backend/migrations/20241028064959_custom_claims.down.sql
Normal file
1
backend/migrations/20241028064959_custom_claims.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE custom_claims;
|
||||||
15
backend/migrations/20241028064959_custom_claims.up.sql
Normal file
15
backend/migrations/20241028064959_custom_claims.up.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE custom_claims
|
||||||
|
(
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
created_at DATETIME,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
|
||||||
|
user_id TEXT,
|
||||||
|
user_group_id TEXT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
CONSTRAINT custom_claims_unique UNIQUE (key, user_id, user_group_id),
|
||||||
|
CHECK (user_id IS NOT NULL OR user_group_id IS NOT NULL)
|
||||||
|
);
|
||||||
BIN
docs/imgs/jelly_fin_img.png
Normal file
BIN
docs/imgs/jelly_fin_img.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
BIN
docs/imgs/jelly_fin_img2.png
Normal file
BIN
docs/imgs/jelly_fin_img2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
BIN
docs/imgs/jelly_fin_img3.png
Normal file
BIN
docs/imgs/jelly_fin_img3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
55
docs/jellyfin.md
Normal file
55
docs/jellyfin.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Jellyfin SSO Integration Guide
|
||||||
|
|
||||||
|
> Due to the current limitations of the Jellyfin SSO plugin, this integration will only work in a browser. When tested, the Jellyfin app did not work and displayed an error, even when custom menu buttons were created.
|
||||||
|
|
||||||
|
> To view the original references and a full list of capabilities, please visit the [Jellyfin SSO OpenID Section](https://github.com/9p4/jellyfin-plugin-sso?tab=readme-ov-file#openid).
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- [Jellyfin Server](https://jellyfin.org/downloads/server)
|
||||||
|
- [Jellyfin SSO Plugin](https://github.com/9p4/jellyfin-plugin-sso)
|
||||||
|
- HTTPS connection to your Jellyfin server
|
||||||
|
|
||||||
|
### OIDC - Pocket ID Setup
|
||||||
|
To start, we need to create a new SSO resource in our Jellyfin application.
|
||||||
|
|
||||||
|
> Replace the `JELLYFINDOMAIN` and `PROVIDER` elements in the URL.
|
||||||
|
|
||||||
|
1. Log into the admin panel, and go to OIDC Clients -> Add OIDC Client.
|
||||||
|
2. **Name**: Jellyfin (or any name you prefer)
|
||||||
|
3. **Callback URL**: `https://JELLYFINDOMAIN.com/sso/OID/redirect/PROVIDER`
|
||||||
|
4. For this example, we’ll be using the provider named "test_resource."
|
||||||
|
5. Click **Save**. Keep the page open, as we will need the OID client ID and OID secret.
|
||||||
|
|
||||||
|
### OIDC Client - Jellyfin SSO Resource
|
||||||
|
|
||||||
|
1. Visit the plugin page (<i>Administration Dashboard -> My Plugins -> SSO-Auth</i>).
|
||||||
|
2. Enter the <i>OID Provider Name (we used "test_resource" as our name in the callback URL), Open ID, OID Secret, and mark it as enabled.</i>
|
||||||
|
3. The following steps are optional based on your needs. In this guide, we’ll be managing only regular users, not admins.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> To manage user access through groups, follow steps **4, 5, and 6**. Otherwise, leave it blank and skip to step 7.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
4. Under <i>Roles</i>, type the name of the group you want to use. **Note:** This must be the group name, not the label. Double-check in Pocket ID, as an incorrect name will lock users out.
|
||||||
|
5. Skip every field until you reach the **Role Claim** field, and type `groups`.
|
||||||
|
> This step is crucial if you want to manage users through groups.
|
||||||
|
6. Repeat the above step under **Request Additional Scopes**. This will pull the group scope during the sign-in process; otherwise, the previous steps won’t work.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
7. Skip the remaining fields until you reach **Scheme Override**. Enter `https` here. If omitted, it will attempt to use HTTP first, which will break as WebAuthn requires an HTTPS connection.
|
||||||
|
8. Click **Save** and restart Jellyfin.
|
||||||
|
|
||||||
|
### Optional Step - Custom Home Button
|
||||||
|
Follow the [guide to create a login button on the login page](https://github.com/9p4/jellyfin-plugin-sso?tab=readme-ov-file#creating-a-login-button-on-the-main-page) to add a custom button on your sign-in page. This step is optional, as you could also provide the sign-in URL via a bookmark or other means.
|
||||||
|
|
||||||
|
### Signing into Your Jellyfin Instance
|
||||||
|
Done! You have successfully set up SSO for your Jellyfin instance using Pocket ID.
|
||||||
|
|
||||||
|
> **Note:** Sometimes there may be a brief delay when using the custom menu option. This is related to the Jellyfin plugin and not Pocket ID.
|
||||||
|
|
||||||
|
If your users already have accounts, as long as their Pocket ID username matches their Jellyfin ID, they will be logged in automatically. Otherwise, a new user will be created with access to all of your folders. Of course, you can modify this in your configuration as desired.
|
||||||
|
|
||||||
|
This setup will only work if sign-in is performed using the `https://jellyfin.example.com/sso/OID/start/PROVIDER` URL. This URL initiates the SSO plugin and applies all the configurations we completed above.
|
||||||
1470
frontend/package-lock.json
generated
1470
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pocket-id-frontend",
|
"name": "pocket-id-frontend",
|
||||||
"version": "0.0.1",
|
"version": "0.13.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --port 3000",
|
"dev": "vite dev --port 3000",
|
||||||
@@ -12,46 +12,46 @@
|
|||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.46.1",
|
"@playwright/test": "^1.48.1",
|
||||||
"@sveltejs/adapter-auto": "^3.2.4",
|
"@sveltejs/adapter-auto": "^3.3.0",
|
||||||
"@sveltejs/adapter-node": "^5.2.2",
|
"@sveltejs/adapter-node": "^5.2.8",
|
||||||
"@sveltejs/kit": "^2.5.24",
|
"@sveltejs/kit": "^2.7.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
"@types/eslint": "^9.6.0",
|
"@types/eslint": "^9.6.1",
|
||||||
"@types/jsonwebtoken": "^9.0.6",
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
"@types/node": "^22.5.0",
|
"@types/node": "^22.7.9",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"cbor-js": "^0.1.0",
|
"cbor-js": "^0.1.0",
|
||||||
"eslint": "^9.9.1",
|
"eslint": "^9.13.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-svelte": "^2.40.0",
|
"eslint-plugin-svelte": "^2.46.0",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.11.0",
|
||||||
"postcss": "^8.4.41",
|
"postcss": "^8.4.47",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"prettier-plugin-svelte": "^3.2.6",
|
"prettier-plugin-svelte": "^3.2.7",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.6",
|
"prettier-plugin-tailwindcss": "^0.6.8",
|
||||||
"svelte": "^5.0.0-next.1",
|
"svelte": "^5.0.5",
|
||||||
"svelte-check": "^3.8.6",
|
"svelte-check": "^4.0.5",
|
||||||
"tailwindcss": "^3.4.10",
|
"tailwindcss": "^3.4.14",
|
||||||
"tslib": "^2.7.0",
|
"tslib": "^2.8.0",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.6.3",
|
||||||
"typescript-eslint": "^8.2.0",
|
"typescript-eslint": "^8.11.0",
|
||||||
"vite": "^5.4.2"
|
"vite": "^5.4.10"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@simplewebauthn/browser": "^10.0.0",
|
"@simplewebauthn/browser": "^10.0.0",
|
||||||
"axios": "^1.7.5",
|
"axios": "^1.7.7",
|
||||||
"bits-ui": "^0.21.15",
|
"bits-ui": "^0.21.16",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"crypto": "^1.0.1",
|
"crypto": "^1.0.1",
|
||||||
"formsnap": "^1.0.1",
|
"formsnap": "^1.0.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-svelte": "^0.435.0",
|
"lucide-svelte": "^0.453.0",
|
||||||
"mode-watcher": "^0.4.1",
|
"mode-watcher": "^0.4.1",
|
||||||
"svelte-sonner": "^0.3.27",
|
"svelte-sonner": "^0.3.28",
|
||||||
"sveltekit-superforms": "^2.17.0",
|
"sveltekit-superforms": "^2.20.0",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.4",
|
||||||
"tailwind-variants": "^0.2.1",
|
"tailwind-variants": "^0.2.1",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ export default defineConfig({
|
|||||||
retries: process.env.CI ? 1 : 0,
|
retries: process.env.CI ? 1 : 0,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
reporter: process.env.CI
|
reporter: process.env.CI
|
||||||
? [['html'], ['github']]
|
? [['html', { outputFolder: 'tests/.report' }], ['github']]
|
||||||
: [['line'], ['html', { open: 'never', outputFolder: 'tests/.output' }]],
|
: [['line'], ['html', { open: 'never', outputFolder: 'tests/.report' }]],
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost',
|
baseURL: 'http://localhost',
|
||||||
video: 'retain-on-failure',
|
video: 'retain-on-failure',
|
||||||
|
|||||||
@@ -11,12 +11,14 @@
|
|||||||
let {
|
let {
|
||||||
items,
|
items,
|
||||||
selectedIds = $bindable(),
|
selectedIds = $bindable(),
|
||||||
|
withoutSearch = false,
|
||||||
fetchItems,
|
fetchItems,
|
||||||
columns,
|
columns,
|
||||||
rows
|
rows
|
||||||
}: {
|
}: {
|
||||||
items: Paginated<T>;
|
items: Paginated<T>;
|
||||||
selectedIds?: string[];
|
selectedIds?: string[];
|
||||||
|
withoutSearch?: boolean;
|
||||||
fetchItems: (search: string, page: number, limit: number) => Promise<Paginated<T>>;
|
fetchItems: (search: string, page: number, limit: number) => Promise<Paginated<T>>;
|
||||||
columns: (string | { label: string; hidden?: boolean })[];
|
columns: (string | { label: string; hidden?: boolean })[];
|
||||||
rows: Snippet<[{ item: T }]>;
|
rows: Snippet<[{ item: T }]>;
|
||||||
@@ -65,12 +67,14 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
|
{#if !withoutSearch}
|
||||||
<Input
|
<Input
|
||||||
class="mb-4 max-w-sm"
|
class="mb-4 max-w-sm"
|
||||||
placeholder={'Search...'}
|
placeholder={'Search...'}
|
||||||
type="text"
|
type="text"
|
||||||
oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
|
oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
|
|||||||
116
frontend/src/lib/components/auto-complete-input.svelte
Normal file
116
frontend/src/lib/components/auto-complete-input.svelte
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Input from '$lib/components/ui/input/input.svelte';
|
||||||
|
import * as Popover from '$lib/components/ui/popover/index.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(''),
|
||||||
|
placeholder,
|
||||||
|
suggestionLimit = 5,
|
||||||
|
suggestions
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
placeholder: string;
|
||||||
|
suggestionLimit?: number;
|
||||||
|
suggestions: string[];
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let filteredSuggestions: string[] = $state(suggestions.slice(0, suggestionLimit));
|
||||||
|
let selectedIndex = $state(-1);
|
||||||
|
let keyError: string | undefined = $state();
|
||||||
|
|
||||||
|
let isInputFocused = $state(false);
|
||||||
|
|
||||||
|
function handleSuggestionClick(suggestion: (typeof suggestions)[0]) {
|
||||||
|
value = suggestion;
|
||||||
|
filteredSuggestions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOnInput() {
|
||||||
|
if (value.length > 0 && !/^[A-Za-z0-9]*$/.test(value)) {
|
||||||
|
keyError = 'Only alphanumeric characters are allowed';
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
keyError = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredSuggestions = suggestions
|
||||||
|
.filter((s) => s.includes(value.toLowerCase()))
|
||||||
|
.slice(0, suggestionLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (!isOpen) return;
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
selectedIndex = Math.min(selectedIndex + 1, filteredSuggestions.length - 1);
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
selectedIndex = Math.max(selectedIndex - 1, -1);
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
if (selectedIndex >= 0) {
|
||||||
|
handleSuggestionClick(filteredSuggestions[selectedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
isInputFocused = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let isOpen = $derived(filteredSuggestions.length > 0 && isInputFocused);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Reset selection when suggestions change
|
||||||
|
if (filteredSuggestions) {
|
||||||
|
selectedIndex = -1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid w-full"
|
||||||
|
role="combobox"
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
aria-controls="suggestion-list"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{placeholder}
|
||||||
|
bind:value
|
||||||
|
oninput={handleOnInput}
|
||||||
|
onfocus={() => (isInputFocused = true)}
|
||||||
|
onblur={() => (isInputFocused = false)}
|
||||||
|
/>
|
||||||
|
{#if keyError}
|
||||||
|
<p class="mt-1 text-sm text-red-500">{keyError}</p>
|
||||||
|
{/if}
|
||||||
|
<Popover.Root
|
||||||
|
open={isOpen}
|
||||||
|
disableFocusTrap
|
||||||
|
openFocus={() => {}}
|
||||||
|
closeOnOutsideClick={false}
|
||||||
|
closeOnEscape={false}
|
||||||
|
>
|
||||||
|
<Popover.Trigger tabindex={-1} class="h-0 w-full" aria-hidden />
|
||||||
|
<Popover.Content class="p-0" sideOffset={5} sameWidth>
|
||||||
|
{#each filteredSuggestions as suggestion, index}
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onmousedown={() => handleSuggestionClick(suggestion)}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSuggestionClick(suggestion);
|
||||||
|
}}
|
||||||
|
class="hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 {selectedIndex ===
|
||||||
|
index
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{suggestion}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
</div>
|
||||||
75
frontend/src/lib/components/custom-claims-input.svelte
Normal file
75
frontend/src/lib/components/custom-claims-input.svelte
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import FormInput from '$lib/components/form-input.svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import CustomClaimService from '$lib/services/custom-claim-service';
|
||||||
|
import type { CustomClaim } from '$lib/types/custom-claim.type';
|
||||||
|
import { LucideMinus, LucidePlus } from 'lucide-svelte';
|
||||||
|
import { onMount, type Snippet } from 'svelte';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
import AutoCompleteInput from './auto-complete-input.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
customClaims = $bindable(),
|
||||||
|
error = $bindable(null),
|
||||||
|
...restProps
|
||||||
|
}: HTMLAttributes<HTMLDivElement> & {
|
||||||
|
customClaims: CustomClaim[];
|
||||||
|
error?: string | null;
|
||||||
|
children?: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const limit = 20;
|
||||||
|
|
||||||
|
const customClaimService = new CustomClaimService();
|
||||||
|
|
||||||
|
let suggestions: string[] = $state([]);
|
||||||
|
let filteredSuggestions: string[] = $derived(
|
||||||
|
suggestions.filter(
|
||||||
|
(suggestion) => !customClaims.some((customClaim) => customClaim.key === suggestion)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
customClaimService.getSuggestions().then((data) => (suggestions = data));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div {...restProps}>
|
||||||
|
<FormInput>
|
||||||
|
<div class="flex flex-col gap-y-2">
|
||||||
|
{#each customClaims as _, i}
|
||||||
|
<div class="flex gap-x-2">
|
||||||
|
<AutoCompleteInput
|
||||||
|
placeholder="Key"
|
||||||
|
suggestions={filteredSuggestions}
|
||||||
|
bind:value={customClaims[i].key}
|
||||||
|
/>
|
||||||
|
<Input placeholder="Value" bind:value={customClaims[i].value} />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Remove custom claim"
|
||||||
|
on:click={() => (customClaims = customClaims.filter((_, index) => index !== i))}
|
||||||
|
>
|
||||||
|
<LucideMinus class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</FormInput>
|
||||||
|
{#if error}
|
||||||
|
<p class="mt-1 text-sm text-red-500">{error}</p>
|
||||||
|
{/if}
|
||||||
|
{#if customClaims.length < limit}
|
||||||
|
<Button
|
||||||
|
class="mt-2"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
on:click={() => (customClaims = [...customClaims, { key: '', value: '' }])}
|
||||||
|
>
|
||||||
|
<LucidePlus class="mr-1 h-4 w-4" />
|
||||||
|
{customClaims.length === 0 ? 'Add custom claim' : 'Add another'}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
...restProps
|
...restProps
|
||||||
}: HTMLAttributes<HTMLDivElement> & {
|
}: HTMLAttributes<HTMLDivElement> & {
|
||||||
input?: FormInput<string | boolean | number>;
|
input?: FormInput<string | boolean | number>;
|
||||||
label: string;
|
label?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox';
|
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox';
|
||||||
@@ -24,15 +24,17 @@
|
|||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const id = label.toLowerCase().replace(/ /g, '-');
|
const id = label?.toLowerCase().replace(/ /g, '-');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div {...restProps}>
|
<div {...restProps}>
|
||||||
|
{#if label}
|
||||||
<Label class="mb-0" for={id}>{label}</Label>
|
<Label class="mb-0" for={id}>{label}</Label>
|
||||||
|
{/if}
|
||||||
{#if description}
|
{#if description}
|
||||||
<p class="text-muted-foreground mt-1 text-xs">{description}</p>
|
<p class="text-muted-foreground mt-1 text-xs">{description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="mt-2">
|
<div class={label || description ? 'mt-2' : ''}>
|
||||||
{#if children}
|
{#if children}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{:else if input}
|
{:else if input}
|
||||||
|
|||||||
17
frontend/src/lib/components/ui/popover/index.ts
Normal file
17
frontend/src/lib/components/ui/popover/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||||
|
import Content from "./popover-content.svelte";
|
||||||
|
const Root = PopoverPrimitive.Root;
|
||||||
|
const Trigger = PopoverPrimitive.Trigger;
|
||||||
|
const Close = PopoverPrimitive.Close;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
Trigger,
|
||||||
|
Close,
|
||||||
|
//
|
||||||
|
Root as Popover,
|
||||||
|
Content as PopoverContent,
|
||||||
|
Trigger as PopoverTrigger,
|
||||||
|
Close as PopoverClose,
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||||
|
import { cn, flyAndScale } from "$lib/utils/style.js";
|
||||||
|
|
||||||
|
type $$Props = PopoverPrimitive.ContentProps;
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export let transition: $$Props["transition"] = flyAndScale;
|
||||||
|
export let transitionConfig: $$Props["transitionConfig"] = undefined;
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
{transition}
|
||||||
|
{transitionConfig}
|
||||||
|
class={cn(
|
||||||
|
"bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...$$restProps}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</PopoverPrimitive.Content>
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { version as currentVersion } from '$app/environment';
|
||||||
import type { AllAppConfig, AppConfigRawResponse } from '$lib/types/application-configuration';
|
import type { AllAppConfig, AppConfigRawResponse } from '$lib/types/application-configuration';
|
||||||
|
import axios from 'axios';
|
||||||
import APIService from './api-service';
|
import APIService from './api-service';
|
||||||
|
|
||||||
export default class AppConfigService extends APIService {
|
export default class AppConfigService extends APIService {
|
||||||
@@ -12,14 +14,19 @@ export default class AppConfigService extends APIService {
|
|||||||
|
|
||||||
const appConfig: Partial<AllAppConfig> = {};
|
const appConfig: Partial<AllAppConfig> = {};
|
||||||
data.forEach(({ key, value }) => {
|
data.forEach(({ key, value }) => {
|
||||||
(appConfig as any)[key] = value;
|
(appConfig as any)[key] = this.parseValue(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
return appConfig as AllAppConfig;
|
return appConfig as AllAppConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(appConfig: AllAppConfig) {
|
async update(appConfig: AllAppConfig) {
|
||||||
const res = await this.api.put('/application-configuration', appConfig);
|
// Convert all values to string
|
||||||
|
const appConfigConvertedToString = {};
|
||||||
|
for (const key in appConfig) {
|
||||||
|
(appConfigConvertedToString as any)[key] = (appConfig as any)[key].toString();
|
||||||
|
}
|
||||||
|
const res = await this.api.put('/application-configuration', appConfigConvertedToString);
|
||||||
return res.data as AllAppConfig;
|
return res.data as AllAppConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,4 +52,31 @@ export default class AppConfigService extends APIService {
|
|||||||
|
|
||||||
await this.api.put(`/application-configuration/background-image`, formData);
|
await this.api.put(`/application-configuration/background-image`, formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getVersionInformation() {
|
||||||
|
const response = (
|
||||||
|
await axios.get('https://api.github.com/repos/stonith404/pocket-id/releases/latest')
|
||||||
|
).data;
|
||||||
|
|
||||||
|
const newestVersion = response.tag_name.replace('v', '');
|
||||||
|
const isUpToDate = newestVersion === currentVersion;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isUpToDate,
|
||||||
|
newestVersion,
|
||||||
|
currentVersion
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseValue(value: string) {
|
||||||
|
if (value === 'true') {
|
||||||
|
return true;
|
||||||
|
} else if (value === 'false') {
|
||||||
|
return false;
|
||||||
|
} else if (!isNaN(parseFloat(value))) {
|
||||||
|
return parseFloat(value);
|
||||||
|
} else {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
frontend/src/lib/services/custom-claim-service.ts
Normal file
19
frontend/src/lib/services/custom-claim-service.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { CustomClaim } from '$lib/types/custom-claim.type';
|
||||||
|
import APIService from './api-service';
|
||||||
|
|
||||||
|
export default class CustomClaimService extends APIService {
|
||||||
|
async getSuggestions() {
|
||||||
|
const res = await this.api.get('/custom-claims/suggestions');
|
||||||
|
return res.data as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUserCustomClaims(userId: string, claims: CustomClaim[]) {
|
||||||
|
const res = await this.api.put(`/custom-claims/user/${userId}`, claims);
|
||||||
|
return res.data as CustomClaim[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUserGroupCustomClaims(userGroupId: string, claims: CustomClaim[]) {
|
||||||
|
const res = await this.api.put(`/custom-claims/user-group/${userGroupId}`, claims);
|
||||||
|
return res.data as CustomClaim[];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,10 +42,10 @@ export default class UserService extends APIService {
|
|||||||
await this.api.delete(`/users/${id}`);
|
await this.api.delete(`/users/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createOneTimeAccessToken(userId: string) {
|
async createOneTimeAccessToken(userId: string, expiresAt: Date) {
|
||||||
const res = await this.api.post(`/users/${userId}/one-time-access-token`, {
|
const res = await this.api.post(`/users/${userId}/one-time-access-token`, {
|
||||||
userId,
|
userId,
|
||||||
expiresAt: new Date(Date.now() + 1000 * 60 * 5).toISOString()
|
expiresAt
|
||||||
});
|
});
|
||||||
return res.data.token;
|
return res.data.token;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
export type AllAppConfig = {
|
export type AppConfig = {
|
||||||
appName: string;
|
appName: string;
|
||||||
sessionDuration: string;
|
allowOwnAccountEdit: boolean;
|
||||||
emailEnabled: string;
|
};
|
||||||
|
|
||||||
|
export type AllAppConfig = AppConfig & {
|
||||||
|
sessionDuration: number;
|
||||||
|
emailsVerified: boolean;
|
||||||
|
emailEnabled: boolean;
|
||||||
smtpHost: string;
|
smtpHost: string;
|
||||||
smtpPort: string;
|
smtpPort: number;
|
||||||
smtpFrom: string;
|
smtpFrom: string;
|
||||||
smtpUser: string;
|
smtpUser: string;
|
||||||
smtpPassword: string;
|
smtpPassword: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppConfig = AllAppConfig;
|
|
||||||
|
|
||||||
export type AppConfigRawResponse = {
|
export type AppConfigRawResponse = {
|
||||||
key: string;
|
key: string;
|
||||||
type: string;
|
type: string;
|
||||||
value: string;
|
value: string;
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
|
export type AppVersionInformation = {
|
||||||
|
isUpToDate: boolean;
|
||||||
|
newestVersion: string;
|
||||||
|
currentVersion: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ export type AuditLog = {
|
|||||||
id: string;
|
id: string;
|
||||||
event: string;
|
event: string;
|
||||||
ipAddress: string;
|
ipAddress: string;
|
||||||
|
country?: string;
|
||||||
|
city?: string;
|
||||||
device: string;
|
device: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
data: any;
|
data: any;
|
||||||
|
|||||||
4
frontend/src/lib/types/custom-claim.type.ts
Normal file
4
frontend/src/lib/types/custom-claim.type.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export type CustomClaim = {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { CustomClaim } from './custom-claim.type';
|
||||||
import type { User } from './user.type';
|
import type { User } from './user.type';
|
||||||
|
|
||||||
export type UserGroup = {
|
export type UserGroup = {
|
||||||
@@ -5,6 +6,7 @@ export type UserGroup = {
|
|||||||
friendlyName: string;
|
friendlyName: string;
|
||||||
name: string;
|
name: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
customClaims: CustomClaim[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserGroupWithUsers = UserGroup & {
|
export type UserGroupWithUsers = UserGroup & {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { CustomClaim } from './custom-claim.type';
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -5,6 +7,7 @@ export type User = {
|
|||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
customClaims: CustomClaim[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserCreate = Omit<User, 'id'>;
|
export type UserCreate = Omit<User, 'id' | 'customClaims'>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export function debounced<T extends (...args: any[]) => void>(func: T, delay: number) {
|
export function debounced<T extends (...args: any[]) => void>(func: T, delay: number) {
|
||||||
let debounceTimeout: number | undefined;
|
let debounceTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
return (...args: Parameters<T>) => {
|
return (...args: Parameters<T>) => {
|
||||||
if (debounceTimeout !== undefined) {
|
if (debounceTimeout !== undefined) {
|
||||||
|
|||||||
@@ -33,11 +33,19 @@
|
|||||||
<Logo class="h-10 w-10" />
|
<Logo class="h-10 w-10" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="font-playfair mt-5 text-4xl font-bold">One Time Access</h1>
|
<h1 class="font-playfair mt-5 text-4xl font-bold">
|
||||||
|
{data.token === 'setup' ? `${$appConfigStore.appName} Setup` : 'One Time Access'}
|
||||||
|
</h1>
|
||||||
<p class="text-muted-foreground mt-2">
|
<p class="text-muted-foreground mt-2">
|
||||||
You've been granted one-time access to your {$appConfigStore.appName} account. Please note that if
|
{#if data.token === 'setup'}
|
||||||
you continue, this link will become invalid. To avoid this, make sure to add a passkey. Otherwise,
|
You're about to sign in to the initial admin account. Anyone with this link can access the
|
||||||
|
account until a passkey is added. Please set up a passkey as soon as possible to prevent
|
||||||
|
unauthorized access.
|
||||||
|
{:else}
|
||||||
|
You've been granted one-time access to your {$appConfigStore.appName} account. Please note that
|
||||||
|
if you continue, this link will become invalid. To avoid this, make sure to add a passkey. Otherwise,
|
||||||
you'll need to request a new link.
|
you'll need to request a new link.
|
||||||
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
<Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button>
|
<Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button>
|
||||||
</SignInWrapper>
|
</SignInWrapper>
|
||||||
|
|||||||
24
frontend/src/routes/settings/+layout.server.ts
Normal file
24
frontend/src/routes/settings/+layout.server.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import AppConfigService from '$lib/services/app-config-service';
|
||||||
|
import type { AppVersionInformation } from '$lib/types/application-configuration';
|
||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
let versionInformation: AppVersionInformation;
|
||||||
|
let versionInformationLastUpdated: number;
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async () => {
|
||||||
|
const appConfigService = new AppConfigService();
|
||||||
|
|
||||||
|
// Cache the version information for 3 hours
|
||||||
|
const cacheExpired =
|
||||||
|
versionInformationLastUpdated &&
|
||||||
|
Date.now() - versionInformationLastUpdated > 1000 * 60 * 60 * 3;
|
||||||
|
|
||||||
|
if (!versionInformation || cacheExpired) {
|
||||||
|
versionInformation = await appConfigService.getVersionInformation();
|
||||||
|
versionInformationLastUpdated = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
versionInformation
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import userStore from '$lib/stores/user-store';
|
import userStore from '$lib/stores/user-store';
|
||||||
|
import { LucideExternalLink } from 'lucide-svelte';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import type { LayoutData } from './$types';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
children
|
children,
|
||||||
|
data
|
||||||
}: {
|
}: {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
|
data: LayoutData;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const { versionInformation } = data;
|
||||||
|
|
||||||
let links = $state([
|
let links = $state([
|
||||||
{ href: '/settings/account', label: 'My Account' },
|
{ href: '/settings/account', label: 'My Account' },
|
||||||
{ href: '/settings/audit-log', label: 'Audit Log' }
|
{ href: '/settings/audit-log', label: 'Audit Log' }
|
||||||
@@ -26,8 +32,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="bg-muted/40 min-h-screen w-full">
|
<div class="bg-muted/40 flex min-h-[calc(100vh-64px)] w-full flex-col justify-between">
|
||||||
<main class="mx-auto flex max-w-[1640px] flex-col gap-x-4 gap-y-10 p-4 md:p-10 lg:flex-row">
|
<main
|
||||||
|
class="mx-auto flex w-full max-w-[1640px] flex-col gap-x-4 gap-y-10 p-4 md:p-10 lg:flex-row"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<div class="mx-auto grid w-full gap-2">
|
<div class="mx-auto grid w-full gap-2">
|
||||||
<h1 class="mb-5 text-3xl font-semibold">Settings</h1>
|
<h1 class="mb-5 text-3xl font-semibold">Settings</h1>
|
||||||
@@ -41,6 +49,15 @@
|
|||||||
{label}
|
{label}
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if $userStore?.isAdmin && !versionInformation.isUpToDate}
|
||||||
|
<a
|
||||||
|
href="https://github.com/stonith404/pocket-id/releases/latest"
|
||||||
|
target="_blank"
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
Update Pocket ID <LucideExternalLink class="my-auto inline-block h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,5 +65,15 @@
|
|||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<p class="text-muted-foreground py-3 text-xs">
|
||||||
|
Powered by <a
|
||||||
|
class="text-foreground"
|
||||||
|
href="https://github.com/stonith404/pocket-id"
|
||||||
|
target="_blank">Pocket ID</a
|
||||||
|
>
|
||||||
|
({versionInformation.currentVersion})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
import WebAuthnService from '$lib/services/webauthn-service';
|
import WebAuthnService from '$lib/services/webauthn-service';
|
||||||
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
import type { Passkey } from '$lib/types/passkey.type';
|
import type { Passkey } from '$lib/types/passkey.type';
|
||||||
import type { UserCreate } from '$lib/types/user.type';
|
import type { UserCreate } from '$lib/types/user.type';
|
||||||
import { axiosErrorToast, getWebauthnErrorMessage } from '$lib/utils/error-util';
|
import { axiosErrorToast, getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||||
@@ -51,14 +52,16 @@
|
|||||||
<title>Account Settings</title>
|
<title>Account Settings</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Card.Root>
|
{#if $appConfigStore.allowOwnAccountEdit}
|
||||||
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>Account Details</Card.Title>
|
<Card.Title>Account Details</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
<AccountForm {account} callback={updateAccount} />
|
<AccountForm {account} callback={updateAccount} />
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
|
|||||||
@@ -15,10 +15,10 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let emailEnabled = $state(appConfig.emailEnabled == 'true');
|
let emailEnabled = $state(appConfig.emailEnabled);
|
||||||
|
|
||||||
const updatedAppConfig = {
|
const updatedAppConfig = {
|
||||||
emailEnabled: emailEnabled.toString(),
|
emailEnabled: appConfig.emailEnabled,
|
||||||
smtpHost: appConfig.smtpHost,
|
smtpHost: appConfig.smtpHost,
|
||||||
smtpPort: appConfig.smtpPort,
|
smtpPort: appConfig.smtpPort,
|
||||||
smtpUser: appConfig.smtpUser,
|
smtpUser: appConfig.smtpUser,
|
||||||
@@ -28,13 +28,13 @@
|
|||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
smtpHost: z.string().min(1),
|
smtpHost: z.string().min(1),
|
||||||
smtpPort: z.string().min(1),
|
smtpPort: z.number().min(1),
|
||||||
smtpUser: z.string().min(1),
|
smtpUser: z.string().min(1),
|
||||||
smtpPassword: z.string().min(1),
|
smtpPassword: z.string().min(1),
|
||||||
smtpFrom: z.string().email()
|
smtpFrom: z.string().email()
|
||||||
});
|
});
|
||||||
|
|
||||||
const { inputs, ...form } = createForm< typeof formSchema>(formSchema, updatedAppConfig);
|
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
const data = form.validate();
|
const data = form.validate();
|
||||||
@@ -42,15 +42,15 @@
|
|||||||
isLoading = true;
|
isLoading = true;
|
||||||
await callback({
|
await callback({
|
||||||
...data,
|
...data,
|
||||||
emailEnabled: 'true'
|
emailEnabled: true
|
||||||
}).finally(() => (isLoading = false));
|
}).finally(() => (isLoading = false));
|
||||||
toast.success('Email configuration updated successfully');
|
toast.success('Email configuration updated successfully');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onDisable() {
|
async function onDisable() {
|
||||||
await callback({ emailEnabled: 'false' });
|
|
||||||
emailEnabled = false;
|
emailEnabled = false;
|
||||||
|
await callback({ emailEnabled });
|
||||||
toast.success('Email disabled successfully');
|
toast.success('Email disabled successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
<form onsubmit={onSubmit}>
|
<form onsubmit={onSubmit}>
|
||||||
<div class="mt-5 grid grid-cols-2 gap-5">
|
<div class="mt-5 grid grid-cols-2 gap-5">
|
||||||
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
|
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
|
||||||
<FormInput label="SMTP Port" bind:input={$inputs.smtpPort} />
|
<FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} />
|
||||||
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
|
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
|
||||||
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
|
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
|
||||||
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
|
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import FormInput from '$lib/components/form-input.svelte';
|
import FormInput from '$lib/components/form-input.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
import type { AllAppConfig } from '$lib/types/application-configuration';
|
import type { AllAppConfig } from '$lib/types/application-configuration';
|
||||||
import { createForm } from '$lib/utils/form-util';
|
import { createForm } from '$lib/utils/form-util';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
@@ -18,20 +20,16 @@
|
|||||||
|
|
||||||
const updatedAppConfig = {
|
const updatedAppConfig = {
|
||||||
appName: appConfig.appName,
|
appName: appConfig.appName,
|
||||||
sessionDuration: appConfig.sessionDuration
|
sessionDuration: appConfig.sessionDuration,
|
||||||
|
emailsVerified: appConfig.emailsVerified,
|
||||||
|
allowOwnAccountEdit: appConfig.allowOwnAccountEdit
|
||||||
};
|
};
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
appName: z.string().min(2).max(30),
|
appName: z.string().min(2).max(30),
|
||||||
sessionDuration: z.string().refine(
|
sessionDuration: z.number().min(1).max(43200),
|
||||||
(val) => {
|
emailsVerified: z.boolean(),
|
||||||
const num = Number(val);
|
allowOwnAccountEdit: z.boolean()
|
||||||
return Number.isInteger(num) && num >= 1 && num <= 43200;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: 'Session duration must be between 1 and 43200 minutes'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
|
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
|
||||||
@@ -49,9 +47,32 @@
|
|||||||
<FormInput label="Application Name" bind:input={$inputs.appName} />
|
<FormInput label="Application Name" bind:input={$inputs.appName} />
|
||||||
<FormInput
|
<FormInput
|
||||||
label="Session Duration"
|
label="Session Duration"
|
||||||
|
type="number"
|
||||||
description="The duration of a session in minutes before the user has to sign in again."
|
description="The duration of a session in minutes before the user has to sign in again."
|
||||||
bind:input={$inputs.sessionDuration}
|
bind:input={$inputs.sessionDuration}
|
||||||
/>
|
/>
|
||||||
|
<div class="items-top mt-5 flex space-x-2">
|
||||||
|
<Checkbox id="admin-privileges" bind:checked={$inputs.allowOwnAccountEdit.value} />
|
||||||
|
<div class="grid gap-1.5 leading-none">
|
||||||
|
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none">
|
||||||
|
Enable Self-Account Editing
|
||||||
|
</Label>
|
||||||
|
<p class="text-muted-foreground text-[0.8rem]">
|
||||||
|
Whether the user should be able to edit their own account details.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="items-top mt-5 flex space-x-2">
|
||||||
|
<Checkbox id="admin-privileges" bind:checked={$inputs.emailsVerified.value} />
|
||||||
|
<div class="grid gap-1.5 leading-none">
|
||||||
|
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none">
|
||||||
|
Emails Verified
|
||||||
|
</Label>
|
||||||
|
<p class="text-muted-foreground text-[0.8rem]">
|
||||||
|
Whether the user's email should be marked as verified for the OIDC clients.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 flex justify-end">
|
<div class="mt-5 flex justify-end">
|
||||||
<Button {isLoading} type="submit">Save</Button>
|
<Button {isLoading} type="submit">Save</Button>
|
||||||
|
|||||||
@@ -26,8 +26,7 @@
|
|||||||
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
|
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
|
||||||
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
|
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
|
||||||
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
|
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
|
||||||
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
|
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`
|
||||||
PKCE: 'Disabled'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
|
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
|
||||||
@@ -96,10 +95,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-2 mt-1 flex items-center">
|
<div class="mb-2 mt-1 flex items-center">
|
||||||
<Label class="w-44">Client secret</Label>
|
<Label class="w-44">Client secret</Label>
|
||||||
|
{#if $clientSecretStore}
|
||||||
|
<CopyToClipboard value={$clientSecretStore}>
|
||||||
|
<span class="text-muted-foreground text-sm" data-testid="client-secret">
|
||||||
|
{$clientSecretStore}
|
||||||
|
</span>
|
||||||
|
</CopyToClipboard>
|
||||||
|
{:else}
|
||||||
<span class="text-muted-foreground text-sm" data-testid="client-secret"
|
<span class="text-muted-foreground text-sm" data-testid="client-secret"
|
||||||
>{$clientSecretStore ?? '••••••••••••••••••••••••••••••••'}</span
|
>••••••••••••••••••••••••••••••••</span
|
||||||
>
|
>
|
||||||
{#if !$clientSecretStore}
|
|
||||||
<Button
|
<Button
|
||||||
class="ml-2"
|
class="ml-2"
|
||||||
onclick={createClientSecret}
|
onclick={createClientSecret}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const limit = 5;
|
const limit = 20;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div {...restProps}>
|
<div {...restProps}>
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
on:click={() => callbackURLs = callbackURLs.filter((_, index) => index !== i)}
|
on:click={() => (callbackURLs = callbackURLs.filter((_, index) => index !== i))}
|
||||||
>
|
>
|
||||||
<LucideMinus class="h-4 w-4" />
|
<LucideMinus class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
class="mt-2"
|
class="mt-2"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
on:click={() => callbackURLs = [...callbackURLs, '']}
|
on:click={() => (callbackURLs = [...callbackURLs, ''])}
|
||||||
>
|
>
|
||||||
<LucidePlus class="mr-1 h-4 w-4" />
|
<LucidePlus class="mr-1 h-4 w-4" />
|
||||||
Add another
|
Add another
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import CustomClaimsInput from '$lib/components/custom-claims-input.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import CustomClaimService from '$lib/services/custom-claim-service';
|
||||||
import UserGroupService from '$lib/services/user-group-service';
|
import UserGroupService from '$lib/services/user-group-service';
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
import type { UserGroupCreate } from '$lib/types/user-group.type';
|
import type { UserGroupCreate } from '$lib/types/user-group.type';
|
||||||
@@ -18,6 +20,7 @@
|
|||||||
|
|
||||||
const userGroupService = new UserGroupService();
|
const userGroupService = new UserGroupService();
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
|
const customClaimService = new CustomClaimService();
|
||||||
|
|
||||||
async function updateUserGroup(updatedUserGroup: UserGroupCreate) {
|
async function updateUserGroup(updatedUserGroup: UserGroupCreate) {
|
||||||
let success = true;
|
let success = true;
|
||||||
@@ -40,6 +43,15 @@
|
|||||||
axiosErrorToast(e);
|
axiosErrorToast(e);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateCustomClaims() {
|
||||||
|
await customClaimService
|
||||||
|
.updateUserGroupCustomClaims(userGroup.id, userGroup.customClaims)
|
||||||
|
.then(() => toast.success('Custom claims updated successfully'))
|
||||||
|
.catch((e) => {
|
||||||
|
axiosErrorToast(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -53,7 +65,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>Meta data</Card.Title>
|
<Card.Title>General</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
|
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
@@ -76,3 +88,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Custom Claims</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
Custom claims are key-value pairs that can be used to store additional information about a
|
||||||
|
user. These claims will be included in the ID token if the scope "profile" is requested.
|
||||||
|
Custom claims defined on the user will be prioritized if there are conflicts.
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<CustomClaimsInput bind:customClaims={userGroup.customClaims} />
|
||||||
|
<div class="mt-5 flex justify-end">
|
||||||
|
<Button onclick={updateCustomClaims} type="submit">Save</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import CustomClaimService from '$lib/services/custom-claim-service';
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
import type { UserCreate } from '$lib/types/user.type';
|
import type { UserCreate } from '$lib/types/user.type';
|
||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import { LucideChevronLeft } from 'lucide-svelte';
|
import { LucideChevronLeft } from 'lucide-svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import CustomClaimsInput from '../../../../../lib/components/custom-claims-input.svelte';
|
||||||
import UserForm from '../user-form.svelte';
|
import UserForm from '../user-form.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
let user = $state(data);
|
let user = $state(data);
|
||||||
|
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
|
const customClaimService = new CustomClaimService();
|
||||||
|
|
||||||
async function updateUser(updatedUser: UserCreate) {
|
async function updateUser(updatedUser: UserCreate) {
|
||||||
let success = true;
|
let success = true;
|
||||||
@@ -24,6 +28,15 @@
|
|||||||
|
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateCustomClaims() {
|
||||||
|
await customClaimService
|
||||||
|
.updateUserCustomClaims(user.id, user.customClaims)
|
||||||
|
.then(() => toast.success('Custom claims updated successfully'))
|
||||||
|
.catch((e) => {
|
||||||
|
axiosErrorToast(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -37,10 +50,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>{user.firstName} {user.lastName}</Card.Title>
|
<Card.Title>General</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
|
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
<UserForm existingUser={user} callback={updateUser} />
|
<UserForm existingUser={user} callback={updateUser} />
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Custom Claims</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
Custom claims are key-value pairs that can be used to store additional information about a
|
||||||
|
user. These claims will be included in the ID token if the scope "profile" is requested.
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<CustomClaimsInput bind:customClaims={user.customClaims} />
|
||||||
|
<div class="mt-5 flex justify-end">
|
||||||
|
<Button onclick={updateCustomClaims} type="submit">Save</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|||||||
@@ -1,22 +1,51 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Dialog from '$lib/components/ui/dialog';
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
import Input from '$lib/components/ui/input/input.svelte';
|
import Input from '$lib/components/ui/input/input.svelte';
|
||||||
import Label from '$lib/components/ui/label/label.svelte';
|
import Label from '$lib/components/ui/label/label.svelte';
|
||||||
|
import * as Select from '$lib/components/ui/select/index.js';
|
||||||
|
import UserService from '$lib/services/user-service';
|
||||||
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
oneTimeLink = $bindable()
|
userId = $bindable()
|
||||||
}: {
|
}: {
|
||||||
oneTimeLink: string | null;
|
userId: string | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const userService = new UserService();
|
||||||
|
|
||||||
|
let oneTimeLink: string | null = $state(null);
|
||||||
|
let selectedExpiration: keyof typeof availableExpirations = $state('1 hour');
|
||||||
|
|
||||||
|
let availableExpirations = {
|
||||||
|
'1 hour': 60 * 60,
|
||||||
|
'12 hours': 60 * 60 * 12,
|
||||||
|
'1 day': 60 * 60 * 24,
|
||||||
|
'1 week': 60 * 60 * 24 * 7,
|
||||||
|
'1 month': 60 * 60 * 24 * 30
|
||||||
|
};
|
||||||
|
|
||||||
|
async function createOneTimeAccessToken() {
|
||||||
|
try {
|
||||||
|
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
|
||||||
|
const token = await userService.createOneTimeAccessToken(userId!, expiration);
|
||||||
|
oneTimeLink = `${$page.url.origin}/login/${token}`;
|
||||||
|
} catch (e) {
|
||||||
|
axiosErrorToast(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onOpenChange(open: boolean) {
|
function onOpenChange(open: boolean) {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
oneTimeLink = null;
|
oneTimeLink = null;
|
||||||
|
userId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root open={!!oneTimeLink} {onOpenChange}>
|
<Dialog.Root open={!!userId} {onOpenChange}>
|
||||||
<Dialog.Content class="max-w-md">
|
<Dialog.Content class="max-w-md">
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>One Time Link</Dialog.Title>
|
<Dialog.Title>One Time Link</Dialog.Title>
|
||||||
@@ -25,9 +54,36 @@
|
|||||||
have lost it.</Dialog.Description
|
have lost it.</Dialog.Description
|
||||||
>
|
>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
{#if oneTimeLink === null}
|
||||||
<div>
|
<div>
|
||||||
<Label for="one-time-link">One Time Link</Label>
|
<Label for="expiration">Expiration</Label>
|
||||||
<Input id="one-time-link" value={oneTimeLink} readonly />
|
<Select.Root
|
||||||
|
selected={{
|
||||||
|
label: Object.keys(availableExpirations)[0],
|
||||||
|
value: Object.keys(availableExpirations)[0]
|
||||||
|
}}
|
||||||
|
onSelectedChange={(v) =>
|
||||||
|
(selectedExpiration = v!.value as keyof typeof availableExpirations)}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="h-9 ">
|
||||||
|
<Select.Value>{selectedExpiration}</Select.Value>
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each Object.keys(availableExpirations) as key}
|
||||||
|
<Select.Item value={key}>{key}</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
onclick={() => createOneTimeAccessToken()}
|
||||||
|
disabled={!selectedExpiration}
|
||||||
|
>
|
||||||
|
Generate Link
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Label for="one-time-link" class="sr-only">One Time Link</Label>
|
||||||
|
<Input id="one-time-link" value={oneTimeLink} readonly />
|
||||||
|
{/if}
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { goto } from '$app/navigation';
|
||||||
|
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
||||||
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
|
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
|
||||||
import { Badge } from '$lib/components/ui/badge/index';
|
import { Badge } from '$lib/components/ui/badge/index';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { buttonVariants } from '$lib/components/ui/button';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
import { Input } from '$lib/components/ui/input';
|
|
||||||
import * as Pagination from '$lib/components/ui/pagination';
|
|
||||||
import * as Table from '$lib/components/ui/table';
|
import * as Table from '$lib/components/ui/table';
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
|
import type { Paginated } from '$lib/types/pagination.type';
|
||||||
import type { User } from '$lib/types/user.type';
|
import type { User } from '$lib/types/user.type';
|
||||||
import { debounced } from '$lib/utils/debounce-util';
|
|
||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import { LucideLink, LucidePencil, LucideTrash } from 'lucide-svelte';
|
import { LucideLink, LucidePencil, LucideTrash } from 'lucide-svelte';
|
||||||
import Ellipsis from 'lucide-svelte/icons/ellipsis';
|
import Ellipsis from 'lucide-svelte/icons/ellipsis';
|
||||||
@@ -19,23 +17,17 @@
|
|||||||
|
|
||||||
let { users: initialUsers }: { users: Paginated<User> } = $props();
|
let { users: initialUsers }: { users: Paginated<User> } = $props();
|
||||||
let users = $state<Paginated<User>>(initialUsers);
|
let users = $state<Paginated<User>>(initialUsers);
|
||||||
let oneTimeLink = $state<string | null>(null);
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
users = initialUsers;
|
users = initialUsers;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let userIdToCreateOneTimeLink: string | null = $state(null);;
|
||||||
|
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
|
|
||||||
let pagination = $state<PaginationRequest>({
|
function fetchItems(search: string, page: number, limit: number) {
|
||||||
page: 1,
|
return userService.list(search, { page, limit });
|
||||||
limit: 10
|
}
|
||||||
});
|
|
||||||
let search = $state('');
|
|
||||||
|
|
||||||
const debouncedSearch = debounced(async (searchValue: string) => {
|
|
||||||
users = await userService.list(searchValue, pagination);
|
|
||||||
}, 400);
|
|
||||||
|
|
||||||
async function deleteUser(user: User) {
|
async function deleteUser(user: User) {
|
||||||
openConfirmDialog({
|
openConfirmDialog({
|
||||||
@@ -47,7 +39,7 @@
|
|||||||
action: async () => {
|
action: async () => {
|
||||||
try {
|
try {
|
||||||
await userService.remove(user.id);
|
await userService.remove(user.id);
|
||||||
users = await userService.list(search, pagination);
|
users = await userService.list();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
axiosErrorToast(e);
|
axiosErrorToast(e);
|
||||||
}
|
}
|
||||||
@@ -56,116 +48,51 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createOneTimeAccessToken(userId: string) {
|
|
||||||
try {
|
|
||||||
const token = await userService.createOneTimeAccessToken(userId);
|
|
||||||
oneTimeLink = `${$page.url.origin}/login/${token}`;
|
|
||||||
} catch (e) {
|
|
||||||
axiosErrorToast(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Input
|
<AdvancedTable
|
||||||
type="search"
|
items={users}
|
||||||
placeholder="Search users"
|
{fetchItems}
|
||||||
bind:value={search}
|
columns={[
|
||||||
on:input={(e) => debouncedSearch((e.target as HTMLInputElement).value)}
|
'First name',
|
||||||
/>
|
'Last name',
|
||||||
<Table.Root>
|
'Email',
|
||||||
<Table.Header>
|
'Username',
|
||||||
<Table.Row>
|
'Role',
|
||||||
<Table.Head class="hidden md:table-cell">First name</Table.Head>
|
{ label: 'Actions', hidden: true }
|
||||||
<Table.Head class="hidden md:table-cell">Last name</Table.Head>
|
]}
|
||||||
<Table.Head>Email</Table.Head>
|
withoutSearch
|
||||||
<Table.Head>Username</Table.Head>
|
>
|
||||||
<Table.Head class="hidden lg:table-cell">Role</Table.Head>
|
{#snippet rows({ item })}
|
||||||
<Table.Head>
|
<Table.Cell>{item.firstName}</Table.Cell>
|
||||||
<span class="sr-only">Actions</span>
|
<Table.Cell>{item.lastName}</Table.Cell>
|
||||||
</Table.Head>
|
<Table.Cell>{item.email}</Table.Cell>
|
||||||
</Table.Row>
|
<Table.Cell>{item.username}</Table.Cell>
|
||||||
</Table.Header>
|
|
||||||
<Table.Body>
|
|
||||||
{#if users.data.length === 0}
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Cell colspan={6} class="text-center">No users found</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
{:else}
|
|
||||||
{#each users.data as user}
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Cell class="hidden md:table-cell">{user.firstName}</Table.Cell>
|
|
||||||
<Table.Cell class="hidden md:table-cell">{user.lastName}</Table.Cell>
|
|
||||||
<Table.Cell>{user.email}</Table.Cell>
|
|
||||||
<Table.Cell>{user.username}</Table.Cell>
|
|
||||||
<Table.Cell class="hidden lg:table-cell">
|
<Table.Cell class="hidden lg:table-cell">
|
||||||
<Badge variant="outline">{user.isAdmin ? 'Admin' : 'User'}</Badge>
|
<Badge variant="outline">{item.isAdmin ? 'Admin' : 'User'}</Badge>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger asChild let:builder>
|
<DropdownMenu.Trigger class={buttonVariants({ variant: 'ghost', size: 'icon' })}>
|
||||||
<Button aria-haspopup="true" size="icon" variant="ghost" builders={[builder]}>
|
|
||||||
<Ellipsis class="h-4 w-4" />
|
<Ellipsis class="h-4 w-4" />
|
||||||
<span class="sr-only">Toggle menu</span>
|
<span class="sr-only">Toggle menu</span>
|
||||||
</Button>
|
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content align="end">
|
<DropdownMenu.Content align="end">
|
||||||
<DropdownMenu.Item on:click={() => createOneTimeAccessToken(user.id)}
|
<DropdownMenu.Item onclick={() => (userIdToCreateOneTimeLink = item.id)}
|
||||||
><LucideLink class="mr-2 h-4 w-4" />One-time link</DropdownMenu.Item
|
><LucideLink class="mr-2 h-4 w-4" />One-time link</DropdownMenu.Item
|
||||||
>
|
>
|
||||||
<DropdownMenu.Item href="/settings/admin/users/{user.id}"
|
<DropdownMenu.Item onclick={() => goto(`/settings/admin/users/${item.id}`)}
|
||||||
><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item
|
><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item
|
||||||
>
|
>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
class="text-red-500 focus:!text-red-700"
|
class="text-red-500 focus:!text-red-700"
|
||||||
on:click={() => deleteUser(user)}
|
onclick={() => deleteUser(item)}
|
||||||
><LucideTrash class="mr-2 h-4 w-4" />Delete</DropdownMenu.Item
|
><LucideTrash class="mr-2 h-4 w-4" />Delete</DropdownMenu.Item
|
||||||
>
|
>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
{/snippet}
|
||||||
{/each}
|
</AdvancedTable>
|
||||||
{/if}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
|
|
||||||
{#if users?.data?.length ?? 0 > 0}
|
<OneTimeLinkModal userId={userIdToCreateOneTimeLink} />
|
||||||
<Pagination.Root
|
|
||||||
class="mt-5"
|
|
||||||
count={users.pagination.totalItems}
|
|
||||||
perPage={pagination.limit}
|
|
||||||
onPageChange={async (p) =>
|
|
||||||
(users = await userService.list(search, {
|
|
||||||
page: p,
|
|
||||||
limit: pagination.limit
|
|
||||||
}))}
|
|
||||||
bind:page={users.pagination.currentPage}
|
|
||||||
let:pages
|
|
||||||
let:currentPage
|
|
||||||
>
|
|
||||||
<Pagination.Content class="flex justify-end">
|
|
||||||
<Pagination.Item>
|
|
||||||
<Pagination.PrevButton />
|
|
||||||
</Pagination.Item>
|
|
||||||
{#each pages as page (page.key)}
|
|
||||||
{#if page.type === 'ellipsis'}
|
|
||||||
<Pagination.Item>
|
|
||||||
<Pagination.Ellipsis />
|
|
||||||
</Pagination.Item>
|
|
||||||
{:else}
|
|
||||||
<Pagination.Item>
|
|
||||||
<Pagination.Link {page} isActive={users.pagination.currentPage === page.value}>
|
|
||||||
{page.value}
|
|
||||||
</Pagination.Link>
|
|
||||||
</Pagination.Item>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
<Pagination.Item>
|
|
||||||
<Pagination.NextButton />
|
|
||||||
</Pagination.Item>
|
|
||||||
</Pagination.Content>
|
|
||||||
</Pagination.Root>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<OneTimeLinkModal {oneTimeLink} />
|
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import * as Pagination from '$lib/components/ui/pagination';
|
|
||||||
import * as Table from '$lib/components/ui/table';
|
import * as Table from '$lib/components/ui/table';
|
||||||
import AuditLogService from '$lib/services/audit-log-service';
|
import AuditLogService from '$lib/services/audit-log-service';
|
||||||
import type { AuditLog } from '$lib/types/audit-log.type';
|
import type { AuditLog } from '$lib/types/audit-log.type';
|
||||||
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
|
import type { Paginated } from '$lib/types/pagination.type';
|
||||||
|
|
||||||
let { auditLogs: initialAuditLog }: { auditLogs: Paginated<AuditLog> } = $props();
|
let { auditLogs: initialAuditLog }: { auditLogs: Paginated<AuditLog> } = $props();
|
||||||
let auditLogs = $state<Paginated<AuditLog>>(initialAuditLog);
|
let auditLogs = $state<Paginated<AuditLog>>(initialAuditLog);
|
||||||
|
|
||||||
const auditLogService = new AuditLogService();
|
const auditLogService = new AuditLogService();
|
||||||
|
|
||||||
let pagination = $state<PaginationRequest>({
|
async function fetchItems(search: string, page: number, limit: number) {
|
||||||
page: 1,
|
return await auditLogService.list({
|
||||||
limit: 15
|
page,
|
||||||
|
limit
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function toFriendlyEventString(event: string) {
|
function toFriendlyEventString(event: string) {
|
||||||
const words = event.split('_');
|
const words = event.split('_');
|
||||||
@@ -25,71 +27,22 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Table.Root>
|
<AdvancedTable
|
||||||
<Table.Header class="whitespace-nowrap">
|
items={auditLogs}
|
||||||
<Table.Row>
|
{fetchItems}
|
||||||
<Table.Head>Time</Table.Head>
|
columns={['Time', 'Event', 'Approximate Location', 'IP Address', 'Device', 'Client']}
|
||||||
<Table.Head>Event</Table.Head>
|
withoutSearch
|
||||||
<Table.Head>IP Address</Table.Head>
|
>
|
||||||
<Table.Head>Device</Table.Head>
|
{#snippet rows({ item })}
|
||||||
<Table.Head>Client</Table.Head>
|
<Table.Cell>{new Date(item.createdAt).toLocaleString()}</Table.Cell>
|
||||||
</Table.Row>
|
|
||||||
</Table.Header>
|
|
||||||
<Table.Body class="whitespace-nowrap">
|
|
||||||
{#if auditLogs.data.length === 0}
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Cell colspan={6} class="text-center">No logs found</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
{:else}
|
|
||||||
{#each auditLogs.data as auditLog}
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Cell>{new Date(auditLog.createdAt).toLocaleString()}</Table.Cell>
|
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Badge variant="outline">{toFriendlyEventString(auditLog.event)}</Badge>
|
<Badge variant="outline">{toFriendlyEventString(item.event)}</Badge>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>{auditLog.ipAddress}</Table.Cell>
|
<Table.Cell
|
||||||
<Table.Cell>{auditLog.device}</Table.Cell>
|
>{item.city && item.country ? `${item.city}, ${item.country}` : 'Unknown'}</Table.Cell
|
||||||
<Table.Cell>{auditLog.data.clientName}</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
|
|
||||||
{#if auditLogs?.data?.length ?? 0 > 0}
|
|
||||||
<Pagination.Root
|
|
||||||
class="mt-5"
|
|
||||||
count={auditLogs.pagination.totalItems}
|
|
||||||
perPage={pagination.limit}
|
|
||||||
onPageChange={async (p) =>
|
|
||||||
(auditLogs = await auditLogService.list({
|
|
||||||
page: p,
|
|
||||||
limit: pagination.limit
|
|
||||||
}))}
|
|
||||||
bind:page={auditLogs.pagination.currentPage}
|
|
||||||
let:pages
|
|
||||||
let:currentPage
|
|
||||||
>
|
>
|
||||||
<Pagination.Content class="flex justify-end">
|
<Table.Cell>{item.ipAddress}</Table.Cell>
|
||||||
<Pagination.Item>
|
<Table.Cell>{item.device}</Table.Cell>
|
||||||
<Pagination.PrevButton />
|
<Table.Cell>{item.data.clientName}</Table.Cell>
|
||||||
</Pagination.Item>
|
{/snippet}
|
||||||
{#each pages as page (page.key)}
|
</AdvancedTable>
|
||||||
{#if page.type === 'ellipsis'}
|
|
||||||
<Pagination.Item>
|
|
||||||
<Pagination.Ellipsis />
|
|
||||||
</Pagination.Item>
|
|
||||||
{:else}
|
|
||||||
<Pagination.Item>
|
|
||||||
<Pagination.Link {page} isActive={auditLogs.pagination.currentPage === page.value}>
|
|
||||||
{page.value}
|
|
||||||
</Pagination.Link>
|
|
||||||
</Pagination.Item>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
<Pagination.Item>
|
|
||||||
<Pagination.NextButton />
|
|
||||||
</Pagination.Item>
|
|
||||||
</Pagination.Content>
|
|
||||||
</Pagination.Root>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import adapter from '@sveltejs/adapter-node';
|
import adapter from '@sveltejs/adapter-node';
|
||||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
import packageJson from "./package.json" assert { type: "json" };
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
@@ -12,6 +13,9 @@ const config = {
|
|||||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||||
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
|
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
|
||||||
adapter: adapter(),
|
adapter: adapter(),
|
||||||
|
version: {
|
||||||
|
name: packageJson.version,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ test('Update account details fails with already taken email', async ({ page }) =
|
|||||||
|
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('status')).toHaveText('Email is already taken');
|
await expect(page.getByRole('status')).toHaveText('Email is already in use');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Update account details fails with already taken username', async ({ page }) => {
|
test('Update account details fails with already taken username', async ({ page }) => {
|
||||||
@@ -34,7 +34,7 @@ test('Update account details fails with already taken username', async ({ page }
|
|||||||
|
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('status')).toHaveText('Username is already taken');
|
await expect(page.getByRole('status')).toHaveText('Username is already in use');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Add passkey to an account', async ({ page }) => {
|
test('Add passkey to an account', async ({ page }) => {
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ test('Create user group', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('status')).toHaveText('User group created successfully');
|
await expect(page.getByRole('status')).toHaveText('User group created successfully');
|
||||||
expect(page.url()).toMatch(/\/settings\/admin\/user-groups\/[a-f0-9-]+/);
|
|
||||||
|
await page.waitForURL('/settings/admin/user-groups/*');
|
||||||
|
|
||||||
await expect(page.getByLabel('Friendly Name')).toHaveValue(group.friendlyName);
|
await expect(page.getByLabel('Friendly Name')).toHaveValue(group.friendlyName);
|
||||||
await expect(page.getByLabel('Name', { exact: true })).toHaveValue(group.name);
|
await expect(page.getByLabel('Name', { exact: true })).toHaveValue(group.name);
|
||||||
@@ -72,3 +73,39 @@ test('Delete user group', async ({ page }) => {
|
|||||||
await expect(page.getByRole('status')).toHaveText('User group deleted successfully');
|
await expect(page.getByRole('status')).toHaveText('User group deleted successfully');
|
||||||
await expect(page.getByRole('row', { name: group.name })).not.toBeVisible();
|
await expect(page.getByRole('row', { name: group.name })).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Update user group custom claims', async ({ page }) => {
|
||||||
|
await page.goto(`/settings/admin/user-groups/${userGroups.designers.id}`);
|
||||||
|
|
||||||
|
// Add two custom claims
|
||||||
|
await page.getByRole('button', { name: 'Add custom claim' }).click();
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Key').fill('customClaim1');
|
||||||
|
await page.getByPlaceholder('Value').fill('customClaim1_value');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Add another' }).click();
|
||||||
|
await page.getByPlaceholder('Key').nth(1).fill('customClaim2');
|
||||||
|
await page.getByPlaceholder('Value').nth(1).fill('customClaim2_value');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save' }).nth(2).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('status')).toHaveText('Custom claims updated successfully');
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Check if custom claims are saved
|
||||||
|
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim1');
|
||||||
|
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim1_value');
|
||||||
|
await expect(page.getByPlaceholder('Key').nth(1)).toHaveValue('customClaim2');
|
||||||
|
await expect(page.getByPlaceholder('Value').nth(1)).toHaveValue('customClaim2_value');
|
||||||
|
|
||||||
|
// Remove one custom claim
|
||||||
|
await page.getByLabel('Remove custom claim').first().click();
|
||||||
|
await page.getByRole('button', { name: 'Save' }).nth(2).click();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Check if custom claim is removed
|
||||||
|
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim2');
|
||||||
|
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim2_value');
|
||||||
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user