Compare commits

...

17 Commits

Author SHA1 Message Date
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
87 changed files with 2271 additions and 825 deletions

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.8.1

View File

@@ -1,3 +1,49 @@
## [](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,6 +31,7 @@ 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

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

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

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

@@ -57,15 +57,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 {

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

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

@@ -15,6 +15,7 @@ type User struct {
LastName string
IsAdmin bool
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
Credentials []WebauthnCredential
}

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

@@ -301,7 +301,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 +316,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,

View File

@@ -56,6 +56,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{

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

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

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -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",
"@playwright/test": "^1.47.2",
"@sveltejs/adapter-auto": "^3.2.5",
"@sveltejs/adapter-node": "^5.2.5",
"@sveltejs/kit": "^2.6.1",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@types/eslint": "^9.6.0",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^22.5.0",
"@types/eslint": "^9.6.1",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.7.4",
"autoprefixer": "^10.4.20",
"cbor-js": "^0.1.0",
"eslint": "^9.9.1",
"eslint": "^9.11.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.40.0",
"globals": "^15.9.0",
"postcss": "^8.4.41",
"eslint-plugin-svelte": "^2.44.1",
"globals": "^15.10.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",
"prettier-plugin-svelte": "^3.2.7",
"prettier-plugin-tailwindcss": "^0.6.8",
"svelte": "^5.0.0-next.262",
"svelte-check": "^4.0.4",
"tailwindcss": "^3.4.13",
"tslib": "^2.7.0",
"typescript": "^5.5.4",
"typescript-eslint": "^8.2.0",
"vite": "^5.4.2"
"typescript": "^5.6.2",
"typescript-eslint": "^8.8.0",
"vite": "^5.4.8"
},
"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.447.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.19.0",
"tailwind-merge": "^2.5.3",
"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,154 @@
<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(),
fetchItems,
columns,
rows
}: {
items: Paginated<T>;
selectedIds?: string[];
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">
<Input
class="mb-4 max-w-sm"
placeholder={'Search...'}
type="text"
oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
/>
<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,4 @@
import type {
AllAppConfig,
AppConfigRawResponse
} from '$lib/types/application-configuration';
import type { AllAppConfig, AppConfigRawResponse } from '$lib/types/application-configuration';
import APIService from './api-service';
export default class AppConfigService extends APIService {
@@ -33,11 +30,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) {

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

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

@@ -18,6 +18,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' }
];

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';
@@ -26,7 +27,6 @@
'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'
};
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
@@ -89,7 +89,9 @@
<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>
@@ -111,7 +113,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

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

@@ -30,6 +30,7 @@
<Table.Row>
<Table.Head>Time</Table.Head>
<Table.Head>Event</Table.Head>
<Table.Head>Approximate Location</Table.Head>
<Table.Head>IP Address</Table.Head>
<Table.Head>Device</Table.Head>
<Table.Head>Client</Table.Head>
@@ -47,6 +48,7 @@
<Table.Cell>
<Badge variant="outline">{toFriendlyEventString(auditLog.event)}</Badge>
</Table.Cell>
<Table.Cell>{auditLog.city && auditLog.country ? `${auditLog.city}, ${auditLog.country}` : 'Unknown'}</Table.Cell>
<Table.Cell>{auditLog.ipAddress}</Table.Cell>
<Table.Cell>{auditLog.device}</Table.Cell>
<Table.Cell>{auditLog.data.clientName}</Table.Cell>

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

View File

@@ -0,0 +1,74 @@
import test, { expect } from '@playwright/test';
import { userGroups, users } from './data';
import { cleanupBackend } from './utils/cleanup.util';
test.beforeEach(cleanupBackend);
test('Create user group', async ({ page }) => {
await page.goto('/settings/admin/user-groups');
const group = userGroups.humanResources;
await page.getByRole('button', { name: 'Add Group' }).click();
await page.getByLabel('Friendly Name').fill(group.friendlyName);
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('status')).toHaveText('User group created successfully');
expect(page.url()).toMatch(/\/settings\/admin\/user-groups\/[a-f0-9-]+/);
await expect(page.getByLabel('Friendly Name')).toHaveValue(group.friendlyName);
await expect(page.getByLabel('Name', { exact: true })).toHaveValue(group.name);
});
test('Edit user group', async ({ page }) => {
await page.goto('/settings/admin/user-groups');
const group = userGroups.developers;
await page.getByRole('row', { name: group.name }).getByRole('button').click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await page.getByLabel('Friendly Name').fill('Developers updated');
await expect(page.getByLabel('Name', { exact: true })).toHaveValue(group.name);
await page.getByLabel('Name', { exact: true }).fill('developers_updated');
await page.getByRole('button', { name: 'Save' }).nth(0).click();
await expect(page.getByRole('status')).toHaveText('User group updated successfully');
await expect(page.getByLabel('Friendly Name')).toHaveValue('Developers updated');
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('developers_updated');
});
test('Update user group users', async ({ page }) => {
const group = userGroups.designers;
await page.goto(`/settings/admin/user-groups/${group.id}`);
await page.getByRole('row', { name: users.tim.email }).getByRole('checkbox').click();
await page.getByRole('row', { name: users.craig.email }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Save' }).nth(1).click();
await expect(page.getByRole('status')).toHaveText('Users updated successfully');
await page.reload();
await expect(
page.getByRole('row', { name: users.tim.email }).getByRole('checkbox')
).toHaveAttribute('data-state', 'unchecked');
await expect(
page.getByRole('row', { name: users.craig.email }).getByRole('checkbox')
).toHaveAttribute('data-state', 'checked');
});
test('Delete user group', async ({ page }) => {
const group = userGroups.developers;
await page.goto('/settings/admin/user-groups');
await page.getByRole('row', { name: group.name }).getByRole('button').click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('status')).toHaveText('User group deleted successfully');
await expect(page.getByRole('row', { name: group.name })).not.toBeVisible();
});

View File

@@ -1,10 +1,10 @@
:80 {
reverse_proxy /api/* http://localhost:{$BACKEND_PORT:8080}
reverse_proxy /.well-known/* http://localhost:{$BACKEND_PORT:8080}
reverse_proxy /* http://localhost:{$PORT:3000}
reverse_proxy /api/* http://localhost:{$BACKEND_PORT:8080}
reverse_proxy /.well-known/* http://localhost:{$BACKEND_PORT:8080}
reverse_proxy /* http://localhost:{$PORT:3000}
log {
output file /var/log/caddy/access.log
level WARN
}
}
log {
output file /var/log/caddy/access.log
level WARN
}
}

View File

@@ -1,16 +1,16 @@
:80 {
reverse_proxy /api/* http://localhost:{$BACKEND_PORT:8080} {
trusted_proxies 0.0.0.0/0
}
reverse_proxy /.well-known/* http://localhost:{$BACKEND_PORT:8080} {
trusted_proxies 0.0.0.0/0
}
reverse_proxy /* http://localhost:{$PORT:3000} {
trusted_proxies 0.0.0.0/0
}
reverse_proxy /api/* http://localhost:{$BACKEND_PORT:8080} {
trusted_proxies 0.0.0.0/0
}
reverse_proxy /.well-known/* http://localhost:{$BACKEND_PORT:8080} {
trusted_proxies 0.0.0.0/0
}
reverse_proxy /* http://localhost:{$PORT:3000} {
trusted_proxies 0.0.0.0/0
}
log {
output file /var/log/caddy/access.log
level WARN
}
}
log {
output file /var/log/caddy/access.log
level WARN
}
}

View File

@@ -0,0 +1,31 @@
#!/bin/bash
# Check if the license key environment variable is set
if [ -z "$MAXMIND_LICENSE_KEY" ]; then
echo "Error: MAXMIND_LICENSE_KEY environment variable is not set."
echo "Please set it using 'export MAXMIND_LICENSE_KEY=your_license_key' and try again."
exit 1
fi
echo $MAXMIND_LICENSE_KEY
# GeoLite2 City Database URL
URL="https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz"
# Download directory
DOWNLOAD_DIR="./geolite2_db"
TARGET_PATH=./backend/GeoLite2-City.mmdb
mkdir -p $DOWNLOAD_DIR
# Download the database
echo "Downloading GeoLite2 City database..."
curl -L -o "$DOWNLOAD_DIR/GeoLite2-City.tar.gz" "$URL"
# Extract the downloaded file
echo "Extracting GeoLite2 City database..."
tar -xzf "$DOWNLOAD_DIR/GeoLite2-City.tar.gz" -C $DOWNLOAD_DIR --strip-components=1
mv "$DOWNLOAD_DIR/GeoLite2-City.mmdb" $TARGET_PATH
# Clean up
rm -rf "$DOWNLOAD_DIR"
echo "GeoLite2 City database downloaded and extracted to $TARGET_PATH"