Compare commits

...

33 Commits

Author SHA1 Message Date
Elias Schneider
bd4f87b2d2 release: 0.10.0 2024-10-23 11:54:47 +02:00
Elias Schneider
6560fd9279 chore: fix wrong file name of package.json in release script 2024-10-23 11:54:35 +02:00
Elias Schneider
29d632c151 fix: cache version information for 3 hours 2024-10-23 11:48:46 +02:00
Elias Schneider
2092007752 chore: dump frontend dependencies 2024-10-23 11:37:22 +02:00
Elias Schneider
0aff6181c9 chore: improve check of required tools in one time access token script 2024-10-23 10:50:49 +02:00
Elias Schneider
824c5cb4f3 fix: no DTO was returned from exchange one time access token endpoint 2024-10-23 10:30:25 +02:00
Elias Schneider
3a300a2b51 refactor: move development scripts into seperate folder 2024-10-23 10:26:18 +02:00
Elias Schneider
a1985ce1b2 feat: add script for creating one time access token 2024-10-23 10:03:17 +02:00
Elias Schneider
b39bc4f79a refactor: save dates as unix timestamps in database 2024-10-23 10:02:11 +02:00
Elias Schneider
0a07344139 fix: improve text for initial admin account setup 2024-10-22 20:41:35 +02:00
Elias Schneider
f3f0e1d56d fix: increase callback url count 2024-10-18 20:52:56 +02:00
Elias Schneider
70ad0b4f39 feat: add version information to footer and update link if new update is available 2024-10-18 20:48:59 +02:00
Elias Schneider
2587058ded release: 0.9.0 2024-10-18 08:23:55 +02:00
Elias Schneider
ff06bf0b34 feat: add environment variable to change the caddy port in Docker 2024-10-18 08:23:06 +02:00
Elias Schneider
11ed661f86 feat: use improve table for users and audit logs 2024-10-16 08:49:19 +02:00
Elias Schneider
29748cc6c7 fix: allow copy to clipboard for client secret 2024-10-13 15:55:17 +02:00
Elias Schneider
edfb99d221 release: 0.8.1 2024-10-11 20:53:47 +02:00
Elias Schneider
282ff82b0c fix: add key id to JWK 2024-10-11 20:52:31 +02:00
Elias Schneider
9d5f83da78 chore: dump dependencies 2024-10-04 14:15:04 +02:00
Elias Schneider
896da812a3 ci/cd: create dummy GeoLite2 City database for e2e tests 2024-10-04 12:17:32 +02:00
Elias Schneider
d2b3b7647d release: 0.8.0 2024-10-04 12:11:43 +02:00
Elias Schneider
025378d14e feat: add location based on ip to the audit log 2024-10-04 12:11:10 +02:00
Elias Schneider
e033ba6d45 release: 0.7.1 2024-10-03 22:20:37 +02:00
Elias Schneider
e09562824a fix: initials don't get displayed if Gravatar avatar doesn't exist 2024-10-03 22:20:22 +02:00
Elias Schneider
08f7fd16a9 release: 0.7.0 2024-10-03 11:31:11 +02:00
Elias Schneider
be45eed125 feat!: add ability to set light and dark mode logo 2024-10-03 11:27:31 +02:00
Elias Schneider
9e94a436cc release: 0.6.0 2024-10-02 11:13:46 +02:00
Elias Schneider
f82020ccfb feat: add copy to clipboard option for OIDC client information 2024-10-02 11:03:30 +02:00
Elias Schneider
a4a90a16a9 fix: only return user groups if it is explicitly requested 2024-10-02 10:41:10 +02:00
Elias Schneider
365734ec5d feat: add gravatar profile picture integration 2024-10-02 10:02:28 +02:00
Elias Schneider
d02d8931a0 tests: add user group tests 2024-10-02 09:38:57 +02:00
Elias Schneider
24c948e6a6 feat: add user groups 2024-10-02 08:43:44 +02:00
Elias Schneider
7a54d3ae20 refactor: format caddyfiles 2024-09-27 11:10:33 +02:00
107 changed files with 2987 additions and 1261 deletions

18
.dockerignore Normal file
View 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

View File

@@ -23,6 +23,9 @@ jobs:
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
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
uses: docker/build-push-action@v4
with:

View File

@@ -16,6 +16,9 @@ jobs:
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Create dummy GeoLite2 City database
run: touch ./backend/GeoLite2-City.mmdb
- name: Build Docker Image
run: docker build -t stonith404/pocket-id .
- name: Run Docker Container

3
.gitignore vendored
View File

@@ -34,4 +34,5 @@ vite.config.ts.timestamp-*
# Application specific
data
/frontend/tests/.auth
pocket-id-backend
pocket-id-backend
/backend/GeoLite2-City.mmdb

View File

@@ -1 +1 @@
0.5.3
0.10.0

View File

@@ -1,3 +1,78 @@
## [](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)
### Bug Fixes
* initials don't get displayed if Gravatar avatar doesn't exist ([e095628](https://github.com/stonith404/pocket-id/commit/e09562824a794bc7d240e9d229709d4b389db7d5))
## [](https://github.com/stonith404/pocket-id/compare/v0.6.0...v) (2024-10-03)
### ⚠ BREAKING CHANGES
* add ability to set light and dark mode logo
### Features
* add ability to set light and dark mode logo ([be45eed](https://github.com/stonith404/pocket-id/commit/be45eed125e33e9930572660a034d5f12dc310ce))
## [](https://github.com/stonith404/pocket-id/compare/v0.5.3...v) (2024-10-02)
### Features
* add copy to clipboard option for OIDC client information ([f82020c](https://github.com/stonith404/pocket-id/commit/f82020ccfb0d4fbaa1dd98182188149d8085252a))
* add gravatar profile picture integration ([365734e](https://github.com/stonith404/pocket-id/commit/365734ec5d8966c2ab877c60cfb176b9cdc36880))
* add user groups ([24c948e](https://github.com/stonith404/pocket-id/commit/24c948e6a66f283866f6c8369c16fa6cbcfa626c))
### Bug Fixes
* only return user groups if it is explicitly requested ([a4a90a1](https://github.com/stonith404/pocket-id/commit/a4a90a16a9726569a22e42560184319b25fd7ca6))
## [](https://github.com/stonith404/pocket-id/compare/v0.5.2...v) (2024-09-26)

View File

@@ -31,10 +31,12 @@ 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/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/images ./backend/images
COPY ./scripts ./scripts
RUN chmod +x ./scripts/*.sh
EXPOSE 3000
ENV APP_ENV=production

View File

@@ -68,6 +68,10 @@ Required tools:
cd ..
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
cd ../frontend
npm install
@@ -94,7 +98,7 @@ You may need the following information:
- **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
@@ -131,6 +135,9 @@ docker compose up -d
cd ..
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
cd ../frontend
npm install
@@ -144,15 +151,16 @@ docker compose up -d
### Environment variables
| Variable | Default Value | Recommended to change | Description |
| ---------------------- | ----------------------- | --------------------- | --------------------------------------------- |
| `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. |
| `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. |
| `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. |
| `PORT` | `3000` | no | The port on which the frontend should listen. |
| `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. |
| Variable | Default Value | Recommended to change | Description |
| ---------------------- | ----------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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. |
| `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. |
| `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. |
| `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. |
## Contribute

View File

@@ -9,9 +9,15 @@
<div class="content">
<h2>New Sign-In Detected</h2>
<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>
<p class="label">IP Address</p>
<p>{{ .Data.IPAddress}}</p>
<p>{{ .Data.IPAddress }}</p>
</div>
<div>
<p class="label">Device</p>
@@ -19,7 +25,7 @@
</div>
<div>
<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>
<p class="message">
@@ -27,4 +33,4 @@
safely ignore this message. If not, please review your account and security settings.
</p>
</div>
{{ end -}}
{{ end -}}

View File

@@ -2,6 +2,9 @@
New Sign-In Detected
====================
{{ if and .Data.City .Data.Country }}
Approximate Location: {{ .Data.City }}, {{ .Data.Country }}
{{ end }}
IP Address: {{ .Data.IPAddress }}
Device: {{ .Data.Device }}
Time: {{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC"}}

View File

@@ -7,22 +7,23 @@ require (
github.com/fxamacker/cbor/v2 v2.7.0
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0
github.com/go-co-op/gocron/v2 v2.11.0
github.com/go-playground/validator/v10 v10.22.0
github.com/go-webauthn/webauthn v0.11.1
github.com/go-co-op/gocron/v2 v2.12.1
github.com/go-playground/validator/v10 v10.22.1
github.com/go-webauthn/webauthn v0.11.2
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/joho/godotenv v1.5.1
github.com/mileusna/useragent v1.3.4
golang.org/x/crypto v0.26.0
github.com/mileusna/useragent v1.3.5
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
gorm.io/driver/sqlite v1.5.6
gorm.io/gorm v1.25.11
gorm.io/gorm v1.25.12
)
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/cloudwego/base64x v0.1.4 // 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/go-playground/locales v0.14.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/google/go-tpm v0.9.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
@@ -43,22 +44,21 @@ require (
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // 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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // 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/rogpeppe/go-internal v1.11.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.9.0 // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/sys v0.23.0 // indirect
golang.org/x/text v0.17.0 // indirect
golang.org/x/arch v0.10.0 // indirect
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
golang.org/x/net v0.29.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -1,5 +1,5 @@
github.com/bytedance/sonic v1.12.1 h1:jWl5Qz1fy7X1ioY74WqO0KjAMtAGQs4sYnjiEBiyX24=
github.com/bytedance/sonic v1.12.1/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
github.com/bytedance/sonic v1.12.3 h1:W2MGa7RCU1QTeYRTPE3+88mVC0yXmsRQRChiyVocVjU=
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.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM=
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-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
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.11.0/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
github.com/go-co-op/gocron/v2 v2.12.1 h1:dCIIBFbzhWKdgXeEifBjHPzgQ1hoWhjS4289Hjjy1uw=
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/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/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/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.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-webauthn/webauthn v0.11.1 h1:5G/+dg91/VcaJHTtJUfwIlNJkLwbJCcnUc4W8VtkpzA=
github.com/go-webauthn/webauthn v0.11.1/go.mod h1:YXRm1WG0OtUyDFaVAgB5KG7kVqW+6dYCJ7FTQH4SxEE=
github.com/go-webauthn/x v0.1.12 h1:RjQ5cvApzyU/xLCiP+rub0PE4HBZsLggbxGR5ZpUf/A=
github.com/go-webauthn/x v0.1.12/go.mod h1:XlRcGkNH8PT45TfeJYc6gqpOtiOendHhVmnOxh+5yHs=
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
github.com/go-webauthn/x v0.1.14 h1:1wrB8jzXAofojJPAaRxnZhRgagvLGnLjhCAwg3kTpT0=
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/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/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4=
github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM=
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
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/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mileusna/useragent v1.3.4 h1:MiuRRuvGjEie1+yZHO88UBYg8YBC/ddF6T7F56i3PCk=
github.com/mileusna/useragent v1.3.4/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
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/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
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/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
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/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1 h1:UihPOz+oIJ5X0JsO7wEkL50fheCODsoZ9r86mJWfNMc=
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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/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.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
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.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.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.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.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.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/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
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.9.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/arch v0.10.0 h1:S3huipmSclq3PJMNe76NGwkBR504WFkQ5dhzWzP8ZW8=
golang.org/x/arch v0.10.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
golang.org/x/sys v0.23.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.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
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/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
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=
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/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" id="a" viewBox="0 0 1015 1015">
<path fill="white" d="M506.6,0c209.52,0,379.98,170.45,379.98,379.96,0,82.33-25.9,160.68-74.91,226.54-48.04,64.59-113.78,111.51-190.13,135.71l-21.1,6.7-50.29-248.04,13.91-6.73c45.41-21.95,74.76-68.71,74.76-119.11,0-72.91-59.31-132.23-132.21-132.23s-132.23,59.32-132.23,132.23c0,50.4,29.36,97.16,74.77,119.11l13.65,6.61-81.01,499.24h-226.36V0h351.18Z"/>
</svg>

After

Width:  |  Height:  |  Size: 434 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" id="a" viewBox="0 0 1015 1015">
<path fill="black" d="M506.6,0c209.52,0,379.98,170.45,379.98,379.96,0,82.33-25.9,160.68-74.91,226.54-48.04,64.59-113.78,111.51-190.13,135.71l-21.1,6.7-50.29-248.04,13.91-6.73c45.41-21.95,74.76-68.71,74.76-119.11,0-72.91-59.31-132.23-132.21-132.23s-132.23,59.32-132.23,132.23c0,50.4,29.36,97.16,74.77,119.11l13.65,6.61-81.01,499.24h-226.36V0h351.18Z"/>
</svg>

After

Width:  |  Height:  |  Size: 434 B

View File

@@ -5,24 +5,53 @@ import (
"github.com/stonith404/pocket-id/backend/internal/utils"
"log"
"os"
"strings"
)
// initApplicationImages copies the images from the images directory to the application-images directory
func initApplicationImages() {
dirPath := common.EnvConfig.UploadPath + "/application-images"
files, err := os.ReadDir(dirPath)
sourceFiles, err := os.ReadDir("./images")
if err != nil && !os.IsNotExist(err) {
log.Fatalf("Error reading directory: %v", err)
}
// Skip if files already exist
if len(files) > 1 {
return
destinationFiles, err := os.ReadDir(dirPath)
if err != nil && !os.IsNotExist(err) {
log.Fatalf("Error reading directory: %v", err)
}
// Copy files from source to destination
err = utils.CopyDirectory("./images", dirPath)
if err != nil {
log.Fatalf("Error copying directory: %v", err)
// Copy images from the images directory to the application-images directory if they don't already exist
for _, sourceFile := range sourceFiles {
if sourceFile.IsDir() || imageAlreadyExists(sourceFile.Name(), destinationFiles) {
continue
}
srcFilePath := "./images/" + sourceFile.Name()
destFilePath := dirPath + "/" + sourceFile.Name()
err := utils.CopyFile(srcFilePath, destFilePath)
if err != nil {
log.Fatalf("Error copying file: %v", err)
}
}
}
func imageAlreadyExists(fileName string, destinationFiles []os.DirEntry) bool {
for _, destinationFile := range destinationFiles {
sourceFileWithoutExtension := getImageNameWithoutExtension(fileName)
destinationFileWithoutExtension := getImageNameWithoutExtension(destinationFile.Name())
if sourceFileWithoutExtension == destinationFileWithoutExtension {
return true
}
}
return false
}
func getImageNameWithoutExtension(fileName string) string {
splitted := strings.Split(fileName, ".")
return strings.Join(splitted[:len(splitted)-1], ".")
}

View File

@@ -41,6 +41,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
userService := service.NewUserService(db, jwtService)
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService)
testService := service.NewTestService(db, appConfigService)
userGroupService := service.NewUserGroupService(db)
r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60))
@@ -57,6 +58,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService)
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService)
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
// Add test controller in non-production environments
if common.EnvConfig.AppEnv != "production" {

View File

@@ -15,4 +15,5 @@ var (
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")
)

View File

@@ -91,8 +91,20 @@ func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
}
func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
imageType := acc.appConfigService.DbConfig.LogoImageType.Value
acc.getImage(c, "logo", imageType)
lightLogo := c.DefaultQuery("light", "true") == "true"
var imageName string
var imageType string
if lightLogo {
imageName = "logoLight"
imageType = acc.appConfigService.DbConfig.LogoLightImageType.Value
} else {
imageName = "logoDark"
imageType = acc.appConfigService.DbConfig.LogoDarkImageType.Value
}
acc.getImage(c, imageName, imageType)
}
func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
@@ -105,8 +117,20 @@ func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
}
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
imageType := acc.appConfigService.DbConfig.LogoImageType.Value
acc.updateImage(c, "logo", imageType)
lightLogo := c.DefaultQuery("light", "true") == "true"
var imageName string
var imageType string
if lightLogo {
imageName = "logoLight"
imageType = acc.appConfigService.DbConfig.LogoLightImageType.Value
} else {
imageName = "logoDark"
imageType = acc.appConfigService.DbConfig.LogoDarkImageType.Value
}
acc.updateImage(c, imageName, imageType)
}
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {

View File

@@ -18,7 +18,7 @@ func NewOidcController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
group.POST("/oidc/authorize", jwtAuthMiddleware.Add(false), oc.authorizeHandler)
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/clients", jwtAuthMiddleware.Add(true), oc.listClientsHandler)
@@ -91,7 +91,7 @@ func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
func (oc *OidcController) createIDTokenHandler(c *gin.Context) {
func (oc *OidcController) createTokensHandler(c *gin.Context) {
var input dto.OidcIdTokenDto
if err := c.ShouldBind(&input); err != nil {

View File

@@ -161,8 +161,14 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err)
return
}
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
c.JSON(http.StatusOK, user)
c.JSON(http.StatusOK, userDto)
}
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {

View File

@@ -0,0 +1,162 @@
package controller
import (
"errors"
"net/http"
"strconv"
"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/middleware"
"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) {
ugc := UserGroupController{
UserGroupService: userGroupService,
}
group.GET("/user-groups", jwtAuthMiddleware.Add(true), ugc.list)
group.GET("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.get)
group.POST("/user-groups", jwtAuthMiddleware.Add(true), ugc.create)
group.PUT("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.update)
group.DELETE("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.delete)
group.PUT("/user-groups/:id/users", jwtAuthMiddleware.Add(true), ugc.updateUsers)
}
type UserGroupController struct {
UserGroupService *service.UserGroupService
}
func (ugc *UserGroupController) list(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
searchTerm := c.Query("search")
groups, pagination, err := ugc.UserGroupService.List(searchTerm, page, pageSize)
if err != nil {
utils.ControllerError(c, err)
return
}
var groupsDto = make([]dto.UserGroupDtoWithUserCount, len(groups))
for i, group := range groups {
var groupDto dto.UserGroupDtoWithUserCount
if err := dto.MapStruct(group, &groupDto); err != nil {
utils.ControllerError(c, err)
return
}
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(group.ID)
if err != nil {
utils.ControllerError(c, err)
return
}
groupsDto[i] = groupDto
}
c.JSON(http.StatusOK, gin.H{
"data": groupsDto,
"pagination": pagination,
})
}
func (ugc *UserGroupController) get(c *gin.Context) {
group, err := ugc.UserGroupService.Get(c.Param("id"))
if err != nil {
utils.ControllerError(c, err)
return
}
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, groupDto)
}
func (ugc *UserGroupController) create(c *gin.Context) {
var input dto.UserGroupCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err)
return
}
group, err := ugc.UserGroupService.Create(input)
if err != nil {
if errors.Is(err, common.ErrNameAlreadyInUse) {
utils.CustomControllerError(c, http.StatusConflict, err.Error())
} else {
utils.ControllerError(c, err)
}
return
}
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusCreated, groupDto)
}
func (ugc *UserGroupController) update(c *gin.Context) {
var input dto.UserGroupCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err)
return
}
group, err := ugc.UserGroupService.Update(c.Param("id"), input)
if err != nil {
if errors.Is(err, common.ErrNameAlreadyInUse) {
utils.CustomControllerError(c, http.StatusConflict, err.Error())
} else {
utils.ControllerError(c, err)
}
return
}
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, groupDto)
}
func (ugc *UserGroupController) delete(c *gin.Context) {
if err := ugc.UserGroupService.Delete(c.Param("id")); err != nil {
utils.ControllerError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
var input dto.UserGroupUpdateUsersDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err)
return
}
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input)
if err != nil {
utils.ControllerError(c, err)
return
}
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, groupDto)
}

View File

@@ -11,6 +11,8 @@ type AuditLogDto struct {
Event model.AuditLogEvent `json:"event"`
IpAddress string `json:"ipAddress"`
Country string `json:"country"`
City string `json:"city"`
Device string `json:"device"`
UserID string `json:"userID"`
Data model.AuditLogData `json:"data"`

View File

@@ -2,7 +2,9 @@ package dto
import (
"errors"
"github.com/stonith404/pocket-id/backend/internal/model/types"
"reflect"
"time"
)
// MapStructList maps a list of source structs to a list of destination structs
@@ -57,15 +59,37 @@ func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
// Handle direct assignment for simple types
if sourceField.Type() == destField.Type() {
destField.Set(sourceField)
} else if sourceField.Kind() == reflect.Slice && destField.Kind() == reflect.Slice {
// Handle slices
if sourceField.Type().Elem() == destField.Type().Elem() {
// Direct assignment for slices of primitive types or non-struct elements
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
for j := 0; j < sourceField.Len(); j++ {
newSlice.Index(j).Set(sourceField.Index(j))
}
destField.Set(newSlice)
} else if sourceField.Type().Elem().Kind() == reflect.Struct && destField.Type().Elem().Kind() == reflect.Struct {
// Recursively map slices of structs
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
for j := 0; j < sourceField.Len(); j++ {
// Get the element from both source and destination slice
sourceElem := sourceField.Index(j)
destElem := reflect.New(destField.Type().Elem()).Elem()
// Recursively map the struct elements
if err := mapStructInternal(sourceElem, destElem); err != nil {
return err
}
// Set the mapped element in the new slice
newSlice.Index(j).Set(destElem)
}
destField.Set(newSlice)
}
} else if sourceField.Kind() == reflect.Struct && destField.Kind() == reflect.Struct {
@@ -73,7 +97,18 @@ func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
if err := mapStructInternal(sourceField, destField); err != nil {
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()))
}
}
}
}
}

View File

@@ -0,0 +1,32 @@
package dto
import "time"
type UserGroupDtoWithUsers struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
Users []UserDto `json:"users"`
CreatedAt time.Time `json:"createdAt"`
}
type UserGroupDtoWithUserCount struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
UserCount int64 `json:"userCount"`
CreatedAt time.Time `json:"createdAt"`
}
type UserGroupCreateDto struct {
FriendlyName string `json:"friendlyName" binding:"required,min=3,max=30"`
Name string `json:"name" binding:"required,min=3,max=30,userGroupName"`
}
type UserGroupUpdateUsersDto struct {
UserIDs []string `json:"userIds" binding:"required"`
}
type AssignUserToGroupDto struct {
UserID string `json:"userId" binding:"required"`
}

View File

@@ -28,6 +28,13 @@ var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
return matched
}
var validateUserGroupName validator.Func = func(fl validator.FieldLevel) bool {
// [a-z0-9_] : The group name can only contain lowercase letters, numbers, and underscores
regex := "^[a-z0-9_]+$"
matched, _ := regexp.MatchString(regex, fl.Field().String())
return matched
}
func init() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("urlList", validateUrlList); err != nil {
@@ -39,4 +46,10 @@ func init() {
log.Fatalf("Failed to register custom validation: %v", err)
}
}
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("userGroupName", validateUserGroupName); err != nil {
log.Fatalf("Failed to register custom validation: %v", err)
}
}
}

View File

@@ -4,7 +4,6 @@ import (
"github.com/go-co-op/gocron/v2"
"github.com/google/uuid"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils"
"gorm.io/gorm"
"log"
"time"
@@ -30,22 +29,22 @@ type Jobs struct {
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
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
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
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
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) {

View File

@@ -11,7 +11,8 @@ type AppConfigVariable struct {
type AppConfig struct {
AppName AppConfigVariable
BackgroundImageType AppConfigVariable
LogoImageType AppConfigVariable
LogoLightImageType AppConfigVariable
LogoDarkImageType AppConfigVariable
SessionDuration AppConfigVariable
EmailEnabled AppConfigVariable

View File

@@ -11,6 +11,8 @@ type AuditLog struct {
Event AuditLogEvent
IpAddress string
Country string
City string
UserAgent string
UserID string
Data AuditLogData

View File

@@ -2,6 +2,7 @@ package model
import (
"github.com/google/uuid"
model "github.com/stonith404/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
"time"
)
@@ -9,12 +10,13 @@ import (
// Base contains common columns for all tables.
type Base struct {
ID string `gorm:"primaryKey;not null"`
CreatedAt time.Time
CreatedAt model.DateTime
}
func (b *Base) BeforeCreate(_ *gorm.DB) (err error) {
if b.ID == "" {
b.ID = uuid.New().String()
}
b.CreatedAt = model.DateTime(time.Now())
return
}

View File

@@ -4,8 +4,8 @@ import (
"database/sql/driver"
"encoding/json"
"errors"
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
"time"
)
type UserAuthorizedOidcClient struct {
@@ -23,7 +23,7 @@ type OidcAuthorizationCode struct {
Code string
Scope string
Nonce string
ExpiresAt time.Time
ExpiresAt datatype.DateTime
UserID string
User User

View 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)
}

View File

@@ -3,7 +3,7 @@ package model
import (
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"time"
"github.com/stonith404/pocket-id/backend/internal/model/types"
)
type User struct {
@@ -15,6 +15,7 @@ type User struct {
LastName string
IsAdmin bool
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
Credentials []WebauthnCredential
}
@@ -60,7 +61,7 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
type OneTimeAccessToken struct {
Base
Token string
ExpiresAt time.Time
ExpiresAt datatype.DateTime
UserID string
User User

View File

@@ -0,0 +1,8 @@
package model
type UserGroup struct {
Base
FriendlyName string
Name string `gorm:"unique"`
Users []User `gorm:"many2many:user_groups_users;"`
}

View File

@@ -47,8 +47,14 @@ var defaultDbConfig = model.AppConfig{
IsInternal: true,
Value: "jpg",
},
LogoImageType: model.AppConfigVariable{
Key: "logoImageType",
LogoLightImageType: model.AppConfigVariable{
Key: "logoLightImageType",
Type: "string",
IsInternal: true,
Value: "svg",
},
LogoDarkImageType: model.AppConfigVariable{
Key: "logoDarkImageType",
Type: "string",
IsInternal: true,
Value: "svg",

View File

@@ -2,11 +2,13 @@ package service
import (
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/utils"
"github.com/stonith404/pocket-id/backend/internal/utils/email"
"gorm.io/gorm"
"log"
"net/netip"
)
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
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{
Event: event,
IpAddress: ipAddress,
Country: country,
City: city,
UserAgent: userAgent,
UserID: userID,
Data: data,
@@ -61,6 +70,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID
Email: user.Email,
}, NewLoginTemplate, &NewLoginTemplateData{
IPAddress: ipAddress,
Country: createdAuditLog.Country,
City: createdAuditLog.City,
Device: s.DeviceStringFromUserAgent(userAgent),
DateTime: createdAuditLog.CreatedAt.UTC(),
})
@@ -86,3 +97,29 @@ func (s *AuditLogService) DeviceStringFromUserAgent(userAgent string) string {
ua := userAgentParser.Parse(userAgent)
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
}

View File

@@ -29,6 +29,8 @@ var NewLoginTemplate = email.Template[NewLoginTemplateData]{
type NewLoginTemplateData struct {
IPAddress string
Country string
City string
Device string
DateTime time.Time
}

View File

@@ -3,6 +3,7 @@ package service
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
@@ -51,6 +52,7 @@ type AccessTokenJWTClaims struct {
}
type JWK struct {
Kid string `json:"kid"`
Kty string `json:"kty"`
Use string `json:"use"`
Alg string `json:"alg"`
@@ -98,7 +100,15 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
},
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.Header["kid"] = kid
return token.SignedString(s.privateKey)
}
@@ -137,9 +147,17 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID
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.Header["kid"] = kid
return token.SignedString(s.privateKey)
}
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
claim := jwt.RegisteredClaims{
Subject: user.ID,
@@ -148,7 +166,15 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
Audience: jwt.ClaimStrings{clientID},
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.Header["kid"] = kid
return token.SignedString(s.privateKey)
}
@@ -174,7 +200,13 @@ func (s *JwtService) GetJWK() (JWK, error) {
return JWK{}, errors.New("public key is not initialized")
}
kid, err := s.generateKeyID(s.publicKey)
if err != nil {
return JWK{}, err
}
jwk := JWK{
Kid: kid,
Kty: "RSA",
Use: "sig",
Alg: "RS256",
@@ -185,6 +217,25 @@ func (s *JwtService) GetJWK() (JWK, error) {
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.
func (s *JwtService) generateKeys() error {
if err := os.MkdirAll(filepath.Dir(privateKeyPath), 0700); err != nil {

View File

@@ -6,6 +6,7 @@ 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"
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
"github.com/stonith404/pocket-id/backend/internal/utils"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
@@ -115,7 +116,7 @@ func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret strin
return "", "", common.ErrOidcInvalidAuthorizationCode
}
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.Before(time.Now()) {
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
return "", "", common.ErrOidcInvalidAuthorizationCode
}
@@ -301,7 +302,7 @@ func (s *OidcService) DeleteClientLogo(clientID string) error {
func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (map[string]interface{}, error) {
var authorizedOidcClient model.UserAuthorizedOidcClient
if err := s.db.Preload("User").First(&authorizedOidcClient, "user_id = ? AND client_id = ?", userID, clientID).Error; err != nil {
if err := s.db.Preload("User.UserGroups").First(&authorizedOidcClient, "user_id = ? AND client_id = ?", userID, clientID).Error; err != nil {
return nil, err
}
@@ -316,6 +317,14 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
claims["email"] = user.Email
}
if strings.Contains(scope, "groups") {
userGroups := make([]string, len(user.UserGroups))
for i, group := range user.UserGroups {
userGroups[i] = group.Name
}
claims["groups"] = userGroups
}
profileClaims := map[string]interface{}{
"given_name": user.FirstName,
"family_name": user.LastName,
@@ -342,7 +351,7 @@ func (s *OidcService) createAuthorizationCode(clientID string, userID string, sc
}
oidcAuthorizationCode := model.OidcAuthorizationCode{
ExpiresAt: time.Now().Add(15 * time.Minute),
ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)),
Code: randomString,
ClientID: clientID,
UserID: userID,

View File

@@ -6,6 +6,7 @@ import (
"encoding/base64"
"fmt"
"github.com/fxamacker/cbor/v2"
"github.com/stonith404/pocket-id/backend/internal/model/types"
"log"
"os"
"time"
@@ -56,6 +57,30 @@ func (s *TestService) SeedDatabase() error {
}
}
userGroups := []model.UserGroup{
{
Base: model.Base{
ID: "4110f814-56f1-4b28-8998-752b69bc97c0e",
},
Name: "developers",
FriendlyName: "Developers",
Users: []model.User{users[0], users[1]},
},
{
Base: model.Base{
ID: "adab18bf-f89d-4087-9ee1-70ff15b48211",
},
Name: "designers",
FriendlyName: "Designers",
Users: []model.User{users[0]},
},
}
for _, group := range userGroups {
if err := tx.Create(&group).Error; err != nil {
return err
}
}
oidcClients := []model.OidcClient{
{
Base: model.Base{
@@ -87,7 +112,7 @@ func (s *TestService) SeedDatabase() error {
Code: "auth-code",
Scope: "openid profile",
Nonce: "nonce",
ExpiresAt: time.Now().Add(1 * time.Hour),
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
}
@@ -97,7 +122,7 @@ func (s *TestService) SeedDatabase() error {
accessToken := model.OneTimeAccessToken{
Token: "one-time-token",
ExpiresAt: time.Now().Add(1 * time.Hour),
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[0].ID,
}
if err := tx.Create(&accessToken).Error; err != nil {

View File

@@ -0,0 +1,111 @@
package service
import (
"errors"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils"
"gorm.io/gorm"
)
type UserGroupService struct {
db *gorm.DB
}
func NewUserGroupService(db *gorm.DB) *UserGroupService {
return &UserGroupService{db: db}
}
func (s *UserGroupService) List(name string, page int, pageSize int) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
query := s.db.Model(&model.UserGroup{})
if name != "" {
query = query.Where("name LIKE ?", "%"+name+"%")
}
response, err = utils.Paginate(page, pageSize, query, &groups)
return groups, response, err
}
func (s *UserGroupService) Get(id string) (group model.UserGroup, err error) {
err = s.db.Where("id = ?", id).Preload("Users").First(&group).Error
return group, err
}
func (s *UserGroupService) Delete(id string) error {
var group model.UserGroup
if err := s.db.Where("id = ?", id).First(&group).Error; err != nil {
return err
}
return s.db.Delete(&group).Error
}
func (s *UserGroupService) Create(input dto.UserGroupCreateDto) (group model.UserGroup, err error) {
group = model.UserGroup{
FriendlyName: input.FriendlyName,
Name: input.Name,
}
if err := s.db.Preload("Users").Create(&group).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.UserGroup{}, common.ErrNameAlreadyInUse
}
return model.UserGroup{}, err
}
return group, nil
}
func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto) (group model.UserGroup, err error) {
group, err = s.Get(id)
if err != nil {
return model.UserGroup{}, err
}
group.Name = input.Name
group.FriendlyName = input.FriendlyName
if err := s.db.Preload("Users").Save(&group).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.UserGroup{}, common.ErrNameAlreadyInUse
}
return model.UserGroup{}, err
}
return group, nil
}
func (s *UserGroupService) UpdateUsers(id string, input dto.UserGroupUpdateUsersDto) (group model.UserGroup, err error) {
group, err = s.Get(id)
if err != nil {
return model.UserGroup{}, err
}
// Fetch the users based on UserIDs in input
var users []model.User
if len(input.UserIDs) > 0 {
if err := s.db.Where("id IN (?)", input.UserIDs).Find(&users).Error; err != nil {
return model.UserGroup{}, err
}
}
// Replace the current users with the new set of users
if err := s.db.Model(&group).Association("Users").Replace(users); err != nil {
return model.UserGroup{}, err
}
// Save the updated group
if err := s.db.Save(&group).Error; err != nil {
return model.UserGroup{}, err
}
return group, nil
}
func (s *UserGroupService) GetUserCountOfGroup(id string) (int64, error) {
var group model.UserGroup
if err := s.db.Preload("Users").Where("id = ?", id).First(&group).Error; err != nil {
return 0, err
}
return s.db.Model(&group).Association("Users").Count(), nil
}

View File

@@ -5,6 +5,7 @@ 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"
"github.com/stonith404/pocket-id/backend/internal/model/types"
"github.com/stonith404/pocket-id/backend/internal/utils"
"gorm.io/gorm"
"time"
@@ -95,7 +96,7 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
oneTimeAccessToken := model.OneTimeAccessToken{
UserID: userID,
ExpiresAt: expiresAt,
ExpiresAt: datatype.DateTime(expiresAt),
Token: randomString,
}
@@ -108,7 +109,7 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, string, error) {
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) {
return model.User{}, "", common.ErrTokenInvalidOrExpired
}

View File

@@ -38,7 +38,7 @@ func CopyDirectory(srcDir, destDir string) error {
srcFilePath := filepath.Join(srcDir, file.Name())
destFilePath := filepath.Join(destDir, file.Name())
err := copyFile(srcFilePath, destFilePath)
err := CopyFile(srcFilePath, destFilePath)
if err != nil {
return err
}
@@ -47,7 +47,7 @@ func CopyDirectory(srcDir, destDir string) error {
return nil
}
func copyFile(srcFilePath, destFilePath string) error {
func CopyFile(srcFilePath, destFilePath string) error {
srcFile, err := os.Open(srcFilePath)
if err != nil {
return err

View File

@@ -5,9 +5,10 @@ import (
)
type PaginationResponse struct {
TotalPages int64 `json:"totalPages"`
TotalItems int64 `json:"totalItems"`
CurrentPage int `json:"currentPage"`
TotalPages int64 `json:"totalPages"`
TotalItems int64 `json:"totalItems"`
CurrentPage int `json:"currentPage"`
ItemsPerPage int `json:"itemsPerPage"`
}
func Paginate(page int, pageSize int, db *gorm.DB, result interface{}) (PaginationResponse, error) {
@@ -33,8 +34,9 @@ func Paginate(page int, pageSize int, db *gorm.DB, result interface{}) (Paginati
}
return PaginationResponse{
TotalPages: (totalItems + int64(pageSize) - 1) / int64(pageSize),
TotalItems: totalItems,
CurrentPage: page,
TotalPages: (totalItems + int64(pageSize) - 1) / int64(pageSize),
TotalItems: totalItems,
CurrentPage: page,
ItemsPerPage: pageSize,
}, nil
}

View File

@@ -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)
}

View File

@@ -0,0 +1,2 @@
DROP TABLE user_groups;
DROP TABLE user_groups_users;

View File

@@ -0,0 +1,16 @@
CREATE TABLE user_groups
(
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME,
friendly_name TEXT NOT NULL,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE user_groups_users
(
user_id TEXT NOT NULL,
user_group_id TEXT NOT NULL,
PRIMARY KEY (user_id, user_group_id),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE
);

View File

@@ -0,0 +1,2 @@
ALTER TABLE audit_logs DROP COLUMN country;
ALTER TABLE audit_logs DROP COLUMN city;

View File

@@ -0,0 +1,2 @@
ALTER TABLE audit_logs ADD COLUMN country TEXT;
ALTER TABLE audit_logs ADD COLUMN city TEXT;

View 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');

View 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);

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "pocket-id-frontend",
"version": "0.0.1",
"version": "0.10.0",
"private": true,
"scripts": {
"dev": "vite dev --port 3000",
@@ -12,46 +12,46 @@
"format": "prettier --write ."
},
"devDependencies": {
"@playwright/test": "^1.46.1",
"@sveltejs/adapter-auto": "^3.2.4",
"@sveltejs/adapter-node": "^5.2.2",
"@sveltejs/kit": "^2.5.24",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@types/eslint": "^9.6.0",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^22.5.0",
"@playwright/test": "^1.48.1",
"@sveltejs/adapter-auto": "^3.3.0",
"@sveltejs/adapter-node": "^5.2.8",
"@sveltejs/kit": "^2.7.2",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/eslint": "^9.6.1",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.7.9",
"autoprefixer": "^10.4.20",
"cbor-js": "^0.1.0",
"eslint": "^9.9.1",
"eslint": "^9.13.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.40.0",
"globals": "^15.9.0",
"postcss": "^8.4.41",
"eslint-plugin-svelte": "^2.46.0",
"globals": "^15.11.0",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.6",
"prettier-plugin-tailwindcss": "^0.6.6",
"svelte": "^5.0.0-next.1",
"svelte-check": "^3.8.6",
"tailwindcss": "^3.4.10",
"tslib": "^2.7.0",
"typescript": "^5.5.4",
"typescript-eslint": "^8.2.0",
"vite": "^5.4.2"
"prettier-plugin-svelte": "^3.2.7",
"prettier-plugin-tailwindcss": "^0.6.8",
"svelte": "^5.0.5",
"svelte-check": "^4.0.5",
"tailwindcss": "^3.4.14",
"tslib": "^2.8.0",
"typescript": "^5.6.3",
"typescript-eslint": "^8.11.0",
"vite": "^5.4.10"
},
"type": "module",
"dependencies": {
"@simplewebauthn/browser": "^10.0.0",
"axios": "^1.7.5",
"bits-ui": "^0.21.13",
"axios": "^1.7.7",
"bits-ui": "^0.21.16",
"clsx": "^2.1.1",
"crypto": "^1.0.1",
"formsnap": "^1.0.1",
"jsonwebtoken": "^9.0.2",
"lucide-svelte": "^0.435.0",
"lucide-svelte": "^0.453.0",
"mode-watcher": "^0.4.1",
"svelte-sonner": "^0.3.27",
"sveltekit-superforms": "^2.17.0",
"tailwind-merge": "^2.5.2",
"svelte-sonner": "^0.3.28",
"sveltekit-superforms": "^2.20.0",
"tailwind-merge": "^2.5.4",
"tailwind-variants": "^0.2.1",
"zod": "^3.23.8"
}

View File

@@ -97,16 +97,4 @@
font-weight: 700;
src: url('/fonts/PlayfairDisplay-Bold.woff') format('woff');
}
}
@layer components {
.application-images-grid {
@apply flex flex-wrap justify-between gap-x-5 gap-y-8;
}
@media (max-width: 1127px) {
.application-images-grid {
justify-content: flex-start;
@apply gap-x-20;
}
}
}
}

View File

@@ -0,0 +1,158 @@
<script lang="ts" generics="T extends {id:string}">
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
import { Input } from '$lib/components/ui/input/index.js';
import * as Pagination from '$lib/components/ui/pagination';
import * as Select from '$lib/components/ui/select';
import * as Table from '$lib/components/ui/table/index.js';
import type { Paginated } from '$lib/types/pagination.type';
import { debounced } from '$lib/utils/debounce-util';
import type { Snippet } from 'svelte';
let {
items,
selectedIds = $bindable(),
withoutSearch = false,
fetchItems,
columns,
rows
}: {
items: Paginated<T>;
selectedIds?: string[];
withoutSearch?: boolean;
fetchItems: (search: string, page: number, limit: number) => Promise<Paginated<T>>;
columns: (string | { label: string; hidden?: boolean })[];
rows: Snippet<[{ item: T }]>;
} = $props();
let availablePageSizes: number[] = [10, 20, 50, 100];
let allChecked = $derived.by(() => {
if (!selectedIds || items.data.length === 0) return false;
for (const item of items.data) {
if (!selectedIds.includes(item.id)) {
return false;
}
}
return true;
});
const onSearch = debounced(async (searchValue: string) => {
items = await fetchItems(searchValue, 1, items.pagination.itemsPerPage);
}, 300);
async function onAllCheck(checked: boolean) {
if (checked) {
selectedIds = items.data.map((item) => item.id);
} else {
selectedIds = [];
}
}
async function onCheck(checked: boolean, id: string) {
if (!selectedIds) return;
if (checked) {
selectedIds = [...selectedIds, id];
} else {
selectedIds = selectedIds.filter((selectedId) => selectedId !== id);
}
}
async function onPageChange(page: number) {
items = await fetchItems('', page, items.pagination.itemsPerPage);
}
async function onPageSizeChange(size: number) {
items = await fetchItems('', 1, size);
}
</script>
<div class="w-full">
{#if !withoutSearch}
<Input
class="mb-4 max-w-sm"
placeholder={'Search...'}
type="text"
oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
/>
{/if}
<Table.Root>
<Table.Header>
<Table.Row>
{#if selectedIds}
<Table.Head>
<Checkbox checked={allChecked} onCheckedChange={(c) => onAllCheck(c as boolean)} />
</Table.Head>
{/if}
{#each columns as column}
{#if typeof column === 'string'}
<Table.Head>{column}</Table.Head>
{:else}
<Table.Head class={column.hidden ? 'sr-only' : ''}>{column.label}</Table.Head>
{/if}
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each items.data as item}
<Table.Row class={selectedIds?.includes(item.id) ? 'bg-muted/20' : ''}>
{#if selectedIds}
<Table.Cell>
<Checkbox
checked={selectedIds.includes(item.id)}
onCheckedChange={(c) => onCheck(c as boolean, item.id)}
/>
</Table.Cell>
{/if}
{@render rows({ item })}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<div class="mt-5 flex items-center justify-between space-x-2">
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">Items per page</p>
<Select.Root
selected={{
label: items.pagination.itemsPerPage.toString(),
value: items.pagination.itemsPerPage
}}
onSelectedChange={(v) => onPageSizeChange(v?.value as number)}
>
<Select.Trigger class="h-9 w-[80px]">
<Select.Value>{items.pagination.itemsPerPage}</Select.Value>
</Select.Trigger>
<Select.Content>
{#each availablePageSizes as size}
<Select.Item value={size}>{size}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Pagination.Root
class="mx-0 w-auto"
count={items.pagination.totalItems}
perPage={items.pagination.itemsPerPage}
{onPageChange}
page={items.pagination.currentPage}
let:pages
>
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis'}
<Pagination.Item>
<Pagination.Link {page} isActive={items.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
</Pagination.Root>
</div>
</div>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip';
import { LucideCheck } from 'lucide-svelte';
import type { Snippet } from 'svelte';
let { value, children }: { value: string; children: Snippet } = $props();
let open = $state(false);
let copied = $state(false);
function onClick() {
open = true;
copyToClipboard();
}
function onOpenChange(state: boolean) {
open = state;
if (!state) {
copied = false;
}
}
function copyToClipboard() {
navigator.clipboard.writeText(value);
copied = true;
setTimeout(() => onOpenChange(false), 1000);
}
</script>
<button onclick={onClick}>
<Tooltip.Root closeOnPointerDown={false} {onOpenChange} {open}>
<Tooltip.Trigger>{@render children()}</Tooltip.Trigger>
<Tooltip.Content onclick={copyToClipboard}>
{#if copied}
<span class="flex items-center"><LucideCheck class="mr-1 h-4 w-4" /> Copied</span>
{:else}
<span>Click to copy</span>
{/if}
</Tooltip.Content>
</Tooltip.Root>
</button>

View File

@@ -3,7 +3,7 @@
import type { FormInput } from '$lib/utils/form-util';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { Input } from './ui/input';
import { Input, type FormInputEvent } from './ui/input';
let {
input = $bindable(),
@@ -12,6 +12,7 @@
disabled = false,
type = 'text',
children,
onInput,
...restProps
}: HTMLAttributes<HTMLDivElement> & {
input?: FormInput<string | boolean | number>;
@@ -19,6 +20,7 @@
description?: string;
disabled?: boolean;
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox';
onInput?: (e: FormInputEvent) => void;
children?: Snippet;
} = $props();
@@ -34,7 +36,7 @@
{#if children}
{@render children()}
{:else if input}
<Input {id} {type} bind:value={input.value} {disabled} />
<Input {id} {type} bind:value={input.value} {disabled} on:input={(e) => onInput?.(e)} />
{/if}
{#if input?.error}
<p class="mt-1 text-sm text-red-500">{input.error}</p>

View File

@@ -3,6 +3,7 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import WebAuthnService from '$lib/services/webauthn-service';
import userStore from '$lib/stores/user-store';
import { createSHA256hash } from '$lib/utils/crypto-util';
import { LucideLogOut, LucideUser } from 'lucide-svelte';
const webauthnService = new WebAuthnService();
@@ -11,6 +12,13 @@
($userStore!.firstName.charAt(0) + $userStore!.lastName?.charAt(0)).toUpperCase()
);
let gravatarURL: string | undefined = $state();
if ($userStore) {
createSHA256hash($userStore.email).then((email) => {
gravatarURL = `https://www.gravatar.com/avatar/${email}?d=404`;
});
}
async function logout() {
await webauthnService.logout();
window.location.reload();
@@ -19,7 +27,8 @@
<DropdownMenu.Root>
<DropdownMenu.Trigger
><Avatar.Root>
><Avatar.Root class="h-9 w-9">
<Avatar.Image src={gravatarURL} />
<Avatar.Fallback>{initials}</Avatar.Fallback>
</Avatar.Root></DropdownMenu.Trigger
>

View File

@@ -12,7 +12,11 @@
</script>
<div class=" w-full {isAuthPage ? 'absolute top-0 z-10 mt-4' : 'border-b'}">
<div class="mx-auto flex w-full max-w-[1640px] items-center justify-between px-4 md:px-10">
<div
class="{!isAuthPage
? 'max-w-[1640px]'
: ''} mx-auto flex w-full items-center justify-between px-4 md:px-10"
>
<div class="flex h-16 items-center">
{#if !isAuthPage}
<Logo class="mr-3 h-10 w-10" />

View File

@@ -1 +1,10 @@
<img class={$$restProps.class} src="/api/application-configuration/logo" alt="Logo" />
<script lang="ts">
import { mode } from 'mode-watcher';
import type { HTMLAttributes } from 'svelte/elements';
let { ...props }: HTMLAttributes<HTMLImageElement> = $props();
const isDarkMode = $derived($mode === 'dark');
</script>
<img {...props} src="/api/application-configuration/logo?light={!isDarkMode}" alt="Logo" />

View File

@@ -0,0 +1,34 @@
import { Select as SelectPrimitive } from "bits-ui";
import Label from "./select-label.svelte";
import Item from "./select-item.svelte";
import Content from "./select-content.svelte";
import Trigger from "./select-trigger.svelte";
import Separator from "./select-separator.svelte";
const Root = SelectPrimitive.Root;
const Group = SelectPrimitive.Group;
const Input = SelectPrimitive.Input;
const Value = SelectPrimitive.Value;
export {
Root,
Group,
Input,
Label,
Item,
Value,
Content,
Trigger,
Separator,
//
Root as Select,
Group as SelectGroup,
Input as SelectInput,
Label as SelectLabel,
Item as SelectItem,
Value as SelectValue,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
};

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { scale } from "svelte/transition";
import { cn, flyAndScale } from "$lib/utils/style.js";
type $$Props = SelectPrimitive.ContentProps;
type $$Events = SelectPrimitive.ContentEvents;
export let sideOffset: $$Props["sideOffset"] = 4;
export let inTransition: $$Props["inTransition"] = flyAndScale;
export let inTransitionConfig: $$Props["inTransitionConfig"] = undefined;
export let outTransition: $$Props["outTransition"] = scale;
export let outTransitionConfig: $$Props["outTransitionConfig"] = {
start: 0.95,
opacity: 0,
duration: 50,
};
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<SelectPrimitive.Content
{inTransition}
{inTransitionConfig}
{outTransition}
{outTransitionConfig}
{sideOffset}
class={cn(
"bg-popover text-popover-foreground relative z-50 min-w-[8rem] overflow-hidden rounded-md border shadow-md outline-none",
className
)}
{...$$restProps}
on:keydown
>
<div class="w-full p-1">
<slot />
</div>
</SelectPrimitive.Content>

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import Check from "lucide-svelte/icons/check";
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils/style.js";
type $$Props = SelectPrimitive.ItemProps;
type $$Events = SelectPrimitive.ItemEvents;
let className: $$Props["class"] = undefined;
export let value: $$Props["value"];
export let label: $$Props["label"] = undefined;
export let disabled: $$Props["disabled"] = undefined;
export { className as class };
</script>
<SelectPrimitive.Item
{value}
{disabled}
{label}
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]: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",
className
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerleave
on:pointermove
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check class="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<slot>
{label || value}
</slot>
</SelectPrimitive.Item>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils/style.js";
type $$Props = SelectPrimitive.LabelProps;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<SelectPrimitive.Label
class={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...$$restProps}
>
<slot />
</SelectPrimitive.Label>

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils/style.js";
type $$Props = SelectPrimitive.SeparatorProps;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<SelectPrimitive.Separator class={cn("bg-muted -mx-1 my-1 h-px", className)} {...$$restProps} />

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import ChevronDown from "lucide-svelte/icons/chevron-down";
import { cn } from "$lib/utils/style.js";
type $$Props = SelectPrimitive.TriggerProps;
type $$Events = SelectPrimitive.TriggerEvents;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<SelectPrimitive.Trigger
class={cn(
"border-input bg-background ring-offset-background focus-visible:ring-ring aria-[invalid]:border-destructive data-[placeholder]:[&>span]:text-muted-foreground flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...$$restProps}
let:builder
on:click
on:keydown
>
<slot {builder} />
<div>
<ChevronDown class="h-4 w-4 opacity-50" />
</div>
</SelectPrimitive.Trigger>

View File

@@ -0,0 +1,15 @@
import { Tooltip as TooltipPrimitive } from "bits-ui";
import Content from "./tooltip-content.svelte";
const Root = TooltipPrimitive.Root;
const Trigger = TooltipPrimitive.Trigger;
export {
Root,
Trigger,
Content,
//
Root as Tooltip,
Content as TooltipContent,
Trigger as TooltipTrigger,
};

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
import { cn, flyAndScale } from "$lib/utils/style.js";
type $$Props = TooltipPrimitive.ContentProps;
let className: $$Props["class"] = undefined;
export let sideOffset: $$Props["sideOffset"] = 4;
export let transition: $$Props["transition"] = flyAndScale;
export let transitionConfig: $$Props["transitionConfig"] = {
y: 8,
duration: 150,
};
export { className as class };
</script>
<TooltipPrimitive.Content
{transition}
{transitionConfig}
{sideOffset}
class={cn(
"bg-popover text-popover-foreground z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md",
className
)}
{...$$restProps}
>
<slot />
</TooltipPrimitive.Content>

View File

@@ -13,7 +13,7 @@ abstract class APIService {
if (browser) {
this.api.defaults.baseURL = '/api';
} else {
this.api.defaults.baseURL = process?.env?.INTERNAL_BACKEND_URL + '/api';
this.api.defaults.baseURL = process!.env!.INTERNAL_BACKEND_URL + '/api';
}
}
}

View File

@@ -1,7 +1,6 @@
import type {
AllAppConfig,
AppConfigRawResponse
} from '$lib/types/application-configuration';
import { version as currentVersion } from '$app/environment';
import type { AllAppConfig, AppConfigRawResponse } from '$lib/types/application-configuration';
import axios from 'axios';
import APIService from './api-service';
export default class AppConfigService extends APIService {
@@ -33,11 +32,13 @@ export default class AppConfigService extends APIService {
await this.api.put(`/application-configuration/favicon`, formData);
}
async updateLogo(logo: File) {
async updateLogo(logo: File, light = true) {
const formData = new FormData();
formData.append('file', logo!);
await this.api.put(`/application-configuration/logo`, formData);
await this.api.put(`/application-configuration/logo`, formData, {
params: { light }
});
}
async updateBackgroundImage(backgroundImage: File) {
@@ -46,4 +47,19 @@ export default class AppConfigService extends APIService {
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
};
}
}

View File

@@ -4,14 +4,8 @@ import APIService from './api-service';
class AuditLogService extends APIService {
async list(pagination?: PaginationRequest) {
const page = pagination?.page || 1;
const limit = pagination?.limit || 10;
const res = await this.api.get('/audit-logs', {
params: {
page,
limit
}
params: pagination
});
return res.data as Paginated<AuditLog>;
}

View File

@@ -3,7 +3,7 @@ import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
import APIService from './api-service';
class OidcService extends APIService {
async authorize(clientId: string, scope: string, callbackURL : string, nonce?: string) {
async authorize(clientId: string, scope: string, callbackURL: string, nonce?: string) {
const res = await this.api.post('/oidc/authorize', {
scope,
nonce,
@@ -26,14 +26,10 @@ class OidcService extends APIService {
}
async listClients(search?: string, pagination?: PaginationRequest) {
const page = pagination?.page || 1;
const limit = pagination?.limit || 10;
const res = await this.api.get('/oidc/clients', {
params: {
search,
page,
limit
...pagination
}
});
return res.data as Paginated<OidcClient>;

View File

@@ -0,0 +1,43 @@
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
import type {
UserGroupCreate,
UserGroupWithUserCount,
UserGroupWithUsers
} from '$lib/types/user-group.type';
import APIService from './api-service';
export default class UserGroupService extends APIService {
async list(search?: string, pagination?: PaginationRequest) {
const res = await this.api.get('/user-groups', {
params: {
search,
...pagination
}
});
return res.data as Paginated<UserGroupWithUserCount>;
}
async get(id: string) {
const res = await this.api.get(`/user-groups/${id}`);
return res.data as UserGroupWithUsers;
}
async create(user: UserGroupCreate) {
const res = await this.api.post('/user-groups', user);
return res.data as UserGroupWithUsers;
}
async update(id: string, user: UserGroupCreate) {
const res = await this.api.put(`/user-groups/${id}`, user);
return res.data as UserGroupWithUsers;
}
async remove(id: string) {
await this.api.delete(`/user-groups/${id}`);
}
async updateUsers(id: string, userIds: string[]) {
const res = await this.api.put(`/user-groups/${id}/users`, { userIds });
return res.data as UserGroupWithUsers;
}
}

View File

@@ -4,14 +4,10 @@ import APIService from './api-service';
export default class UserService extends APIService {
async list(search?: string, pagination?: PaginationRequest) {
const page = pagination?.page || 1;
const limit = pagination?.limit || 10;
const res = await this.api.get('/users', {
params: {
search,
page,
limit
...pagination
}
});
return res.data as Paginated<User>;

View File

@@ -16,3 +16,9 @@ export type AppConfigRawResponse = {
type: string;
value: string;
}[];
export type AppVersionInformation = {
isUpToDate: boolean;
newestVersion: string;
currentVersion: string;
};

View File

@@ -2,6 +2,8 @@ export type AuditLog = {
id: string;
event: string;
ipAddress: string;
country?: string;
city?: string;
device: string;
createdAt: string;
data: any;

View File

@@ -7,6 +7,7 @@ export type PaginationResponse = {
totalPages: number;
totalItems: number;
currentPage: number;
itemsPerPage: number;
};
export type Paginated<T> = {

View File

@@ -0,0 +1,18 @@
import type { User } from './user.type';
export type UserGroup = {
id: string;
friendlyName: string;
name: string;
createdAt: string;
};
export type UserGroupWithUsers = UserGroup & {
users: User[];
};
export type UserGroupWithUserCount = UserGroup & {
userCount: number;
};
export type UserGroupCreate = Pick<UserGroup, 'friendlyName' | 'name'>;

View File

@@ -0,0 +1,7 @@
export async function createSHA256hash(input: string) {
const msgUint8 = new TextEncoder().encode(input); // encode as (utf-8) Uint8Array
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8); // hash the message
const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); // convert bytes to hex string
return hashHex;
}

View File

@@ -9,7 +9,7 @@
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication } from '@simplewebauthn/browser';
import { AxiosError } from 'axios';
import { LucideMail, LucideUser } from 'lucide-svelte';
import { LucideMail, LucideUser, LucideUsers } from 'lucide-svelte';
import { slide } from 'svelte/transition';
import type { PageData } from './$types';
import ClientProviderImages from './components/client-provider-images.svelte';
@@ -113,6 +113,13 @@
description="View your profile information"
/>
{/if}
{#if scope!.includes('groups')}
<ScopeItem
icon={LucideUsers}
name="Groups"
description="View the groups you are a member of"
/>
{/if}
</div>
</Card.Content>
</Card.Root>

View File

@@ -33,11 +33,19 @@
<Logo class="h-10 w-10" />
</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">
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.
{#if data.token === 'setup'}
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.
{/if}
</p>
<Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button>
</SignInWrapper>

View 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
};
};

View File

@@ -1,14 +1,20 @@
<script lang="ts">
import { page } from '$app/stores';
import userStore from '$lib/stores/user-store';
import { LucideExternalLink } from 'lucide-svelte';
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types';
let {
children
children,
data
}: {
children: Snippet;
data: LayoutData;
} = $props();
const { versionInformation } = data;
let links = $state([
{ href: '/settings/account', label: 'My Account' },
{ href: '/settings/audit-log', label: 'Audit Log' }
@@ -18,6 +24,7 @@
links = [
...links,
{ href: '/settings/admin/users', label: 'Users' },
{ href: '/settings/admin/user-groups', label: 'User Groups' },
{ href: '/settings/admin/oidc-clients', label: 'OIDC Clients' },
{ href: '/settings/admin/application-configuration', label: 'Application Configuration' }
];
@@ -25,8 +32,10 @@
</script>
<section>
<div class="bg-muted/40 min-h-screen w-full">
<main class="mx-auto flex max-w-[1640px] flex-col gap-x-4 gap-y-10 p-4 md:p-10 lg:flex-row">
<div class="bg-muted/40 flex min-h-[calc(100vh-64px)] w-full flex-col justify-between">
<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 class="mx-auto grid w-full gap-2">
<h1 class="mb-5 text-3xl font-semibold">Settings</h1>
@@ -40,6 +49,15 @@
{label}
</a>
{/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>
</div>
</div>
@@ -47,5 +65,15 @@
{@render children()}
</div>
</main>
<div class="flex flex-col items-center">
<p class="text-muted-foreground py-3 text-xs">
Powered by <a
class="text-white"
href="https://github.com/stonith404/pocket-id"
target="_blank">Pocket ID</a
>
({versionInformation.currentVersion})
</p>
</div>
</div>
</section>

View File

@@ -28,17 +28,19 @@
}
async function updateImages(
logo: File | null,
logoLight: File | null,
logoDark: File | null,
backgroundImage: File | null,
favicon: File | null
) {
const faviconPromise = favicon ? appConfigService.updateFavicon(favicon) : Promise.resolve();
const logoPromise = logo ? appConfigService.updateLogo(logo) : Promise.resolve();
const lightLogoPromise = logoLight ? appConfigService.updateLogo(logoLight, true) : Promise.resolve();
const darkLogoPromise = logoDark ? appConfigService.updateLogo(logoDark, false) : Promise.resolve();
const backgroundImagePromise = backgroundImage
? appConfigService.updateBackgroundImage(backgroundImage)
: Promise.resolve();
await Promise.all([logoPromise, backgroundImagePromise, faviconPromise])
await Promise.all([lightLogoPromise, darkLogoPromise, backgroundImagePromise, faviconPromise])
.then(() => toast.success('Images updated successfully'))
.catch(axiosErrorToast);
}

View File

@@ -8,9 +8,10 @@
id,
imageClass,
label,
image = $bindable<File | null>(null),
image = $bindable(),
imageURL,
accept = 'image/png, image/jpeg, image/svg+xml',
forceColorScheme,
...restProps
}: HTMLAttributes<HTMLDivElement> & {
id: string;
@@ -18,6 +19,7 @@
label: string;
image: File | null;
imageURL: string;
forceColorScheme?: 'light' | 'dark';
accept?: string;
} = $props();
@@ -37,10 +39,16 @@
}
</script>
<div {...restProps}>
<Label for={id}>{label}</Label>
<div class="flex flex-col items-start md:flex-row md:items-center" {...restProps}>
<Label class="w-52" for={id}>{label}</Label>
<FileInput {id} variant="secondary" {accept} onchange={onImageChange}>
<div class="bg-muted group relative flex items-center rounded">
<div
class="{forceColorScheme === 'light'
? 'bg-[#F1F1F5]'
: forceColorScheme === 'dark'
? 'bg-[#27272A]'
: 'bg-muted'} group relative flex items-center rounded"
>
<img
class={cn(
'h-full w-full rounded object-cover p-3 transition-opacity duration-200 group-hover:opacity-10',

View File

@@ -5,15 +5,21 @@
let {
callback
}: {
callback: (logo: File | null, backgroundImage: File | null, favicon: File | null) => void;
callback: (
logoLight: File | null,
logoDark: File | null,
backgroundImage: File | null,
favicon: File | null
) => void;
} = $props();
let logo = $state<File | null>(null);
let logoLight = $state<File | null>(null);
let logoDark = $state<File | null>(null);
let backgroundImage = $state<File | null>(null);
let favicon = $state<File | null>(null);
</script>
<div class="application-images-grid">
<div class="flex flex-col gap-8">
<ApplicationImage
id="favicon"
imageClass="h-14 w-14 p-2"
@@ -23,15 +29,23 @@
accept="image/x-icon"
/>
<ApplicationImage
id="logo"
id="logo-light"
imageClass="h-32 w-32"
label="Logo"
bind:image={logo}
imageURL="/api/application-configuration/logo"
label="Light Mode Logo"
bind:image={logoLight}
imageURL="/api/application-configuration/logo?light=true"
forceColorScheme="light"
/>
<ApplicationImage
id="logo-dark"
imageClass="h-32 w-32"
label="Dark Mode Logo"
bind:image={logoDark}
imageURL="/api/application-configuration/logo?light=false"
forceColorScheme="dark"
/>
<ApplicationImage
id="background-image"
class="basis-full lg:basis-auto"
imageClass="h-[350px] max-w-[500px]"
label="Background Image"
bind:image={backgroundImage}
@@ -39,5 +53,7 @@
/>
</div>
<div class="flex justify-end">
<Button class="mt-5" onclick={() => callback(logo, backgroundImage, favicon)}>Save</Button>
<Button class="mt-5" onclick={() => callback(logoLight, logoDark, backgroundImage, favicon)}
>Save</Button
>
</div>

View File

@@ -2,6 +2,7 @@
import { beforeNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { openConfirmDialog } from '$lib/components/confirm-dialog';
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import Label from '$lib/components/ui/label/label.svelte';
@@ -25,8 +26,7 @@
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
PKCE: 'Disabled'
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`
};
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
@@ -89,14 +89,22 @@
<div class="flex flex-col">
<div class="mb-2 flex">
<Label class="mb-0 w-44">Client ID</Label>
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
<CopyToClipboard value={client.id}>
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
</CopyToClipboard>
</div>
<div class="mb-2 mt-1 flex items-center">
<Label class="w-44">Client secret</Label>
<span class="text-muted-foreground text-sm" data-testid="client-secret"
>{$clientSecretStore ?? '••••••••••••••••••••••••••••••••'}</span
>
{#if !$clientSecretStore}
{#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
>
<Button
class="ml-2"
onclick={createClientSecret}
@@ -111,7 +119,9 @@
{#each Object.entries(setupDetails) as [key, value]}
<div class="mb-5 flex">
<Label class="mb-0 w-44">{key}</Label>
<span class="text-muted-foreground text-sm">{value}</span>
<CopyToClipboard {value}>
<span class="text-muted-foreground text-sm">{value}</span>
</CopyToClipboard>
</div>
{/each}
</div>

View File

@@ -16,7 +16,7 @@
children?: Snippet;
} = $props();
const limit = 5;
const limit = 20;
</script>
<div {...restProps}>
@@ -25,15 +25,15 @@
{#each callbackURLs as _, i}
<div class="flex gap-x-2">
<Input data-testid={`callback-url-${i + 1}`} bind:value={callbackURLs[i]} />
{#if callbackURLs.length > 1}
<Button
variant="outline"
size="sm"
on:click={() => callbackURLs = callbackURLs.filter((_, index) => index !== i)}
>
<LucideMinus class="h-4 w-4" />
</Button>
{/if}
{#if callbackURLs.length > 1}
<Button
variant="outline"
size="sm"
on:click={() => (callbackURLs = callbackURLs.filter((_, index) => index !== i))}
>
<LucideMinus class="h-4 w-4" />
</Button>
{/if}
</div>
{/each}
</div>
@@ -46,7 +46,7 @@
class="mt-2"
variant="secondary"
size="sm"
on:click={() => callbackURLs = [...callbackURLs, '']}
on:click={() => (callbackURLs = [...callbackURLs, ''])}
>
<LucidePlus class="mr-1 h-4 w-4" />
Add another

View File

@@ -0,0 +1,8 @@
import UserGroupService from '$lib/services/user-group-service';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => {
const userGroupService = new UserGroupService(cookies.get('access_token'));
const userGroups = await userGroupService.list();
return userGroups;
};

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import UserGroupService from '$lib/services/user-group-service';
import type { Paginated } from '$lib/types/pagination.type';
import type { UserGroupCreate, UserGroupWithUserCount } from '$lib/types/user-group.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideMinus } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import UserGroupForm from './user-group-form.svelte';
import UserGroupList from './user-group-list.svelte';
let { data } = $props();
let userGroups: Paginated<UserGroupWithUserCount> = $state(data);
let expandAddUserGroup = $state(false);
const userGroupService = new UserGroupService();
async function createUserGroup(userGroup: UserGroupCreate) {
let success = true;
await userGroupService
.create(userGroup)
.then((createdUserGroup) => {
toast.success('User group created successfully');
goto(`/settings/admin/user-groups/${createdUserGroup.id}`);
})
.catch((e) => {
axiosErrorToast(e);
success = false;
});
return success;
}
</script>
<svelte:head>
<title>User Groups</title>
</svelte:head>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Create User Group</Card.Title>
<Card.Description>Create a new group that can be assigned to users.</Card.Description>
</div>
{#if !expandAddUserGroup}
<Button on:click={() => (expandAddUserGroup = true)}>Add Group</Button>
{:else}
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddUserGroup = false)}>
<LucideMinus class="h-5 w-5" />
</Button>
{/if}
</div>
</Card.Header>
{#if expandAddUserGroup}
<div transition:slide>
<Card.Content>
<UserGroupForm callback={createUserGroup} />
</Card.Content>
</div>
{/if}
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Manage User Groups</Card.Title>
</Card.Header>
<Card.Content>
<UserGroupList {userGroups} />
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,9 @@
import UserGroupService from '$lib/services/user-group-service';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, cookies }) => {
const userGroupService = new UserGroupService(cookies.get('access_token'));
const userGroup = await userGroupService.get(params.id);
return { userGroup };
};

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import UserGroupService from '$lib/services/user-group-service';
import UserService from '$lib/services/user-service';
import type { UserGroupCreate } from '$lib/types/user-group.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideChevronLeft } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import UserGroupForm from '../user-group-form.svelte';
import UserSelection from '../user-selection.svelte';
let { data } = $props();
let userGroup = $state({
...data.userGroup,
userIds: data.userGroup.users.map((u) => u.id)
});
const userGroupService = new UserGroupService();
const userService = new UserService();
async function updateUserGroup(updatedUserGroup: UserGroupCreate) {
let success = true;
await userGroupService
.update(userGroup.id, updatedUserGroup)
.then(() => toast.success('User group updated successfully'))
.catch((e) => {
axiosErrorToast(e);
success = false;
});
return success;
}
async function updateUserGroupUsers(userIds: string[]) {
await userGroupService
.updateUsers(userGroup.id, userIds)
.then(() => toast.success('Users updated successfully'))
.catch((e) => {
axiosErrorToast(e);
});
}
</script>
<svelte:head>
<title>User Group Details {userGroup.name}</title>
</svelte:head>
<div>
<a class="text-muted-foreground flex text-sm" href="/settings/admin/user-groups"
><LucideChevronLeft class="h-5 w-5" /> Back</a
>
</div>
<Card.Root>
<Card.Header>
<Card.Title>Meta data</Card.Title>
</Card.Header>
<Card.Content>
<UserGroupForm existingUserGroup={userGroup} callback={updateUserGroup} />
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Users</Card.Title>
<Card.Description>Assign users to this group.</Card.Description>
</Card.Header>
<Card.Content>
{#await userService.list() then users}
<UserSelection {users} bind:selectedUserIds={userGroup.userIds} />
{/await}
<div class="mt-5 flex justify-end">
<Button on:click={() => updateUserGroupUsers(userGroup.userIds)}>Save</Button>
</div>
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import type { UserGroupCreate } from '$lib/types/user-group.type';
import { createForm } from '$lib/utils/form-util';
import { z } from 'zod';
let {
callback,
existingUserGroup
}: {
existingUserGroup?: UserGroupCreate;
callback: (userGroup: UserGroupCreate) => Promise<boolean>;
} = $props();
let isLoading = $state(false);
let hasManualNameEdit = $state(!!existingUserGroup?.friendlyName);
const userGroup = {
name: existingUserGroup?.name || '',
friendlyName: existingUserGroup?.friendlyName || ''
};
const formSchema = z.object({
friendlyName: z.string().min(2).max(30),
name: z
.string()
.min(2)
.max(30)
.regex(/^[a-z0-9_]+$/, 'Name can only contain lowercase letters, numbers, and underscores')
});
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, userGroup);
function onFriendlyNameInput(e: any) {
if (!hasManualNameEdit) {
$inputs.name.value = e.target!.value.toLowerCase().replace(/[^a-z0-9_]/g, '_');
}
}
function onNameInput(_: Event) {
hasManualNameEdit = true;
}
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
const success = await callback(data);
// Reset form if user group was successfully created
if (success && !existingUserGroup) {
form.reset();
hasManualNameEdit = false;
}
isLoading = false;
}
</script>
<form onsubmit={onSubmit}>
<div class="flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput
label="Friendly Name"
description="Name that will be displayed in the UI"
bind:input={$inputs.friendlyName}
onInput={onFriendlyNameInput}
/>
</div>
<div class="w-full">
<FormInput
label="Name"
description={`Name that will be in the "groups" claim`}
bind:input={$inputs.name}
onInput={onNameInput}
/>
</div>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button>
</div>
</form>

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import AdvancedTable from '$lib/components/advanced-table.svelte';
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Table from '$lib/components/ui/table';
import UserGroupService from '$lib/services/user-group-service';
import type { Paginated } from '$lib/types/pagination.type';
import type { UserGroup, UserGroupWithUserCount } from '$lib/types/user-group.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucidePencil, LucideTrash } from 'lucide-svelte';
import Ellipsis from 'lucide-svelte/icons/ellipsis';
import { toast } from 'svelte-sonner';
let { userGroups: initialUserGroups }: { userGroups: Paginated<UserGroupWithUserCount> } =
$props();
let userGroups = $state<Paginated<UserGroupWithUserCount>>(initialUserGroups);
const userGroupService = new UserGroupService();
async function deleteUserGroup(userGroup: UserGroup) {
openConfirmDialog({
title: `Delete ${userGroup.name}`,
message: 'Are you sure you want to delete this user group?',
confirm: {
label: 'Delete',
destructive: true,
action: async () => {
try {
await userGroupService.remove(userGroup.id);
userGroups = await userGroupService.list();
} catch (e) {
axiosErrorToast(e);
}
toast.success('User group deleted successfully');
}
}
});
}
async function fetchItems(search: string, page: number, limit: number) {
return userGroupService.list(search, { page, limit });
}
</script>
<AdvancedTable items={userGroups} {fetchItems} columns={['Friendly Name', 'Name', 'User Count', {label: "Actions", hidden: true}]}>
{#snippet rows({ item })}
<Table.Cell>{item.friendlyName}</Table.Cell>
<Table.Cell>{item.name}</Table.Cell>
<Table.Cell>{item.userCount}</Table.Cell>
<Table.Cell class="flex justify-end">
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild let:builder>
<Button aria-haspopup="true" size="icon" variant="ghost" builders={[builder]}>
<Ellipsis class="h-4 w-4" />
<span class="sr-only">Toggle menu</span>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item href="/settings/admin/user-groups/{item.id}"
><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item
>
<DropdownMenu.Item
class="text-red-500 focus:!text-red-700"
on:click={() => deleteUserGroup(item)}
><LucideTrash class="mr-2 h-4 w-4" />Delete</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{/snippet}
</AdvancedTable>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import AdvancedTable from '$lib/components/advanced-table.svelte';
import * as Table from '$lib/components/ui/table';
import UserService from '$lib/services/user-service';
import type { Paginated } from '$lib/types/pagination.type';
import type { User } from '$lib/types/user.type';
let {
users: initialUsers,
selectedUserIds = $bindable()
}: { users: Paginated<User>; selectedUserIds: string[] } = $props();
const userService = new UserService();
let users = $state(initialUsers);
function fetchItems(search: string, page: number, limit: number) {
return userService.list(search, { page, limit });
}
</script>
<AdvancedTable
items={users}
{fetchItems}
columns={['Name', 'Email']}
bind:selectedIds={selectedUserIds}
>
{#snippet rows({ item })}
<Table.Cell>{item.firstName} {item.lastName}</Table.Cell>
<Table.Cell>{item.email}</Table.Cell>
{/snippet}
</AdvancedTable>

View File

@@ -9,7 +9,7 @@
import { LucideMinus } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import CreateUser from './user-form.svelte';
import UserForm from './user-form.svelte';
import UserList from './user-list.svelte';
let { data } = $props();
@@ -56,7 +56,7 @@
{#if expandAddUser}
<div transition:slide>
<Card.Content>
<CreateUser callback={createUser} />
<UserForm callback={createUser} />
</Card.Content>
</div>
{/if}

View File

@@ -1,16 +1,14 @@
<script lang="ts">
import { page } from '$app/stores';
import AdvancedTable from '$lib/components/advanced-table.svelte';
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
import { Badge } from '$lib/components/ui/badge/index';
import { Button } from '$lib/components/ui/button';
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 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 { debounced } from '$lib/utils/debounce-util';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideLink, LucidePencil, LucideTrash } from 'lucide-svelte';
import Ellipsis from 'lucide-svelte/icons/ellipsis';
@@ -19,23 +17,17 @@
let { users: initialUsers }: { users: Paginated<User> } = $props();
let users = $state<Paginated<User>>(initialUsers);
let oneTimeLink = $state<string | null>(null);
$effect(() => {
users = initialUsers;
});
let oneTimeLink = $state<string | null>(null);
const userService = new UserService();
let pagination = $state<PaginationRequest>({
page: 1,
limit: 10
});
let search = $state('');
const debouncedSearch = debounced(async (searchValue: string) => {
users = await userService.list(searchValue, pagination);
}, 400);
function fetchItems(search: string, page: number, limit: number) {
return userService.list(search, { page, limit });
}
async function deleteUser(user: User) {
openConfirmDialog({
@@ -47,7 +39,7 @@
action: async () => {
try {
await userService.remove(user.id);
users = await userService.list(search, pagination);
users = await userService.list();
} catch (e) {
axiosErrorToast(e);
}
@@ -67,105 +59,51 @@
}
</script>
<Input
type="search"
placeholder="Search users"
bind:value={search}
on:input={(e) => debouncedSearch((e.target as HTMLInputElement).value)}
/>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="hidden md:table-cell">First name</Table.Head>
<Table.Head class="hidden md:table-cell">Last name</Table.Head>
<Table.Head>Email</Table.Head>
<Table.Head>Username</Table.Head>
<Table.Head class="hidden lg:table-cell">Role</Table.Head>
<Table.Head>
<span class="sr-only">Actions</span>
</Table.Head>
</Table.Row>
</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">
<Badge variant="outline">{user.isAdmin ? 'Admin' : 'User'}</Badge>
</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild let:builder>
<Button aria-haspopup="true" size="icon" variant="ghost" builders={[builder]}>
<Ellipsis class="h-4 w-4" />
<span class="sr-only">Toggle menu</span>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item on:click={() => createOneTimeAccessToken(user.id)}
><LucideLink class="mr-2 h-4 w-4" />One-time link</DropdownMenu.Item
>
<DropdownMenu.Item href="/settings/admin/users/{user.id}"
><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item
>
<DropdownMenu.Item
class="text-red-500 focus:!text-red-700"
on:click={() => deleteUser(user)}
><LucideTrash class="mr-2 h-4 w-4" />Delete</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
{#if users?.data?.length ?? 0 > 0}
<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}
<AdvancedTable
items={users}
{fetchItems}
columns={[
'First name',
'Last name',
'Email',
'Username',
'Role',
{ label: 'Actions', hidden: true }
]}
withoutSearch
>
{#snippet rows({ item })}
<Table.Cell>{item.firstName}</Table.Cell>
<Table.Cell>{item.lastName}</Table.Cell>
<Table.Cell>{item.email}</Table.Cell>
<Table.Cell>{item.username}</Table.Cell>
<Table.Cell class="hidden lg:table-cell">
<Badge variant="outline">{item.isAdmin ? 'Admin' : 'User'}</Badge>
</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild let:builder>
<Button aria-haspopup="true" size="icon" variant="ghost" builders={[builder]}>
<Ellipsis class="h-4 w-4" />
<span class="sr-only">Toggle menu</span>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item on:click={() => createOneTimeAccessToken(item.id)}
><LucideLink class="mr-2 h-4 w-4" />One-time link</DropdownMenu.Item
>
<DropdownMenu.Item href="/settings/admin/users/{item.id}"
><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item
>
<DropdownMenu.Item
class="text-red-500 focus:!text-red-700"
on:click={() => deleteUser(item)}
><LucideTrash class="mr-2 h-4 w-4" />Delete</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{/snippet}
</AdvancedTable>
<OneTimeLinkModal {oneTimeLink} />

View File

@@ -1,20 +1,22 @@
<script lang="ts">
import AdvancedTable from '$lib/components/advanced-table.svelte';
import { Badge } from '$lib/components/ui/badge';
import * as Pagination from '$lib/components/ui/pagination';
import * as Table from '$lib/components/ui/table';
import AuditLogService from '$lib/services/audit-log-service';
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 = $state<Paginated<AuditLog>>(initialAuditLog);
const auditLogService = new AuditLogService();
let pagination = $state<PaginationRequest>({
page: 1,
limit: 15
});
async function fetchItems(search: string, page: number, limit: number) {
return await auditLogService.list({
page,
limit
});
}
function toFriendlyEventString(event: string) {
const words = event.split('_');
@@ -25,71 +27,22 @@
}
</script>
<Table.Root>
<Table.Header class="whitespace-nowrap">
<Table.Row>
<Table.Head>Time</Table.Head>
<Table.Head>Event</Table.Head>
<Table.Head>IP Address</Table.Head>
<Table.Head>Device</Table.Head>
<Table.Head>Client</Table.Head>
</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>
<Badge variant="outline">{toFriendlyEventString(auditLog.event)}</Badge>
</Table.Cell>
<Table.Cell>{auditLog.ipAddress}</Table.Cell>
<Table.Cell>{auditLog.device}</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">
<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={auditLogs.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
</Pagination.Root>
{/if}
<AdvancedTable
items={auditLogs}
{fetchItems}
columns={['Time', 'Event', 'Approximate Location', 'IP Address', 'Device', 'Client']}
withoutSearch
>
{#snippet rows({ item })}
<Table.Cell>{new Date(item.createdAt).toLocaleString()}</Table.Cell>
<Table.Cell>
<Badge variant="outline">{toFriendlyEventString(item.event)}</Badge>
</Table.Cell>
<Table.Cell
>{item.city && item.country ? `${item.city}, ${item.country}` : 'Unknown'}</Table.Cell
>
<Table.Cell>{item.ipAddress}</Table.Cell>
<Table.Cell>{item.device}</Table.Cell>
<Table.Cell>{item.data.clientName}</Table.Cell>
{/snippet}
</AdvancedTable>

View File

@@ -1,5 +1,6 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import packageJson from "./package.json" assert { type: "json" };
/** @type {import('@sveltejs/kit').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.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter(),
version: {
name: packageJson.version,
}
}
};

View File

@@ -52,7 +52,8 @@ test('Update application images', async ({ page }) => {
await page.goto('/settings/admin/application-configuration');
await page.getByLabel('Favicon').setInputFiles('tests/assets/w3-schools-favicon.ico');
await page.getByLabel('Logo').setInputFiles('tests/assets/pingvin-share-logo.png');
await page.getByLabel('Light Mode Logo').setInputFiles('tests/assets/pingvin-share-logo.png');
await page.getByLabel('Dark Mode Logo').setInputFiles('tests/assets/nextcloud-logo.png');
await page.getByLabel('Background Image').setInputFiles('tests/assets/clouds.jpg');
await page.getByRole('button', { name: 'Save' }).nth(1).click();
@@ -62,9 +63,11 @@ test('Update application images', async ({ page }) => {
.get('/api/application-configuration/favicon')
.then((res) => expect.soft(res.status()).toBe(200));
await page.request
.get('/api/application-configuration/logo')
.get('/api/application-configuration/logo?light=true')
.then((res) => expect.soft(res.status()).toBe(200));
await page.request
.get('/api/application-configuration/logo?light=false')
.then((res) => expect.soft(res.status()).toBe(200));
await page.request
.get('/api/application-configuration/background-image')
.then((res) => expect.soft(res.status()).toBe(200));

View File

@@ -38,3 +38,20 @@ export const oidcClients = {
secondCallbackUrl: 'http://pingvin.share/auth/callback2'
}
};
export const userGroups = {
developers: {
id: '4110f814-56f1-4b28-8998-752b69bc97c0e',
friendlyName: 'Developers',
name: 'developers'
},
designers: {
id: 'adab18bf-f89d-4087-9ee1-70ff15b48211',
friendlyName: 'Designers',
name: 'designers'
},
humanResources: {
friendlyName: 'Human Resources',
name: 'human_resources'
}
};

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