Compare commits

...

26 Commits

Author SHA1 Message Date
Elias Schneider
6e44b5e367 release: 0.26.0 2025-01-20 11:23:52 +01:00
Elias Schneider
8a1db0cb4a feat: support wildcard callback URLs 2025-01-20 11:19:23 +01:00
Elias Schneider
3f02d08109 fix: non LDAP users get created with a empty LDAP ID string 2025-01-20 10:41:01 +01:00
Elias Schneider
715040ba04 release: 0.25.1 2025-01-19 22:14:29 +01:00
Elias Schneider
a8b9d60a86 fix: disable account details inputs if user is imported from LDAP 2025-01-19 22:14:20 +01:00
Elias Schneider
712ff396f4 release: 0.25.0 2025-01-19 20:18:07 +01:00
Elias Schneider
090eca202d fix: improve spacing of checkboxes on application configuration page 2025-01-19 19:15:11 +01:00
Elias Schneider
d4055af3f4 tests: adapt OIDC tests 2025-01-19 15:56:54 +01:00
Elias Schneider
692ff70c91 refactor: run formatter 2025-01-19 15:41:16 +01:00
Elias Schneider
d5dd118a3f feat: automatically authorize client if signed in 2025-01-19 15:39:55 +01:00
Elias Schneider
06b90eddd6 feat: allow sign in with email (#100) 2025-01-19 15:30:31 +01:00
Elias Schneider
e284e352e2 fix: don't panic if LDAP sync fails on startup 2025-01-19 13:09:16 +01:00
Kyle Mendell
5101b14eec feat: add LDAP sync (#106)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-01-19 13:02:07 +01:00
Elias Schneider
bc8f454ea1 fix: session duration ignored in cookie expiration 2025-01-18 23:27:55 +01:00
Chris Danis
fda08ac1cd fix: always set secure on cookie (#130) 2025-01-18 22:33:41 +01:00
Elias Schneider
05a98ebe87 fix: search input not displayed if response hasn't any items 2025-01-18 22:29:20 +01:00
Elias Schneider
6e3728ddc8 docs: add guide to setup Pocket ID with Caddy 2025-01-15 14:05:44 +01:00
Elias Schneider
5c57beb4d7 release: 0.24.1 2025-01-13 15:14:10 +01:00
Elias Schneider
2a984eeaf1 docs: add account recovery to README 2025-01-13 15:13:56 +01:00
Elias Schneider
be6e25a167 fix: remove restrictive validation for group names 2025-01-13 12:38:02 +01:00
Elias Schneider
888557171d fix: optional arguments not working with create-one-time-access-token.sh 2025-01-13 12:32:22 +01:00
Elias Schneider
4d337a20c5 fix: audit log table overflow if row data is long 2025-01-12 01:21:47 +01:00
Elias Schneider
69afd9ad9f release: 0.24.0 2025-01-11 23:46:39 +01:00
Elias Schneider
fd69830c26 feat: add sorting for tables 2025-01-11 20:32:22 +01:00
Elias Schneider
61d18a9d1b fix: pkce state not correctly reflected in oidc client info 2025-01-10 09:32:51 +01:00
Elias Schneider
a649c4b4a5 fix: send test email to the user that has requested it 2025-01-10 09:25:26 +01:00
216 changed files with 2616 additions and 1161 deletions

View File

@@ -1 +1 @@
0.23.0 0.26.0

View File

@@ -1,3 +1,62 @@
## [](https://github.com/stonith404/pocket-id/compare/v0.25.1...v) (2025-01-20)
### Features
* support wildcard callback URLs ([8a1db0c](https://github.com/stonith404/pocket-id/commit/8a1db0cb4a5d4b32b4fdc19d41fff688a7c71a56))
### Bug Fixes
* non LDAP users get created with a empty LDAP ID string ([3f02d08](https://github.com/stonith404/pocket-id/commit/3f02d081098ad2caaa60a56eea4705639f80d01f))
## [](https://github.com/stonith404/pocket-id/compare/v0.25.0...v) (2025-01-19)
### Bug Fixes
* disable account details inputs if user is imported from LDAP ([a8b9d60](https://github.com/stonith404/pocket-id/commit/a8b9d60a86e80c10d6fba07072b1d32cec400ecb))
## [](https://github.com/stonith404/pocket-id/compare/v0.24.1...v) (2025-01-19)
### Features
* add LDAP sync ([#106](https://github.com/stonith404/pocket-id/issues/106)) ([5101b14](https://github.com/stonith404/pocket-id/commit/5101b14eec68a9507e1730994178d0ebe8185876))
* allow sign in with email ([#100](https://github.com/stonith404/pocket-id/issues/100)) ([06b90ed](https://github.com/stonith404/pocket-id/commit/06b90eddd645cce57813f2536e4a6a8836548f2b))
* automatically authorize client if signed in ([d5dd118](https://github.com/stonith404/pocket-id/commit/d5dd118a3f4ad6eed9ca496c458201bb10f148a0))
### Bug Fixes
* always set secure on cookie ([#130](https://github.com/stonith404/pocket-id/issues/130)) ([fda08ac](https://github.com/stonith404/pocket-id/commit/fda08ac1cd88842e25dc47395ed1288a5cfac4f8))
* don't panic if LDAP sync fails on startup ([e284e35](https://github.com/stonith404/pocket-id/commit/e284e352e2b95fac1d098de3d404e8531de4b869))
* improve spacing of checkboxes on application configuration page ([090eca2](https://github.com/stonith404/pocket-id/commit/090eca202d198852e6fbf4e6bebaf3b5ada13944))
* search input not displayed if response hasn't any items ([05a98eb](https://github.com/stonith404/pocket-id/commit/05a98ebe87d7a88e8b96b144c53250a40d724ec3))
* session duration ignored in cookie expiration ([bc8f454](https://github.com/stonith404/pocket-id/commit/bc8f454ea173ecc60e06450a1d22e24207f76714))
## [](https://github.com/stonith404/pocket-id/compare/v0.24.0...v) (2025-01-13)
### Bug Fixes
* audit log table overflow if row data is long ([4d337a2](https://github.com/stonith404/pocket-id/commit/4d337a20c5cb92ef80bb7402f9b99b08e3ad0b6b))
* optional arguments not working with `create-one-time-access-token.sh` ([8885571](https://github.com/stonith404/pocket-id/commit/888557171d61589211b10f70dce405126216ad61))
* remove restrictive validation for group names ([be6e25a](https://github.com/stonith404/pocket-id/commit/be6e25a167de8bf07075b46f09d9fc1fa6c74426))
## [](https://github.com/stonith404/pocket-id/compare/v0.23.0...v) (2025-01-11)
### Features
* add sorting for tables ([fd69830](https://github.com/stonith404/pocket-id/commit/fd69830c2681985e4fd3c5336a2b75c9fb7bc5d4))
### Bug Fixes
* pkce state not correctly reflected in oidc client info ([61d18a9](https://github.com/stonith404/pocket-id/commit/61d18a9d1b167ff59a59523ff00d00ca8f23258d))
* send test email to the user that has requested it ([a649c4b](https://github.com/stonith404/pocket-id/commit/a649c4b4a543286123f4d1f3c411fe1a7e2c6d71))
## [](https://github.com/stonith404/pocket-id/compare/v0.22.0...v) (2025-01-03) ## [](https://github.com/stonith404/pocket-id/compare/v0.22.0...v) (2025-01-03)

View File

@@ -10,6 +10,24 @@ The goal of Pocket ID is to be a simple and easy-to-use. There are other self-ho
Additionally, what makes Pocket ID special is that it only supports [passkey](https://www.passkeys.io/) authentication, which means you dont need a password. Some people might not like this idea at first, but I believe passkeys are the future, and once you try them, youll love them. For example, you can now use a physical Yubikey to sign in to all your self-hosted services easily and securely. Additionally, what makes Pocket ID special is that it only supports [passkey](https://www.passkeys.io/) authentication, which means you dont need a password. Some people might not like this idea at first, but I believe passkeys are the future, and once you try them, youll love them. For example, you can now use a physical Yubikey to sign in to all your self-hosted services easily and securely.
## Table of Contents
- [ Pocket ID](#-pocket-id)
- [Table of Contents](#table-of-contents)
- [Setup](#setup)
- [Before you start](#before-you-start)
- [Installation with Docker (recommended)](#installation-with-docker-recommended)
- [Unraid](#unraid)
- [Stand-alone Installation](#stand-alone-installation)
- [Nginx Reverse Proxy](#nginx-reverse-proxy)
- [Proxy Services with Pocket ID](#proxy-services-with-pocket-id)
- [Update](#update)
- [Docker](#docker)
- [Stand-alone](#stand-alone)
- [Environment variables](#environment-variables)
- [Account recovery](#account-recovery)
- [Contribute](#contribute)
## Setup ## Setup
> [!WARNING] > [!WARNING]
@@ -95,7 +113,7 @@ proxy_buffer_size 256k;
## Proxy Services with Pocket ID ## Proxy Services with Pocket ID
As the goal of Pocket ID is to stay simple, it doesn't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/oauth2-proxy) to add authentication to your services that don't support OIDC. The goal of Pocket ID is to function exclusively as an OIDC provider. As such, we don't have a built-in proxy provider. However, you can use other tools that act as a middleware to protect your services and support OIDC as an authentication provider.
See the [guide](docs/proxy-services.md) for more information. See the [guide](docs/proxy-services.md) for more information.
@@ -157,6 +175,17 @@ docker compose up -d
| `PORT` | `3000` | no | The port on which the frontend should listen. | | `PORT` | `3000` | no | The port on which the frontend should listen. |
| `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. | | `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. |
## Account recovery
There are two ways to create a one-time access link for a user:
1. **UI**: An admin can create a one-time access link for the user in the admin panel under the "Users" tab by clicking on the three dots next to the user's name and selecting "One-time link".
2. **Terminal**: You can create a one-time access link for a user by running the `scripts/create-one-time-access-token.sh` script. To execute this script with Docker you have to run the following command:
```bash
docker compose exec pocket-id sh "sh scripts/create-one-time-access-token.sh <username or email>"
```
## Contribute ## Contribute
You're very welcome to contribute to Pocket ID! Please follow the [contribution guide](/CONTRIBUTING.md) to get started. You're very welcome to contribute to Pocket ID! Please follow the [contribution guide](/CONTRIBUTING.md) to get started.

1
backend/.gitignore vendored
View File

@@ -14,3 +14,4 @@
# Dependency directories (remove the comment below to include it) # Dependency directories (remove the comment below to include it)
# vendor/ # vendor/
./data ./data
.env

View File

@@ -15,7 +15,7 @@ require (
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/mileusna/useragent v1.3.5 github.com/mileusna/useragent v1.3.5
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1 github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1
golang.org/x/crypto v0.27.0 golang.org/x/crypto v0.31.0
golang.org/x/time v0.6.0 golang.org/x/time v0.6.0
gorm.io/driver/postgres v1.5.11 gorm.io/driver/postgres v1.5.11
gorm.io/driver/sqlite v1.5.6 gorm.io/driver/sqlite v1.5.6
@@ -23,12 +23,15 @@ require (
) )
require ( require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/bytedance/sonic v1.12.3 // indirect github.com/bytedance/sonic v1.12.3 // indirect
github.com/bytedance/sonic/loader v0.2.0 // indirect github.com/bytedance/sonic/loader v0.2.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.5 // indirect github.com/gabriel-vasile/mimetype v1.4.5 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/go-ldap/ldap/v3 v3.4.10 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-webauthn/x v0.1.14 // indirect github.com/go-webauthn/x v0.1.14 // indirect
@@ -61,10 +64,10 @@ require (
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.10.0 // indirect golang.org/x/arch v0.10.0 // indirect
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
golang.org/x/net v0.29.0 // indirect golang.org/x/net v0.33.0 // indirect
golang.org/x/sync v0.8.0 // indirect golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.25.0 // indirect golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.18.0 // indirect golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

View File

@@ -1,7 +1,10 @@
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/bytedance/sonic v1.12.3 h1:W2MGa7RCU1QTeYRTPE3+88mVC0yXmsRQRChiyVocVjU= 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 v1.12.3/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
@@ -37,8 +40,12 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-co-op/gocron/v2 v2.12.1 h1:dCIIBFbzhWKdgXeEifBjHPzgQ1hoWhjS4289Hjjy1uw= 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-co-op/gocron/v2 v2.12.1/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -70,11 +77,15 @@ github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
@@ -83,6 +94,12 @@ github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -146,6 +163,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@@ -158,6 +176,7 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
@@ -172,27 +191,98 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.10.0 h1:S3huipmSclq3PJMNe76NGwkBR504WFkQ5dhzWzP8ZW8= golang.org/x/arch v0.10.0 h1:S3huipmSclq3PJMNe76NGwkBR504WFkQ5dhzWzP8ZW8=
golang.org/x/arch v0.10.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.10.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= 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/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= 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/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= 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/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= 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/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -2,7 +2,6 @@ package bootstrap
import ( import (
_ "github.com/golang-migrate/migrate/v4/source/file" _ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/stonith404/pocket-id/backend/internal/job"
"github.com/stonith404/pocket-id/backend/internal/service" "github.com/stonith404/pocket-id/backend/internal/service"
) )
@@ -11,6 +10,5 @@ func Bootstrap() {
appConfigService := service.NewAppConfigService(db) appConfigService := service.NewAppConfigService(db)
initApplicationImages() initApplicationImages()
job.RegisterJobs(db)
initRouter(db, appConfigService) initRouter(db, appConfigService)
} }

View File

@@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/controller" "github.com/stonith404/pocket-id/backend/internal/controller"
"github.com/stonith404/pocket-id/backend/internal/job"
"github.com/stonith404/pocket-id/backend/internal/middleware" "github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/service" "github.com/stonith404/pocket-id/backend/internal/service"
"golang.org/x/time/rate" "golang.org/x/time/rate"
@@ -37,27 +38,34 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
auditLogService := service.NewAuditLogService(db, appConfigService, emailService, geoLiteService) auditLogService := service.NewAuditLogService(db, appConfigService, emailService, geoLiteService)
jwtService := service.NewJwtService(appConfigService) jwtService := service.NewJwtService(appConfigService)
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService) webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
userService := service.NewUserService(db, jwtService, auditLogService) userService := service.NewUserService(db, jwtService, auditLogService, emailService)
customClaimService := service.NewCustomClaimService(db) customClaimService := service.NewCustomClaimService(db)
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService) oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
testService := service.NewTestService(db, appConfigService) testService := service.NewTestService(db, appConfigService)
userGroupService := service.NewUserGroupService(db) userGroupService := service.NewUserGroupService(db)
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
rateLimitMiddleware := middleware.NewRateLimitMiddleware()
// Setup global middleware
r.Use(middleware.NewCorsMiddleware().Add()) r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewErrorHandlerMiddleware().Add()) r.Use(middleware.NewErrorHandlerMiddleware().Add())
r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60)) r.Use(rateLimitMiddleware.Add(rate.Every(time.Second), 60))
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false)) r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
// Initialize middleware job.RegisterLdapJobs(ldapService, appConfigService)
job.RegisterDbCleanupJobs(db)
// Initialize middleware for specific routes
jwtAuthMiddleware := middleware.NewJwtAuthMiddleware(jwtService, false) jwtAuthMiddleware := middleware.NewJwtAuthMiddleware(jwtService, false)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware() fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
// Set up API routes // Set up API routes
apiGroup := r.Group("/api") apiGroup := r.Group("/api")
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService) controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService, appConfigService)
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService) controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService) controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService, emailService) controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService, emailService, ldapService)
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware) controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService) controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService) controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)

View File

@@ -1,9 +1,10 @@
package common package common
import ( import (
"log"
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
_ "github.com/joho/godotenv/autoload" _ "github.com/joho/godotenv/autoload"
"log"
) )
type DbProvider string type DbProvider string

View File

@@ -58,7 +58,9 @@ func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
type OidcInvalidCallbackURLError struct{} type OidcInvalidCallbackURLError struct{}
func (e *OidcInvalidCallbackURLError) Error() string { return "invalid callback URL, it might be necessary for an admin to fix this" } func (e *OidcInvalidCallbackURLError) Error() string {
return "invalid callback URL, it might be necessary for an admin to fix this"
}
func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return 400 } func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return 400 }
type FileTypeNotSupportedError struct{} type FileTypeNotSupportedError struct{}
@@ -95,7 +97,7 @@ func (e *MissingPermissionError) HttpStatusCode() int { return http.StatusForbid
type TooManyRequestsError struct{} type TooManyRequestsError struct{}
func (e *TooManyRequestsError) Error() string { func (e *TooManyRequestsError) Error() string {
return "Too many requests. Please wait a while before trying again." return "Too many requests"
} }
func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests } func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
@@ -160,3 +162,17 @@ func (e *OidcMissingCodeChallengeError) Error() string {
return "Missing code challenge" return "Missing code challenge"
} }
func (e *OidcMissingCodeChallengeError) HttpStatusCode() int { return http.StatusBadRequest } func (e *OidcMissingCodeChallengeError) HttpStatusCode() int { return http.StatusBadRequest }
type LdapUserUpdateError struct{}
func (e *LdapUserUpdateError) Error() string {
return "LDAP users can't be updated"
}
func (e *LdapUserUpdateError) HttpStatusCode() int { return http.StatusForbidden }
type LdapUserGroupUpdateError struct{}
func (e *LdapUserGroupUpdateError) Error() string {
return "LDAP user groups can't be updated"
}
func (e *LdapUserGroupUpdateError) HttpStatusCode() int { return http.StatusForbidden }

View File

@@ -16,11 +16,13 @@ func NewAppConfigController(
jwtAuthMiddleware *middleware.JwtAuthMiddleware, jwtAuthMiddleware *middleware.JwtAuthMiddleware,
appConfigService *service.AppConfigService, appConfigService *service.AppConfigService,
emailService *service.EmailService, emailService *service.EmailService,
ldapService *service.LdapService,
) { ) {
acc := &AppConfigController{ acc := &AppConfigController{
appConfigService: appConfigService, appConfigService: appConfigService,
emailService: emailService, emailService: emailService,
ldapService: ldapService,
} }
group.GET("/application-configuration", acc.listAppConfigHandler) group.GET("/application-configuration", acc.listAppConfigHandler)
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler) group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
@@ -34,11 +36,13 @@ func NewAppConfigController(
group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler) group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler)
group.POST("/application-configuration/test-email", jwtAuthMiddleware.Add(true), acc.testEmailHandler) group.POST("/application-configuration/test-email", jwtAuthMiddleware.Add(true), acc.testEmailHandler)
group.POST("/application-configuration/sync-ldap", jwtAuthMiddleware.Add(true), acc.syncLdapHandler)
} }
type AppConfigController struct { type AppConfigController struct {
appConfigService *service.AppConfigService appConfigService *service.AppConfigService
emailService *service.EmailService emailService *service.EmailService
ldapService *service.LdapService
} }
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) { func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
@@ -182,8 +186,19 @@ func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, ol
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }
func (acc *AppConfigController) testEmailHandler(c *gin.Context) { func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
err := acc.emailService.SendTestEmail() err := acc.ldapService.SyncAll()
if err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
userID := c.GetString("userID")
err := acc.emailService.SendTestEmail(userID)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return

View File

@@ -3,8 +3,8 @@ package controller
import ( import (
"github.com/stonith404/pocket-id/backend/internal/dto" "github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware" "github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http" "net/http"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/service" "github.com/stonith404/pocket-id/backend/internal/service"
@@ -23,12 +23,16 @@ type AuditLogController struct {
} }
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) { func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
return
}
userID := c.GetString("userID") userID := c.GetString("userID")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
// Fetch audit logs for the user // Fetch audit logs for the user
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, page, pageSize) logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, sortedPaginationRequest)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return

View File

@@ -5,8 +5,8 @@ import (
"github.com/stonith404/pocket-id/backend/internal/dto" "github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware" "github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/service" "github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http" "net/http"
"strconv"
"strings" "strings"
) )
@@ -153,11 +153,14 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
} }
func (oc *OidcController) listClientsHandler(c *gin.Context) { func (oc *OidcController) listClientsHandler(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
searchTerm := c.Query("search") searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
return
}
clients, pagination, err := oc.oidcService.ListClients(searchTerm, page, pageSize) clients, pagination, err := oc.oidcService.ListClients(searchTerm, sortedPaginationRequest)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return

View File

@@ -1,21 +1,22 @@
package controller package controller
import ( import (
"net/http"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto" "github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware" "github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/service" "github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"golang.org/x/time/rate" "golang.org/x/time/rate"
"net/http"
"strconv"
"time"
) )
func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) { func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) {
uc := UserController{ uc := UserController{
UserService: userService, userService: userService,
AppConfigService: appConfigService, appConfigService: appConfigService,
} }
group.GET("/users", jwtAuthMiddleware.Add(true), uc.listUsersHandler) group.GET("/users", jwtAuthMiddleware.Add(true), uc.listUsersHandler)
@@ -29,19 +30,23 @@ func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
group.POST("/users/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createOneTimeAccessTokenHandler) group.POST("/users/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createOneTimeAccessTokenHandler)
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler) group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler) group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler)
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.requestOneTimeAccessEmailHandler)
} }
type UserController struct { type UserController struct {
UserService *service.UserService userService *service.UserService
AppConfigService *service.AppConfigService appConfigService *service.AppConfigService
} }
func (uc *UserController) listUsersHandler(c *gin.Context) { func (uc *UserController) listUsersHandler(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
searchTerm := c.Query("search") searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
return
}
users, pagination, err := uc.UserService.ListUsers(searchTerm, page, pageSize) users, pagination, err := uc.userService.ListUsers(searchTerm, sortedPaginationRequest)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return
@@ -60,7 +65,7 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
} }
func (uc *UserController) getUserHandler(c *gin.Context) { func (uc *UserController) getUserHandler(c *gin.Context) {
user, err := uc.UserService.GetUser(c.Param("id")) user, err := uc.userService.GetUser(c.Param("id"))
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return
@@ -76,7 +81,7 @@ func (uc *UserController) getUserHandler(c *gin.Context) {
} }
func (uc *UserController) getCurrentUserHandler(c *gin.Context) { func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
user, err := uc.UserService.GetUser(c.GetString("userID")) user, err := uc.userService.GetUser(c.GetString("userID"))
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return
@@ -92,7 +97,7 @@ func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
} }
func (uc *UserController) deleteUserHandler(c *gin.Context) { func (uc *UserController) deleteUserHandler(c *gin.Context) {
if err := uc.UserService.DeleteUser(c.Param("id")); err != nil { if err := uc.userService.DeleteUser(c.Param("id")); err != nil {
c.Error(err) c.Error(err)
return return
} }
@@ -107,7 +112,7 @@ func (uc *UserController) createUserHandler(c *gin.Context) {
return return
} }
user, err := uc.UserService.CreateUser(input) user, err := uc.userService.CreateUser(input)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return
@@ -127,7 +132,7 @@ func (uc *UserController) updateUserHandler(c *gin.Context) {
} }
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) { func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
if uc.AppConfigService.DbConfig.AllowOwnAccountEdit.Value != "true" { if uc.appConfigService.DbConfig.AllowOwnAccountEdit.Value != "true" {
c.Error(&common.AccountEditNotAllowedError{}) c.Error(&common.AccountEditNotAllowedError{})
return return
} }
@@ -141,7 +146,7 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
return return
} }
token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt, c.ClientIP(), c.Request.UserAgent()) token, err := uc.userService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return
@@ -150,8 +155,24 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"token": token}) c.JSON(http.StatusCreated, gin.H{"token": token})
} }
func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) {
var input dto.OneTimeAccessEmailDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
err := uc.userService.RequestOneTimeAccessEmail(input.Email, input.RedirectPath)
if err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) { func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
user, token, err := uc.UserService.ExchangeOneTimeAccessToken(c.Param("token")) user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Param("token"), c.ClientIP(), c.Request.UserAgent())
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return
@@ -163,12 +184,12 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
return return
} }
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true) utils.AddAccessTokenCookie(c, uc.appConfigService.DbConfig.SessionDuration.Value, token)
c.JSON(http.StatusOK, userDto) c.JSON(http.StatusOK, userDto)
} }
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) { func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
user, token, err := uc.UserService.SetupInitialAdmin() user, token, err := uc.userService.SetupInitialAdmin()
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return
@@ -180,7 +201,7 @@ func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
return return
} }
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true) utils.AddAccessTokenCookie(c, uc.appConfigService.DbConfig.SessionDuration.Value, token)
c.JSON(http.StatusOK, userDto) c.JSON(http.StatusOK, userDto)
} }
@@ -198,7 +219,7 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
userID = c.Param("id") userID = c.Param("id")
} }
user, err := uc.UserService.UpdateUser(userID, input, updateOwnUser) user, err := uc.userService.UpdateUser(userID, input, updateOwnUser, false)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return

View File

@@ -1,13 +1,12 @@
package controller package controller
import ( import (
"net/http"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/dto" "github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware" "github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/service" "github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
) )
func NewUserGroupController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, userGroupService *service.UserGroupService) { func NewUserGroupController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, userGroupService *service.UserGroupService) {
@@ -28,16 +27,20 @@ type UserGroupController struct {
} }
func (ugc *UserGroupController) list(c *gin.Context) { 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") searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
return
}
groups, pagination, err := ugc.UserGroupService.List(searchTerm, page, pageSize) groups, pagination, err := ugc.UserGroupService.List(searchTerm, sortedPaginationRequest)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return
} }
// Map the user groups to DTOs. The user count can't be mapped directly, so we have to do it manually.
var groupsDto = make([]dto.UserGroupDtoWithUserCount, len(groups)) var groupsDto = make([]dto.UserGroupDtoWithUserCount, len(groups))
for i, group := range groups { for i, group := range groups {
var groupDto dto.UserGroupDtoWithUserCount var groupDto dto.UserGroupDtoWithUserCount
@@ -104,7 +107,7 @@ func (ugc *UserGroupController) update(c *gin.Context) {
return return
} }
group, err := ugc.UserGroupService.Update(c.Param("id"), input) group, err := ugc.UserGroupService.Update(c.Param("id"), input, false)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return

View File

@@ -5,6 +5,7 @@ import (
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto" "github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware" "github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http" "net/http"
"time" "time"
@@ -13,8 +14,8 @@ import (
"golang.org/x/time/rate" "golang.org/x/time/rate"
) )
func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService) { func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService, appConfigService *service.AppConfigService) {
wc := &WebauthnController{webAuthnService: webauthnService} wc := &WebauthnController{webAuthnService: webauthnService, appConfigService: appConfigService}
group.GET("/webauthn/register/start", jwtAuthMiddleware.Add(false), wc.beginRegistrationHandler) group.GET("/webauthn/register/start", jwtAuthMiddleware.Add(false), wc.beginRegistrationHandler)
group.POST("/webauthn/register/finish", jwtAuthMiddleware.Add(false), wc.verifyRegistrationHandler) group.POST("/webauthn/register/finish", jwtAuthMiddleware.Add(false), wc.verifyRegistrationHandler)
@@ -29,7 +30,8 @@ func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware
} }
type WebauthnController struct { type WebauthnController struct {
webAuthnService *service.WebAuthnService webAuthnService *service.WebAuthnService
appConfigService *service.AppConfigService
} }
func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) { func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
@@ -40,7 +42,7 @@ func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
return return
} }
c.SetCookie("session_id", options.SessionID, int(options.Timeout.Seconds()), "/", "", false, true) c.SetCookie("session_id", options.SessionID, int(options.Timeout.Seconds()), "/", "", true, true)
c.JSON(http.StatusOK, options.Response) c.JSON(http.StatusOK, options.Response)
} }
@@ -74,7 +76,7 @@ func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
return return
} }
c.SetCookie("session_id", options.SessionID, int(options.Timeout.Seconds()), "/", "", false, true) c.SetCookie("session_id", options.SessionID, int(options.Timeout.Seconds()), "/", "", true, true)
c.JSON(http.StatusOK, options.Response) c.JSON(http.StatusOK, options.Response)
} }
@@ -103,7 +105,7 @@ func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
return return
} }
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true) utils.AddAccessTokenCookie(c, wc.appConfigService.DbConfig.SessionDuration.Value, token)
c.JSON(http.StatusOK, userDto) c.JSON(http.StatusOK, userDto)
} }
@@ -163,6 +165,6 @@ func (wc *WebauthnController) updateCredentialHandler(c *gin.Context) {
} }
func (wc *WebauthnController) logoutHandler(c *gin.Context) { func (wc *WebauthnController) logoutHandler(c *gin.Context) {
c.SetCookie("access_token", "", 0, "/", "", false, true) utils.AddAccessTokenCookie(c, "0", "")
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }

View File

@@ -12,16 +12,31 @@ type AppConfigVariableDto struct {
} }
type AppConfigUpdateDto struct { type AppConfigUpdateDto struct {
AppName string `json:"appName" binding:"required,min=1,max=30"` AppName string `json:"appName" binding:"required,min=1,max=30"`
SessionDuration string `json:"sessionDuration" binding:"required"` SessionDuration string `json:"sessionDuration" binding:"required"`
EmailsVerified string `json:"emailsVerified" binding:"required"` EmailsVerified string `json:"emailsVerified" binding:"required"`
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"` AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
EmailEnabled string `json:"emailEnabled" binding:"required"` SmtHost string `json:"smtpHost"`
SmtHost string `json:"smtpHost"` SmtpPort string `json:"smtpPort"`
SmtpPort string `json:"smtpPort"` SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"` SmtpUser string `json:"smtpUser"`
SmtpUser string `json:"smtpUser"` SmtpPassword string `json:"smtpPassword"`
SmtpPassword string `json:"smtpPassword"` SmtpTls string `json:"smtpTls"`
SmtpTls string `json:"smtpTls"` SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"` LdapEnabled string `json:"ldapEnabled" binding:"required"`
LdapUrl string `json:"ldapUrl"`
LdapBindDn string `json:"ldapBindDn"`
LdapBindPassword string `json:"ldapBindPassword"`
LdapBase string `json:"ldapBase"`
LdapSkipCertVerify string `json:"ldapSkipCertVerify"`
LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"`
LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"`
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
EmailOneTimeAccessEnabled string `json:"emailOneTimeAccessEnabled" binding:"required"`
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
} }

View File

@@ -16,7 +16,7 @@ type OidcClientDto struct {
type OidcClientCreateDto struct { type OidcClientCreateDto struct {
Name string `json:"name" binding:"required,max=50"` Name string `json:"name" binding:"required,max=50"`
CallbackURLs []string `json:"callbackURLs" binding:"required,urlList"` CallbackURLs []string `json:"callbackURLs" binding:"required"`
IsPublic bool `json:"isPublic"` IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"` PkceEnabled bool `json:"pkceEnabled"`
} }

View File

@@ -10,6 +10,7 @@ type UserDto struct {
LastName string `json:"lastName"` LastName string `json:"lastName"`
IsAdmin bool `json:"isAdmin"` IsAdmin bool `json:"isAdmin"`
CustomClaims []CustomClaimDto `json:"customClaims"` CustomClaims []CustomClaimDto `json:"customClaims"`
LdapID *string `json:"ldapId"`
} }
type UserCreateDto struct { type UserCreateDto struct {
@@ -18,9 +19,15 @@ type UserCreateDto struct {
FirstName string `json:"firstName" binding:"required,min=1,max=50"` FirstName string `json:"firstName" binding:"required,min=1,max=50"`
LastName string `json:"lastName" binding:"required,min=1,max=50"` LastName string `json:"lastName" binding:"required,min=1,max=50"`
IsAdmin bool `json:"isAdmin"` IsAdmin bool `json:"isAdmin"`
LdapID string `json:"-"`
} }
type OneTimeAccessTokenCreateDto struct { type OneTimeAccessTokenCreateDto struct {
UserID string `json:"userId" binding:"required"` UserID string `json:"userId" binding:"required"`
ExpiresAt time.Time `json:"expiresAt" binding:"required"` ExpiresAt time.Time `json:"expiresAt" binding:"required"`
} }
type OneTimeAccessEmailDto struct {
Email string `json:"email" binding:"required,email"`
RedirectPath string `json:"redirectPath"`
}

View File

@@ -10,6 +10,7 @@ type UserGroupDtoWithUsers struct {
Name string `json:"name"` Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"` CustomClaims []CustomClaimDto `json:"customClaims"`
Users []UserDto `json:"users"` Users []UserDto `json:"users"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"` CreatedAt datatype.DateTime `json:"createdAt"`
} }
@@ -19,12 +20,14 @@ type UserGroupDtoWithUserCount struct {
Name string `json:"name"` Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"` CustomClaims []CustomClaimDto `json:"customClaims"`
UserCount int64 `json:"userCount"` UserCount int64 `json:"userCount"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"` CreatedAt datatype.DateTime `json:"createdAt"`
} }
type UserGroupCreateDto struct { type UserGroupCreateDto struct {
FriendlyName string `json:"friendlyName" binding:"required,min=3,max=30"` FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50"`
Name string `json:"name" binding:"required,min=3,max=30,userGroupName"` Name string `json:"name" binding:"required,min=2,max=255"`
LdapID string `json:"-"`
} }
type UserGroupUpdateUsersDto struct { type UserGroupUpdateUsersDto struct {

View File

@@ -4,21 +4,9 @@ import (
"github.com/gin-gonic/gin/binding" "github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"log" "log"
"net/url"
"regexp" "regexp"
) )
var validateUrlList validator.Func = func(fl validator.FieldLevel) bool {
urls := fl.Field().Interface().([]string)
for _, u := range urls {
_, err := url.ParseRequestURI(u)
if err != nil {
return false
}
}
return true
}
var validateUsername validator.Func = func(fl validator.FieldLevel) bool { var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
// [a-zA-Z0-9] : The username must start with an alphanumeric character // [a-zA-Z0-9] : The username must start with an alphanumeric character
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols // [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
@@ -28,13 +16,6 @@ var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
return matched return matched
} }
var validateUserGroupName validator.Func = func(fl validator.FieldLevel) bool {
// The string can only contain lowercase letters, numbers, and underscores
regex := "^[a-z0-9_]*$"
matched, _ := regexp.MatchString(regex, fl.Field().String())
return matched
}
var validateClaimKey validator.Func = func(fl validator.FieldLevel) bool { var validateClaimKey validator.Func = func(fl validator.FieldLevel) bool {
// The string can only contain letters and numbers // The string can only contain letters and numbers
regex := "^[A-Za-z0-9]*$" regex := "^[A-Za-z0-9]*$"
@@ -43,23 +24,11 @@ var validateClaimKey validator.Func = func(fl validator.FieldLevel) bool {
} }
func init() { func init() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("urlList", validateUrlList); err != nil {
log.Fatalf("Failed to register custom validation: %v", err)
}
}
if v, ok := binding.Validator.Engine().(*validator.Validate); ok { if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("username", validateUsername); err != nil { if err := v.RegisterValidation("username", validateUsername); err != nil {
log.Fatalf("Failed to register custom validation: %v", err) log.Fatalf("Failed to register custom validation: %v", err)
} }
} }
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("userGroupName", validateUserGroupName); err != nil {
log.Fatalf("Failed to register custom validation: %v", err)
}
}
if v, ok := binding.Validator.Engine().(*validator.Validate); ok { if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("claimKey", validateClaimKey); err != nil { if err := v.RegisterValidation("claimKey", validateClaimKey); err != nil {
log.Fatalf("Failed to register custom validation: %v", err) log.Fatalf("Failed to register custom validation: %v", err)

View File

@@ -10,7 +10,7 @@ import (
"time" "time"
) )
func RegisterJobs(db *gorm.DB) { func RegisterDbCleanupJobs(db *gorm.DB) {
scheduler, err := gocron.NewScheduler() scheduler, err := gocron.NewScheduler()
if err != nil { if err != nil {
log.Fatalf("Failed to create a new scheduler: %s", err) log.Fatalf("Failed to create a new scheduler: %s", err)

View File

@@ -0,0 +1,39 @@
package job
import (
"log"
"github.com/go-co-op/gocron/v2"
"github.com/stonith404/pocket-id/backend/internal/service"
)
type LdapJobs struct {
ldapService *service.LdapService
appConfigService *service.AppConfigService
}
func RegisterLdapJobs(ldapService *service.LdapService, appConfigService *service.AppConfigService) {
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
scheduler, err := gocron.NewScheduler()
if err != nil {
log.Fatalf("Failed to create a new scheduler: %s", err)
}
// Register the job to run every hour
registerJob(scheduler, "SyncLdap", "0 * * * *", jobs.syncLdap)
// Run the job immediately on startup
if err := jobs.syncLdap(); err != nil {
log.Printf("Failed to sync LDAP: %s", err)
}
scheduler.Start()
}
func (j *LdapJobs) syncLdap() error {
if j.appConfigService.DbConfig.LdapEnabled.Value == "true" {
return j.ldapService.SyncAll()
}
return nil
}

View File

@@ -83,8 +83,6 @@ func handleValidationError(validationErrors validator.ValidationErrors) string {
errorMessage = fmt.Sprintf("%s must be at least %s characters long", fieldName, ve.Param()) errorMessage = fmt.Sprintf("%s must be at least %s characters long", fieldName, ve.Param())
case "max": case "max":
errorMessage = fmt.Sprintf("%s must be at most %s characters long", fieldName, ve.Param()) errorMessage = fmt.Sprintf("%s must be at most %s characters long", fieldName, ve.Param())
case "urlList":
errorMessage = fmt.Sprintf("%s must be a list of valid URLs", fieldName)
default: default:
errorMessage = fmt.Sprintf("%s is invalid", fieldName) errorMessage = fmt.Sprintf("%s is invalid", fieldName)
} }

View File

@@ -16,8 +16,12 @@ func NewRateLimitMiddleware() *RateLimitMiddleware {
} }
func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc { func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
// Map to store the rate limiters per IP
var clients = make(map[string]*client)
var mu sync.Mutex
// Start the cleanup routine // Start the cleanup routine
go cleanupClients() go cleanupClients(&mu, clients)
return func(c *gin.Context) { return func(c *gin.Context) {
ip := c.ClientIP() ip := c.ClientIP()
@@ -29,7 +33,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
return return
} }
limiter := getLimiter(ip, limit, burst) limiter := getLimiter(ip, limit, burst, &mu, clients)
if !limiter.Allow() { if !limiter.Allow() {
c.Error(&common.TooManyRequestsError{}) c.Error(&common.TooManyRequestsError{})
c.Abort() c.Abort()
@@ -45,12 +49,8 @@ type client struct {
lastSeen time.Time lastSeen time.Time
} }
// Map to store the rate limiters per IP
var clients = make(map[string]*client)
var mu sync.Mutex
// Cleanup routine to remove stale clients that haven't been seen for a while // Cleanup routine to remove stale clients that haven't been seen for a while
func cleanupClients() { func cleanupClients(mu *sync.Mutex, clients map[string]*client) {
for { for {
time.Sleep(time.Minute) time.Sleep(time.Minute)
mu.Lock() mu.Lock()
@@ -64,7 +64,7 @@ func cleanupClients() {
} }
// getLimiter retrieves the rate limiter for a given IP address, creating one if it doesn't exist // getLimiter retrieves the rate limiter for a given IP address, creating one if it doesn't exist
func getLimiter(ip string, limit rate.Limit, burst int) *rate.Limiter { func getLimiter(ip string, limit rate.Limit, burst int, mu *sync.Mutex, clients map[string]*client) *rate.Limiter {
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()

View File

@@ -10,16 +10,16 @@ type AppConfigVariable struct {
} }
type AppConfig struct { type AppConfig struct {
// General
AppName AppConfigVariable AppName AppConfigVariable
SessionDuration AppConfigVariable SessionDuration AppConfigVariable
EmailsVerified AppConfigVariable EmailsVerified AppConfigVariable
AllowOwnAccountEdit AppConfigVariable AllowOwnAccountEdit AppConfigVariable
// Internal
BackgroundImageType AppConfigVariable BackgroundImageType AppConfigVariable
LogoLightImageType AppConfigVariable LogoLightImageType AppConfigVariable
LogoDarkImageType AppConfigVariable LogoDarkImageType AppConfigVariable
// Email
EmailEnabled AppConfigVariable
SmtpHost AppConfigVariable SmtpHost AppConfigVariable
SmtpPort AppConfigVariable SmtpPort AppConfigVariable
SmtpFrom AppConfigVariable SmtpFrom AppConfigVariable
@@ -27,4 +27,21 @@ type AppConfig struct {
SmtpPassword AppConfigVariable SmtpPassword AppConfigVariable
SmtpTls AppConfigVariable SmtpTls AppConfigVariable
SmtpSkipCertVerify AppConfigVariable SmtpSkipCertVerify AppConfigVariable
EmailLoginNotificationEnabled AppConfigVariable
EmailOneTimeAccessEnabled AppConfigVariable
// LDAP
LdapEnabled AppConfigVariable
LdapUrl AppConfigVariable
LdapBindDn AppConfigVariable
LdapBindPassword AppConfigVariable
LdapBase AppConfigVariable
LdapSkipCertVerify AppConfigVariable
LdapAttributeUserUniqueIdentifier AppConfigVariable
LdapAttributeUserUsername AppConfigVariable
LdapAttributeUserEmail AppConfigVariable
LdapAttributeUserFirstName AppConfigVariable
LdapAttributeUserLastName AppConfigVariable
LdapAttributeGroupUniqueIdentifier AppConfigVariable
LdapAttributeGroupName AppConfigVariable
LdapAttributeAdminGroup AppConfigVariable
} }

View File

@@ -9,11 +9,11 @@ import (
type AuditLog struct { type AuditLog struct {
Base Base
Event AuditLogEvent Event AuditLogEvent `sortable:"true"`
IpAddress string IpAddress string `sortable:"true"`
Country string Country string `sortable:"true"`
City string City string `sortable:"true"`
UserAgent string UserAgent string `sortable:"true"`
UserID string UserID string
Data AuditLogData Data AuditLogData
} }

View File

@@ -9,8 +9,8 @@ import (
// Base contains common columns for all tables. // Base contains common columns for all tables.
type Base struct { type Base struct {
ID string `gorm:"primaryKey;not null"` ID string `gorm:"primaryKey;not null"`
CreatedAt model.DateTime CreatedAt model.DateTime `sortable:"true"`
} }
func (b *Base) BeforeCreate(_ *gorm.DB) (err error) { func (b *Base) BeforeCreate(_ *gorm.DB) (err error) {

View File

@@ -36,7 +36,7 @@ type OidcAuthorizationCode struct {
type OidcClient struct { type OidcClient struct {
Base Base
Name string Name string `sortable:"true"`
Secret string Secret string
CallbackURLs CallbackURLs CallbackURLs CallbackURLs
ImageType *string ImageType *string

View File

@@ -9,11 +9,12 @@ import (
type User struct { type User struct {
Base Base
Username string Username string `sortable:"true"`
Email string Email string `sortable:"true"`
FirstName string FirstName string `sortable:"true"`
LastName string LastName string `sortable:"true"`
IsAdmin bool IsAdmin bool `sortable:"true"`
LdapID *string
CustomClaims []CustomClaim CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"` UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`

View File

@@ -2,8 +2,9 @@ package model
type UserGroup struct { type UserGroup struct {
Base Base
FriendlyName string FriendlyName string `sortable:"true"`
Name string `gorm:"unique"` Name string `sortable:"true"`
LdapID *string
Users []User `gorm:"many2many:user_groups_users;"` Users []User `gorm:"many2many:user_groups_users;"`
CustomClaims []CustomClaim CustomClaims []CustomClaim
} }

View File

@@ -2,15 +2,16 @@ package service
import ( import (
"fmt" "fmt"
"log"
"mime/multipart"
"os"
"reflect"
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto" "github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/model" "github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils" "github.com/stonith404/pocket-id/backend/internal/utils"
"gorm.io/gorm" "gorm.io/gorm"
"log"
"mime/multipart"
"os"
"reflect"
) )
type AppConfigService struct { type AppConfigService struct {
@@ -30,6 +31,7 @@ func NewAppConfigService(db *gorm.DB) *AppConfigService {
} }
var defaultDbConfig = model.AppConfig{ var defaultDbConfig = model.AppConfig{
// General
AppName: model.AppConfigVariable{ AppName: model.AppConfigVariable{
Key: "appName", Key: "appName",
Type: "string", Type: "string",
@@ -52,6 +54,7 @@ var defaultDbConfig = model.AppConfig{
IsPublic: true, IsPublic: true,
DefaultValue: "true", DefaultValue: "true",
}, },
// Internal
BackgroundImageType: model.AppConfigVariable{ BackgroundImageType: model.AppConfigVariable{
Key: "backgroundImageType", Key: "backgroundImageType",
Type: "string", Type: "string",
@@ -70,11 +73,7 @@ var defaultDbConfig = model.AppConfig{
IsInternal: true, IsInternal: true,
DefaultValue: "svg", DefaultValue: "svg",
}, },
EmailEnabled: model.AppConfigVariable{ // Email
Key: "emailEnabled",
Type: "bool",
DefaultValue: "false",
},
SmtpHost: model.AppConfigVariable{ SmtpHost: model.AppConfigVariable{
Key: "smtpHost", Key: "smtpHost",
Type: "string", Type: "string",
@@ -105,6 +104,76 @@ var defaultDbConfig = model.AppConfig{
Type: "bool", Type: "bool",
DefaultValue: "false", DefaultValue: "false",
}, },
EmailLoginNotificationEnabled: model.AppConfigVariable{
Key: "emailLoginNotificationEnabled",
Type: "bool",
DefaultValue: "false",
},
EmailOneTimeAccessEnabled: model.AppConfigVariable{
Key: "emailOneTimeAccessEnabled",
Type: "bool",
IsPublic: true,
DefaultValue: "false",
},
// LDAP
LdapEnabled: model.AppConfigVariable{
Key: "ldapEnabled",
Type: "bool",
DefaultValue: "false",
},
LdapUrl: model.AppConfigVariable{
Key: "ldapUrl",
Type: "string",
},
LdapBindDn: model.AppConfigVariable{
Key: "ldapBindDn",
Type: "string",
},
LdapBindPassword: model.AppConfigVariable{
Key: "ldapBindPassword",
Type: "string",
},
LdapBase: model.AppConfigVariable{
Key: "ldapBase",
Type: "string",
},
LdapSkipCertVerify: model.AppConfigVariable{
Key: "ldapSkipCertVerify",
Type: "bool",
DefaultValue: "false",
},
LdapAttributeUserUniqueIdentifier: model.AppConfigVariable{
Key: "ldapAttributeUserUniqueIdentifier",
Type: "string",
},
LdapAttributeUserUsername: model.AppConfigVariable{
Key: "ldapAttributeUserUsername",
Type: "string",
},
LdapAttributeUserEmail: model.AppConfigVariable{
Key: "ldapAttributeUserEmail",
Type: "string",
},
LdapAttributeUserFirstName: model.AppConfigVariable{
Key: "ldapAttributeUserFirstName",
Type: "string",
},
LdapAttributeUserLastName: model.AppConfigVariable{
Key: "ldapAttributeUserLastName",
Type: "string",
},
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{
Key: "ldapAttributeGroupUniqueIdentifier",
Type: "string",
},
LdapAttributeGroupName: model.AppConfigVariable{
Key: "ldapAttributeGroupName",
Type: "string",
},
LdapAttributeAdminGroup: model.AppConfigVariable{
Key: "ldapAttributeAdminGroup",
Type: "string",
},
} }
func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) { func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
@@ -119,6 +188,13 @@ func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]mode
key := field.Tag.Get("json") key := field.Tag.Get("json")
value := rv.FieldByName(field.Name).String() value := rv.FieldByName(field.Name).String()
// If the emailEnabled is set to false, disable the emailOneTimeAccessEnabled
if key == s.DbConfig.EmailOneTimeAccessEnabled.Key {
if rv.FieldByName("EmailEnabled").String() == "false" {
value = "false"
}
}
var appConfigVariable model.AppConfigVariable var appConfigVariable model.AppConfigVariable
if err := tx.First(&appConfigVariable, "key = ? AND is_internal = false", key).Error; err != nil { if err := tx.First(&appConfigVariable, "key = ? AND is_internal = false", key).Error; err != nil {
tx.Rollback() tx.Rollback()

View File

@@ -58,8 +58,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID
return createdAuditLog return createdAuditLog
} }
// If the user hasn't logged in from the same device before, send an email // If the user hasn't logged in from the same device before and email notifications are enabled, send an email
if count <= 1 { if s.appConfigService.DbConfig.EmailLoginNotificationEnabled.Value == "true" && count <= 1 {
go func() { go func() {
var user model.User var user model.User
s.db.Where("id = ?", userID).First(&user) s.db.Where("id = ?", userID).First(&user)
@@ -84,11 +84,11 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID
} }
// ListAuditLogsForUser retrieves all audit logs for a given user ID // ListAuditLogsForUser retrieves all audit logs for a given user ID
func (s *AuditLogService) ListAuditLogsForUser(userID string, page int, pageSize int) ([]model.AuditLog, utils.PaginationResponse, error) { func (s *AuditLogService) ListAuditLogsForUser(userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.AuditLog, utils.PaginationResponse, error) {
var logs []model.AuditLog var logs []model.AuditLog
query := s.db.Model(&model.AuditLog{}).Where("user_id = ?", userID).Order("created_at desc") query := s.db.Model(&model.AuditLog{}).Where("user_id = ?", userID)
pagination, err := utils.Paginate(page, pageSize, query, &logs) pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
return logs, pagination, err return logs, pagination, err
} }

View File

@@ -3,7 +3,6 @@ package service
import ( import (
"bytes" "bytes"
"crypto/tls" "crypto/tls"
"errors"
"fmt" "fmt"
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/model" "github.com/stonith404/pocket-id/backend/internal/model"
@@ -16,8 +15,13 @@ import (
"net/smtp" "net/smtp"
"net/textproto" "net/textproto"
ttemplate "text/template" ttemplate "text/template"
"time"
) )
var netDialer = &net.Dialer{
Timeout: 3 * time.Second,
}
type EmailService struct { type EmailService struct {
appConfigService *AppConfigService appConfigService *AppConfigService
db *gorm.DB db *gorm.DB
@@ -44,9 +48,9 @@ func NewEmailService(appConfigService *AppConfigService, db *gorm.DB) (*EmailSer
}, nil }, nil
} }
func (srv *EmailService) SendTestEmail() error { func (srv *EmailService) SendTestEmail(recipientUserId string) error {
var user model.User var user model.User
if err := srv.db.First(&user).Error; err != nil { if err := srv.db.First(&user, "id = ?", recipientUserId).Error; err != nil {
return err return err
} }
@@ -58,11 +62,6 @@ func (srv *EmailService) SendTestEmail() error {
} }
func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error { func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error {
// Check if SMTP settings are set
if srv.appConfigService.DbConfig.EmailEnabled.Value != "true" {
return errors.New("email not enabled")
}
data := &email.TemplateData[V]{ data := &email.TemplateData[V]{
AppName: srv.appConfigService.DbConfig.AppName.Value, AppName: srv.appConfigService.DbConfig.AppName.Value,
LogoURL: common.EnvConfig.AppURL + "/api/application-configuration/logo", LogoURL: common.EnvConfig.AppURL + "/api/application-configuration/logo",
@@ -112,11 +111,13 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
tlsConfig, tlsConfig,
) )
} }
defer client.Quit()
if err != nil { if err != nil {
return fmt.Errorf("failed to connect to SMTP server: %w", err) return fmt.Errorf("failed to connect to SMTP server: %w", err)
} }
defer client.Close()
smtpUser := srv.appConfigService.DbConfig.SmtpUser.Value smtpUser := srv.appConfigService.DbConfig.SmtpUser.Value
smtpPassword := srv.appConfigService.DbConfig.SmtpPassword.Value smtpPassword := srv.appConfigService.DbConfig.SmtpPassword.Value
@@ -141,7 +142,11 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
} }
func (srv *EmailService) connectToSmtpServerUsingImplicitTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) { func (srv *EmailService) connectToSmtpServerUsingImplicitTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
conn, err := tls.Dial("tcp", serverAddr, tlsConfig) tlsDialer := &tls.Dialer{
NetDialer: netDialer,
Config: tlsConfig,
}
conn, err := tlsDialer.Dial("tcp", serverAddr)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err) return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
} }
@@ -156,7 +161,7 @@ func (srv *EmailService) connectToSmtpServerUsingImplicitTLS(serverAddr string,
} }
func (srv *EmailService) connectToSmtpServerUsingStartTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) { func (srv *EmailService) connectToSmtpServerUsingStartTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
conn, err := net.Dial("tcp", serverAddr) conn, err := netDialer.Dial("tcp", serverAddr)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err) return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
} }

View File

@@ -9,7 +9,7 @@ import (
/** /**
How to add new template: How to add new template:
- pick unique and descriptive template ${name} (for example "login-with-new-device") - pick unique and descriptive template ${name} (for example "login-with-new-device")
- in backend/email-templates/ create "${name}_html.tmpl" and "${name}_text.tmpl" - in backend/resources/email-templates/ create "${name}_html.tmpl" and "${name}_text.tmpl"
- create xxxxTemplate and xxxxTemplateData (for example NewLoginTemplate and NewLoginTemplateData) - create xxxxTemplate and xxxxTemplateData (for example NewLoginTemplate and NewLoginTemplateData)
- Path *must* be ${name} - Path *must* be ${name}
- add xxxTemplate.Path to "emailTemplatePaths" at the end - add xxxTemplate.Path to "emailTemplatePaths" at the end
@@ -27,6 +27,13 @@ var NewLoginTemplate = email.Template[NewLoginTemplateData]{
}, },
} }
var OneTimeAccessTemplate = email.Template[OneTimeAccessTemplateData]{
Path: "one-time-access",
Title: func(data *email.TemplateData[OneTimeAccessTemplateData]) string {
return "One time access"
},
}
var TestTemplate = email.Template[struct{}]{ var TestTemplate = email.Template[struct{}]{
Path: "test", Path: "test",
Title: func(data *email.TemplateData[struct{}]) string { Title: func(data *email.TemplateData[struct{}]) string {
@@ -42,5 +49,9 @@ type NewLoginTemplateData struct {
DateTime time.Time DateTime time.Time
} }
type OneTimeAccessTemplateData = struct {
Link string
}
// this is list of all template paths used for preloading templates // this is list of all template paths used for preloading templates
var emailTemplatesPaths = []string{NewLoginTemplate.Path, TestTemplate.Path} var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path}

View File

@@ -0,0 +1,261 @@
package service
import (
"crypto/tls"
"fmt"
"log"
"strings"
"github.com/go-ldap/ldap/v3"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/model"
"gorm.io/gorm"
)
type LdapService struct {
db *gorm.DB
appConfigService *AppConfigService
userService *UserService
groupService *UserGroupService
}
func NewLdapService(db *gorm.DB, appConfigService *AppConfigService, userService *UserService, groupService *UserGroupService) *LdapService {
return &LdapService{db: db, appConfigService: appConfigService, userService: userService, groupService: groupService}
}
func (s *LdapService) createClient() (*ldap.Conn, error) {
if s.appConfigService.DbConfig.LdapEnabled.Value != "true" {
return nil, fmt.Errorf("LDAP is not enabled")
}
// Setup LDAP connection
ldapURL := s.appConfigService.DbConfig.LdapUrl.Value
skipTLSVerify := s.appConfigService.DbConfig.LdapSkipCertVerify.Value == "true"
client, err := ldap.DialURL(ldapURL, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: skipTLSVerify}))
if err != nil {
return nil, fmt.Errorf("failed to connect to LDAP: %w", err)
}
// Bind as service account
bindDn := s.appConfigService.DbConfig.LdapBindDn.Value
bindPassword := s.appConfigService.DbConfig.LdapBindPassword.Value
err = client.Bind(bindDn, bindPassword)
if err != nil {
return nil, fmt.Errorf("failed to bind to LDAP: %w", err)
}
return client, nil
}
func (s *LdapService) SyncAll() error {
err := s.SyncUsers()
if err != nil {
return fmt.Errorf("failed to sync users: %w", err)
}
err = s.SyncGroups()
if err != nil {
return fmt.Errorf("failed to sync groups: %w", err)
}
return nil
}
func (s *LdapService) SyncGroups() error {
// Setup LDAP connection
client, err := s.createClient()
if err != nil {
return fmt.Errorf("failed to create LDAP client: %w", err)
}
defer client.Close()
baseDN := s.appConfigService.DbConfig.LdapBase.Value
nameAttribute := s.appConfigService.DbConfig.LdapAttributeGroupName.Value
uniqueIdentifierAttribute := s.appConfigService.DbConfig.LdapAttributeGroupUniqueIdentifier.Value
filter := "(objectClass=groupOfUniqueNames)"
searchAttrs := []string{
nameAttribute,
uniqueIdentifierAttribute,
"member",
}
searchReq := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, 0, 0, 0, false, filter, searchAttrs, []ldap.Control{})
result, err := client.Search(searchReq)
if err != nil {
return fmt.Errorf("failed to query LDAP: %w", err)
}
// Create a mapping for groups that exist
ldapGroupIDs := make(map[string]bool)
for _, value := range result.Entries {
var usersToAddDto dto.UserGroupUpdateUsersDto
var membersUserId []string
ldapId := value.GetAttributeValue(uniqueIdentifierAttribute)
ldapGroupIDs[ldapId] = true
// Try to find the group in the database
var databaseGroup model.UserGroup
s.db.Where("ldap_id = ?", ldapId).First(&databaseGroup)
// Get group members and add to the correct Group
groupMembers := value.GetAttributeValues("member")
for _, member := range groupMembers {
// Normal output of this would be CN=username,ou=people,dc=example,dc=com
// Splitting at the "=" and "," then just grabbing the username for that string
singleMember := strings.Split(strings.Split(member, "=")[1], ",")[0]
var databaseUser model.User
s.db.Where("username = ?", singleMember).First(&databaseUser)
membersUserId = append(membersUserId, databaseUser.ID)
}
syncGroup := dto.UserGroupCreateDto{
Name: value.GetAttributeValue(nameAttribute),
FriendlyName: value.GetAttributeValue(nameAttribute),
LdapID: value.GetAttributeValue(uniqueIdentifierAttribute),
}
usersToAddDto = dto.UserGroupUpdateUsersDto{
UserIDs: membersUserId,
}
if databaseGroup.ID == "" {
newGroup, err := s.groupService.Create(syncGroup)
if err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
} else {
if _, err = s.groupService.UpdateUsers(newGroup.ID, usersToAddDto); err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
}
}
} else {
_, err = s.groupService.Update(databaseGroup.ID, syncGroup, true)
_, err = s.groupService.UpdateUsers(databaseGroup.ID, usersToAddDto)
if err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
return err
}
}
}
// Get all LDAP groups from the database
var ldapGroupsInDb []model.UserGroup
if err := s.db.Find(&ldapGroupsInDb, "ldap_id IS NOT NULL").Select("ldap_id").Error; err != nil {
fmt.Println(fmt.Errorf("failed to fetch groups from database: %v", err))
}
// Delete groups that no longer exist in LDAP
for _, group := range ldapGroupsInDb {
if _, exists := ldapGroupIDs[*group.LdapID]; !exists {
if err := s.db.Delete(&model.UserGroup{}, "ldap_id = ?", group.LdapID).Error; err != nil {
log.Printf("Failed to delete group %s with: %v", group.Name, err)
} else {
log.Printf("Deleted group %s", group.Name)
}
}
}
return nil
}
func (s *LdapService) SyncUsers() error {
// Setup LDAP connection
client, err := s.createClient()
if err != nil {
return fmt.Errorf("failed to create LDAP client: %w", err)
}
defer client.Close()
baseDN := s.appConfigService.DbConfig.LdapBase.Value
uniqueIdentifierAttribute := s.appConfigService.DbConfig.LdapAttributeUserUniqueIdentifier.Value
usernameAttribute := s.appConfigService.DbConfig.LdapAttributeUserUsername.Value
emailAttribute := s.appConfigService.DbConfig.LdapAttributeUserEmail.Value
firstNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserFirstName.Value
lastNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserLastName.Value
adminGroupAttribute := s.appConfigService.DbConfig.LdapAttributeAdminGroup.Value
filter := "(objectClass=person)"
searchAttrs := []string{
"memberOf",
"sn",
"cn",
uniqueIdentifierAttribute,
usernameAttribute,
emailAttribute,
firstNameAttribute,
lastNameAttribute,
}
// Filters must start and finish with ()!
searchReq := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, 0, 0, 0, false, filter, searchAttrs, []ldap.Control{})
result, err := client.Search(searchReq)
if err != nil {
fmt.Println(fmt.Errorf("failed to query LDAP: %w", err))
}
// Create a mapping for users that exist
ldapUserIDs := make(map[string]bool)
for _, value := range result.Entries {
ldapId := value.GetAttributeValue(uniqueIdentifierAttribute)
ldapUserIDs[ldapId] = true
// Get the user from the database
var databaseUser model.User
s.db.Where("ldap_id = ?", ldapId).First(&databaseUser)
// Check if user is admin by checking if they are in the admin group
isAdmin := false
for _, group := range value.GetAttributeValues("memberOf") {
if strings.Contains(group, adminGroupAttribute) {
isAdmin = true
}
}
newUser := dto.UserCreateDto{
Username: value.GetAttributeValue(usernameAttribute),
Email: value.GetAttributeValue(emailAttribute),
FirstName: value.GetAttributeValue(firstNameAttribute),
LastName: value.GetAttributeValue(lastNameAttribute),
IsAdmin: isAdmin,
LdapID: ldapId,
}
if databaseUser.ID == "" {
_, err = s.userService.CreateUser(newUser)
if err != nil {
log.Printf("Error syncing user %s: %s", newUser.Username, err)
}
} else {
_, err = s.userService.UpdateUser(databaseUser.ID, newUser, false, true)
if err != nil {
log.Printf("Error syncing user %s: %s", newUser.Username, err)
}
}
}
// Get all LDAP users from the database
var ldapUsersInDb []model.User
if err := s.db.Find(&ldapUsersInDb, "ldap_id IS NOT NULL").Select("ldap_id").Error; err != nil {
fmt.Println(fmt.Errorf("failed to fetch users from database: %v", err))
}
// Delete users that no longer exist in LDAP
for _, user := range ldapUsersInDb {
if _, exists := ldapUserIDs[*user.LdapID]; !exists {
if err := s.db.Delete(&model.User{}, "ldap_id = ?", user.LdapID).Error; err != nil {
log.Printf("Failed to delete user %s with: %v", user.Username, err)
} else {
log.Printf("Deleted user %s", user.Username)
}
}
}
return nil
}

View File

@@ -14,7 +14,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"mime/multipart" "mime/multipart"
"os" "os"
"slices" "regexp"
"strings" "strings"
"time" "time"
) )
@@ -167,7 +167,7 @@ func (s *OidcService) GetClient(clientID string) (model.OidcClient, error) {
return client, nil return client, nil
} }
func (s *OidcService) ListClients(searchTerm string, page int, pageSize int) ([]model.OidcClient, utils.PaginationResponse, error) { func (s *OidcService) ListClients(searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.OidcClient, utils.PaginationResponse, error) {
var clients []model.OidcClient var clients []model.OidcClient
query := s.db.Preload("CreatedBy").Model(&model.OidcClient{}) query := s.db.Preload("CreatedBy").Model(&model.OidcClient{})
@@ -176,7 +176,7 @@ func (s *OidcService) ListClients(searchTerm string, page int, pageSize int) ([]
query = query.Where("name LIKE ?", searchPattern) query = query.Where("name LIKE ?", searchPattern)
} }
pagination, err := utils.Paginate(page, pageSize, query, &clients) pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &clients)
if err != nil { if err != nil {
return nil, utils.PaginationResponse{}, err return nil, utils.PaginationResponse{}, err
} }
@@ -432,8 +432,16 @@ func (s *OidcService) getCallbackURL(client model.OidcClient, inputCallbackURL s
if inputCallbackURL == "" { if inputCallbackURL == "" {
return client.CallbackURLs[0], nil return client.CallbackURLs[0], nil
} }
if slices.Contains(client.CallbackURLs, inputCallbackURL) {
return inputCallbackURL, nil for _, callbackPattern := range client.CallbackURLs {
regexPattern := strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$"
matched, err := regexp.MatchString(regexPattern, inputCallbackURL)
if err != nil {
return "", err
}
if matched {
return inputCallbackURL, nil
}
} }
return "", &common.OidcInvalidCallbackURLError{} return "", &common.OidcInvalidCallbackURLError{}

View File

@@ -17,14 +17,26 @@ func NewUserGroupService(db *gorm.DB) *UserGroupService {
return &UserGroupService{db: db} return &UserGroupService{db: db}
} }
func (s *UserGroupService) List(name string, page int, pageSize int) (groups []model.UserGroup, response utils.PaginationResponse, err error) { func (s *UserGroupService) List(name string, sortedPaginationRequest utils.SortedPaginationRequest) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
query := s.db.Preload("CustomClaims").Model(&model.UserGroup{}) query := s.db.Preload("CustomClaims").Model(&model.UserGroup{})
if name != "" { if name != "" {
query = query.Where("name LIKE ?", "%"+name+"%") query = query.Where("name LIKE ?", "%"+name+"%")
} }
response, err = utils.Paginate(page, pageSize, query, &groups) // As userCount is not a column we need to manually sort it
isValidSortDirection := sortedPaginationRequest.Sort.Direction == "asc" || sortedPaginationRequest.Sort.Direction == "desc"
if sortedPaginationRequest.Sort.Column == "userCount" && isValidSortDirection {
query = query.Select("user_groups.*, COUNT(user_groups_users.user_id)").
Joins("LEFT JOIN user_groups_users ON user_groups.id = user_groups_users.user_group_id").
Group("user_groups.id").
Order("COUNT(user_groups_users.user_id) " + sortedPaginationRequest.Sort.Direction)
response, err := utils.Paginate(sortedPaginationRequest.Pagination.Page, sortedPaginationRequest.Pagination.Limit, query, &groups)
return groups, response, err
}
response, err = utils.PaginateAndSort(sortedPaginationRequest, query, &groups)
return groups, response, err return groups, response, err
} }
@@ -39,6 +51,10 @@ func (s *UserGroupService) Delete(id string) error {
return err return err
} }
if group.LdapID != nil {
return &common.LdapUserGroupUpdateError{}
}
return s.db.Delete(&group).Error return s.db.Delete(&group).Error
} }
@@ -48,6 +64,10 @@ func (s *UserGroupService) Create(input dto.UserGroupCreateDto) (group model.Use
Name: input.Name, Name: input.Name,
} }
if input.LdapID != "" {
group.LdapID = &input.LdapID
}
if err := s.db.Preload("Users").Create(&group).Error; err != nil { if err := s.db.Preload("Users").Create(&group).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) { if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"} return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
@@ -57,14 +77,19 @@ func (s *UserGroupService) Create(input dto.UserGroupCreateDto) (group model.Use
return group, nil return group, nil
} }
func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto) (group model.UserGroup, err error) { func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto, allowLdapUpdate bool) (group model.UserGroup, err error) {
group, err = s.Get(id) group, err = s.Get(id)
if err != nil { if err != nil {
return model.UserGroup{}, err return model.UserGroup{}, err
} }
if group.LdapID != nil && !allowLdapUpdate {
return model.UserGroup{}, &common.LdapUserGroupUpdateError{}
}
group.Name = input.Name group.Name = input.Name
group.FriendlyName = input.FriendlyName group.FriendlyName = input.FriendlyName
group.LdapID = &input.LdapID
if err := s.db.Preload("Users").Save(&group).Error; err != nil { if err := s.db.Preload("Users").Save(&group).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) { if errors.Is(err, gorm.ErrDuplicatedKey) {

View File

@@ -2,12 +2,17 @@ package service
import ( import (
"errors" "errors"
"fmt"
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto" "github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/model" "github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/model/types" "github.com/stonith404/pocket-id/backend/internal/model/types"
"github.com/stonith404/pocket-id/backend/internal/utils" "github.com/stonith404/pocket-id/backend/internal/utils"
"github.com/stonith404/pocket-id/backend/internal/utils/email"
"gorm.io/gorm" "gorm.io/gorm"
"log"
"net/url"
"strings"
"time" "time"
) )
@@ -15,13 +20,14 @@ type UserService struct {
db *gorm.DB db *gorm.DB
jwtService *JwtService jwtService *JwtService
auditLogService *AuditLogService auditLogService *AuditLogService
emailService *EmailService
} }
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService) *UserService { func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService) *UserService {
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService} return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService, emailService: emailService}
} }
func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]model.User, utils.PaginationResponse, error) { func (s *UserService) ListUsers(searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) {
var users []model.User var users []model.User
query := s.db.Model(&model.User{}) query := s.db.Model(&model.User{})
@@ -30,7 +36,7 @@ func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]mo
query = query.Where("email LIKE ? OR first_name LIKE ? OR username LIKE ?", searchPattern, searchPattern, searchPattern) query = query.Where("email LIKE ? OR first_name LIKE ? OR username LIKE ?", searchPattern, searchPattern, searchPattern)
} }
pagination, err := utils.Paginate(page, pageSize, query, &users) pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &users)
return users, pagination, err return users, pagination, err
} }
@@ -46,6 +52,10 @@ func (s *UserService) DeleteUser(userID string) error {
return err return err
} }
if user.LdapID != nil {
return &common.LdapUserUpdateError{}
}
return s.db.Delete(&user).Error return s.db.Delete(&user).Error
} }
@@ -57,6 +67,10 @@ func (s *UserService) CreateUser(input dto.UserCreateDto) (model.User, error) {
Username: input.Username, Username: input.Username,
IsAdmin: input.IsAdmin, IsAdmin: input.IsAdmin,
} }
if input.LdapID != "" {
user.LdapID = &input.LdapID
}
if err := s.db.Create(&user).Error; err != nil { if err := s.db.Create(&user).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) { if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.User{}, s.checkDuplicatedFields(user) return model.User{}, s.checkDuplicatedFields(user)
@@ -66,11 +80,16 @@ func (s *UserService) CreateUser(input dto.UserCreateDto) (model.User, error) {
return user, nil return user, nil
} }
func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, updateOwnUser bool) (model.User, error) { func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, allowLdapUpdate bool) (model.User, error) {
var user model.User var user model.User
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil { if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
return model.User{}, err return model.User{}, err
} }
if user.LdapID != nil && !allowLdapUpdate {
return model.User{}, &common.LdapUserUpdateError{}
}
user.FirstName = updatedUser.FirstName user.FirstName = updatedUser.FirstName
user.LastName = updatedUser.LastName user.LastName = updatedUser.LastName
user.Email = updatedUser.Email user.Email = updatedUser.Email
@@ -89,7 +108,46 @@ func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, u
return user, nil return user, nil
} }
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time, ipAddress, userAgent string) (string, error) { func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath string) error {
var user model.User
if err := s.db.Where("email = ?", emailAddress).First(&user).Error; err != nil {
// Do not return error if user not found to prevent email enumeration
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
} else {
return err
}
}
oneTimeAccessToken, err := s.CreateOneTimeAccessToken(user.ID, time.Now().Add(time.Hour))
if err != nil {
return err
}
link := fmt.Sprintf("%s/login/%s", common.EnvConfig.AppURL, oneTimeAccessToken)
// Add redirect path to the link
if strings.HasPrefix(redirectPath, "/") {
encodedRedirectPath := url.QueryEscape(redirectPath)
link = fmt.Sprintf("%s?redirect=%s", link, encodedRedirectPath)
}
go func() {
err := SendEmail(s.emailService, email.Address{
Name: user.Username,
Email: user.Email,
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
Link: link,
})
if err != nil {
log.Printf("Failed to send email to '%s': %v\n", user.Email, err)
}
}()
return nil
}
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time) (string, error) {
randomString, err := utils.GenerateRandomAlphanumericString(16) randomString, err := utils.GenerateRandomAlphanumericString(16)
if err != nil { if err != nil {
return "", err return "", err
@@ -105,12 +163,10 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
return "", err return "", err
} }
s.auditLogService.Create(model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, userID, model.AuditLogData{})
return oneTimeAccessToken.Token, nil return oneTimeAccessToken.Token, nil
} }
func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, string, error) { func (s *UserService) ExchangeOneTimeAccessToken(token string, ipAddress, userAgent string) (model.User, string, error) {
var oneTimeAccessToken model.OneTimeAccessToken var oneTimeAccessToken model.OneTimeAccessToken
if err := s.db.Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).Preload("User").First(&oneTimeAccessToken).Error; err != nil { if err := s.db.Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -127,6 +183,10 @@ func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, stri
return model.User{}, "", err return model.User{}, "", err
} }
if ipAddress != "" && userAgent != "" {
s.auditLogService.Create(model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{})
}
return oneTimeAccessToken.User, accessToken, nil return oneTimeAccessToken.User, accessToken, nil
} }

View File

@@ -0,0 +1,12 @@
package utils
import (
"github.com/gin-gonic/gin"
"strconv"
)
func AddAccessTokenCookie(c *gin.Context, sessionDurationInMinutes string, token string) {
sessionDurationInMinutesParsed, _ := strconv.Atoi(sessionDurationInMinutes)
maxAge := sessionDurationInMinutesParsed * 60
c.SetCookie("access_token", token, maxAge, "/", "", true, true)
}

View File

@@ -9,8 +9,6 @@ import (
ttemplate "text/template" ttemplate "text/template"
) )
const templateComponentsDir = "components"
type Template[V any] struct { type Template[V any] struct {
Path string Path string
Title func(data *TemplateData[V]) string Title func(data *TemplateData[V]) string

View File

@@ -2,6 +2,7 @@ package utils
import ( import (
"gorm.io/gorm" "gorm.io/gorm"
"reflect"
) )
type PaginationResponse struct { type PaginationResponse struct {
@@ -11,7 +12,36 @@ type PaginationResponse struct {
ItemsPerPage int `json:"itemsPerPage"` ItemsPerPage int `json:"itemsPerPage"`
} }
func Paginate(page int, pageSize int, db *gorm.DB, result interface{}) (PaginationResponse, error) { type SortedPaginationRequest struct {
Pagination struct {
Page int `form:"pagination[page]"`
Limit int `form:"pagination[limit]"`
} `form:"pagination"`
Sort struct {
Column string `form:"sort[column]"`
Direction string `form:"sort[direction]"`
} `form:"sort"`
}
func PaginateAndSort(sortedPaginationRequest SortedPaginationRequest, query *gorm.DB, result interface{}) (PaginationResponse, error) {
pagination := sortedPaginationRequest.Pagination
sort := sortedPaginationRequest.Sort
capitalizedSortColumn := CapitalizeFirstLetter(sort.Column)
sortField, sortFieldFound := reflect.TypeOf(result).Elem().Elem().FieldByName(capitalizedSortColumn)
isSortable := sortField.Tag.Get("sortable") == "true"
isValidSortOrder := sort.Direction == "asc" || sort.Direction == "desc"
if sortFieldFound && isSortable && isValidSortOrder {
query = query.Order(CamelCaseToSnakeCase(sort.Column) + " " + sort.Direction)
}
return Paginate(pagination.Page, pagination.Limit, query, result)
}
func Paginate(page int, pageSize int, query *gorm.DB, result interface{}) (PaginationResponse, error) {
if page < 1 { if page < 1 {
page = 1 page = 1
} }
@@ -25,16 +55,21 @@ func Paginate(page int, pageSize int, db *gorm.DB, result interface{}) (Paginati
offset := (page - 1) * pageSize offset := (page - 1) * pageSize
var totalItems int64 var totalItems int64
if err := db.Count(&totalItems).Error; err != nil { if err := query.Count(&totalItems).Error; err != nil {
return PaginationResponse{}, err return PaginationResponse{}, err
} }
if err := db.Offset(offset).Limit(pageSize).Find(result).Error; err != nil { if err := query.Offset(offset).Limit(pageSize).Find(result).Error; err != nil {
return PaginationResponse{}, err return PaginationResponse{}, err
} }
totalPages := (totalItems + int64(pageSize) - 1) / int64(pageSize)
if totalItems == 0 {
totalPages = 1
}
return PaginationResponse{ return PaginationResponse{
TotalPages: (totalItems + int64(pageSize) - 1) / int64(pageSize), TotalPages: totalPages,
TotalItems: totalItems, TotalItems: totalItems,
CurrentPage: page, CurrentPage: page,
ItemsPerPage: pageSize, ItemsPerPage: pageSize,

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"math/big" "math/big"
"net/url" "net/url"
"unicode"
) )
// GenerateRandomAlphanumericString generates a random alphanumeric string of the given length // GenerateRandomAlphanumericString generates a random alphanumeric string of the given length
@@ -41,3 +42,23 @@ func GetHostnameFromURL(rawURL string) string {
func StringPointer(s string) *string { func StringPointer(s string) *string {
return &s return &s
} }
func CapitalizeFirstLetter(s string) string {
if s == "" {
return s
}
runes := []rune(s)
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}
func CamelCaseToSnakeCase(s string) string {
var result []rune
for i, r := range s {
if unicode.IsUpper(r) && i > 0 {
result = append(result, '_')
}
result = append(result, unicode.ToLower(r))
}
return string(result)
}

View File

@@ -76,5 +76,20 @@
font-size: 1rem; font-size: 1rem;
line-height: 1.5; line-height: 1.5;
} }
.button {
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
background-color: #000000;
color: #ffffff;
padding: 0.7rem 1.5rem;
outline: none;
border: none;
text-decoration: none;
}
.button-container {
text-align: center;
margin-top: 24px;
}
</style> </style>
{{ end }} {{ end }}

View File

@@ -1,7 +1,7 @@
{{ define "base" }} {{ define "base" }}
<div class="header"> <div class="header">
<div class="logo"> <div class="logo">
<img src="{{ .LogoURL }}" alt="Pocket ID"/> <img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
<h1>{{ .AppName }}</h1> <h1>{{ .AppName }}</h1>
</div> </div>
<div class="warning">Warning</div> <div class="warning">Warning</div>

View File

@@ -0,0 +1,17 @@
{{ define "base" }}
<div class="header">
<div class="logo">
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
<h1>{{ .AppName }}</h1>
</div>
</div>
<div class="content">
<h2>One-Time Access</h2>
<p class="message">
Click the button below to sign in to {{ .AppName }} with a one-time access link. This link expires in 15 minutes.
</p>
<div class="button-container">
<a class="button" href="{{ .Data.Link }}" class="button">Sign In</a>
</div>
</div>
{{ end -}}

View File

@@ -0,0 +1,8 @@
{{ define "base" -}}
One-Time Access
====================
Click the link below to sign in to {{ .AppName }} with a one-time access link. This link expires in 15 minutes.
{{ .Data.Link }}
{{ end -}}

View File

@@ -1,7 +1,7 @@
{{ define "base" -}} {{ define "base" -}}
<div class="header"> <div class="header">
<div class="logo"> <div class="logo">
<img src="{{ .LogoURL }}" alt="Pocket ID"/> <img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
<h1>{{ .AppName }}</h1> <h1>{{ .AppName }}</h1>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,5 @@
ALTER TABLE users
DROP COLUMN ldap_id;
ALTER TABLE user_groups
DROP COLUMN ldap_id;

View File

@@ -0,0 +1,5 @@
ALTER TABLE users ADD COLUMN ldap_id TEXT;
ALTER TABLE user_groups ADD COLUMN ldap_id TEXT;
CREATE UNIQUE INDEX users_ldap_id ON users (ldap_id);
CREATE UNIQUE INDEX user_groups_ldap_id ON user_groups (ldap_id);

View File

@@ -0,0 +1 @@
UPDATE app_config_variables SET key = 'emailEnabled' WHERE key = 'emailLoginNotificationEnabled';

View File

@@ -0,0 +1 @@
UPDATE app_config_variables SET key = 'emailLoginNotificationEnabled' WHERE key = 'emailEnabled';

View File

@@ -0,0 +1,2 @@
UPDATE users SET ldap_id = '' WHERE ldap_id IS NULL;
UPDATE user_groups SET ldap_id = '' WHERE ldap_id IS NULL;

View File

@@ -0,0 +1,2 @@
UPDATE users SET ldap_id = null WHERE ldap_id = '';
UPDATE user_groups SET ldap_id = null WHERE ldap_id = '';

View File

@@ -0,0 +1,2 @@
ALTER TABLE users DROP COLUMN ldap_id;
ALTER TABLE user_groups DROP COLUMN ldap_id;

View File

@@ -0,0 +1,5 @@
ALTER TABLE users ADD COLUMN ldap_id TEXT;
ALTER TABLE user_groups ADD COLUMN ldap_id TEXT;
CREATE UNIQUE INDEX users_ldap_id ON users (ldap_id);
CREATE UNIQUE INDEX user_groups_ldap_id ON user_groups (ldap_id);

View File

@@ -0,0 +1 @@
UPDATE app_config_variables SET key = 'emailEnabled' WHERE key = 'emailLoginNotificationEnabled';

View File

@@ -0,0 +1 @@
UPDATE app_config_variables SET key = 'emailLoginNotificationEnabled' WHERE key = 'emailEnabled';

View File

@@ -0,0 +1,2 @@
UPDATE users SET ldap_id = '' WHERE ldap_id IS NULL;
UPDATE user_groups SET ldap_id = '' WHERE ldap_id IS NULL;

View File

@@ -0,0 +1,2 @@
UPDATE users SET ldap_id = null WHERE ldap_id = '';
UPDATE user_groups SET ldap_id = null WHERE ldap_id = '';

View File

@@ -1,8 +1,105 @@
# Proxy Services through Pocket ID # Proxy Services through Pocket ID
The goal of Pocket ID is to stay simple. Because of that we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/oauth2-proxy/) to add authentication to your services that don't support OIDC. This guide will show you how to set up OAuth2 Proxy with Pocket ID. The goal of Pocket ID is to function exclusively as an OIDC provider. As such, we don't have a built-in proxy provider. However, you can use other tools that act as a middleware to protect your services and support OIDC as an authentication provider.
## Docker Setup There are two ways to do this:
- Implement OIDC into your reverse proxy
- Use [OAuth2 Proxy](https://oauth2-proxy.github.io/oauth2-proxy/)
## Reverse Proxy
Almost every reverse proxy somehow supports protecting your services with OIDC. Currently only Caddy is documented but you can search on Google for your reverse proxy and OIDC.
We would really appreciate if you contribute to this documentation by adding your reverse proxy and how to configure it with Pocket ID.
### Caddy
With [caddy-security](https://github.com/greenpau/caddy-security) you can easily protect your services with Pocket ID.
#### 1. Create a new OIDC client in Pocket ID.
Create a new OIDC client in Pocket ID by navigating to `https://<your-domain>/settings/admin/oidc-clients`. Now enter `https://<domain-of-proxied-service>/auth/oauth2/generic/authorization-code-callback` as the callback URL. After adding the client, you will obtain the client ID and client secret, which you will need in the next step.
#### 2. Install caddy-security
Run the following command to install caddy-security:
```bash
caddy add-package github.com/greenpau/caddy-security
```
#### 3. Create your Caddyfile
```bash
{
# Port to listen on
http_port 443
# Configure caddy-security.
order authenticate before respond
security {
oauth identity provider generic {
realm generic
driver generic
client_id client-id-from-pocket-id # Replace with your own client ID
client_secret client-secret-from-pocket-id # Replace with your own client secret
scopes openid email profile
base_auth_url http://localhost
metadata_url http://localhost/.well-known/openid-configuration
}
authentication portal myportal {
crypto default token lifetime 3600 # Seconds until you have to re-authenticate
enable identity provider generic
cookie insecure off # Set to "on" if you're not using HTTPS
transform user {
match realm generic
action add role user
}
}
authorization policy mypolicy {
set auth url /auth/oauth2/generic
allow roles user
inject headers with claims
}
}
}
https://<domain-of-your-service> {
@auth {
path /auth/oauth2/generic
path /auth/oauth2/generic/authorization-code-callback
}
route @auth {
authenticate with myportal
}
route /* {
authorize with mypolicy
reverse_proxy http://<service-to-be-proxied>:<port> # Replace with your own service
}
}
```
For additional configuration options, refer to the official [caddy-security documentation](https://docs.authcrunch.com/docs/intro).
#### 4. Start Caddy
```bash
caddy run --config Caddyfile
```
#### 5. Access the service
Your service should now be protected by Pocket ID.
## OAuth2 Proxy
### Docker Installation
#### 1. Add OAuth2 proxy to the service that should be proxied. #### 1. Add OAuth2 proxy to the service that should be proxied.
@@ -74,7 +171,7 @@ docker compose up -d
You can now access the service through OAuth2 Proxy by visiting `http://localhost:4180`. You can now access the service through OAuth2 Proxy by visiting `http://localhost:4180`.
## Standalone Installation ### Standalone Installation
Setting up OAuth2 Proxy with Pocket ID without Docker is similar to the Docker setup. As the setup depends on your environment, you have to adjust the steps accordingly but is should be similar to the Docker setup. Setting up OAuth2 Proxy with Pocket ID without Docker is similar to the Docker setup. As the setup depends on your environment, you have to adjust the steps accordingly but is should be similar to the Docker setup.

View File

@@ -32,7 +32,7 @@ export default [
}, },
{ {
rules: { rules: {
"@typescript-eslint/no-explicit-any": "off" '@typescript-eslint/no-explicit-any': 'off'
} }
} }
]; ];

View File

@@ -1,12 +1,12 @@
{ {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.10.0", "version": "0.24.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.10.0", "version": "0.24.1",
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^10.0.0", "@simplewebauthn/browser": "^10.0.0",
"axios": "^1.7.7", "axios": "^1.7.7",

View File

@@ -1,6 +1,6 @@
{ {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.23.0", "version": "0.26.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev --port 3000", "dev": "vite dev --port 3000",

View File

@@ -5,26 +5,48 @@
import * as Select from '$lib/components/ui/select'; import * as Select from '$lib/components/ui/select';
import * as Table from '$lib/components/ui/table/index.js'; import * as Table from '$lib/components/ui/table/index.js';
import Empty from '$lib/icons/empty.svelte'; import Empty from '$lib/icons/empty.svelte';
import type { Paginated } from '$lib/types/pagination.type'; import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import { debounced } from '$lib/utils/debounce-util'; import { debounced } from '$lib/utils/debounce-util';
import { cn } from '$lib/utils/style';
import { ChevronDown } from 'lucide-svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import Button from './ui/button/button.svelte';
let { let {
items, items,
requestOptions = $bindable(),
selectedIds = $bindable(), selectedIds = $bindable(),
withoutSearch = false, withoutSearch = false,
fetchItems, selectionDisabled = false,
defaultSort,
onRefresh,
columns, columns,
rows rows
}: { }: {
items: Paginated<T>; items: Paginated<T>;
requestOptions?: SearchPaginationSortRequest;
selectedIds?: string[]; selectedIds?: string[];
withoutSearch?: boolean; withoutSearch?: boolean;
fetchItems: (search: string, page: number, limit: number) => Promise<Paginated<T>>; selectionDisabled?: boolean;
columns: (string | { label: string; hidden?: boolean })[]; defaultSort?: { column: string; direction: 'asc' | 'desc' };
onRefresh: (requestOptions: SearchPaginationSortRequest) => Promise<Paginated<T>>;
columns: { label: string; hidden?: boolean; sortColumn?: string }[];
rows: Snippet<[{ item: T }]>; rows: Snippet<[{ item: T }]>;
} = $props(); } = $props();
let searchValue = $state('');
if (!requestOptions) {
requestOptions = {
search: '',
sort: defaultSort,
pagination: {
page: items.pagination.currentPage,
limit: items.pagination.itemsPerPage
}
};
}
let availablePageSizes: number[] = [10, 20, 50, 100]; let availablePageSizes: number[] = [10, 20, 50, 100];
let allChecked = $derived.by(() => { let allChecked = $derived.by(() => {
@@ -37,8 +59,10 @@
return true; return true;
}); });
const onSearch = debounced(async (searchValue: string) => { const onSearch = debounced(async (search: string) => {
items = await fetchItems(searchValue, 1, items.pagination.itemsPerPage); requestOptions.search = search;
await onRefresh(requestOptions);
searchValue = search;
}, 300); }, 300);
async function onAllCheck(checked: boolean) { async function onAllCheck(checked: boolean) {
@@ -59,112 +83,146 @@
} }
async function onPageChange(page: number) { async function onPageChange(page: number) {
items = await fetchItems('', page, items.pagination.itemsPerPage); requestOptions!.pagination = { limit: items.pagination.itemsPerPage, page };
onRefresh(requestOptions!);
} }
async function onPageSizeChange(size: number) { async function onPageSizeChange(size: number) {
items = await fetchItems('', 1, size); requestOptions!.pagination = { limit: size, page: 1 };
onRefresh(requestOptions!);
}
async function onSort(column?: string, direction: 'asc' | 'desc' = 'asc') {
if (!column) return;
requestOptions!.sort = { column, direction };
onRefresh(requestOptions!);
} }
</script> </script>
{#if items.data.length === 0} {#if !withoutSearch}
<Input
value={searchValue}
class={cn(
'relative z-50 mb-4 max-w-sm',
items.data.length == 0 && searchValue == '' && 'hidden'
)}
placeholder={'Search...'}
type="text"
oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
/>
{/if}
{#if items.data.length === 0 && searchValue === ''}
<div class="my-5 flex flex-col items-center"> <div class="my-5 flex flex-col items-center">
<Empty class="text-muted-foreground h-20" /> <Empty class="h-20 text-muted-foreground" />
<p class="text-muted-foreground mt-3 text-sm">No items found</p> <p class="mt-3 text-sm text-muted-foreground">No items found</p>
</div> </div>
{:else} {:else}
<div class="w-full"> <Table.Root class="min-w-full table-auto overflow-x-auto">
{#if !withoutSearch} <Table.Header>
<Input <Table.Row>
class="mb-4 max-w-sm" {#if selectedIds}
placeholder={'Search...'} <Table.Head class="w-12">
type="text" <Checkbox
oninput={(e) => onSearch((e.target as HTMLInputElement).value)} disabled={selectionDisabled}
/> checked={allChecked}
{/if} onCheckedChange={(c) => onAllCheck(c as boolean)}
/>
<Table.Root> </Table.Head>
<Table.Header> {/if}
<Table.Row> {#each columns as column}
{#if selectedIds} <Table.Head class={cn(column.hidden && 'sr-only', column.sortColumn && 'px-0')}>
<Table.Head> {#if column.sortColumn}
<Checkbox checked={allChecked} onCheckedChange={(c) => onAllCheck(c as boolean)} /> <Button
</Table.Head> variant="ghost"
{/if} class="flex items-center"
{#each columns as column} on:click={() =>
{#if typeof column === 'string'} onSort(
<Table.Head>{column}</Table.Head> column.sortColumn,
requestOptions.sort?.direction === 'desc' ? 'asc' : 'desc'
)}
>
{column.label}
{#if requestOptions.sort?.column === column.sortColumn}
<ChevronDown
class={cn(
'ml-2 h-4 w-4',
requestOptions.sort?.direction === 'asc' ? 'rotate-180' : ''
)}
/>
{/if}
</Button>
{:else} {:else}
<Table.Head class={column.hidden ? 'sr-only' : ''}>{column.label}</Table.Head> {column.label}
{/if} {/if}
{/each} </Table.Head>
</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} {/each}
</Table.Body> </Table.Row>
</Table.Root> </Table.Header>
<Table.Body>
{#each items.data as item}
<Table.Row class={selectedIds?.includes(item.id) ? 'bg-muted/20' : ''}>
{#if selectedIds}
<Table.Cell class="w-12">
<Checkbox
disabled={selectionDisabled}
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 <div class="mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
class="mt-5 flex flex-col-reverse items-center justify-between gap-3 space-x-2 sm:flex-row" <div class="flex items-center space-x-2">
> <p class="text-sm font-medium">Items per page</p>
<div class="flex items-center space-x-2"> <Select.Root
<p class="text-sm font-medium">Items per page</p> selected={{
<Select.Root label: items.pagination.itemsPerPage.toString(),
selected={{ value: items.pagination.itemsPerPage
label: items.pagination.itemsPerPage.toString(), }}
value: items.pagination.itemsPerPage onSelectedChange={(v) => onPageSizeChange(v?.value as number)}
}}
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"> <Select.Trigger class="h-9 w-[80px]">
<Pagination.Item> <Select.Value>{items.pagination.itemsPerPage}</Select.Value>
<Pagination.PrevButton /> </Select.Trigger>
</Pagination.Item> <Select.Content>
{#each pages as page (page.key)} {#each availablePageSizes as size}
{#if page.type !== 'ellipsis'} <Select.Item value={size}>{size}</Select.Item>
<Pagination.Item>
<Pagination.Link {page} isActive={items.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each} {/each}
<Pagination.Item> </Select.Content>
<Pagination.NextButton /> </Select.Root>
</Pagination.Item>
</Pagination.Content>
</Pagination.Root>
</div> </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' && page.value != 0}
<Pagination.Item>
<Pagination.Link {page} isActive={items.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
</Pagination.Root>
</div> </div>
{/if} {/if}

View File

@@ -103,7 +103,7 @@
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Enter') handleSuggestionClick(suggestion); if (e.key === 'Enter') handleSuggestionClick(suggestion);
}} }}
class="hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 {selectedIndex === class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 {selectedIndex ===
index index
? 'bg-accent text-accent-foreground' ? 'bg-accent text-accent-foreground'
: ''}" : ''}"

View File

@@ -19,7 +19,7 @@
} = $props(); } = $props();
</script> </script>
<div class="items-top mt-5 flex space-x-2"> <div class="items-top flex space-x-2">
<Checkbox <Checkbox
{id} {id}
{disabled} {disabled}
@@ -31,7 +31,7 @@
{label} {label}
</Label> </Label>
{#if description} {#if description}
<p class="text-muted-foreground text-[0.8rem]"> <p class="text-[0.8rem] text-muted-foreground">
{description} {description}
</p> </p>
{/if} {/if}

View File

@@ -6,7 +6,7 @@
</script> </script>
<div class="mt-[20%] flex flex-col items-center"> <div class="mt-[20%] flex flex-col items-center">
<LucideXCircle class="text-muted-foreground h-12 w-12" /> <LucideXCircle class="h-12 w-12 text-muted-foreground" />
<h1 class="mt-3 text-2xl font-semibold">Something went wrong</h1> <h1 class="mt-3 text-2xl font-semibold">Something went wrong</h1>
<p class="text-muted-foreground">{message}</p> <p class="text-muted-foreground">{message}</p>
{#if showButton} {#if showButton}

View File

@@ -9,6 +9,7 @@
input = $bindable(), input = $bindable(),
label, label,
description, description,
placeholder,
disabled = false, disabled = false,
type = 'text', type = 'text',
children, children,
@@ -18,6 +19,7 @@
input?: FormInput<string | boolean | number>; input?: FormInput<string | boolean | number>;
label?: string; label?: string;
description?: string; description?: string;
placeholder?: string;
disabled?: boolean; disabled?: boolean;
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox'; type?: 'text' | 'password' | 'email' | 'number' | 'checkbox';
onInput?: (e: FormInputEvent) => void; onInput?: (e: FormInputEvent) => void;
@@ -32,13 +34,20 @@
<Label class="mb-0" for={id}>{label}</Label> <Label class="mb-0" for={id}>{label}</Label>
{/if} {/if}
{#if description} {#if description}
<p class="text-muted-foreground mt-1 text-xs">{description}</p> <p class="mt-1 text-xs text-muted-foreground">{description}</p>
{/if} {/if}
<div class={label || description ? 'mt-2' : ''}> <div class={label || description ? 'mt-2' : ''}>
{#if children} {#if children}
{@render children()} {@render children()}
{:else if input} {:else if input}
<Input {id} {type} bind:value={input.value} {disabled} on:input={(e) => onInput?.(e)} /> <Input
{id}
{placeholder}
{type}
bind:value={input.value}
{disabled}
on:input={(e) => onInput?.(e)}
/>
{/if} {/if}
{#if input?.error} {#if input?.error}
<p class="mt-1 text-sm text-red-500">{input.error}</p> <p class="mt-1 text-sm text-red-500">{input.error}</p>

View File

@@ -39,7 +39,7 @@
{$userStore?.firstName} {$userStore?.firstName}
{$userStore?.lastName} {$userStore?.lastName}
</p> </p>
<p class="text-muted-foreground text-xs leading-none">{$userStore?.email}</p> <p class="text-xs leading-none text-muted-foreground">{$userStore?.email}</p>
</div> </div>
</DropdownMenu.Label> </DropdownMenu.Label>
<DropdownMenu.Separator /> <DropdownMenu.Separator />

View File

@@ -2,22 +2,44 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { browserSupportsWebAuthn } from '@simplewebauthn/browser'; import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { Button } from './ui/button';
import * as Card from './ui/card'; import * as Card from './ui/card';
import WebAuthnUnsupported from './web-authn-unsupported.svelte'; import WebAuthnUnsupported from './web-authn-unsupported.svelte';
import { page } from '$app/stores';
let { let {
children children,
showEmailOneTimeAccessButton = false
}: { }: {
children: Snippet; children: Snippet;
showEmailOneTimeAccessButton?: boolean;
} = $props(); } = $props();
</script> </script>
<!-- Desktop -->
<div class="hidden h-screen items-center text-center lg:flex"> <div class="hidden h-screen items-center text-center lg:flex">
<div class="min-w-[650px] p-16"> <div class="h-full min-w-[650px] p-16 {showEmailOneTimeAccessButton ? 'pb-0' : ''}">
{#if browser && !browserSupportsWebAuthn()} {#if browser && !browserSupportsWebAuthn()}
<WebAuthnUnsupported /> <WebAuthnUnsupported />
{:else} {:else}
{@render children()} <div class="flex h-full flex-col">
<div class="flex flex-grow flex-col items-center justify-center">
{@render children()}
</div>
{#if showEmailOneTimeAccessButton}
<div class="mb-4 flex justify-center">
<Button
href="/login/email?redirect={encodeURIComponent(
$page.url.pathname + $page.url.search
)}"
variant="link"
class="text-xs text-muted-foreground"
>
Don't have access to your passkey?
</Button>
</div>
{/if}
</div>
{/if} {/if}
</div> </div>
<img <img
@@ -27,15 +49,31 @@
/> />
</div> </div>
<!-- Mobile -->
<div <div
class="flex h-screen items-center justify-center bg-[url('/api/application-configuration/background-image')] bg-cover bg-center text-center lg:hidden" class="flex h-screen items-center justify-center bg-[url('/api/application-configuration/background-image')] bg-cover bg-center text-center lg:hidden"
> >
<Card.Root class="mx-3"> <Card.Root class="mx-3">
<Card.CardContent class="px-4 py-10 sm:p-10"> <Card.CardContent
class="px-4 py-10 sm:p-10 {showEmailOneTimeAccessButton ? 'pb-3 sm:pb-3' : ''}"
>
{#if browser && !browserSupportsWebAuthn()} {#if browser && !browserSupportsWebAuthn()}
<WebAuthnUnsupported /> <WebAuthnUnsupported />
{:else} {:else}
{@render children()} {@render children()}
{#if showEmailOneTimeAccessButton}
<div class="mt-5">
<Button
href="/login/email?redirect={encodeURIComponent(
$page.url.pathname + $page.url.search
)}"
variant="link"
class="text-xs text-muted-foreground"
>
Don't have access to your passkey?
</Button>
</div>
{/if}
{/if} {/if}
</Card.CardContent> </Card.CardContent>
</Card.Root> </Card.Root>

View File

@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { buttonVariants } from "$lib/components/ui/button/index.js"; import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from "$lib/utils/style.js"; import { cn } from '$lib/utils/style.js';
type $$Props = AlertDialogPrimitive.ActionProps; type $$Props = AlertDialogPrimitive.ActionProps;
type $$Events = AlertDialogPrimitive.ActionEvents; type $$Events = AlertDialogPrimitive.ActionEvents;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export { className as class }; export { className as class };
</script> </script>

View File

@@ -1,17 +1,17 @@
<script lang="ts"> <script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { buttonVariants } from "$lib/components/ui/button/index.js"; import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from "$lib/utils/style.js"; import { cn } from '$lib/utils/style.js';
type $$Props = AlertDialogPrimitive.CancelProps; type $$Props = AlertDialogPrimitive.CancelProps;
type $$Events = AlertDialogPrimitive.CancelEvents; type $$Events = AlertDialogPrimitive.CancelEvents;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export { className as class }; export { className as class };
</script> </script>
<AlertDialogPrimitive.Cancel <AlertDialogPrimitive.Cancel
class={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)} class={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
{...$$restProps} {...$$restProps}
on:click on:click
on:keydown on:keydown

View File

@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import * as AlertDialog from "./index.js"; import * as AlertDialog from './index.js';
import { cn, flyAndScale } from "$lib/utils/style.js"; import { cn, flyAndScale } from '$lib/utils/style.js';
type $$Props = AlertDialogPrimitive.ContentProps; type $$Props = AlertDialogPrimitive.ContentProps;
export let transition: $$Props["transition"] = flyAndScale; export let transition: $$Props['transition'] = flyAndScale;
export let transitionConfig: $$Props["transitionConfig"] = undefined; export let transitionConfig: $$Props['transitionConfig'] = undefined;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export { className as class }; export { className as class };
</script> </script>
@@ -18,7 +18,7 @@
{transition} {transition}
{transitionConfig} {transitionConfig}
class={cn( class={cn(
"bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg md:w-full", 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg sm:rounded-lg md:w-full',
className className
)} )}
{...$$restProps} {...$$restProps}

View File

@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from "$lib/utils/style.js"; import { cn } from '$lib/utils/style.js';
type $$Props = AlertDialogPrimitive.DescriptionProps; type $$Props = AlertDialogPrimitive.DescriptionProps;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export { className as class }; export { className as class };
</script> </script>
<AlertDialogPrimitive.Description <AlertDialogPrimitive.Description
class={cn("text-muted-foreground text-sm", className)} class={cn('text-sm text-muted-foreground', className)}
{...$$restProps} {...$$restProps}
> >
<slot /> <slot />

View File

@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn } from "$lib/utils/style.js"; import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLDivElement>; type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export { className as class }; export { className as class };
</script> </script>
<div <div
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} class={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...$$restProps} {...$$restProps}
> >
<slot /> <slot />

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn } from "$lib/utils/style.js"; import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLDivElement>; type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export { className as class }; export { className as class };
</script> </script>
<div class={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...$$restProps}> <div class={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...$$restProps}>
<slot /> <slot />
</div> </div>

View File

@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { fade } from "svelte/transition"; import { fade } from 'svelte/transition';
import { cn } from "$lib/utils/style.js"; import { cn } from '$lib/utils/style.js';
type $$Props = AlertDialogPrimitive.OverlayProps; type $$Props = AlertDialogPrimitive.OverlayProps;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export let transition: $$Props["transition"] = fade; export let transition: $$Props['transition'] = fade;
export let transitionConfig: $$Props["transitionConfig"] = { export let transitionConfig: $$Props['transitionConfig'] = {
duration: 150, duration: 150
}; };
export { className as class }; export { className as class };
</script> </script>
@@ -16,6 +16,6 @@
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
{transition} {transition}
{transitionConfig} {transitionConfig}
class={cn("bg-background/80 fixed inset-0 z-50 backdrop-blur-sm ", className)} class={cn('fixed inset-0 z-50 bg-background/80 backdrop-blur-sm ', className)}
{...$$restProps} {...$$restProps}
/> />

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
type $$Props = AlertDialogPrimitive.PortalProps; type $$Props = AlertDialogPrimitive.PortalProps;
</script> </script>

View File

@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from "$lib/utils/style.js"; import { cn } from '$lib/utils/style.js';
type $$Props = AlertDialogPrimitive.TitleProps; type $$Props = AlertDialogPrimitive.TitleProps;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export let level: $$Props["level"] = "h3"; export let level: $$Props['level'] = 'h3';
export { className as class }; export { className as class };
</script> </script>
<AlertDialogPrimitive.Title class={cn("text-lg font-semibold", className)} {level} {...$$restProps}> <AlertDialogPrimitive.Title class={cn('text-lg font-semibold', className)} {level} {...$$restProps}>
<slot /> <slot />
</AlertDialogPrimitive.Title> </AlertDialogPrimitive.Title>

View File

@@ -1,14 +1,14 @@
import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import Title from "./alert-dialog-title.svelte"; import Title from './alert-dialog-title.svelte';
import Action from "./alert-dialog-action.svelte"; import Action from './alert-dialog-action.svelte';
import Cancel from "./alert-dialog-cancel.svelte"; import Cancel from './alert-dialog-cancel.svelte';
import Portal from "./alert-dialog-portal.svelte"; import Portal from './alert-dialog-portal.svelte';
import Footer from "./alert-dialog-footer.svelte"; import Footer from './alert-dialog-footer.svelte';
import Header from "./alert-dialog-header.svelte"; import Header from './alert-dialog-header.svelte';
import Overlay from "./alert-dialog-overlay.svelte"; import Overlay from './alert-dialog-overlay.svelte';
import Content from "./alert-dialog-content.svelte"; import Content from './alert-dialog-content.svelte';
import Description from "./alert-dialog-description.svelte"; import Description from './alert-dialog-description.svelte';
const Root = AlertDialogPrimitive.Root; const Root = AlertDialogPrimitive.Root;
const Trigger = AlertDialogPrimitive.Trigger; const Trigger = AlertDialogPrimitive.Trigger;
@@ -36,5 +36,5 @@ export {
Trigger as AlertDialogTrigger, Trigger as AlertDialogTrigger,
Overlay as AlertDialogOverlay, Overlay as AlertDialogOverlay,
Content as AlertDialogContent, Content as AlertDialogContent,
Description as AlertDialogDescription, Description as AlertDialogDescription
}; };

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn } from "$lib/utils/style.js"; import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLDivElement>; type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export { className as class }; export { className as class };
</script> </script>
<div class={cn("text-sm [&_p]:leading-relaxed", className)} {...$$restProps}> <div class={cn('text-sm [&_p]:leading-relaxed', className)} {...$$restProps}>
<slot /> <slot />
</div> </div>

View File

@@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import type { HeadingLevel } from "./index.js"; import type { HeadingLevel } from './index.js';
import { cn } from "$lib/utils/style.js"; import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLHeadingElement> & { type $$Props = HTMLAttributes<HTMLHeadingElement> & {
level?: HeadingLevel; level?: HeadingLevel;
}; };
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export let level: $$Props["level"] = "h5"; export let level: $$Props['level'] = 'h5';
export { className as class }; export { className as class };
</script> </script>
<svelte:element <svelte:element
this={level} this={level}
class={cn("mb-1 font-medium leading-none tracking-tight", className)} class={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...$$restProps} {...$$restProps}
> >
<slot /> <slot />

View File

@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { type Variant, alertVariants } from "./index.js"; import { type Variant, alertVariants } from './index.js';
import { cn } from "$lib/utils/style.js"; import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLDivElement> & { type $$Props = HTMLAttributes<HTMLDivElement> & {
variant?: Variant; variant?: Variant;
}; };
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export let variant: $$Props["variant"] = "default"; export let variant: $$Props['variant'] = 'default';
export { className as class }; export { className as class };
</script> </script>

View File

@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui"; import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from "$lib/utils/style.js"; import { cn } from '$lib/utils/style.js';
type $$Props = AvatarPrimitive.FallbackProps; type $$Props = AvatarPrimitive.FallbackProps;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export { className as class }; export { className as class };
</script> </script>
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
class={cn("bg-muted flex h-full w-full items-center justify-center rounded-full", className)} class={cn('flex h-full w-full items-center justify-center rounded-full bg-muted', className)}
{...$$restProps} {...$$restProps}
> >
<slot /> <slot />

View File

@@ -1,18 +1,18 @@
<script lang="ts"> <script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui"; import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from "$lib/utils/style.js"; import { cn } from '$lib/utils/style.js';
type $$Props = AvatarPrimitive.ImageProps; type $$Props = AvatarPrimitive.ImageProps;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export let src: $$Props["src"] = undefined; export let src: $$Props['src'] = undefined;
export let alt: $$Props["alt"] = undefined; export let alt: $$Props['alt'] = undefined;
export { className as class }; export { className as class };
</script> </script>
<AvatarPrimitive.Image <AvatarPrimitive.Image
{src} {src}
{alt} {alt}
class={cn("aspect-square h-full w-full", className)} class={cn('aspect-square h-full w-full', className)}
{...$$restProps} {...$$restProps}
/> />

View File

@@ -1,17 +1,17 @@
<script lang="ts"> <script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui"; import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from "$lib/utils/style.js"; import { cn } from '$lib/utils/style.js';
type $$Props = AvatarPrimitive.Props; type $$Props = AvatarPrimitive.Props;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export let delayMs: $$Props["delayMs"] = undefined; export let delayMs: $$Props['delayMs'] = undefined;
export { className as class }; export { className as class };
</script> </script>
<AvatarPrimitive.Root <AvatarPrimitive.Root
{delayMs} {delayMs}
class={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)} class={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
{...$$restProps} {...$$restProps}
> >
<slot /> <slot />

View File

@@ -1,6 +1,6 @@
import Root from "./avatar.svelte"; import Root from './avatar.svelte';
import Image from "./avatar-image.svelte"; import Image from './avatar-image.svelte';
import Fallback from "./avatar-fallback.svelte"; import Fallback from './avatar-fallback.svelte';
export { export {
Root, Root,
@@ -9,5 +9,5 @@ export {
// //
Root as Avatar, Root as Avatar,
Image as AvatarImage, Image as AvatarImage,
Fallback as AvatarFallback, Fallback as AvatarFallback
}; };

View File

@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { type Variant, badgeVariants } from "./index.js"; import { type Variant, badgeVariants } from './index.js';
import { cn } from "$lib/utils/style.js"; import { cn } from '$lib/utils/style.js';
let className: string | undefined | null = undefined; let className: string | undefined | null = undefined;
export let href: string | undefined = undefined; export let href: string | undefined = undefined;
export let variant: Variant = "default"; export let variant: Variant = 'default';
export { className as class }; export { className as class };
</script> </script>
<svelte:element <svelte:element
this={href ? "a" : "span"} this={href ? 'a' : 'span'}
{href} {href}
class={cn(badgeVariants({ variant, className }))} class={cn(badgeVariants({ variant, className }))}
{...$$restProps} {...$$restProps}

View File

@@ -1,21 +1,20 @@
import { type VariantProps, tv } from "tailwind-variants"; import { type VariantProps, tv } from 'tailwind-variants';
export { default as Badge } from "./badge.svelte"; export { default as Badge } from './badge.svelte';
export const badgeVariants = tv({ export const badgeVariants = tv({
base: "inline-flex select-none items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 break-keep whitespace-nowrap", base: 'inline-flex select-none items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 break-keep whitespace-nowrap',
variants: { variants: {
variant: { variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary: secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: "text-foreground", outline: 'text-foreground'
}, }
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default'
}, }
}); });
export type Variant = VariantProps<typeof badgeVariants>["variant"]; export type Variant = VariantProps<typeof badgeVariants>['variant'];

View File

@@ -1,34 +1,33 @@
import { type VariantProps, tv } from "tailwind-variants"; import { type VariantProps, tv } from 'tailwind-variants';
import type { Button as ButtonPrimitive } from "bits-ui"; import type { Button as ButtonPrimitive } from 'bits-ui';
import Root from "./button.svelte"; import Root from './button.svelte';
const buttonVariants = tv({ const buttonVariants = tv({
base: "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", base: 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
"border border-input bg-background hover:bg-accent hover:text-accent-foreground", secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: 'hover:bg-accent hover:text-accent-foreground',
ghost: "hover:bg-accent hover:text-accent-foreground", link: 'text-primary underline-offset-4 hover:underline'
link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-10 px-4 py-2", default: 'h-10 px-4 py-2',
sm: "h-9 rounded-md px-3", sm: 'h-9 rounded-md px-3',
lg: "h-11 rounded-md px-8", lg: 'h-11 rounded-md px-8',
icon: "h-10 w-10", icon: 'h-10 w-10'
}, }
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
size: "default", size: 'default'
}, }
}); });
type Variant = VariantProps<typeof buttonVariants>["variant"]; type Variant = VariantProps<typeof buttonVariants>['variant'];
type Size = VariantProps<typeof buttonVariants>["size"]; type Size = VariantProps<typeof buttonVariants>['size'];
type Props = ButtonPrimitive.Props & { type Props = ButtonPrimitive.Props & {
variant?: Variant; variant?: Variant;
@@ -46,5 +45,5 @@ export {
Root as Button, Root as Button,
type Props as ButtonProps, type Props as ButtonProps,
type Events as ButtonEvents, type Events as ButtonEvents,
buttonVariants, buttonVariants
}; };

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn } from "$lib/utils/style.js"; import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLDivElement>; type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export { className as class }; export { className as class };
</script> </script>
<div class={cn("p-6 pt-0", className)} {...$$restProps}> <div class={cn('p-6 pt-0', className)} {...$$restProps}>
<slot /> <slot />
</div> </div>

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn } from "$lib/utils/style.js"; import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLParagraphElement>; type $$Props = HTMLAttributes<HTMLParagraphElement>;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export { className as class }; export { className as class };
</script> </script>
<p class={cn("text-sm text-muted-foreground", className)} {...$$restProps}> <p class={cn('text-sm text-muted-foreground', className)} {...$$restProps}>
<slot /> <slot />
</p> </p>

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn } from "$lib/utils/style.js"; import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLDivElement>; type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export { className as class }; export { className as class };
</script> </script>
<div class={cn("flex items-center p-6 pt-0", className)} {...$$restProps}> <div class={cn('flex items-center p-6 pt-0', className)} {...$$restProps}>
<slot /> <slot />
</div> </div>

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn } from "$lib/utils/style.js"; import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLDivElement>; type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export { className as class }; export { className as class };
</script> </script>
<div class={cn("flex flex-col space-y-1.5 p-6", className)} {...$$restProps}> <div class={cn('flex flex-col space-y-1.5 p-6', className)} {...$$restProps}>
<slot /> <slot />
</div> </div>

View File

@@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import type { HeadingLevel } from "./index.js"; import type { HeadingLevel } from './index.js';
import { cn } from "$lib/utils/style.js"; import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLHeadingElement> & { type $$Props = HTMLAttributes<HTMLHeadingElement> & {
tag?: HeadingLevel; tag?: HeadingLevel;
}; };
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export let tag: $$Props["tag"] = "h3"; export let tag: $$Props['tag'] = 'h3';
export { className as class }; export { className as class };
</script> </script>
<svelte:element <svelte:element
this={tag} this={tag}
class={cn("text-xl font-semibold leading-none tracking-tight", className)} class={cn('text-xl font-semibold leading-none tracking-tight', className)}
{...$$restProps} {...$$restProps}
> >
<slot /> <slot />

View File

@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn } from "$lib/utils/style.js"; import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLDivElement>; type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export { className as class }; export { className as class };
</script> </script>
<div <div
class={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} class={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...$$restProps} {...$$restProps}
> >
<slot /> <slot />

View File

@@ -1,9 +1,9 @@
import Root from "./card.svelte"; import Root from './card.svelte';
import Content from "./card-content.svelte"; import Content from './card-content.svelte';
import Description from "./card-description.svelte"; import Description from './card-description.svelte';
import Footer from "./card-footer.svelte"; import Footer from './card-footer.svelte';
import Header from "./card-header.svelte"; import Header from './card-header.svelte';
import Title from "./card-title.svelte"; import Title from './card-title.svelte';
export { export {
Root, Root,
@@ -18,7 +18,7 @@ export {
Description as CardDescription, Description as CardDescription,
Footer as CardFooter, Footer as CardFooter,
Header as CardHeader, Header as CardHeader,
Title as CardTitle, Title as CardTitle
}; };
export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; export type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';

View File

@@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import { Checkbox as CheckboxPrimitive } from "bits-ui"; import { Checkbox as CheckboxPrimitive } from 'bits-ui';
import Check from "lucide-svelte/icons/check"; import Check from 'lucide-svelte/icons/check';
import Minus from "lucide-svelte/icons/minus"; import Minus from 'lucide-svelte/icons/minus';
import { cn } from "$lib/utils/style.js"; import { cn } from '$lib/utils/style.js';
type $$Props = CheckboxPrimitive.Props; type $$Props = CheckboxPrimitive.Props;
type $$Events = CheckboxPrimitive.Events; type $$Events = CheckboxPrimitive.Events;
let className: $$Props["class"] = undefined; let className: $$Props['class'] = undefined;
export let checked: $$Props["checked"] = false; export let checked: $$Props['checked'] = false;
export { className as class }; export { className as class };
</script> </script>
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
class={cn( class={cn(
"peer box-content h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[disabled=true]:cursor-not-allowed data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[disabled=true]:opacity-50", 'peer box-content h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[disabled=true]:cursor-not-allowed data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[disabled=true]:opacity-50',
className className
)} )}
bind:checked bind:checked
@@ -22,7 +22,7 @@
on:click on:click
> >
<CheckboxPrimitive.Indicator <CheckboxPrimitive.Indicator
class={cn("flex h-4 w-4 items-center justify-center text-current")} class={cn('flex h-4 w-4 items-center justify-center text-current')}
let:isChecked let:isChecked
let:isIndeterminate let:isIndeterminate
> >

View File

@@ -1,6 +1,6 @@
import Root from "./checkbox.svelte"; import Root from './checkbox.svelte';
export { export {
Root, Root,
// //
Root as Checkbox, Root as Checkbox
}; };

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