mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-13 00:33:06 +03:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f29325f45 | ||
|
|
aca2240a50 | ||
|
|
de45398903 | ||
|
|
3d3fb4d855 | ||
|
|
725388fcc7 | ||
|
|
ad1d3560f9 | ||
|
|
becfc0004a | ||
|
|
376d747616 | ||
|
|
5b9f4d7326 | ||
|
|
0de4b55dc4 | ||
|
|
78c88f5339 | ||
|
|
60e7dafa01 | ||
|
|
2ccabf835c | ||
|
|
590cb02f6c | ||
|
|
8c96ab9574 | ||
|
|
3484daf870 | ||
|
|
cfbc0d6d35 | ||
|
|
939601b6a4 | ||
|
|
b9daa5d757 | ||
|
|
8304065652 | ||
|
|
7bfc3f43a5 | ||
|
|
c056089c60 | ||
|
|
3350398abc | ||
|
|
0b0a6781ff | ||
|
|
735dc70d5f | ||
|
|
47e164b4b5 | ||
|
|
18c5103c20 | ||
|
|
5565f60d6d | ||
|
|
bd4f87b2d2 | ||
|
|
6560fd9279 | ||
|
|
29d632c151 | ||
|
|
2092007752 | ||
|
|
0aff6181c9 | ||
|
|
824c5cb4f3 | ||
|
|
3a300a2b51 | ||
|
|
a1985ce1b2 | ||
|
|
b39bc4f79a | ||
|
|
0a07344139 | ||
|
|
f3f0e1d56d | ||
|
|
70ad0b4f39 |
18
.dockerignore
Normal file
18
.dockerignore
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
/frontend/.svelte-kit
|
||||||
|
/frontend/build
|
||||||
|
/backend/bin
|
||||||
|
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
|
||||||
|
# Application specific
|
||||||
|
data
|
||||||
|
/scripts/development
|
||||||
12
.github/workflows/e2e-tests.yml
vendored
12
.github/workflows/e2e-tests.yml
vendored
@@ -15,12 +15,13 @@ jobs:
|
|||||||
node-version: lts/*
|
node-version: lts/*
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
- name: Create dummy GeoLite2 City database
|
- name: Create dummy GeoLite2 City database
|
||||||
run: touch ./backend/GeoLite2-City.mmdb
|
run: touch ./backend/GeoLite2-City.mmdb
|
||||||
|
|
||||||
- name: Build Docker Image
|
- name: Build Docker Image
|
||||||
run: docker build -t stonith404/pocket-id .
|
run: docker build -t stonith404/pocket-id .
|
||||||
|
|
||||||
- name: Run Docker Container
|
- name: Run Docker Container
|
||||||
run: docker run -d --name pocket-id -p 80:80 --env-file .env.test stonith404/pocket-id
|
run: docker run -d --name pocket-id -p 80:80 --env-file .env.test stonith404/pocket-id
|
||||||
|
|
||||||
@@ -36,13 +37,10 @@ jobs:
|
|||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
run: npx playwright test
|
run: npx playwright test
|
||||||
|
|
||||||
- name: Get container logs
|
|
||||||
if: always()
|
|
||||||
run: docker logs pocket-id
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
path: frontend/tests/.output
|
path: frontend/tests/.report
|
||||||
|
include-hidden-files: true
|
||||||
retention-days: 15
|
retention-days: 15
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -34,5 +34,6 @@ vite.config.ts.timestamp-*
|
|||||||
# Application specific
|
# Application specific
|
||||||
data
|
data
|
||||||
/frontend/tests/.auth
|
/frontend/tests/.auth
|
||||||
|
/frontend/tests/.report
|
||||||
pocket-id-backend
|
pocket-id-backend
|
||||||
/backend/GeoLite2-City.mmdb
|
/backend/GeoLite2-City.mmdb
|
||||||
70
CHANGELOG.md
70
CHANGELOG.md
@@ -1,3 +1,73 @@
|
|||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.13.1...v) (2024-11-11)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add audit log event for one time access token sign in ([aca2240](https://github.com/stonith404/pocket-id/commit/aca2240a50a12e849cfb6e1aa56390b000aebae0))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* overflow of pagination control on mobile ([de45398](https://github.com/stonith404/pocket-id/commit/de4539890349153c467013c24c4d6b30feb8fed8))
|
||||||
|
* time displayed incorrectly in audit log ([3d3fb4d](https://github.com/stonith404/pocket-id/commit/3d3fb4d855ef510f2292e98fcaaaf83debb5d3e0))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.13.0...v) (2024-11-01)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add list empty indicator ([becfc00](https://github.com/stonith404/pocket-id/commit/becfc0004a87c01e18eb92ac85bf4e33f105b6a3))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* errors in middleware do not abort the request ([376d747](https://github.com/stonith404/pocket-id/commit/376d747616b1e835f252d20832c5ae42b8b0b737))
|
||||||
|
* typo in Self-Account Editing description ([5b9f4d7](https://github.com/stonith404/pocket-id/commit/5b9f4d732615f428c13d3317da96a86c5daebd89))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.12.0...v) (2024-10-31)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add ability to define expiration of one time link ([2ccabf8](https://github.com/stonith404/pocket-id/commit/2ccabf835c2c923d6986d9cafb4e878f5110b91a))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.11.0...v) (2024-10-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add option to disable self-account editing ([8304065](https://github.com/stonith404/pocket-id/commit/83040656525cf7b6c8f2acf416c5f8f3288f3d48))
|
||||||
|
* add validation to custom claim input ([7bfc3f4](https://github.com/stonith404/pocket-id/commit/7bfc3f43a591287c038187ed5e782de6b9dd738b))
|
||||||
|
* custom claims ([#53](https://github.com/stonith404/pocket-id/issues/53)) ([c056089](https://github.com/stonith404/pocket-id/commit/c056089c6043a825aaaaecf0c57454892a108f1d))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.10.0...v) (2024-10-25)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add `email_verified` claim ([5565f60](https://github.com/stonith404/pocket-id/commit/5565f60d6d62ca24bedea337e21effc13e5853a5))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* powered by link text color in light mode ([18c5103](https://github.com/stonith404/pocket-id/commit/18c5103c20ce79abdc0f724cdedd642c09269e78))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.9.0...v) (2024-10-23)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add script for creating one time access token ([a1985ce](https://github.com/stonith404/pocket-id/commit/a1985ce1b200550e91c5cb42a8d19899dcec831e))
|
||||||
|
* add version information to footer and update link if new update is available ([70ad0b4](https://github.com/stonith404/pocket-id/commit/70ad0b4f39699fd81ffdfd5c8d6839f49348be78))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* cache version information for 3 hours ([29d632c](https://github.com/stonith404/pocket-id/commit/29d632c1514d6edacdfebe6deae4c95fc5a0f621))
|
||||||
|
* improve text for initial admin account setup ([0a07344](https://github.com/stonith404/pocket-id/commit/0a0734413943b1fff27d8f4ccf07587e207e2189))
|
||||||
|
* increase callback url count ([f3f0e1d](https://github.com/stonith404/pocket-id/commit/f3f0e1d56d7656bdabbd745a4eaf967f63193b6c))
|
||||||
|
* no DTO was returned from exchange one time access token endpoint ([824c5cb](https://github.com/stonith404/pocket-id/commit/824c5cb4f3d6be7f940c1758112fbe9322df5768))
|
||||||
|
|
||||||
## [](https://github.com/stonith404/pocket-id/compare/v0.8.1...v) (2024-10-18)
|
## [](https://github.com/stonith404/pocket-id/compare/v0.8.1...v) (2024-10-18)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ COPY --from=backend-builder /app/backend/email-templates ./backend/email-templat
|
|||||||
COPY --from=backend-builder /app/backend/images ./backend/images
|
COPY --from=backend-builder /app/backend/images ./backend/images
|
||||||
|
|
||||||
COPY ./scripts ./scripts
|
COPY ./scripts ./scripts
|
||||||
|
RUN chmod +x ./scripts/*.sh
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 80
|
||||||
ENV APP_ENV=production
|
ENV APP_ENV=production
|
||||||
|
|
||||||
# Use a shell form to run both the frontend and backend
|
# Use a shell form to run both the frontend and backend
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -85,28 +85,23 @@ Required tools:
|
|||||||
|
|
||||||
You can now sign in with the admin account on `http://localhost/login/setup`.
|
You can now sign in with the admin account on `http://localhost/login/setup`.
|
||||||
|
|
||||||
### Add Pocket ID as an OIDC provider
|
### Nginx Reverse Proxy
|
||||||
|
|
||||||
You can add a new OIDC client on `https://<your-domain>/settings/admin/oidc-clients`
|
To use Nginx in front of Pocket ID, add the following configuration to increase the header buffer size because, as SvelteKit generates larger headers.
|
||||||
|
|
||||||
After you have added the client, you can obtain the client ID and client secret.
|
```nginx
|
||||||
|
proxy_busy_buffers_size 512k;
|
||||||
|
proxy_buffers 4 512k;
|
||||||
|
proxy_buffer_size 256k;
|
||||||
|
```
|
||||||
|
|
||||||
You may need the following information:
|
## Proxy Services with Pocket ID
|
||||||
|
|
||||||
- **Authorization URL**: `https://<your-domain>/authorize`
|
|
||||||
- **Token URL**: `https://<your-domain>/api/oidc/token`
|
|
||||||
- **Userinfo URL**: `https://<your-domain>/api/oidc/userinfo`
|
|
||||||
- **Certificate URL**: `https://<your-domain>/.well-known/jwks.json`
|
|
||||||
- **OIDC Discovery URL**: `https://<your-domain>/.well-known/openid-configuration`
|
|
||||||
- **Scopes**: At least `openid email`. Optionally you can add `profile` and `groups`.
|
|
||||||
|
|
||||||
### Proxy Services with Pocket ID
|
|
||||||
|
|
||||||
As the goal of Pocket ID is to stay simple, we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/) to add authentication to your services that don't support OIDC.
|
As the goal of Pocket ID is to stay simple, we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/) to add authentication to your services that don't support OIDC.
|
||||||
|
|
||||||
See the [guide](docs/proxy-services.md) for more information.
|
See the [guide](docs/proxy-services.md) for more information.
|
||||||
|
|
||||||
### Update
|
## Update
|
||||||
|
|
||||||
#### Docker
|
#### Docker
|
||||||
|
|
||||||
@@ -149,7 +144,7 @@ docker compose up -d
|
|||||||
pm2 start caddy --name pocket-id-caddy -- run --config Caddyfile
|
pm2 start caddy --name pocket-id-caddy -- run --config Caddyfile
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment variables
|
## Environment variables
|
||||||
|
|
||||||
| Variable | Default Value | Recommended to change | Description |
|
| Variable | Default Value | Recommended to change | Description |
|
||||||
| ---------------------- | ----------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------- | ----------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ func newDatabase() (db *gorm.DB) {
|
|||||||
log.Fatalf("failed to connect to database: %v", err)
|
log.Fatalf("failed to connect to database: %v", err)
|
||||||
}
|
}
|
||||||
sqlDb, err := db.DB()
|
sqlDb, err := db.DB()
|
||||||
|
sqlDb.SetMaxOpenConns(1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to get sql.DB: %v", err)
|
log.Fatalf("failed to get sql.DB: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,12 +38,14 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
|||||||
auditLogService := service.NewAuditLogService(db, appConfigService, emailService)
|
auditLogService := service.NewAuditLogService(db, appConfigService, emailService)
|
||||||
jwtService := service.NewJwtService(appConfigService)
|
jwtService := service.NewJwtService(appConfigService)
|
||||||
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
|
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
|
||||||
userService := service.NewUserService(db, jwtService)
|
userService := service.NewUserService(db, jwtService, auditLogService)
|
||||||
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService)
|
customClaimService := service.NewCustomClaimService(db)
|
||||||
|
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
|
||||||
testService := service.NewTestService(db, appConfigService)
|
testService := service.NewTestService(db, appConfigService)
|
||||||
userGroupService := service.NewUserGroupService(db)
|
userGroupService := service.NewUserGroupService(db)
|
||||||
|
|
||||||
r.Use(middleware.NewCorsMiddleware().Add())
|
r.Use(middleware.NewCorsMiddleware().Add())
|
||||||
|
r.Use(middleware.NewErrorHandlerMiddleware().Add())
|
||||||
r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60))
|
r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60))
|
||||||
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
|
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
|
||||||
|
|
||||||
@@ -55,10 +57,11 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
|||||||
apiGroup := r.Group("/api")
|
apiGroup := r.Group("/api")
|
||||||
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService)
|
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService)
|
||||||
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
|
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
|
||||||
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService)
|
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
|
||||||
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService)
|
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService)
|
||||||
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
|
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
|
||||||
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
|
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
|
||||||
|
controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)
|
||||||
|
|
||||||
// Add test controller in non-production environments
|
// Add test controller in non-production environments
|
||||||
if common.EnvConfig.AppEnv != "production" {
|
if common.EnvConfig.AppEnv != "production" {
|
||||||
|
|||||||
@@ -1,19 +1,148 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import "errors"
|
import (
|
||||||
|
"fmt"
|
||||||
var (
|
"net/http"
|
||||||
ErrUsernameTaken = errors.New("username is already taken")
|
|
||||||
ErrEmailTaken = errors.New("email is already taken")
|
|
||||||
ErrSetupAlreadyCompleted = errors.New("setup already completed")
|
|
||||||
ErrTokenInvalidOrExpired = errors.New("token is invalid or expired")
|
|
||||||
ErrOidcMissingAuthorization = errors.New("missing authorization")
|
|
||||||
ErrOidcGrantTypeNotSupported = errors.New("grant type not supported")
|
|
||||||
ErrOidcMissingClientCredentials = errors.New("client id or secret not provided")
|
|
||||||
ErrOidcClientSecretInvalid = errors.New("invalid client secret")
|
|
||||||
ErrOidcInvalidAuthorizationCode = errors.New("invalid authorization code")
|
|
||||||
ErrOidcInvalidCallbackURL = errors.New("invalid callback URL")
|
|
||||||
ErrFileTypeNotSupported = errors.New("file type not supported")
|
|
||||||
ErrInvalidCredentials = errors.New("no user found with provided credentials")
|
|
||||||
ErrNameAlreadyInUse = errors.New("name is already in use")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type AppError interface {
|
||||||
|
error
|
||||||
|
HttpStatusCode() int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom error types for various conditions
|
||||||
|
|
||||||
|
type AlreadyInUseError struct {
|
||||||
|
Property string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *AlreadyInUseError) Error() string {
|
||||||
|
return fmt.Sprintf("%s is already in use", e.Property)
|
||||||
|
}
|
||||||
|
func (e *AlreadyInUseError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type SetupAlreadyCompletedError struct{}
|
||||||
|
|
||||||
|
func (e *SetupAlreadyCompletedError) Error() string { return "setup already completed" }
|
||||||
|
func (e *SetupAlreadyCompletedError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type TokenInvalidOrExpiredError struct{}
|
||||||
|
|
||||||
|
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
|
||||||
|
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type OidcMissingAuthorizationError struct{}
|
||||||
|
|
||||||
|
func (e *OidcMissingAuthorizationError) Error() string { return "missing authorization" }
|
||||||
|
func (e *OidcMissingAuthorizationError) HttpStatusCode() int { return http.StatusForbidden }
|
||||||
|
|
||||||
|
type OidcGrantTypeNotSupportedError struct{}
|
||||||
|
|
||||||
|
func (e *OidcGrantTypeNotSupportedError) Error() string { return "grant type not supported" }
|
||||||
|
func (e *OidcGrantTypeNotSupportedError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type OidcMissingClientCredentialsError struct{}
|
||||||
|
|
||||||
|
func (e *OidcMissingClientCredentialsError) Error() string { return "client id or secret not provided" }
|
||||||
|
func (e *OidcMissingClientCredentialsError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type OidcClientSecretInvalidError struct{}
|
||||||
|
|
||||||
|
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
|
||||||
|
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type OidcInvalidAuthorizationCodeError struct{}
|
||||||
|
|
||||||
|
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
|
||||||
|
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type OidcInvalidCallbackURLError struct{}
|
||||||
|
|
||||||
|
func (e *OidcInvalidCallbackURLError) Error() string { return "invalid callback URL" }
|
||||||
|
func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type FileTypeNotSupportedError struct{}
|
||||||
|
|
||||||
|
func (e *FileTypeNotSupportedError) Error() string { return "file type not supported" }
|
||||||
|
func (e *FileTypeNotSupportedError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type InvalidCredentialsError struct{}
|
||||||
|
|
||||||
|
func (e *InvalidCredentialsError) Error() string { return "no user found with provided credentials" }
|
||||||
|
func (e *InvalidCredentialsError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type FileTooLargeError struct {
|
||||||
|
MaxSize string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *FileTooLargeError) Error() string {
|
||||||
|
return fmt.Sprintf("The file can't be larger than %s", e.MaxSize)
|
||||||
|
}
|
||||||
|
func (e *FileTooLargeError) HttpStatusCode() int { return http.StatusRequestEntityTooLarge }
|
||||||
|
|
||||||
|
type NotSignedInError struct{}
|
||||||
|
|
||||||
|
func (e *NotSignedInError) Error() string { return "You are not signed in" }
|
||||||
|
func (e *NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||||
|
|
||||||
|
type MissingPermissionError struct{}
|
||||||
|
|
||||||
|
func (e *MissingPermissionError) Error() string {
|
||||||
|
return "You don't have permission to perform this action"
|
||||||
|
}
|
||||||
|
func (e *MissingPermissionError) HttpStatusCode() int { return http.StatusForbidden }
|
||||||
|
|
||||||
|
type TooManyRequestsError struct{}
|
||||||
|
|
||||||
|
func (e *TooManyRequestsError) Error() string {
|
||||||
|
return "Too many requests. Please wait a while before trying again."
|
||||||
|
}
|
||||||
|
func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
|
||||||
|
|
||||||
|
type ClientIdOrSecretNotProvidedError struct{}
|
||||||
|
|
||||||
|
func (e *ClientIdOrSecretNotProvidedError) Error() string {
|
||||||
|
return "Client id and secret not provided"
|
||||||
|
}
|
||||||
|
func (e *ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type WrongFileTypeError struct {
|
||||||
|
ExpectedFileType string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *WrongFileTypeError) Error() string {
|
||||||
|
return fmt.Sprintf("File must be of type %s", e.ExpectedFileType)
|
||||||
|
}
|
||||||
|
func (e *WrongFileTypeError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type MissingSessionIdError struct{}
|
||||||
|
|
||||||
|
func (e *MissingSessionIdError) Error() string {
|
||||||
|
return "Missing session id"
|
||||||
|
}
|
||||||
|
func (e *MissingSessionIdError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type ReservedClaimError struct {
|
||||||
|
Key string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ReservedClaimError) Error() string {
|
||||||
|
return fmt.Sprintf("Claim %s is reserved and can't be used", e.Key)
|
||||||
|
}
|
||||||
|
func (e *ReservedClaimError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type DuplicateClaimError struct {
|
||||||
|
Key string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *DuplicateClaimError) Error() string {
|
||||||
|
return fmt.Sprintf("Claim %s is already defined", e.Key)
|
||||||
|
}
|
||||||
|
func (e *DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type AccountEditNotAllowedError struct{}
|
||||||
|
|
||||||
|
func (e *AccountEditNotAllowedError) Error() string {
|
||||||
|
return "You are not allowed to edit your account"
|
||||||
|
}
|
||||||
|
func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
@@ -39,13 +38,13 @@ type AppConfigController struct {
|
|||||||
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
||||||
configuration, err := acc.appConfigService.ListAppConfig(false)
|
configuration, err := acc.appConfigService.ListAppConfig(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var configVariablesDto []dto.PublicAppConfigVariableDto
|
var configVariablesDto []dto.PublicAppConfigVariableDto
|
||||||
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,13 +54,13 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
|||||||
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
||||||
configuration, err := acc.appConfigService.ListAppConfig(true)
|
configuration, err := acc.appConfigService.ListAppConfig(true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var configVariablesDto []dto.AppConfigVariableDto
|
var configVariablesDto []dto.AppConfigVariableDto
|
||||||
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,19 +70,19 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
|||||||
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
|
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
|
||||||
var input dto.AppConfigUpdateDto
|
var input dto.AppConfigUpdateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(input)
|
savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var configVariablesDto []dto.AppConfigVariableDto
|
var configVariablesDto []dto.AppConfigVariableDto
|
||||||
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
|
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,13 +135,13 @@ func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
|
|||||||
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
|
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
|
||||||
file, err := c.FormFile("file")
|
file, err := c.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fileType := utils.GetFileExtension(file.Filename)
|
fileType := utils.GetFileExtension(file.Filename)
|
||||||
if fileType != "ico" {
|
if fileType != "ico" {
|
||||||
utils.CustomControllerError(c, http.StatusBadRequest, "File must be of type .ico")
|
c.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
acc.updateImage(c, "favicon", "ico")
|
acc.updateImage(c, "favicon", "ico")
|
||||||
@@ -164,17 +163,13 @@ func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType
|
|||||||
func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) {
|
func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) {
|
||||||
file, err := c.FormFile("file")
|
file, err := c.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = acc.appConfigService.UpdateImage(file, imageName, oldImageType)
|
err = acc.appConfigService.UpdateImage(file, imageName, oldImageType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrFileTypeNotSupported) {
|
c.Error(err)
|
||||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, jwtAuthMiddleware *middleware.JwtAuthMiddleware) {
|
func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, jwtAuthMiddleware *middleware.JwtAuthMiddleware) {
|
||||||
@@ -31,7 +30,7 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
|||||||
// Fetch audit logs for the user
|
// Fetch audit logs for the user
|
||||||
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, page, pageSize)
|
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +38,7 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
|||||||
var logsDtos []dto.AuditLogDto
|
var logsDtos []dto.AuditLogDto
|
||||||
err = dto.MapStructList(logs, &logsDtos)
|
err = dto.MapStructList(logs, &logsDtos)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
78
backend/internal/controller/custom_claim_controller.go
Normal file
78
backend/internal/controller/custom_claim_controller.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewCustomClaimController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, customClaimService *service.CustomClaimService) {
|
||||||
|
wkc := &CustomClaimController{customClaimService: customClaimService}
|
||||||
|
group.GET("/custom-claims/suggestions", jwtAuthMiddleware.Add(true), wkc.getSuggestionsHandler)
|
||||||
|
group.PUT("/custom-claims/user/:userId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserHandler)
|
||||||
|
group.PUT("/custom-claims/user-group/:userGroupId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserGroupHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomClaimController struct {
|
||||||
|
customClaimService *service.CustomClaimService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
|
||||||
|
claims, err := ccc.customClaimService.GetSuggestions()
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
|
||||||
|
var input []dto.CustomClaimCreateDto
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId := c.Param("userId")
|
||||||
|
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(userId, input)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var customClaimsDto []dto.CustomClaimDto
|
||||||
|
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, customClaimsDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
|
||||||
|
var input []dto.CustomClaimCreateDto
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId := c.Param("userGroupId")
|
||||||
|
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(userId, input)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var customClaimsDto []dto.CustomClaimDto
|
||||||
|
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, customClaimsDto)
|
||||||
|
}
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -42,19 +40,13 @@ type OidcController struct {
|
|||||||
func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
||||||
var input dto.AuthorizeOidcClientRequestDto
|
var input dto.AuthorizeOidcClientRequestDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
|
code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrOidcMissingAuthorization) {
|
c.Error(err)
|
||||||
utils.CustomControllerError(c, http.StatusForbidden, err.Error())
|
|
||||||
} else if errors.Is(err, common.ErrOidcInvalidCallbackURL) {
|
|
||||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,17 +61,13 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
|||||||
func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
|
func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
|
||||||
var input dto.AuthorizeOidcClientRequestDto
|
var input dto.AuthorizeOidcClientRequestDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
code, callbackURL, err := oc.oidcService.AuthorizeNewClient(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
|
code, callbackURL, err := oc.oidcService.AuthorizeNewClient(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrOidcInvalidCallbackURL) {
|
c.Error(err)
|
||||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +83,7 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
|||||||
var input dto.OidcIdTokenDto
|
var input dto.OidcIdTokenDto
|
||||||
|
|
||||||
if err := c.ShouldBind(&input); err != nil {
|
if err := c.ShouldBind(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,21 +95,14 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
|||||||
var ok bool
|
var ok bool
|
||||||
clientID, clientSecret, ok = c.Request.BasicAuth()
|
clientID, clientSecret, ok = c.Request.BasicAuth()
|
||||||
if !ok {
|
if !ok {
|
||||||
utils.CustomControllerError(c, http.StatusBadRequest, "Client id and secret not provided")
|
c.Error(&common.ClientIdOrSecretNotProvidedError{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret)
|
idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrOidcGrantTypeNotSupported) ||
|
c.Error(err)
|
||||||
errors.Is(err, common.ErrOidcMissingClientCredentials) ||
|
|
||||||
errors.Is(err, common.ErrOidcClientSecretInvalid) ||
|
|
||||||
errors.Is(err, common.ErrOidcInvalidAuthorizationCode) {
|
|
||||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,14 +113,14 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
|
|||||||
token := strings.Split(c.GetHeader("Authorization"), " ")[1]
|
token := strings.Split(c.GetHeader("Authorization"), " ")[1]
|
||||||
jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token)
|
jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.CustomControllerError(c, http.StatusUnauthorized, common.ErrTokenInvalidOrExpired.Error())
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID := jwtClaims.Subject
|
userID := jwtClaims.Subject
|
||||||
clientId := jwtClaims.Audience[0]
|
clientId := jwtClaims.Audience[0]
|
||||||
claims, err := oc.oidcService.GetUserClaimsForClient(userID, clientId)
|
claims, err := oc.oidcService.GetUserClaimsForClient(userID, clientId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +131,7 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
|
|||||||
clientId := c.Param("id")
|
clientId := c.Param("id")
|
||||||
client, err := oc.oidcService.GetClient(clientId)
|
client, err := oc.oidcService.GetClient(clientId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +152,7 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
||||||
@@ -181,13 +162,13 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
|||||||
|
|
||||||
clients, pagination, err := oc.oidcService.ListClients(searchTerm, page, pageSize)
|
clients, pagination, err := oc.oidcService.ListClients(searchTerm, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var clientsDto []dto.OidcClientDto
|
var clientsDto []dto.OidcClientDto
|
||||||
if err := dto.MapStructList(clients, &clientsDto); err != nil {
|
if err := dto.MapStructList(clients, &clientsDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,19 +181,19 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
|||||||
func (oc *OidcController) createClientHandler(c *gin.Context) {
|
func (oc *OidcController) createClientHandler(c *gin.Context) {
|
||||||
var input dto.OidcClientCreateDto
|
var input dto.OidcClientCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := oc.oidcService.CreateClient(input, c.GetString("userID"))
|
client, err := oc.oidcService.CreateClient(input, c.GetString("userID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var clientDto dto.OidcClientDto
|
var clientDto dto.OidcClientDto
|
||||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +203,7 @@ func (oc *OidcController) createClientHandler(c *gin.Context) {
|
|||||||
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
||||||
err := oc.oidcService.DeleteClient(c.Param("id"))
|
err := oc.oidcService.DeleteClient(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,19 +213,19 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
|||||||
func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
||||||
var input dto.OidcClientCreateDto
|
var input dto.OidcClientCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := oc.oidcService.UpdateClient(c.Param("id"), input)
|
client, err := oc.oidcService.UpdateClient(c.Param("id"), input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var clientDto dto.OidcClientDto
|
var clientDto dto.OidcClientDto
|
||||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +235,7 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
|||||||
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
||||||
secret, err := oc.oidcService.CreateClientSecret(c.Param("id"))
|
secret, err := oc.oidcService.CreateClientSecret(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +245,7 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
|||||||
func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
||||||
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Param("id"))
|
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,17 +256,13 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
|||||||
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
||||||
file, err := c.FormFile("file")
|
file, err := c.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = oc.oidcService.UpdateClientLogo(c.Param("id"), file)
|
err = oc.oidcService.UpdateClientLogo(c.Param("id"), file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrFileTypeNotSupported) {
|
c.Error(err)
|
||||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +272,7 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
|||||||
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
|
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
|
||||||
err := oc.oidcService.DeleteClientLogo(c.Param("id"))
|
err := oc.oidcService.DeleteClientLogo(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,17 +18,22 @@ type TestController struct {
|
|||||||
|
|
||||||
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
||||||
if err := tc.TestService.ResetDatabase(); err != nil {
|
if err := tc.TestService.ResetDatabase(); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tc.TestService.ResetApplicationImages(); err != nil {
|
if err := tc.TestService.ResetApplicationImages(); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tc.TestService.SeedDatabase(); err != nil {
|
if err := tc.TestService.SeedDatabase(); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tc.TestService.ResetAppConfig(); err != nil {
|
||||||
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService) {
|
func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) {
|
||||||
uc := UserController{
|
uc := UserController{
|
||||||
UserService: userService,
|
UserService: userService,
|
||||||
|
AppConfigService: appConfigService,
|
||||||
}
|
}
|
||||||
|
|
||||||
group.GET("/users", jwtAuthMiddleware.Add(true), uc.listUsersHandler)
|
group.GET("/users", jwtAuthMiddleware.Add(true), uc.listUsersHandler)
|
||||||
@@ -33,7 +32,8 @@ func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserController struct {
|
type UserController struct {
|
||||||
UserService *service.UserService
|
UserService *service.UserService
|
||||||
|
AppConfigService *service.AppConfigService
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uc *UserController) listUsersHandler(c *gin.Context) {
|
func (uc *UserController) listUsersHandler(c *gin.Context) {
|
||||||
@@ -43,13 +43,13 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
|
|||||||
|
|
||||||
users, pagination, err := uc.UserService.ListUsers(searchTerm, page, pageSize)
|
users, pagination, err := uc.UserService.ListUsers(searchTerm, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var usersDto []dto.UserDto
|
var usersDto []dto.UserDto
|
||||||
if err := dto.MapStructList(users, &usersDto); err != nil {
|
if err := dto.MapStructList(users, &usersDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,13 +62,13 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
|
|||||||
func (uc *UserController) getUserHandler(c *gin.Context) {
|
func (uc *UserController) getUserHandler(c *gin.Context) {
|
||||||
user, err := uc.UserService.GetUser(c.Param("id"))
|
user, err := uc.UserService.GetUser(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var userDto dto.UserDto
|
var userDto dto.UserDto
|
||||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,13 +78,13 @@ func (uc *UserController) getUserHandler(c *gin.Context) {
|
|||||||
func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
|
func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
|
||||||
user, err := uc.UserService.GetUser(c.GetString("userID"))
|
user, err := uc.UserService.GetUser(c.GetString("userID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var userDto dto.UserDto
|
var userDto dto.UserDto
|
||||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
|
|||||||
|
|
||||||
func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
||||||
if err := uc.UserService.DeleteUser(c.Param("id")); err != nil {
|
if err := uc.UserService.DeleteUser(c.Param("id")); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,23 +103,19 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
|||||||
func (uc *UserController) createUserHandler(c *gin.Context) {
|
func (uc *UserController) createUserHandler(c *gin.Context) {
|
||||||
var input dto.UserCreateDto
|
var input dto.UserCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := uc.UserService.CreateUser(input)
|
user, err := uc.UserService.CreateUser(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrEmailTaken) || errors.Is(err, common.ErrUsernameTaken) {
|
c.Error(err)
|
||||||
utils.CustomControllerError(c, http.StatusConflict, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var userDto dto.UserDto
|
var userDto dto.UserDto
|
||||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,19 +127,23 @@ func (uc *UserController) updateUserHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
|
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
|
||||||
|
if uc.AppConfigService.DbConfig.AllowOwnAccountEdit.Value != "true" {
|
||||||
|
c.Error(&common.AccountEditNotAllowedError{})
|
||||||
|
return
|
||||||
|
}
|
||||||
uc.updateUser(c, true)
|
uc.updateUser(c, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
|
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
|
||||||
var input dto.OneTimeAccessTokenCreateDto
|
var input dto.OneTimeAccessTokenCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
|
token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt, c.ClientIP(), c.Request.UserAgent())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,32 +153,30 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
|
|||||||
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
||||||
user, token, err := uc.UserService.ExchangeOneTimeAccessToken(c.Param("token"))
|
user, token, err := uc.UserService.ExchangeOneTimeAccessToken(c.Param("token"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrTokenInvalidOrExpired) {
|
c.Error(err)
|
||||||
utils.CustomControllerError(c, http.StatusUnauthorized, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
|
|
||||||
c.JSON(http.StatusOK, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
|
|
||||||
user, token, err := uc.UserService.SetupInitialAdmin()
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, common.ErrSetupAlreadyCompleted) {
|
|
||||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var userDto dto.UserDto
|
var userDto dto.UserDto
|
||||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
|
||||||
|
c.JSON(http.StatusOK, userDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
|
||||||
|
user, token, err := uc.UserService.SetupInitialAdmin()
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var userDto dto.UserDto
|
||||||
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +187,7 @@ func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
|
|||||||
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||||
var input dto.UserCreateDto
|
var input dto.UserCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,17 +200,13 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
|||||||
|
|
||||||
user, err := uc.UserService.UpdateUser(userID, input, updateOwnUser)
|
user, err := uc.UserService.UpdateUser(userID, input, updateOwnUser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrEmailTaken) || errors.Is(err, common.ErrUsernameTaken) {
|
c.Error(err)
|
||||||
utils.CustomControllerError(c, http.StatusConflict, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var userDto dto.UserDto
|
var userDto dto.UserDto
|
||||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
|
||||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewUserGroupController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, userGroupService *service.UserGroupService) {
|
func NewUserGroupController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, userGroupService *service.UserGroupService) {
|
||||||
@@ -37,7 +34,7 @@ func (ugc *UserGroupController) list(c *gin.Context) {
|
|||||||
|
|
||||||
groups, pagination, err := ugc.UserGroupService.List(searchTerm, page, pageSize)
|
groups, pagination, err := ugc.UserGroupService.List(searchTerm, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,12 +42,12 @@ func (ugc *UserGroupController) list(c *gin.Context) {
|
|||||||
for i, group := range groups {
|
for i, group := range groups {
|
||||||
var groupDto dto.UserGroupDtoWithUserCount
|
var groupDto dto.UserGroupDtoWithUserCount
|
||||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(group.ID)
|
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(group.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
groupsDto[i] = groupDto
|
groupsDto[i] = groupDto
|
||||||
@@ -65,13 +62,13 @@ func (ugc *UserGroupController) list(c *gin.Context) {
|
|||||||
func (ugc *UserGroupController) get(c *gin.Context) {
|
func (ugc *UserGroupController) get(c *gin.Context) {
|
||||||
group, err := ugc.UserGroupService.Get(c.Param("id"))
|
group, err := ugc.UserGroupService.Get(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var groupDto dto.UserGroupDtoWithUsers
|
var groupDto dto.UserGroupDtoWithUsers
|
||||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,23 +78,19 @@ func (ugc *UserGroupController) get(c *gin.Context) {
|
|||||||
func (ugc *UserGroupController) create(c *gin.Context) {
|
func (ugc *UserGroupController) create(c *gin.Context) {
|
||||||
var input dto.UserGroupCreateDto
|
var input dto.UserGroupCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
group, err := ugc.UserGroupService.Create(input)
|
group, err := ugc.UserGroupService.Create(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrNameAlreadyInUse) {
|
c.Error(err)
|
||||||
utils.CustomControllerError(c, http.StatusConflict, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var groupDto dto.UserGroupDtoWithUsers
|
var groupDto dto.UserGroupDtoWithUsers
|
||||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,23 +100,19 @@ func (ugc *UserGroupController) create(c *gin.Context) {
|
|||||||
func (ugc *UserGroupController) update(c *gin.Context) {
|
func (ugc *UserGroupController) update(c *gin.Context) {
|
||||||
var input dto.UserGroupCreateDto
|
var input dto.UserGroupCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
group, err := ugc.UserGroupService.Update(c.Param("id"), input)
|
group, err := ugc.UserGroupService.Update(c.Param("id"), input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrNameAlreadyInUse) {
|
c.Error(err)
|
||||||
utils.CustomControllerError(c, http.StatusConflict, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var groupDto dto.UserGroupDtoWithUsers
|
var groupDto dto.UserGroupDtoWithUsers
|
||||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +121,7 @@ func (ugc *UserGroupController) update(c *gin.Context) {
|
|||||||
|
|
||||||
func (ugc *UserGroupController) delete(c *gin.Context) {
|
func (ugc *UserGroupController) delete(c *gin.Context) {
|
||||||
if err := ugc.UserGroupService.Delete(c.Param("id")); err != nil {
|
if err := ugc.UserGroupService.Delete(c.Param("id")); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,19 +131,19 @@ func (ugc *UserGroupController) delete(c *gin.Context) {
|
|||||||
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
|
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
|
||||||
var input dto.UserGroupUpdateUsersDto
|
var input dto.UserGroupUpdateUsersDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input)
|
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var groupDto dto.UserGroupDtoWithUsers
|
var groupDto dto.UserGroupDtoWithUsers
|
||||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"github.com/go-webauthn/webauthn/protocol"
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
|
||||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,7 +36,7 @@ func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
|
|||||||
userID := c.GetString("userID")
|
userID := c.GetString("userID")
|
||||||
options, err := wc.webAuthnService.BeginRegistration(userID)
|
options, err := wc.webAuthnService.BeginRegistration(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,20 +47,20 @@ func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
|
|||||||
func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
|
func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
|
||||||
sessionID, err := c.Cookie("session_id")
|
sessionID, err := c.Cookie("session_id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.CustomControllerError(c, http.StatusBadRequest, "Session ID missing")
|
c.Error(&common.MissingSessionIdError{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID := c.GetString("userID")
|
userID := c.GetString("userID")
|
||||||
credential, err := wc.webAuthnService.VerifyRegistration(sessionID, userID, c.Request)
|
credential, err := wc.webAuthnService.VerifyRegistration(sessionID, userID, c.Request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var credentialDto dto.WebauthnCredentialDto
|
var credentialDto dto.WebauthnCredentialDto
|
||||||
if err := dto.MapStruct(credential, &credentialDto); err != nil {
|
if err := dto.MapStruct(credential, &credentialDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +70,7 @@ func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
|
|||||||
func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
|
func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
|
||||||
options, err := wc.webAuthnService.BeginLogin()
|
options, err := wc.webAuthnService.BeginLogin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,13 +81,13 @@ func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
|
|||||||
func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
|
func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
|
||||||
sessionID, err := c.Cookie("session_id")
|
sessionID, err := c.Cookie("session_id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.CustomControllerError(c, http.StatusBadRequest, "Session ID missing")
|
c.Error(&common.MissingSessionIdError{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
|
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,17 +95,13 @@ func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
|
|||||||
|
|
||||||
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, userID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
|
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, userID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrInvalidCredentials) {
|
c.Error(err)
|
||||||
utils.CustomControllerError(c, http.StatusUnauthorized, err.Error())
|
|
||||||
} else {
|
|
||||||
utils.ControllerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var userDto dto.UserDto
|
var userDto dto.UserDto
|
||||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,13 +113,13 @@ func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) {
|
|||||||
userID := c.GetString("userID")
|
userID := c.GetString("userID")
|
||||||
credentials, err := wc.webAuthnService.ListCredentials(userID)
|
credentials, err := wc.webAuthnService.ListCredentials(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var credentialDtos []dto.WebauthnCredentialDto
|
var credentialDtos []dto.WebauthnCredentialDto
|
||||||
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
|
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +132,7 @@ func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
|
|||||||
|
|
||||||
err := wc.webAuthnService.DeleteCredential(userID, credentialID)
|
err := wc.webAuthnService.DeleteCredential(userID, credentialID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,19 +145,19 @@ func (wc *WebauthnController) updateCredentialHandler(c *gin.Context) {
|
|||||||
|
|
||||||
var input dto.WebauthnCredentialUpdateDto
|
var input dto.WebauthnCredentialUpdateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
credential, err := wc.webAuthnService.UpdateCredential(userID, credentialID, input.Name)
|
credential, err := wc.webAuthnService.UpdateCredential(userID, credentialID, input.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var credentialDto dto.WebauthnCredentialDto
|
var credentialDto dto.WebauthnCredentialDto
|
||||||
if err := dto.MapStruct(credential, &credentialDto); err != nil {
|
if err := dto.MapStruct(credential, &credentialDto); err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,7 +20,7 @@ type WellKnownController struct {
|
|||||||
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
|
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
|
||||||
jwk, err := wkc.jwtService.GetJWK()
|
jwk, err := wkc.jwtService.GetJWK()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ControllerError(c, err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +36,7 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
|
|||||||
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
|
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
|
||||||
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
||||||
"scopes_supported": []string{"openid", "profile", "email"},
|
"scopes_supported": []string{"openid", "profile", "email"},
|
||||||
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "preferred_username"},
|
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username"},
|
||||||
"response_types_supported": []string{"code", "id_token"},
|
"response_types_supported": []string{"code", "id_token"},
|
||||||
"subject_types_supported": []string{"public"},
|
"subject_types_supported": []string{"public"},
|
||||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ 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"`
|
||||||
EmailEnabled string `json:"emailEnabled" binding:"required"`
|
EmailsVerified string `json:"emailsVerified" binding:"required"`
|
||||||
SmtHost string `json:"smtpHost"`
|
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
|
||||||
SmtpPort string `json:"smtpPort"`
|
EmailEnabled string `json:"emailEnabled" binding:"required"`
|
||||||
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
|
SmtHost string `json:"smtpHost"`
|
||||||
SmtpUser string `json:"smtpUser"`
|
SmtpPort string `json:"smtpPort"`
|
||||||
SmtpPassword string `json:"smtpPassword"`
|
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
|
||||||
|
SmtpUser string `json:"smtpUser"`
|
||||||
|
SmtpPassword string `json:"smtpPassword"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ package dto
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||||
"time"
|
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuditLogDto struct {
|
type AuditLogDto struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
|
|
||||||
Event model.AuditLogEvent `json:"event"`
|
Event model.AuditLogEvent `json:"event"`
|
||||||
IpAddress string `json:"ipAddress"`
|
IpAddress string `json:"ipAddress"`
|
||||||
|
|||||||
11
backend/internal/dto/custom_claim_dto.go
Normal file
11
backend/internal/dto/custom_claim_dto.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
type CustomClaimDto struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomClaimCreateDto struct {
|
||||||
|
Key string `json:"key" binding:"required,claimKey"`
|
||||||
|
Value string `json:"value" binding:"required"`
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ package dto
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MapStructList maps a list of source structs to a list of destination structs
|
// MapStructList maps a list of source structs to a list of destination structs
|
||||||
@@ -95,7 +97,18 @@ func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
|
|||||||
if err := mapStructInternal(sourceField, destField); err != nil {
|
if err := mapStructInternal(sourceField, destField); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Type switch for specific type conversions
|
||||||
|
switch sourceField.Interface().(type) {
|
||||||
|
case datatype.DateTime:
|
||||||
|
// Convert datatype.DateTime to time.Time
|
||||||
|
if sourceField.Type() == reflect.TypeOf(datatype.DateTime{}) && destField.Type() == reflect.TypeOf(time.Time{}) {
|
||||||
|
dateValue := sourceField.Interface().(datatype.DateTime)
|
||||||
|
destField.Set(reflect.ValueOf(dateValue.ToTime()))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ package dto
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type UserDto struct {
|
type UserDto struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Email string `json:"email" `
|
Email string `json:"email" `
|
||||||
FirstName string `json:"firstName"`
|
FirstName string `json:"firstName"`
|
||||||
LastName string `json:"lastName"`
|
LastName string `json:"lastName"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
|
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserCreateDto struct {
|
type UserCreateDto struct {
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||||
|
)
|
||||||
|
|
||||||
type UserGroupDtoWithUsers struct {
|
type UserGroupDtoWithUsers struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
FriendlyName string `json:"friendlyName"`
|
FriendlyName string `json:"friendlyName"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Users []UserDto `json:"users"`
|
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
Users []UserDto `json:"users"`
|
||||||
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserGroupDtoWithUserCount struct {
|
type UserGroupDtoWithUserCount struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
FriendlyName string `json:"friendlyName"`
|
FriendlyName string `json:"friendlyName"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
UserCount int64 `json:"userCount"`
|
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
UserCount int64 `json:"userCount"`
|
||||||
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserGroupCreateDto struct {
|
type UserGroupCreateDto struct {
|
||||||
|
|||||||
@@ -29,8 +29,15 @@ var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var validateUserGroupName validator.Func = func(fl validator.FieldLevel) bool {
|
var validateUserGroupName validator.Func = func(fl validator.FieldLevel) bool {
|
||||||
// [a-z0-9_] : The group name can only contain lowercase letters, numbers, and underscores
|
// The string can only contain lowercase letters, numbers, and underscores
|
||||||
regex := "^[a-z0-9_]+$"
|
regex := "^[a-z0-9_]*$"
|
||||||
|
matched, _ := regexp.MatchString(regex, fl.Field().String())
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
|
var validateClaimKey validator.Func = func(fl validator.FieldLevel) bool {
|
||||||
|
// The string can only contain letters and numbers
|
||||||
|
regex := "^[A-Za-z0-9]*$"
|
||||||
matched, _ := regexp.MatchString(regex, fl.Field().String())
|
matched, _ := regexp.MatchString(regex, fl.Field().String())
|
||||||
return matched
|
return matched
|
||||||
}
|
}
|
||||||
@@ -52,4 +59,10 @@ func init() {
|
|||||||
log.Fatalf("Failed to register custom validation: %v", err)
|
log.Fatalf("Failed to register custom validation: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||||
|
if err := v.RegisterValidation("claimKey", validateClaimKey); err != nil {
|
||||||
|
log.Fatalf("Failed to register custom validation: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package dto
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/go-webauthn/webauthn/protocol"
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
"time"
|
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WebauthnCredentialDto struct {
|
type WebauthnCredentialDto struct {
|
||||||
@@ -15,7 +15,7 @@ type WebauthnCredentialDto struct {
|
|||||||
BackupEligible bool `json:"backupEligible"`
|
BackupEligible bool `json:"backupEligible"`
|
||||||
BackupState bool `json:"backupState"`
|
BackupState bool `json:"backupState"`
|
||||||
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebauthnCredentialUpdateDto struct {
|
type WebauthnCredentialUpdateDto struct {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"github.com/go-co-op/gocron/v2"
|
"github.com/go-co-op/gocron/v2"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
@@ -30,22 +29,22 @@ type Jobs struct {
|
|||||||
|
|
||||||
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
|
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
|
||||||
func (j *Jobs) clearWebauthnSessions() error {
|
func (j *Jobs) clearWebauthnSessions() error {
|
||||||
return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", utils.FormatDateForDb(time.Now())).Error
|
return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", time.Now().Unix()).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
|
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
|
||||||
func (j *Jobs) clearOneTimeAccessTokens() error {
|
func (j *Jobs) clearOneTimeAccessTokens() error {
|
||||||
return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", utils.FormatDateForDb(time.Now())).Error
|
return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", time.Now().Unix()).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
||||||
func (j *Jobs) clearOidcAuthorizationCodes() error {
|
func (j *Jobs) clearOidcAuthorizationCodes() error {
|
||||||
return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", utils.FormatDateForDb(time.Now())).Error
|
return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", time.Now().Unix()).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearAuditLogs deletes audit logs older than 90 days
|
// ClearAuditLogs deletes audit logs older than 90 days
|
||||||
func (j *Jobs) clearAuditLogs() error {
|
func (j *Jobs) clearAuditLogs() error {
|
||||||
return j.db.Delete(&model.AuditLog{}, "created_at < ?", utils.FormatDateForDb(time.Now().AddDate(0, 0, -90))).Error
|
return j.db.Delete(&model.AuditLog{}, "created_at < ?", time.Now().AddDate(0, 0, -90).Unix()).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {
|
func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {
|
||||||
|
|||||||
@@ -1,37 +1,67 @@
|
|||||||
package utils
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gin-gonic/gin/binding"
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
import (
|
type ErrorHandlerMiddleware struct{}
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ControllerError(c *gin.Context, err error) {
|
func NewErrorHandlerMiddleware() *ErrorHandlerMiddleware {
|
||||||
// Check for record not found errors
|
return &ErrorHandlerMiddleware{}
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
}
|
||||||
CustomControllerError(c, http.StatusNotFound, "Record not found")
|
|
||||||
return
|
func (m *ErrorHandlerMiddleware) Add() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Next()
|
||||||
|
for _, err := range c.Errors {
|
||||||
|
|
||||||
|
// Check for record not found errors
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
errorResponse(c, http.StatusNotFound, "Record not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for validation errors
|
||||||
|
var validationErrors validator.ValidationErrors
|
||||||
|
if errors.As(err, &validationErrors) {
|
||||||
|
message := handleValidationError(validationErrors)
|
||||||
|
errorResponse(c, http.StatusBadRequest, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for slice validation errors
|
||||||
|
var sliceValidationErrors binding.SliceValidationError
|
||||||
|
if errors.As(err, &sliceValidationErrors) {
|
||||||
|
if errors.As(sliceValidationErrors[0], &validationErrors) {
|
||||||
|
message := handleValidationError(validationErrors)
|
||||||
|
errorResponse(c, http.StatusBadRequest, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var appErr common.AppError
|
||||||
|
if errors.As(err, &appErr) {
|
||||||
|
errorResponse(c, appErr.HttpStatusCode(), appErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for validation errors
|
func errorResponse(c *gin.Context, statusCode int, message string) {
|
||||||
var validationErrors validator.ValidationErrors
|
// Capitalize the first letter of the message
|
||||||
if errors.As(err, &validationErrors) {
|
message = strings.ToUpper(message[:1]) + message[1:]
|
||||||
message := handleValidationError(validationErrors)
|
c.JSON(statusCode, gin.H{"error": message})
|
||||||
CustomControllerError(c, http.StatusBadRequest, message)
|
|
||||||
return
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println(err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleValidationError(validationErrors validator.ValidationErrors) string {
|
func handleValidationError(validationErrors validator.ValidationErrors) string {
|
||||||
@@ -67,9 +97,3 @@ func handleValidationError(validationErrors validator.ValidationErrors) string {
|
|||||||
|
|
||||||
return combinedErrors
|
return combinedErrors
|
||||||
}
|
}
|
||||||
|
|
||||||
func CustomControllerError(c *gin.Context, statusCode int, message string) {
|
|
||||||
// Capitalize the first letter of the message
|
|
||||||
message = strings.ToUpper(message[:1]) + message[1:]
|
|
||||||
c.JSON(statusCode, gin.H{"error": message})
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,7 +17,8 @@ func (m *FileSizeLimitMiddleware) Add(maxSize int64) gin.HandlerFunc {
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
|
||||||
if err := c.Request.ParseMultipartForm(maxSize); err != nil {
|
if err := c.Request.ParseMultipartForm(maxSize); err != nil {
|
||||||
utils.CustomControllerError(c, http.StatusRequestEntityTooLarge, fmt.Sprintf("The file can't be larger than %s bytes", formatFileSize(maxSize)))
|
err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)}
|
||||||
|
c.Error(err)
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,7 +28,7 @@ func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
|
|||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
utils.CustomControllerError(c, http.StatusUnauthorized, "You're not signed in")
|
c.Error(&common.NotSignedInError{})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -40,14 +39,14 @@ func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
|
|||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
utils.CustomControllerError(c, http.StatusUnauthorized, "You're not signed in")
|
c.Error(&common.NotSignedInError{})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the user is an admin
|
// Check if the user is an admin
|
||||||
if adminOnly && !claims.IsAdmin {
|
if adminOnly && !claims.IsAdmin {
|
||||||
utils.CustomControllerError(c, http.StatusForbidden, "You don't have permission to access this resource")
|
c.Error(&common.MissingPermissionError{})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -33,7 +31,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
|
|||||||
|
|
||||||
limiter := getLimiter(ip, limit, burst)
|
limiter := getLimiter(ip, limit, burst)
|
||||||
if !limiter.Allow() {
|
if !limiter.Allow() {
|
||||||
utils.CustomControllerError(c, http.StatusTooManyRequests, "Too many requests. Please wait a while before trying again.")
|
c.Error(&common.TooManyRequestsError{})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
type AppConfigVariable struct {
|
type AppConfigVariable struct {
|
||||||
Key string `gorm:"primaryKey;not null"`
|
Key string `gorm:"primaryKey;not null"`
|
||||||
Type string
|
Type string
|
||||||
IsPublic bool
|
IsPublic bool
|
||||||
IsInternal bool
|
IsInternal bool
|
||||||
Value string
|
Value string
|
||||||
|
DefaultValue string
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
AppName AppConfigVariable
|
AppName AppConfigVariable
|
||||||
|
SessionDuration AppConfigVariable
|
||||||
|
EmailsVerified AppConfigVariable
|
||||||
|
AllowOwnAccountEdit AppConfigVariable
|
||||||
|
|
||||||
BackgroundImageType AppConfigVariable
|
BackgroundImageType AppConfigVariable
|
||||||
LogoLightImageType AppConfigVariable
|
LogoLightImageType AppConfigVariable
|
||||||
LogoDarkImageType AppConfigVariable
|
LogoDarkImageType AppConfigVariable
|
||||||
SessionDuration AppConfigVariable
|
|
||||||
|
|
||||||
EmailEnabled AppConfigVariable
|
EmailEnabled AppConfigVariable
|
||||||
SmtpHost AppConfigVariable
|
SmtpHost AppConfigVariable
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ type AuditLogData map[string]string
|
|||||||
type AuditLogEvent string
|
type AuditLogEvent string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
|
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
|
||||||
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
|
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
|
||||||
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
|
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
|
||||||
|
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Scan and Value methods for GORM to handle the custom type
|
// Scan and Value methods for GORM to handle the custom type
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package model
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
model "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -9,12 +10,13 @@ import (
|
|||||||
// Base contains common columns for all tables.
|
// Base contains common columns for all tables.
|
||||||
type Base struct {
|
type Base struct {
|
||||||
ID string `gorm:"primaryKey;not null"`
|
ID string `gorm:"primaryKey;not null"`
|
||||||
CreatedAt time.Time
|
CreatedAt model.DateTime
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Base) BeforeCreate(_ *gorm.DB) (err error) {
|
func (b *Base) BeforeCreate(_ *gorm.DB) (err error) {
|
||||||
if b.ID == "" {
|
if b.ID == "" {
|
||||||
b.ID = uuid.New().String()
|
b.ID = uuid.New().String()
|
||||||
}
|
}
|
||||||
|
b.CreatedAt = model.DateTime(time.Now())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
11
backend/internal/model/custom_claim.go
Normal file
11
backend/internal/model/custom_claim.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type CustomClaim struct {
|
||||||
|
Base
|
||||||
|
|
||||||
|
Key string
|
||||||
|
Value string
|
||||||
|
|
||||||
|
UserID *string
|
||||||
|
UserGroupID *string
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"database/sql/driver"
|
"database/sql/driver"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserAuthorizedOidcClient struct {
|
type UserAuthorizedOidcClient struct {
|
||||||
@@ -23,7 +23,7 @@ type OidcAuthorizationCode struct {
|
|||||||
Code string
|
Code string
|
||||||
Scope string
|
Scope string
|
||||||
Nonce string
|
Nonce string
|
||||||
ExpiresAt time.Time
|
ExpiresAt datatype.DateTime
|
||||||
|
|
||||||
UserID string
|
UserID string
|
||||||
User User
|
User User
|
||||||
|
|||||||
47
backend/internal/model/types/date_time.go
Normal file
47
backend/internal/model/types/date_time.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package datatype
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DateTime custom type for time.Time to store date as unix timestamp in the database
|
||||||
|
type DateTime time.Time
|
||||||
|
|
||||||
|
func (date *DateTime) Scan(value interface{}) (err error) {
|
||||||
|
*date = DateTime(value.(time.Time))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (date DateTime) Value() (driver.Value, error) {
|
||||||
|
return time.Time(date).Unix(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (date DateTime) UTC() time.Time {
|
||||||
|
return time.Time(date).UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (date DateTime) ToTime() time.Time {
|
||||||
|
return time.Time(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GormDataType gorm common data type
|
||||||
|
func (date DateTime) GormDataType() string {
|
||||||
|
return "date"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (date DateTime) GobEncode() ([]byte, error) {
|
||||||
|
return time.Time(date).GobEncode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (date *DateTime) GobDecode(b []byte) error {
|
||||||
|
return (*time.Time)(date).GobDecode(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (date DateTime) MarshalJSON() ([]byte, error) {
|
||||||
|
return time.Time(date).MarshalJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (date *DateTime) UnmarshalJSON(b []byte) error {
|
||||||
|
return (*time.Time)(date).UnmarshalJSON(b)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package model
|
|||||||
import (
|
import (
|
||||||
"github.com/go-webauthn/webauthn/protocol"
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
"github.com/go-webauthn/webauthn/webauthn"
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
"time"
|
"github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
@@ -15,8 +15,9 @@ type User struct {
|
|||||||
LastName string
|
LastName string
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
|
|
||||||
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
CustomClaims []CustomClaim
|
||||||
Credentials []WebauthnCredential
|
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
||||||
|
Credentials []WebauthnCredential
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u User) WebAuthnID() []byte { return []byte(u.ID) }
|
func (u User) WebAuthnID() []byte { return []byte(u.ID) }
|
||||||
@@ -61,7 +62,7 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
|
|||||||
type OneTimeAccessToken struct {
|
type OneTimeAccessToken struct {
|
||||||
Base
|
Base
|
||||||
Token string
|
Token string
|
||||||
ExpiresAt time.Time
|
ExpiresAt datatype.DateTime
|
||||||
|
|
||||||
UserID string
|
UserID string
|
||||||
User User
|
User User
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ type UserGroup struct {
|
|||||||
FriendlyName string
|
FriendlyName string
|
||||||
Name string `gorm:"unique"`
|
Name string `gorm:"unique"`
|
||||||
Users []User `gorm:"many2many:user_groups_users;"`
|
Users []User `gorm:"many2many:user_groups_users;"`
|
||||||
|
CustomClaims []CustomClaim
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,38 +31,49 @@ func NewAppConfigService(db *gorm.DB) *AppConfigService {
|
|||||||
|
|
||||||
var defaultDbConfig = model.AppConfig{
|
var defaultDbConfig = model.AppConfig{
|
||||||
AppName: model.AppConfigVariable{
|
AppName: model.AppConfigVariable{
|
||||||
Key: "appName",
|
Key: "appName",
|
||||||
Type: "string",
|
Type: "string",
|
||||||
IsPublic: true,
|
IsPublic: true,
|
||||||
Value: "Pocket ID",
|
DefaultValue: "Pocket ID",
|
||||||
},
|
},
|
||||||
SessionDuration: model.AppConfigVariable{
|
SessionDuration: model.AppConfigVariable{
|
||||||
Key: "sessionDuration",
|
Key: "sessionDuration",
|
||||||
Type: "number",
|
Type: "number",
|
||||||
Value: "60",
|
DefaultValue: "60",
|
||||||
|
},
|
||||||
|
EmailsVerified: model.AppConfigVariable{
|
||||||
|
Key: "emailsVerified",
|
||||||
|
Type: "bool",
|
||||||
|
DefaultValue: "false",
|
||||||
|
},
|
||||||
|
AllowOwnAccountEdit: model.AppConfigVariable{
|
||||||
|
Key: "allowOwnAccountEdit",
|
||||||
|
Type: "bool",
|
||||||
|
IsPublic: true,
|
||||||
|
DefaultValue: "true",
|
||||||
},
|
},
|
||||||
BackgroundImageType: model.AppConfigVariable{
|
BackgroundImageType: model.AppConfigVariable{
|
||||||
Key: "backgroundImageType",
|
Key: "backgroundImageType",
|
||||||
Type: "string",
|
Type: "string",
|
||||||
IsInternal: true,
|
IsInternal: true,
|
||||||
Value: "jpg",
|
DefaultValue: "jpg",
|
||||||
},
|
},
|
||||||
LogoLightImageType: model.AppConfigVariable{
|
LogoLightImageType: model.AppConfigVariable{
|
||||||
Key: "logoLightImageType",
|
Key: "logoLightImageType",
|
||||||
Type: "string",
|
Type: "string",
|
||||||
IsInternal: true,
|
IsInternal: true,
|
||||||
Value: "svg",
|
DefaultValue: "svg",
|
||||||
},
|
},
|
||||||
LogoDarkImageType: model.AppConfigVariable{
|
LogoDarkImageType: model.AppConfigVariable{
|
||||||
Key: "logoDarkImageType",
|
Key: "logoDarkImageType",
|
||||||
Type: "string",
|
Type: "string",
|
||||||
IsInternal: true,
|
IsInternal: true,
|
||||||
Value: "svg",
|
DefaultValue: "svg",
|
||||||
},
|
},
|
||||||
EmailEnabled: model.AppConfigVariable{
|
EmailEnabled: model.AppConfigVariable{
|
||||||
Key: "emailEnabled",
|
Key: "emailEnabled",
|
||||||
Type: "bool",
|
Type: "bool",
|
||||||
Value: "false",
|
DefaultValue: "false",
|
||||||
},
|
},
|
||||||
SmtpHost: model.AppConfigVariable{
|
SmtpHost: model.AppConfigVariable{
|
||||||
Key: "smtpHost",
|
Key: "smtpHost",
|
||||||
@@ -115,7 +126,7 @@ func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]mode
|
|||||||
|
|
||||||
tx.Commit()
|
tx.Commit()
|
||||||
|
|
||||||
if err := s.loadDbConfigFromDb(); err != nil {
|
if err := s.LoadDbConfigFromDb(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +140,7 @@ func (s *AppConfigService) UpdateImageType(imageName string, fileType string) er
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.loadDbConfigFromDb()
|
return s.LoadDbConfigFromDb()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AppConfigService) ListAppConfig(showAll bool) ([]model.AppConfigVariable, error) {
|
func (s *AppConfigService) ListAppConfig(showAll bool) ([]model.AppConfigVariable, error) {
|
||||||
@@ -146,6 +157,13 @@ func (s *AppConfigService) ListAppConfig(showAll bool) ([]model.AppConfigVariabl
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set the value to the default value if it is empty
|
||||||
|
for i := range configuration {
|
||||||
|
if configuration[i].Value == "" && configuration[i].DefaultValue != "" {
|
||||||
|
configuration[i].Value = configuration[i].DefaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return configuration, nil
|
return configuration, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +171,7 @@ func (s *AppConfigService) UpdateImage(uploadedFile *multipart.FileHeader, image
|
|||||||
fileType := utils.GetFileExtension(uploadedFile.Filename)
|
fileType := utils.GetFileExtension(uploadedFile.Filename)
|
||||||
mimeType := utils.GetImageMimeType(fileType)
|
mimeType := utils.GetImageMimeType(fileType)
|
||||||
if mimeType == "" {
|
if mimeType == "" {
|
||||||
return common.ErrFileTypeNotSupported
|
return &common.FileTypeNotSupportedError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the old image if it has a different file type
|
// Delete the old image if it has a different file type
|
||||||
@@ -201,10 +219,11 @@ func (s *AppConfigService) InitDbConfig() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update existing configuration if it differs from the default
|
// Update existing configuration if it differs from the default
|
||||||
if storedConfigVar.Type != defaultConfigVar.Type || storedConfigVar.IsPublic != defaultConfigVar.IsPublic || storedConfigVar.IsInternal != defaultConfigVar.IsInternal {
|
if storedConfigVar.Type != defaultConfigVar.Type || storedConfigVar.IsPublic != defaultConfigVar.IsPublic || storedConfigVar.IsInternal != defaultConfigVar.IsInternal || storedConfigVar.DefaultValue != defaultConfigVar.DefaultValue {
|
||||||
storedConfigVar.Type = defaultConfigVar.Type
|
storedConfigVar.Type = defaultConfigVar.Type
|
||||||
storedConfigVar.IsPublic = defaultConfigVar.IsPublic
|
storedConfigVar.IsPublic = defaultConfigVar.IsPublic
|
||||||
storedConfigVar.IsInternal = defaultConfigVar.IsInternal
|
storedConfigVar.IsInternal = defaultConfigVar.IsInternal
|
||||||
|
storedConfigVar.DefaultValue = defaultConfigVar.DefaultValue
|
||||||
if err := s.db.Save(&storedConfigVar).Error; err != nil {
|
if err := s.db.Save(&storedConfigVar).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -224,10 +243,11 @@ func (s *AppConfigService) InitDbConfig() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s.loadDbConfigFromDb()
|
return s.LoadDbConfigFromDb()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AppConfigService) loadDbConfigFromDb() error {
|
// LoadDbConfigFromDb loads the configuration values from the database into the DbConfig struct.
|
||||||
|
func (s *AppConfigService) LoadDbConfigFromDb() error {
|
||||||
dbConfigReflectValue := reflect.ValueOf(s.DbConfig).Elem()
|
dbConfigReflectValue := reflect.ValueOf(s.DbConfig).Elem()
|
||||||
|
|
||||||
for i := 0; i < dbConfigReflectValue.NumField(); i++ {
|
for i := 0; i < dbConfigReflectValue.NumField(); i++ {
|
||||||
@@ -238,6 +258,10 @@ func (s *AppConfigService) loadDbConfigFromDb() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if storedConfigVar.Value == "" && storedConfigVar.DefaultValue != "" {
|
||||||
|
storedConfigVar.Value = storedConfigVar.DefaultValue
|
||||||
|
}
|
||||||
|
|
||||||
dbConfigField.Set(reflect.ValueOf(storedConfigVar))
|
dbConfigField.Set(reflect.ValueOf(storedConfigVar))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before
|
// CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before
|
||||||
func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog {
|
func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID string) model.AuditLog {
|
||||||
createdAuditLog := s.Create(model.AuditLogEventSignIn, ipAddress, userAgent, userID, data)
|
createdAuditLog := s.Create(model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{})
|
||||||
|
|
||||||
// Count the number of times the user has logged in from the same device
|
// Count the number of times the user has logged in from the same device
|
||||||
var count int64
|
var count int64
|
||||||
|
|||||||
197
backend/internal/service/custom_claim_service.go
Normal file
197
backend/internal/service/custom_claim_service.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reserved claims
|
||||||
|
var reservedClaims = map[string]struct{}{
|
||||||
|
"given_name": {},
|
||||||
|
"family_name": {},
|
||||||
|
"name": {},
|
||||||
|
"email": {},
|
||||||
|
"preferred_username": {},
|
||||||
|
"groups": {},
|
||||||
|
"sub": {},
|
||||||
|
"iss": {},
|
||||||
|
"aud": {},
|
||||||
|
"exp": {},
|
||||||
|
"iat": {},
|
||||||
|
"auth_time": {},
|
||||||
|
"nonce": {},
|
||||||
|
"acr": {},
|
||||||
|
"amr": {},
|
||||||
|
"azp": {},
|
||||||
|
"nbf": {},
|
||||||
|
"jti": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomClaimService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCustomClaimService(db *gorm.DB) *CustomClaimService {
|
||||||
|
return &CustomClaimService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isReservedClaim checks if a claim key is reserved e.g. email, preferred_username
|
||||||
|
func isReservedClaim(key string) bool {
|
||||||
|
_, ok := reservedClaims[key]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// idType is the type of the id used to identify the user or user group
|
||||||
|
type idType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
UserID idType = "user_id"
|
||||||
|
UserGroupID idType = "user_group_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpdateCustomClaimsForUser updates the custom claims for a user
|
||||||
|
func (s *CustomClaimService) UpdateCustomClaimsForUser(userID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
||||||
|
return s.updateCustomClaims(UserID, userID, claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCustomClaimsForUserGroup updates the custom claims for a user group
|
||||||
|
func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(userGroupID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
||||||
|
return s.updateCustomClaims(UserGroupID, userGroupID, claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateCustomClaims updates the custom claims for a user or user group
|
||||||
|
func (s *CustomClaimService) updateCustomClaims(idType idType, value string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
||||||
|
// Check for duplicate keys in the claims slice
|
||||||
|
seenKeys := make(map[string]bool)
|
||||||
|
for _, claim := range claims {
|
||||||
|
if seenKeys[claim.Key] {
|
||||||
|
return nil, &common.DuplicateClaimError{Key: claim.Key}
|
||||||
|
}
|
||||||
|
seenKeys[claim.Key] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingClaims []model.CustomClaim
|
||||||
|
err := s.db.Where(string(idType), value).Find(&existingClaims).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete claims that are not in the new list
|
||||||
|
for _, existingClaim := range existingClaims {
|
||||||
|
found := false
|
||||||
|
for _, claim := range claims {
|
||||||
|
if claim.Key == existingClaim.Key {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
err = s.db.Delete(&existingClaim).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add or update claims
|
||||||
|
for _, claim := range claims {
|
||||||
|
if isReservedClaim(claim.Key) {
|
||||||
|
return nil, &common.ReservedClaimError{Key: claim.Key}
|
||||||
|
}
|
||||||
|
customClaim := model.CustomClaim{
|
||||||
|
Key: claim.Key,
|
||||||
|
Value: claim.Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
if idType == UserID {
|
||||||
|
customClaim.UserID = &value
|
||||||
|
} else if idType == UserGroupID {
|
||||||
|
customClaim.UserGroupID = &value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the claim if it already exists or create a new one
|
||||||
|
err = s.db.Where(string(idType)+" = ? AND key = ?", value, claim.Key).Assign(&customClaim).FirstOrCreate(&model.CustomClaim{}).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the updated claims
|
||||||
|
var updatedClaims []model.CustomClaim
|
||||||
|
err = s.db.Where(string(idType)+" = ?", value).Find(&updatedClaims).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedClaims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomClaimService) GetCustomClaimsForUser(userID string) ([]model.CustomClaim, error) {
|
||||||
|
var customClaims []model.CustomClaim
|
||||||
|
err := s.db.Where("user_id = ?", userID).Find(&customClaims).Error
|
||||||
|
return customClaims, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomClaimService) GetCustomClaimsForUserGroup(userGroupID string) ([]model.CustomClaim, error) {
|
||||||
|
var customClaims []model.CustomClaim
|
||||||
|
err := s.db.Where("user_group_id = ?", userGroupID).Find(&customClaims).Error
|
||||||
|
return customClaims, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCustomClaimsForUserWithUserGroups returns the custom claims of a user and all user groups the user is a member of,
|
||||||
|
// prioritizing the user's claims over user group claims with the same key.
|
||||||
|
func (s *CustomClaimService) GetCustomClaimsForUserWithUserGroups(userID string) ([]model.CustomClaim, error) {
|
||||||
|
// Get the custom claims of the user
|
||||||
|
customClaims, err := s.GetCustomClaimsForUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store user's claims in a map to prioritize and prevent duplicates
|
||||||
|
claimsMap := make(map[string]model.CustomClaim)
|
||||||
|
for _, claim := range customClaims {
|
||||||
|
claimsMap[claim.Key] = claim
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all user groups of the user
|
||||||
|
var userGroupsOfUser []model.UserGroup
|
||||||
|
err = s.db.Preload("CustomClaims").
|
||||||
|
Joins("JOIN user_groups_users ON user_groups_users.user_group_id = user_groups.id").
|
||||||
|
Where("user_groups_users.user_id = ?", userID).
|
||||||
|
Find(&userGroupsOfUser).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add only non-duplicate custom claims from user groups
|
||||||
|
for _, userGroup := range userGroupsOfUser {
|
||||||
|
for _, groupClaim := range userGroup.CustomClaims {
|
||||||
|
// Only add claim if it does not exist in the user's claims
|
||||||
|
if _, exists := claimsMap[groupClaim.Key]; !exists {
|
||||||
|
claimsMap[groupClaim.Key] = groupClaim
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the claimsMap back to a slice
|
||||||
|
finalClaims := make([]model.CustomClaim, 0, len(claimsMap))
|
||||||
|
for _, claim := range claimsMap {
|
||||||
|
finalClaims = append(finalClaims, claim)
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalClaims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSuggestions returns a list of custom claim keys that have been used before
|
||||||
|
func (s *CustomClaimService) GetSuggestions() ([]string, error) {
|
||||||
|
var customClaimsKeys []string
|
||||||
|
|
||||||
|
err := s.db.Model(&model.CustomClaim{}).
|
||||||
|
Group("key").
|
||||||
|
Order("COUNT(*) DESC").
|
||||||
|
Pluck("key", &customClaimsKeys).Error
|
||||||
|
|
||||||
|
return customClaimsKeys, err
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||||
|
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -17,18 +18,20 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type OidcService struct {
|
type OidcService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
jwtService *JwtService
|
jwtService *JwtService
|
||||||
appConfigService *AppConfigService
|
appConfigService *AppConfigService
|
||||||
auditLogService *AuditLogService
|
auditLogService *AuditLogService
|
||||||
|
customClaimService *CustomClaimService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppConfigService, auditLogService *AuditLogService) *OidcService {
|
func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppConfigService, auditLogService *AuditLogService, customClaimService *CustomClaimService) *OidcService {
|
||||||
return &OidcService{
|
return &OidcService{
|
||||||
db: db,
|
db: db,
|
||||||
jwtService: jwtService,
|
jwtService: jwtService,
|
||||||
appConfigService: appConfigService,
|
appConfigService: appConfigService,
|
||||||
auditLogService: auditLogService,
|
auditLogService: auditLogService,
|
||||||
|
customClaimService: customClaimService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +40,7 @@ func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID,
|
|||||||
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
|
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
|
||||||
|
|
||||||
if userAuthorizedOIDCClient.Scope != input.Scope {
|
if userAuthorizedOIDCClient.Scope != input.Scope {
|
||||||
return "", "", common.ErrOidcMissingAuthorization
|
return "", "", &common.OidcMissingAuthorizationError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
callbackURL, err := getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
|
callbackURL, err := getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
|
||||||
@@ -92,11 +95,11 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
|
|||||||
|
|
||||||
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) {
|
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) {
|
||||||
if grantType != "authorization_code" {
|
if grantType != "authorization_code" {
|
||||||
return "", "", common.ErrOidcGrantTypeNotSupported
|
return "", "", &common.OidcGrantTypeNotSupportedError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
if clientID == "" || clientSecret == "" {
|
if clientID == "" || clientSecret == "" {
|
||||||
return "", "", common.ErrOidcMissingClientCredentials
|
return "", "", &common.OidcMissingClientCredentialsError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
var client model.OidcClient
|
var client model.OidcClient
|
||||||
@@ -106,17 +109,17 @@ func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret strin
|
|||||||
|
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
|
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", common.ErrOidcClientSecretInvalid
|
return "", "", &common.OidcClientSecretInvalidError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
var authorizationCodeMetaData model.OidcAuthorizationCode
|
var authorizationCodeMetaData model.OidcAuthorizationCode
|
||||||
err = s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error
|
err = s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", common.ErrOidcInvalidAuthorizationCode
|
return "", "", &common.OidcInvalidAuthorizationCodeError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.Before(time.Now()) {
|
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
|
||||||
return "", "", common.ErrOidcInvalidAuthorizationCode
|
return "", "", &common.OidcInvalidAuthorizationCodeError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
userClaims, err := s.GetUserClaimsForClient(authorizationCodeMetaData.UserID, clientID)
|
userClaims, err := s.GetUserClaimsForClient(authorizationCodeMetaData.UserID, clientID)
|
||||||
@@ -248,7 +251,7 @@ func (s *OidcService) GetClientLogo(clientID string) (string, string, error) {
|
|||||||
func (s *OidcService) UpdateClientLogo(clientID string, file *multipart.FileHeader) error {
|
func (s *OidcService) UpdateClientLogo(clientID string, file *multipart.FileHeader) error {
|
||||||
fileType := utils.GetFileExtension(file.Filename)
|
fileType := utils.GetFileExtension(file.Filename)
|
||||||
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
|
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
|
||||||
return common.ErrFileTypeNotSupported
|
return &common.FileTypeNotSupportedError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, clientID, fileType)
|
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, clientID, fileType)
|
||||||
@@ -314,6 +317,7 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
|
|||||||
|
|
||||||
if strings.Contains(scope, "email") {
|
if strings.Contains(scope, "email") {
|
||||||
claims["email"] = user.Email
|
claims["email"] = user.Email
|
||||||
|
claims["email_verified"] = s.appConfigService.DbConfig.EmailsVerified.Value == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(scope, "groups") {
|
if strings.Contains(scope, "groups") {
|
||||||
@@ -332,9 +336,20 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
|
|||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(scope, "profile") {
|
if strings.Contains(scope, "profile") {
|
||||||
|
// Add profile claims
|
||||||
for k, v := range profileClaims {
|
for k, v := range profileClaims {
|
||||||
claims[k] = v
|
claims[k] = v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add custom claims
|
||||||
|
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, customClaim := range customClaims {
|
||||||
|
claims[customClaim.Key] = customClaim.Value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if strings.Contains(scope, "email") {
|
if strings.Contains(scope, "email") {
|
||||||
claims["email"] = user.Email
|
claims["email"] = user.Email
|
||||||
@@ -350,7 +365,7 @@ func (s *OidcService) createAuthorizationCode(clientID string, userID string, sc
|
|||||||
}
|
}
|
||||||
|
|
||||||
oidcAuthorizationCode := model.OidcAuthorizationCode{
|
oidcAuthorizationCode := model.OidcAuthorizationCode{
|
||||||
ExpiresAt: time.Now().Add(15 * time.Minute),
|
ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)),
|
||||||
Code: randomString,
|
Code: randomString,
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
@@ -373,5 +388,5 @@ func getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackU
|
|||||||
return inputCallbackURL, nil
|
return inputCallbackURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", common.ErrOidcInvalidCallbackURL
|
return "", &common.OidcInvalidCallbackURLError{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
@@ -111,7 +112,7 @@ func (s *TestService) SeedDatabase() error {
|
|||||||
Code: "auth-code",
|
Code: "auth-code",
|
||||||
Scope: "openid profile",
|
Scope: "openid profile",
|
||||||
Nonce: "nonce",
|
Nonce: "nonce",
|
||||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||||
UserID: users[0].ID,
|
UserID: users[0].ID,
|
||||||
ClientID: oidcClients[0].ID,
|
ClientID: oidcClients[0].ID,
|
||||||
}
|
}
|
||||||
@@ -121,7 +122,7 @@ func (s *TestService) SeedDatabase() error {
|
|||||||
|
|
||||||
accessToken := model.OneTimeAccessToken{
|
accessToken := model.OneTimeAccessToken{
|
||||||
Token: "one-time-token",
|
Token: "one-time-token",
|
||||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||||
UserID: users[0].ID,
|
UserID: users[0].ID,
|
||||||
}
|
}
|
||||||
if err := tx.Create(&accessToken).Error; err != nil {
|
if err := tx.Create(&accessToken).Error; err != nil {
|
||||||
@@ -137,8 +138,8 @@ func (s *TestService) SeedDatabase() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
publicKey1, err := getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==")
|
publicKey1, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==")
|
||||||
publicKey2, err := getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESq/wR8QbBu3dKnpaw/v0mDxFFDwnJ/L5XHSg2tAmq5x1BpSMmIr3+DxCbybVvGRmWGh8kKhy7SMnK91M6rFHTA==")
|
publicKey2, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESq/wR8QbBu3dKnpaw/v0mDxFFDwnJ/L5XHSg2tAmq5x1BpSMmIr3+DxCbybVvGRmWGh8kKhy7SMnK91M6rFHTA==")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -186,17 +187,16 @@ func (s *TestService) ResetDatabase() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete all rows from all tables
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
if err := tx.Exec("DELETE FROM " + table).Error; err != nil {
|
if err := tx.Exec("DELETE FROM " + table).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = s.appConfigService.InitDbConfig()
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,8 +214,23 @@ func (s *TestService) ResetApplicationImages() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *TestService) ResetAppConfig() error {
|
||||||
|
// Reseed the config variables
|
||||||
|
if err := s.appConfigService.InitDbConfig(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset all app config variables to their default values
|
||||||
|
if err := s.db.Session(&gorm.Session{AllowGlobalUpdate: true}).Model(&model.AppConfigVariable{}).Update("value", "").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload the app config from the database after resetting the values
|
||||||
|
return s.appConfigService.LoadDbConfigFromDb()
|
||||||
|
}
|
||||||
|
|
||||||
// getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key
|
// getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key
|
||||||
func getCborPublicKey(base64PublicKey string) ([]byte, error) {
|
func (s *TestService) getCborPublicKey(base64PublicKey string) ([]byte, error) {
|
||||||
decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey)
|
decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to decode base64 key: %w", err)
|
return nil, fmt.Errorf("failed to decode base64 key: %w", err)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func NewUserGroupService(db *gorm.DB) *UserGroupService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserGroupService) List(name string, page int, pageSize int) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
|
func (s *UserGroupService) List(name string, page int, pageSize int) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
|
||||||
query := s.db.Model(&model.UserGroup{})
|
query := s.db.Preload("CustomClaims").Model(&model.UserGroup{})
|
||||||
|
|
||||||
if name != "" {
|
if name != "" {
|
||||||
query = query.Where("name LIKE ?", "%"+name+"%")
|
query = query.Where("name LIKE ?", "%"+name+"%")
|
||||||
@@ -29,7 +29,7 @@ func (s *UserGroupService) List(name string, page int, pageSize int) (groups []m
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserGroupService) Get(id string) (group model.UserGroup, err error) {
|
func (s *UserGroupService) Get(id string) (group model.UserGroup, err error) {
|
||||||
err = s.db.Where("id = ?", id).Preload("Users").First(&group).Error
|
err = s.db.Where("id = ?", id).Preload("CustomClaims").Preload("Users").First(&group).Error
|
||||||
return group, err
|
return group, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ func (s *UserGroupService) Create(input dto.UserGroupCreateDto) (group model.Use
|
|||||||
|
|
||||||
if err := s.db.Preload("Users").Create(&group).Error; err != nil {
|
if err := s.db.Preload("Users").Create(&group).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||||
return model.UserGroup{}, common.ErrNameAlreadyInUse
|
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
|
||||||
}
|
}
|
||||||
return model.UserGroup{}, err
|
return model.UserGroup{}, err
|
||||||
}
|
}
|
||||||
@@ -68,7 +68,7 @@ func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto) (grou
|
|||||||
|
|
||||||
if err := s.db.Preload("Users").Save(&group).Error; err != nil {
|
if err := s.db.Preload("Users").Save(&group).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||||
return model.UserGroup{}, common.ErrNameAlreadyInUse
|
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
|
||||||
}
|
}
|
||||||
return model.UserGroup{}, err
|
return model.UserGroup{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,20 @@ import (
|
|||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserService struct {
|
type UserService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
jwtService *JwtService
|
jwtService *JwtService
|
||||||
|
auditLogService *AuditLogService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUserService(db *gorm.DB, jwtService *JwtService) *UserService {
|
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService) *UserService {
|
||||||
return &UserService{db: db, jwtService: jwtService}
|
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]model.User, utils.PaginationResponse, error) {
|
func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]model.User, utils.PaginationResponse, error) {
|
||||||
@@ -34,7 +36,7 @@ func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]mo
|
|||||||
|
|
||||||
func (s *UserService) GetUser(userID string) (model.User, error) {
|
func (s *UserService) GetUser(userID string) (model.User, error) {
|
||||||
var user model.User
|
var user model.User
|
||||||
err := s.db.Where("id = ?", userID).First(&user).Error
|
err := s.db.Preload("CustomClaims").Where("id = ?", userID).First(&user).Error
|
||||||
return user, err
|
return user, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +89,7 @@ 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) (string, error) {
|
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time, ipAddress, userAgent string) (string, error) {
|
||||||
randomString, err := utils.GenerateRandomAlphanumericString(16)
|
randomString, err := utils.GenerateRandomAlphanumericString(16)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -95,7 +97,7 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
|
|||||||
|
|
||||||
oneTimeAccessToken := model.OneTimeAccessToken{
|
oneTimeAccessToken := model.OneTimeAccessToken{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ExpiresAt: expiresAt,
|
ExpiresAt: datatype.DateTime(expiresAt),
|
||||||
Token: randomString,
|
Token: randomString,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,14 +105,16 @@ 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) (model.User, string, error) {
|
||||||
var oneTimeAccessToken model.OneTimeAccessToken
|
var oneTimeAccessToken model.OneTimeAccessToken
|
||||||
if err := s.db.Where("token = ? AND expires_at > ?", token, utils.FormatDateForDb(time.Now())).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
|
if err := s.db.Where("token = ? AND expires_at > ?", token, time.Now().Unix()).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return model.User{}, "", common.ErrTokenInvalidOrExpired
|
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||||
}
|
}
|
||||||
return model.User{}, "", err
|
return model.User{}, "", err
|
||||||
}
|
}
|
||||||
@@ -132,7 +136,7 @@ func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
|
|||||||
return model.User{}, "", err
|
return model.User{}, "", err
|
||||||
}
|
}
|
||||||
if userCount > 1 {
|
if userCount > 1 {
|
||||||
return model.User{}, "", common.ErrSetupAlreadyCompleted
|
return model.User{}, "", &common.SetupAlreadyCompletedError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
user := model.User{
|
user := model.User{
|
||||||
@@ -148,7 +152,7 @@ func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(user.Credentials) > 0 {
|
if len(user.Credentials) > 0 {
|
||||||
return model.User{}, "", common.ErrSetupAlreadyCompleted
|
return model.User{}, "", &common.SetupAlreadyCompletedError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := s.jwtService.GenerateAccessToken(user)
|
token, err := s.jwtService.GenerateAccessToken(user)
|
||||||
@@ -162,11 +166,11 @@ func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
|
|||||||
func (s *UserService) checkDuplicatedFields(user model.User) error {
|
func (s *UserService) checkDuplicatedFields(user model.User) error {
|
||||||
var existingUser model.User
|
var existingUser model.User
|
||||||
if s.db.Where("id != ? AND email = ?", user.ID, user.Email).First(&existingUser).Error == nil {
|
if s.db.Where("id != ? AND email = ?", user.ID, user.Email).First(&existingUser).Error == nil {
|
||||||
return common.ErrEmailTaken
|
return &common.AlreadyInUseError{Property: "email"}
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.db.Where("id != ? AND username = ?", user.ID, user.Username).First(&existingUser).Error == nil {
|
if s.db.Where("id != ? AND username = ?", user.ID, user.Username).First(&existingUser).Error == nil {
|
||||||
return common.ErrUsernameTaken
|
return &common.AlreadyInUseError{Property: "username"}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssert
|
|||||||
return model.User{}, "", err
|
return model.User{}, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.auditLogService.CreateNewSignInWithEmail(ipAddress, userAgent, user.ID, model.AuditLogData{})
|
s.auditLogService.CreateNewSignInWithEmail(ipAddress, userAgent, user.ID)
|
||||||
|
|
||||||
return *user, token, nil
|
return *user, token, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
func FormatDateForDb(time time.Time) string {
|
|
||||||
const layout = "2006-01-02 15:04:05.000-07:00"
|
|
||||||
return time.Format(layout)
|
|
||||||
}
|
|
||||||
28
backend/migrations/20241023072742_unix-timestamps.down.sql
Normal file
28
backend/migrations/20241023072742_unix-timestamps.down.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- Convert the Unix timestamps back to DATETIME format
|
||||||
|
|
||||||
|
UPDATE user_groups
|
||||||
|
SET created_at = datetime(created_at, 'unixepoch');
|
||||||
|
|
||||||
|
UPDATE users
|
||||||
|
SET created_at = datetime(created_at, 'unixepoch');
|
||||||
|
|
||||||
|
UPDATE audit_logs
|
||||||
|
SET created_at = datetime(created_at, 'unixepoch');
|
||||||
|
|
||||||
|
UPDATE oidc_authorization_codes
|
||||||
|
SET created_at = datetime(created_at, 'unixepoch'),
|
||||||
|
expires_at = datetime(expires_at, 'unixepoch');
|
||||||
|
|
||||||
|
UPDATE oidc_clients
|
||||||
|
SET created_at = datetime(created_at, 'unixepoch');
|
||||||
|
|
||||||
|
UPDATE one_time_access_tokens
|
||||||
|
SET created_at = datetime(created_at, 'unixepoch'),
|
||||||
|
expires_at = datetime(expires_at, 'unixepoch');
|
||||||
|
|
||||||
|
UPDATE webauthn_credentials
|
||||||
|
SET created_at = datetime(created_at, 'unixepoch');
|
||||||
|
|
||||||
|
UPDATE webauthn_sessions
|
||||||
|
SET created_at = datetime(created_at, 'unixepoch'),
|
||||||
|
expires_at = datetime(expires_at, 'unixepoch');
|
||||||
27
backend/migrations/20241023072742_unix-timestamps.up.sql
Normal file
27
backend/migrations/20241023072742_unix-timestamps.up.sql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
-- Convert the DATETIME fields to Unix timestamps (in seconds)
|
||||||
|
UPDATE user_groups
|
||||||
|
SET created_at = strftime('%s', created_at);
|
||||||
|
|
||||||
|
UPDATE users
|
||||||
|
SET created_at = strftime('%s', created_at);
|
||||||
|
|
||||||
|
UPDATE audit_logs
|
||||||
|
SET created_at = strftime('%s', created_at);
|
||||||
|
|
||||||
|
UPDATE oidc_authorization_codes
|
||||||
|
SET created_at = strftime('%s', created_at),
|
||||||
|
expires_at = strftime('%s', expires_at);
|
||||||
|
|
||||||
|
UPDATE oidc_clients
|
||||||
|
SET created_at = strftime('%s', created_at);
|
||||||
|
|
||||||
|
UPDATE one_time_access_tokens
|
||||||
|
SET created_at = strftime('%s', created_at),
|
||||||
|
expires_at = strftime('%s', expires_at);
|
||||||
|
|
||||||
|
UPDATE webauthn_credentials
|
||||||
|
SET created_at = strftime('%s', created_at);
|
||||||
|
|
||||||
|
UPDATE webauthn_sessions
|
||||||
|
SET created_at = strftime('%s', created_at),
|
||||||
|
expires_at = strftime('%s', expires_at);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE app_config_variables DROP COLUMN default_value;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE app_config_variables ADD COLUMN default_value TEXT;
|
||||||
1
backend/migrations/20241028064959_custom_claims.down.sql
Normal file
1
backend/migrations/20241028064959_custom_claims.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE custom_claims;
|
||||||
15
backend/migrations/20241028064959_custom_claims.up.sql
Normal file
15
backend/migrations/20241028064959_custom_claims.up.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE custom_claims
|
||||||
|
(
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
created_at DATETIME,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
|
||||||
|
user_id TEXT,
|
||||||
|
user_group_id TEXT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
CONSTRAINT custom_claims_unique UNIQUE (key, user_id, user_group_id),
|
||||||
|
CHECK (user_id IS NOT NULL OR user_group_id IS NOT NULL)
|
||||||
|
);
|
||||||
BIN
docs/imgs/jelly_fin_img.png
Normal file
BIN
docs/imgs/jelly_fin_img.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
BIN
docs/imgs/jelly_fin_img2.png
Normal file
BIN
docs/imgs/jelly_fin_img2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
BIN
docs/imgs/jelly_fin_img3.png
Normal file
BIN
docs/imgs/jelly_fin_img3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
55
docs/jellyfin.md
Normal file
55
docs/jellyfin.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Jellyfin SSO Integration Guide
|
||||||
|
|
||||||
|
> Due to the current limitations of the Jellyfin SSO plugin, this integration will only work in a browser. When tested, the Jellyfin app did not work and displayed an error, even when custom menu buttons were created.
|
||||||
|
|
||||||
|
> To view the original references and a full list of capabilities, please visit the [Jellyfin SSO OpenID Section](https://github.com/9p4/jellyfin-plugin-sso?tab=readme-ov-file#openid).
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- [Jellyfin Server](https://jellyfin.org/downloads/server)
|
||||||
|
- [Jellyfin SSO Plugin](https://github.com/9p4/jellyfin-plugin-sso)
|
||||||
|
- HTTPS connection to your Jellyfin server
|
||||||
|
|
||||||
|
### OIDC - Pocket ID Setup
|
||||||
|
To start, we need to create a new SSO resource in our Jellyfin application.
|
||||||
|
|
||||||
|
> Replace the `JELLYFINDOMAIN` and `PROVIDER` elements in the URL.
|
||||||
|
|
||||||
|
1. Log into the admin panel, and go to OIDC Clients -> Add OIDC Client.
|
||||||
|
2. **Name**: Jellyfin (or any name you prefer)
|
||||||
|
3. **Callback URL**: `https://JELLYFINDOMAIN.com/sso/OID/redirect/PROVIDER`
|
||||||
|
4. For this example, we’ll be using the provider named "test_resource."
|
||||||
|
5. Click **Save**. Keep the page open, as we will need the OID client ID and OID secret.
|
||||||
|
|
||||||
|
### OIDC Client - Jellyfin SSO Resource
|
||||||
|
|
||||||
|
1. Visit the plugin page (<i>Administration Dashboard -> My Plugins -> SSO-Auth</i>).
|
||||||
|
2. Enter the <i>OID Provider Name (we used "test_resource" as our name in the callback URL), Open ID, OID Secret, and mark it as enabled.</i>
|
||||||
|
3. The following steps are optional based on your needs. In this guide, we’ll be managing only regular users, not admins.
|
||||||
|
|
||||||
|

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

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

|
||||||
|
|
||||||
|
7. Skip the remaining fields until you reach **Scheme Override**. Enter `https` here. If omitted, it will attempt to use HTTP first, which will break as WebAuthn requires an HTTPS connection.
|
||||||
|
8. Click **Save** and restart Jellyfin.
|
||||||
|
|
||||||
|
### Optional Step - Custom Home Button
|
||||||
|
Follow the [guide to create a login button on the login page](https://github.com/9p4/jellyfin-plugin-sso?tab=readme-ov-file#creating-a-login-button-on-the-main-page) to add a custom button on your sign-in page. This step is optional, as you could also provide the sign-in URL via a bookmark or other means.
|
||||||
|
|
||||||
|
### Signing into Your Jellyfin Instance
|
||||||
|
Done! You have successfully set up SSO for your Jellyfin instance using Pocket ID.
|
||||||
|
|
||||||
|
> **Note:** Sometimes there may be a brief delay when using the custom menu option. This is related to the Jellyfin plugin and not Pocket ID.
|
||||||
|
|
||||||
|
If your users already have accounts, as long as their Pocket ID username matches their Jellyfin ID, they will be logged in automatically. Otherwise, a new user will be created with access to all of your folders. Of course, you can modify this in your configuration as desired.
|
||||||
|
|
||||||
|
This setup will only work if sign-in is performed using the `https://jellyfin.example.com/sso/OID/start/PROVIDER` URL. This URL initiates the SSO plugin and applies all the configurations we completed above.
|
||||||
743
frontend/package-lock.json
generated
743
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pocket-id-frontend",
|
"name": "pocket-id-frontend",
|
||||||
"version": "0.0.1",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --port 3000",
|
"dev": "vite dev --port 3000",
|
||||||
@@ -12,31 +12,31 @@
|
|||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.47.2",
|
"@playwright/test": "^1.48.1",
|
||||||
"@sveltejs/adapter-auto": "^3.2.5",
|
"@sveltejs/adapter-auto": "^3.3.0",
|
||||||
"@sveltejs/adapter-node": "^5.2.5",
|
"@sveltejs/adapter-node": "^5.2.8",
|
||||||
"@sveltejs/kit": "^2.6.1",
|
"@sveltejs/kit": "^2.7.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
"@types/eslint": "^9.6.1",
|
"@types/eslint": "^9.6.1",
|
||||||
"@types/jsonwebtoken": "^9.0.7",
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
"@types/node": "^22.7.4",
|
"@types/node": "^22.7.9",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"cbor-js": "^0.1.0",
|
"cbor-js": "^0.1.0",
|
||||||
"eslint": "^9.11.1",
|
"eslint": "^9.13.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-svelte": "^2.44.1",
|
"eslint-plugin-svelte": "^2.46.0",
|
||||||
"globals": "^15.10.0",
|
"globals": "^15.11.0",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"prettier-plugin-svelte": "^3.2.7",
|
"prettier-plugin-svelte": "^3.2.7",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.8",
|
"prettier-plugin-tailwindcss": "^0.6.8",
|
||||||
"svelte": "^5.0.0-next.262",
|
"svelte": "^5.0.5",
|
||||||
"svelte-check": "^4.0.4",
|
"svelte-check": "^4.0.5",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.14",
|
||||||
"tslib": "^2.7.0",
|
"tslib": "^2.8.0",
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.6.3",
|
||||||
"typescript-eslint": "^8.8.0",
|
"typescript-eslint": "^8.11.0",
|
||||||
"vite": "^5.4.8"
|
"vite": "^5.4.10"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -47,11 +47,11 @@
|
|||||||
"crypto": "^1.0.1",
|
"crypto": "^1.0.1",
|
||||||
"formsnap": "^1.0.1",
|
"formsnap": "^1.0.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-svelte": "^0.447.0",
|
"lucide-svelte": "^0.453.0",
|
||||||
"mode-watcher": "^0.4.1",
|
"mode-watcher": "^0.4.1",
|
||||||
"svelte-sonner": "^0.3.28",
|
"svelte-sonner": "^0.3.28",
|
||||||
"sveltekit-superforms": "^2.19.0",
|
"sveltekit-superforms": "^2.20.0",
|
||||||
"tailwind-merge": "^2.5.3",
|
"tailwind-merge": "^2.5.4",
|
||||||
"tailwind-variants": "^0.2.1",
|
"tailwind-variants": "^0.2.1",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ export default defineConfig({
|
|||||||
retries: process.env.CI ? 1 : 0,
|
retries: process.env.CI ? 1 : 0,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
reporter: process.env.CI
|
reporter: process.env.CI
|
||||||
? [['html'], ['github']]
|
? [['html', { outputFolder: 'tests/.report' }], ['github']]
|
||||||
: [['line'], ['html', { open: 'never', outputFolder: 'tests/.output' }]],
|
: [['line'], ['html', { open: 'never', outputFolder: 'tests/.report' }]],
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost',
|
baseURL: 'http://localhost',
|
||||||
video: 'retain-on-failure',
|
video: 'retain-on-failure',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import * as Pagination from '$lib/components/ui/pagination';
|
import * as Pagination from '$lib/components/ui/pagination';
|
||||||
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 type { Paginated } from '$lib/types/pagination.type';
|
import type { Paginated } from '$lib/types/pagination.type';
|
||||||
import { debounced } from '$lib/utils/debounce-util';
|
import { debounced } from '$lib/utils/debounce-util';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
@@ -66,93 +67,104 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full">
|
{#if items.data.length === 0}
|
||||||
{#if !withoutSearch}
|
<div class="my-5 flex flex-col items-center">
|
||||||
<Input
|
<Empty class="text-muted-foreground h-20" />
|
||||||
class="mb-4 max-w-sm"
|
<p class="text-muted-foreground mt-3 text-sm">No items found</p>
|
||||||
placeholder={'Search...'}
|
|
||||||
type="text"
|
|
||||||
oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<Table.Root>
|
|
||||||
<Table.Header>
|
|
||||||
<Table.Row>
|
|
||||||
{#if selectedIds}
|
|
||||||
<Table.Head>
|
|
||||||
<Checkbox checked={allChecked} onCheckedChange={(c) => onAllCheck(c as boolean)} />
|
|
||||||
</Table.Head>
|
|
||||||
{/if}
|
|
||||||
{#each columns as column}
|
|
||||||
{#if typeof column === 'string'}
|
|
||||||
<Table.Head>{column}</Table.Head>
|
|
||||||
{:else}
|
|
||||||
<Table.Head class={column.hidden ? 'sr-only' : ''}>{column.label}</Table.Head>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</Table.Row>
|
|
||||||
</Table.Header>
|
|
||||||
<Table.Body>
|
|
||||||
{#each items.data as item}
|
|
||||||
<Table.Row class={selectedIds?.includes(item.id) ? 'bg-muted/20' : ''}>
|
|
||||||
{#if selectedIds}
|
|
||||||
<Table.Cell>
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedIds.includes(item.id)}
|
|
||||||
onCheckedChange={(c) => onCheck(c as boolean, item.id)}
|
|
||||||
/>
|
|
||||||
</Table.Cell>
|
|
||||||
{/if}
|
|
||||||
{@render rows({ item })}
|
|
||||||
</Table.Row>
|
|
||||||
{/each}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
<div class="mt-5 flex items-center justify-between space-x-2">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<p class="text-sm font-medium">Items per page</p>
|
|
||||||
<Select.Root
|
|
||||||
selected={{
|
|
||||||
label: items.pagination.itemsPerPage.toString(),
|
|
||||||
value: items.pagination.itemsPerPage
|
|
||||||
}}
|
|
||||||
onSelectedChange={(v) => onPageSizeChange(v?.value as number)}
|
|
||||||
>
|
|
||||||
<Select.Trigger class="h-9 w-[80px]">
|
|
||||||
<Select.Value>{items.pagination.itemsPerPage}</Select.Value>
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each availablePageSizes as size}
|
|
||||||
<Select.Item value={size}>{size}</Select.Item>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</div>
|
|
||||||
<Pagination.Root
|
|
||||||
class="mx-0 w-auto"
|
|
||||||
count={items.pagination.totalItems}
|
|
||||||
perPage={items.pagination.itemsPerPage}
|
|
||||||
{onPageChange}
|
|
||||||
page={items.pagination.currentPage}
|
|
||||||
let:pages
|
|
||||||
>
|
|
||||||
<Pagination.Content class="flex justify-end">
|
|
||||||
<Pagination.Item>
|
|
||||||
<Pagination.PrevButton />
|
|
||||||
</Pagination.Item>
|
|
||||||
{#each pages as page (page.key)}
|
|
||||||
{#if page.type !== 'ellipsis'}
|
|
||||||
<Pagination.Item>
|
|
||||||
<Pagination.Link {page} isActive={items.pagination.currentPage === page.value}>
|
|
||||||
{page.value}
|
|
||||||
</Pagination.Link>
|
|
||||||
</Pagination.Item>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
<Pagination.Item>
|
|
||||||
<Pagination.NextButton />
|
|
||||||
</Pagination.Item>
|
|
||||||
</Pagination.Content>
|
|
||||||
</Pagination.Root>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{:else}
|
||||||
|
<div class="w-full">
|
||||||
|
{#if !withoutSearch}
|
||||||
|
<Input
|
||||||
|
class="mb-4 max-w-sm"
|
||||||
|
placeholder={'Search...'}
|
||||||
|
type="text"
|
||||||
|
oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
{#if selectedIds}
|
||||||
|
<Table.Head>
|
||||||
|
<Checkbox checked={allChecked} onCheckedChange={(c) => onAllCheck(c as boolean)} />
|
||||||
|
</Table.Head>
|
||||||
|
{/if}
|
||||||
|
{#each columns as column}
|
||||||
|
{#if typeof column === 'string'}
|
||||||
|
<Table.Head>{column}</Table.Head>
|
||||||
|
{:else}
|
||||||
|
<Table.Head class={column.hidden ? 'sr-only' : ''}>{column.label}</Table.Head>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each items.data as item}
|
||||||
|
<Table.Row class={selectedIds?.includes(item.id) ? 'bg-muted/20' : ''}>
|
||||||
|
{#if selectedIds}
|
||||||
|
<Table.Cell>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.includes(item.id)}
|
||||||
|
onCheckedChange={(c) => onCheck(c as boolean, item.id)}
|
||||||
|
/>
|
||||||
|
</Table.Cell>
|
||||||
|
{/if}
|
||||||
|
{@render rows({ item })}
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-5 flex 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>
|
||||||
|
<Select.Root
|
||||||
|
selected={{
|
||||||
|
label: items.pagination.itemsPerPage.toString(),
|
||||||
|
value: items.pagination.itemsPerPage
|
||||||
|
}}
|
||||||
|
onSelectedChange={(v) => onPageSizeChange(v?.value as number)}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="h-9 w-[80px]">
|
||||||
|
<Select.Value>{items.pagination.itemsPerPage}</Select.Value>
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each availablePageSizes as size}
|
||||||
|
<Select.Item value={size}>{size}</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
<Pagination.Root
|
||||||
|
class="mx-0 w-auto"
|
||||||
|
count={items.pagination.totalItems}
|
||||||
|
perPage={items.pagination.itemsPerPage}
|
||||||
|
{onPageChange}
|
||||||
|
page={items.pagination.currentPage}
|
||||||
|
let:pages
|
||||||
|
>
|
||||||
|
<Pagination.Content class="flex justify-end">
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.PrevButton />
|
||||||
|
</Pagination.Item>
|
||||||
|
{#each pages as page (page.key)}
|
||||||
|
{#if page.type !== 'ellipsis'}
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.Link {page} isActive={items.pagination.currentPage === page.value}>
|
||||||
|
{page.value}
|
||||||
|
</Pagination.Link>
|
||||||
|
</Pagination.Item>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.NextButton />
|
||||||
|
</Pagination.Item>
|
||||||
|
</Pagination.Content>
|
||||||
|
</Pagination.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
116
frontend/src/lib/components/auto-complete-input.svelte
Normal file
116
frontend/src/lib/components/auto-complete-input.svelte
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Input from '$lib/components/ui/input/input.svelte';
|
||||||
|
import * as Popover from '$lib/components/ui/popover/index.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(''),
|
||||||
|
placeholder,
|
||||||
|
suggestionLimit = 5,
|
||||||
|
suggestions
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
placeholder: string;
|
||||||
|
suggestionLimit?: number;
|
||||||
|
suggestions: string[];
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let filteredSuggestions: string[] = $state(suggestions.slice(0, suggestionLimit));
|
||||||
|
let selectedIndex = $state(-1);
|
||||||
|
let keyError: string | undefined = $state();
|
||||||
|
|
||||||
|
let isInputFocused = $state(false);
|
||||||
|
|
||||||
|
function handleSuggestionClick(suggestion: (typeof suggestions)[0]) {
|
||||||
|
value = suggestion;
|
||||||
|
filteredSuggestions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOnInput() {
|
||||||
|
if (value.length > 0 && !/^[A-Za-z0-9]*$/.test(value)) {
|
||||||
|
keyError = 'Only alphanumeric characters are allowed';
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
keyError = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredSuggestions = suggestions
|
||||||
|
.filter((s) => s.includes(value.toLowerCase()))
|
||||||
|
.slice(0, suggestionLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (!isOpen) return;
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
selectedIndex = Math.min(selectedIndex + 1, filteredSuggestions.length - 1);
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
selectedIndex = Math.max(selectedIndex - 1, -1);
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
if (selectedIndex >= 0) {
|
||||||
|
handleSuggestionClick(filteredSuggestions[selectedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
isInputFocused = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let isOpen = $derived(filteredSuggestions.length > 0 && isInputFocused);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Reset selection when suggestions change
|
||||||
|
if (filteredSuggestions) {
|
||||||
|
selectedIndex = -1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid w-full"
|
||||||
|
role="combobox"
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
aria-controls="suggestion-list"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{placeholder}
|
||||||
|
bind:value
|
||||||
|
oninput={handleOnInput}
|
||||||
|
onfocus={() => (isInputFocused = true)}
|
||||||
|
onblur={() => (isInputFocused = false)}
|
||||||
|
/>
|
||||||
|
{#if keyError}
|
||||||
|
<p class="mt-1 text-sm text-red-500">{keyError}</p>
|
||||||
|
{/if}
|
||||||
|
<Popover.Root
|
||||||
|
open={isOpen}
|
||||||
|
disableFocusTrap
|
||||||
|
openFocus={() => {}}
|
||||||
|
closeOnOutsideClick={false}
|
||||||
|
closeOnEscape={false}
|
||||||
|
>
|
||||||
|
<Popover.Trigger tabindex={-1} class="h-0 w-full" aria-hidden />
|
||||||
|
<Popover.Content class="p-0" sideOffset={5} sameWidth>
|
||||||
|
{#each filteredSuggestions as suggestion, index}
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onmousedown={() => handleSuggestionClick(suggestion)}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSuggestionClick(suggestion);
|
||||||
|
}}
|
||||||
|
class="hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 {selectedIndex ===
|
||||||
|
index
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{suggestion}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
</div>
|
||||||
75
frontend/src/lib/components/custom-claims-input.svelte
Normal file
75
frontend/src/lib/components/custom-claims-input.svelte
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import FormInput from '$lib/components/form-input.svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import CustomClaimService from '$lib/services/custom-claim-service';
|
||||||
|
import type { CustomClaim } from '$lib/types/custom-claim.type';
|
||||||
|
import { LucideMinus, LucidePlus } from 'lucide-svelte';
|
||||||
|
import { onMount, type Snippet } from 'svelte';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
import AutoCompleteInput from './auto-complete-input.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
customClaims = $bindable(),
|
||||||
|
error = $bindable(null),
|
||||||
|
...restProps
|
||||||
|
}: HTMLAttributes<HTMLDivElement> & {
|
||||||
|
customClaims: CustomClaim[];
|
||||||
|
error?: string | null;
|
||||||
|
children?: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const limit = 20;
|
||||||
|
|
||||||
|
const customClaimService = new CustomClaimService();
|
||||||
|
|
||||||
|
let suggestions: string[] = $state([]);
|
||||||
|
let filteredSuggestions: string[] = $derived(
|
||||||
|
suggestions.filter(
|
||||||
|
(suggestion) => !customClaims.some((customClaim) => customClaim.key === suggestion)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
customClaimService.getSuggestions().then((data) => (suggestions = data));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div {...restProps}>
|
||||||
|
<FormInput>
|
||||||
|
<div class="flex flex-col gap-y-2">
|
||||||
|
{#each customClaims as _, i}
|
||||||
|
<div class="flex gap-x-2">
|
||||||
|
<AutoCompleteInput
|
||||||
|
placeholder="Key"
|
||||||
|
suggestions={filteredSuggestions}
|
||||||
|
bind:value={customClaims[i].key}
|
||||||
|
/>
|
||||||
|
<Input placeholder="Value" bind:value={customClaims[i].value} />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Remove custom claim"
|
||||||
|
on:click={() => (customClaims = customClaims.filter((_, index) => index !== i))}
|
||||||
|
>
|
||||||
|
<LucideMinus class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</FormInput>
|
||||||
|
{#if error}
|
||||||
|
<p class="mt-1 text-sm text-red-500">{error}</p>
|
||||||
|
{/if}
|
||||||
|
{#if customClaims.length < limit}
|
||||||
|
<Button
|
||||||
|
class="mt-2"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
on:click={() => (customClaims = [...customClaims, { key: '', value: '' }])}
|
||||||
|
>
|
||||||
|
<LucidePlus class="mr-1 h-4 w-4" />
|
||||||
|
{customClaims.length === 0 ? 'Add custom claim' : 'Add another'}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
...restProps
|
...restProps
|
||||||
}: HTMLAttributes<HTMLDivElement> & {
|
}: HTMLAttributes<HTMLDivElement> & {
|
||||||
input?: FormInput<string | boolean | number>;
|
input?: FormInput<string | boolean | number>;
|
||||||
label: string;
|
label?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox';
|
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox';
|
||||||
@@ -24,15 +24,17 @@
|
|||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const id = label.toLowerCase().replace(/ /g, '-');
|
const id = label?.toLowerCase().replace(/ /g, '-');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div {...restProps}>
|
<div {...restProps}>
|
||||||
<Label class="mb-0" for={id}>{label}</Label>
|
{#if label}
|
||||||
|
<Label class="mb-0" for={id}>{label}</Label>
|
||||||
|
{/if}
|
||||||
{#if description}
|
{#if description}
|
||||||
<p class="text-muted-foreground mt-1 text-xs">{description}</p>
|
<p class="text-muted-foreground mt-1 text-xs">{description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="mt-2">
|
<div class={label || description ? 'mt-2' : ''}>
|
||||||
{#if children}
|
{#if children}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{:else if input}
|
{:else if input}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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",
|
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",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden="true"
|
||||||
class={cn("flex h-9 w-9 items-center justify-center", className)}
|
class={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||||
{...$$restProps}
|
{...$$restProps}
|
||||||
>
|
>
|
||||||
|
|||||||
17
frontend/src/lib/components/ui/popover/index.ts
Normal file
17
frontend/src/lib/components/ui/popover/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||||
|
import Content from "./popover-content.svelte";
|
||||||
|
const Root = PopoverPrimitive.Root;
|
||||||
|
const Trigger = PopoverPrimitive.Trigger;
|
||||||
|
const Close = PopoverPrimitive.Close;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
Trigger,
|
||||||
|
Close,
|
||||||
|
//
|
||||||
|
Root as Popover,
|
||||||
|
Content as PopoverContent,
|
||||||
|
Trigger as PopoverTrigger,
|
||||||
|
Close as PopoverClose,
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||||
|
import { cn, flyAndScale } from "$lib/utils/style.js";
|
||||||
|
|
||||||
|
type $$Props = PopoverPrimitive.ContentProps;
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export let transition: $$Props["transition"] = flyAndScale;
|
||||||
|
export let transitionConfig: $$Props["transitionConfig"] = undefined;
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
{transition}
|
||||||
|
{transitionConfig}
|
||||||
|
class={cn(
|
||||||
|
"bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...$$restProps}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</PopoverPrimitive.Content>
|
||||||
24
frontend/src/lib/icons/empty.svelte
Normal file
24
frontend/src/lib/icons/empty.svelte
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
class: className
|
||||||
|
}: {
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 336.19673868301203 129.38671875"
|
||||||
|
class={className}
|
||||||
|
>
|
||||||
|
<g stroke-linecap="round" transform="translate(10 10) rotate(0 158.09836934150601 54.693359375)">
|
||||||
|
<path
|
||||||
|
d="M27.35 0 C121.36 -0.62, 208.79 0.52, 288.85 0 M288.85 0 C305.5 3.32, 316.8 6.14, 316.2 27.35 M316.2 27.35 C315.58 42.15, 314.92 54.54, 316.2 82.04 M316.2 82.04 C313.79 100.68, 304.9 110.1, 288.85 109.39 M288.85 109.39 C192.86 108.68, 93.17 110.07, 27.35 109.39 M27.35 109.39 C13.09 109.46, -1.61 102.22, 0 82.04 M0 82.04 C-0.35 60.8, -1.11 41.01, 0 27.35 M0 27.35 C1.94 9.62, 8.6 1.41, 27.35 0"
|
||||||
|
stroke="#A1A1AA"
|
||||||
|
stroke-width="4.5"
|
||||||
|
fill="none"
|
||||||
|
stroke-dasharray="8 12"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { version as currentVersion } from '$app/environment';
|
||||||
import type { AllAppConfig, AppConfigRawResponse } from '$lib/types/application-configuration';
|
import type { AllAppConfig, AppConfigRawResponse } from '$lib/types/application-configuration';
|
||||||
|
import axios from 'axios';
|
||||||
import APIService from './api-service';
|
import APIService from './api-service';
|
||||||
|
|
||||||
export default class AppConfigService extends APIService {
|
export default class AppConfigService extends APIService {
|
||||||
@@ -12,14 +14,19 @@ export default class AppConfigService extends APIService {
|
|||||||
|
|
||||||
const appConfig: Partial<AllAppConfig> = {};
|
const appConfig: Partial<AllAppConfig> = {};
|
||||||
data.forEach(({ key, value }) => {
|
data.forEach(({ key, value }) => {
|
||||||
(appConfig as any)[key] = value;
|
(appConfig as any)[key] = this.parseValue(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
return appConfig as AllAppConfig;
|
return appConfig as AllAppConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(appConfig: AllAppConfig) {
|
async update(appConfig: AllAppConfig) {
|
||||||
const res = await this.api.put('/application-configuration', appConfig);
|
// Convert all values to string
|
||||||
|
const appConfigConvertedToString = {};
|
||||||
|
for (const key in appConfig) {
|
||||||
|
(appConfigConvertedToString as any)[key] = (appConfig as any)[key].toString();
|
||||||
|
}
|
||||||
|
const res = await this.api.put('/application-configuration', appConfigConvertedToString);
|
||||||
return res.data as AllAppConfig;
|
return res.data as AllAppConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,4 +52,31 @@ export default class AppConfigService extends APIService {
|
|||||||
|
|
||||||
await this.api.put(`/application-configuration/background-image`, formData);
|
await this.api.put(`/application-configuration/background-image`, formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getVersionInformation() {
|
||||||
|
const response = (
|
||||||
|
await axios.get('https://api.github.com/repos/stonith404/pocket-id/releases/latest')
|
||||||
|
).data;
|
||||||
|
|
||||||
|
const newestVersion = response.tag_name.replace('v', '');
|
||||||
|
const isUpToDate = newestVersion === currentVersion;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isUpToDate,
|
||||||
|
newestVersion,
|
||||||
|
currentVersion
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseValue(value: string) {
|
||||||
|
if (value === 'true') {
|
||||||
|
return true;
|
||||||
|
} else if (value === 'false') {
|
||||||
|
return false;
|
||||||
|
} else if (!isNaN(parseFloat(value))) {
|
||||||
|
return parseFloat(value);
|
||||||
|
} else {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
frontend/src/lib/services/custom-claim-service.ts
Normal file
19
frontend/src/lib/services/custom-claim-service.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { CustomClaim } from '$lib/types/custom-claim.type';
|
||||||
|
import APIService from './api-service';
|
||||||
|
|
||||||
|
export default class CustomClaimService extends APIService {
|
||||||
|
async getSuggestions() {
|
||||||
|
const res = await this.api.get('/custom-claims/suggestions');
|
||||||
|
return res.data as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUserCustomClaims(userId: string, claims: CustomClaim[]) {
|
||||||
|
const res = await this.api.put(`/custom-claims/user/${userId}`, claims);
|
||||||
|
return res.data as CustomClaim[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUserGroupCustomClaims(userGroupId: string, claims: CustomClaim[]) {
|
||||||
|
const res = await this.api.put(`/custom-claims/user-group/${userGroupId}`, claims);
|
||||||
|
return res.data as CustomClaim[];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,10 +42,10 @@ export default class UserService extends APIService {
|
|||||||
await this.api.delete(`/users/${id}`);
|
await this.api.delete(`/users/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createOneTimeAccessToken(userId: string) {
|
async createOneTimeAccessToken(userId: string, expiresAt: Date) {
|
||||||
const res = await this.api.post(`/users/${userId}/one-time-access-token`, {
|
const res = await this.api.post(`/users/${userId}/one-time-access-token`, {
|
||||||
userId,
|
userId,
|
||||||
expiresAt: new Date(Date.now() + 1000 * 60 * 5).toISOString()
|
expiresAt
|
||||||
});
|
});
|
||||||
return res.data.token;
|
return res.data.token;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
export type AllAppConfig = {
|
export type AppConfig = {
|
||||||
appName: string;
|
appName: string;
|
||||||
sessionDuration: string;
|
allowOwnAccountEdit: boolean;
|
||||||
emailEnabled: string;
|
};
|
||||||
|
|
||||||
|
export type AllAppConfig = AppConfig & {
|
||||||
|
sessionDuration: number;
|
||||||
|
emailsVerified: boolean;
|
||||||
|
emailEnabled: boolean;
|
||||||
smtpHost: string;
|
smtpHost: string;
|
||||||
smtpPort: string;
|
smtpPort: number;
|
||||||
smtpFrom: string;
|
smtpFrom: string;
|
||||||
smtpUser: string;
|
smtpUser: string;
|
||||||
smtpPassword: string;
|
smtpPassword: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppConfig = AllAppConfig;
|
|
||||||
|
|
||||||
export type AppConfigRawResponse = {
|
export type AppConfigRawResponse = {
|
||||||
key: string;
|
key: string;
|
||||||
type: string;
|
type: string;
|
||||||
value: string;
|
value: string;
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
|
export type AppVersionInformation = {
|
||||||
|
isUpToDate: boolean;
|
||||||
|
newestVersion: string;
|
||||||
|
currentVersion: string;
|
||||||
|
};
|
||||||
|
|||||||
4
frontend/src/lib/types/custom-claim.type.ts
Normal file
4
frontend/src/lib/types/custom-claim.type.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export type CustomClaim = {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { CustomClaim } from './custom-claim.type';
|
||||||
import type { User } from './user.type';
|
import type { User } from './user.type';
|
||||||
|
|
||||||
export type UserGroup = {
|
export type UserGroup = {
|
||||||
@@ -5,6 +6,7 @@ export type UserGroup = {
|
|||||||
friendlyName: string;
|
friendlyName: string;
|
||||||
name: string;
|
name: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
customClaims: CustomClaim[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserGroupWithUsers = UserGroup & {
|
export type UserGroupWithUsers = UserGroup & {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { CustomClaim } from './custom-claim.type';
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -5,6 +7,7 @@ export type User = {
|
|||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
customClaims: CustomClaim[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserCreate = Omit<User, 'id'>;
|
export type UserCreate = Omit<User, 'id' | 'customClaims'>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export function debounced<T extends (...args: any[]) => void>(func: T, delay: number) {
|
export function debounced<T extends (...args: any[]) => void>(func: T, delay: number) {
|
||||||
let debounceTimeout: number | undefined;
|
let debounceTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
return (...args: Parameters<T>) => {
|
return (...args: Parameters<T>) => {
|
||||||
if (debounceTimeout !== undefined) {
|
if (debounceTimeout !== undefined) {
|
||||||
@@ -10,4 +10,4 @@ export function debounced<T extends (...args: any[]) => void>(func: T, delay: nu
|
|||||||
func(...args);
|
func(...args);
|
||||||
}, delay);
|
}, delay);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,11 +33,19 @@
|
|||||||
<Logo class="h-10 w-10" />
|
<Logo class="h-10 w-10" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="font-playfair mt-5 text-4xl font-bold">One Time Access</h1>
|
<h1 class="font-playfair mt-5 text-4xl font-bold">
|
||||||
|
{data.token === 'setup' ? `${$appConfigStore.appName} Setup` : 'One Time Access'}
|
||||||
|
</h1>
|
||||||
<p class="text-muted-foreground mt-2">
|
<p class="text-muted-foreground mt-2">
|
||||||
You've been granted one-time access to your {$appConfigStore.appName} account. Please note that if
|
{#if data.token === 'setup'}
|
||||||
you continue, this link will become invalid. To avoid this, make sure to add a passkey. Otherwise,
|
You're about to sign in to the initial admin account. Anyone with this link can access the
|
||||||
you'll need to request a new link.
|
account until a passkey is added. Please set up a passkey as soon as possible to prevent
|
||||||
|
unauthorized access.
|
||||||
|
{:else}
|
||||||
|
You've been granted one-time access to your {$appConfigStore.appName} account. Please note that
|
||||||
|
if you continue, this link will become invalid. To avoid this, make sure to add a passkey. Otherwise,
|
||||||
|
you'll need to request a new link.
|
||||||
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
<Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button>
|
<Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button>
|
||||||
</SignInWrapper>
|
</SignInWrapper>
|
||||||
|
|||||||
24
frontend/src/routes/settings/+layout.server.ts
Normal file
24
frontend/src/routes/settings/+layout.server.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import AppConfigService from '$lib/services/app-config-service';
|
||||||
|
import type { AppVersionInformation } from '$lib/types/application-configuration';
|
||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
let versionInformation: AppVersionInformation;
|
||||||
|
let versionInformationLastUpdated: number;
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async () => {
|
||||||
|
const appConfigService = new AppConfigService();
|
||||||
|
|
||||||
|
// Cache the version information for 3 hours
|
||||||
|
const cacheExpired =
|
||||||
|
versionInformationLastUpdated &&
|
||||||
|
Date.now() - versionInformationLastUpdated > 1000 * 60 * 60 * 3;
|
||||||
|
|
||||||
|
if (!versionInformation || cacheExpired) {
|
||||||
|
versionInformation = await appConfigService.getVersionInformation();
|
||||||
|
versionInformationLastUpdated = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
versionInformation
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import userStore from '$lib/stores/user-store';
|
import userStore from '$lib/stores/user-store';
|
||||||
|
import { LucideExternalLink } from 'lucide-svelte';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import type { LayoutData } from './$types';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
children
|
children,
|
||||||
|
data
|
||||||
}: {
|
}: {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
|
data: LayoutData;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const { versionInformation } = data;
|
||||||
|
|
||||||
let links = $state([
|
let links = $state([
|
||||||
{ href: '/settings/account', label: 'My Account' },
|
{ href: '/settings/account', label: 'My Account' },
|
||||||
{ href: '/settings/audit-log', label: 'Audit Log' }
|
{ href: '/settings/audit-log', label: 'Audit Log' }
|
||||||
@@ -16,6 +22,7 @@
|
|||||||
|
|
||||||
if ($userStore?.isAdmin) {
|
if ($userStore?.isAdmin) {
|
||||||
links = [
|
links = [
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
...links,
|
...links,
|
||||||
{ href: '/settings/admin/users', label: 'Users' },
|
{ href: '/settings/admin/users', label: 'Users' },
|
||||||
{ href: '/settings/admin/user-groups', label: 'User Groups' },
|
{ href: '/settings/admin/user-groups', label: 'User Groups' },
|
||||||
@@ -26,8 +33,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="bg-muted/40 min-h-screen w-full">
|
<div class="bg-muted/40 flex min-h-[calc(100vh-64px)] w-full flex-col justify-between">
|
||||||
<main class="mx-auto flex max-w-[1640px] flex-col gap-x-4 gap-y-10 p-4 md:p-10 lg:flex-row">
|
<main
|
||||||
|
class="mx-auto flex w-full max-w-[1640px] flex-col gap-x-4 gap-y-10 p-4 md:p-10 lg:flex-row"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<div class="mx-auto grid w-full gap-2">
|
<div class="mx-auto grid w-full gap-2">
|
||||||
<h1 class="mb-5 text-3xl font-semibold">Settings</h1>
|
<h1 class="mb-5 text-3xl font-semibold">Settings</h1>
|
||||||
@@ -41,6 +50,15 @@
|
|||||||
{label}
|
{label}
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if $userStore?.isAdmin && !versionInformation.isUpToDate}
|
||||||
|
<a
|
||||||
|
href="https://github.com/stonith404/pocket-id/releases/latest"
|
||||||
|
target="_blank"
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
Update Pocket ID <LucideExternalLink class="my-auto inline-block h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,5 +66,15 @@
|
|||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<p class="text-muted-foreground py-3 text-xs">
|
||||||
|
Powered by <a
|
||||||
|
class="text-foreground"
|
||||||
|
href="https://github.com/stonith404/pocket-id"
|
||||||
|
target="_blank">Pocket ID</a
|
||||||
|
>
|
||||||
|
({versionInformation.currentVersion})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
import WebAuthnService from '$lib/services/webauthn-service';
|
import WebAuthnService from '$lib/services/webauthn-service';
|
||||||
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
import type { Passkey } from '$lib/types/passkey.type';
|
import type { Passkey } from '$lib/types/passkey.type';
|
||||||
import type { UserCreate } from '$lib/types/user.type';
|
import type { UserCreate } from '$lib/types/user.type';
|
||||||
import { axiosErrorToast, getWebauthnErrorMessage } from '$lib/utils/error-util';
|
import { axiosErrorToast, getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||||
@@ -51,14 +52,16 @@
|
|||||||
<title>Account Settings</title>
|
<title>Account Settings</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Card.Root>
|
{#if $appConfigStore.allowOwnAccountEdit}
|
||||||
<Card.Header>
|
<Card.Root>
|
||||||
<Card.Title>Account Details</Card.Title>
|
<Card.Header>
|
||||||
</Card.Header>
|
<Card.Title>Account Details</Card.Title>
|
||||||
<Card.Content>
|
</Card.Header>
|
||||||
<AccountForm {account} callback={updateAccount} />
|
<Card.Content>
|
||||||
</Card.Content>
|
<AccountForm {account} callback={updateAccount} />
|
||||||
</Card.Root>
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
|
|||||||
@@ -15,10 +15,10 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let emailEnabled = $state(appConfig.emailEnabled == 'true');
|
let emailEnabled = $state(appConfig.emailEnabled);
|
||||||
|
|
||||||
const updatedAppConfig = {
|
const updatedAppConfig = {
|
||||||
emailEnabled: emailEnabled.toString(),
|
emailEnabled: appConfig.emailEnabled,
|
||||||
smtpHost: appConfig.smtpHost,
|
smtpHost: appConfig.smtpHost,
|
||||||
smtpPort: appConfig.smtpPort,
|
smtpPort: appConfig.smtpPort,
|
||||||
smtpUser: appConfig.smtpUser,
|
smtpUser: appConfig.smtpUser,
|
||||||
@@ -28,13 +28,13 @@
|
|||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
smtpHost: z.string().min(1),
|
smtpHost: z.string().min(1),
|
||||||
smtpPort: z.string().min(1),
|
smtpPort: z.number().min(1),
|
||||||
smtpUser: z.string().min(1),
|
smtpUser: z.string().min(1),
|
||||||
smtpPassword: z.string().min(1),
|
smtpPassword: z.string().min(1),
|
||||||
smtpFrom: z.string().email()
|
smtpFrom: z.string().email()
|
||||||
});
|
});
|
||||||
|
|
||||||
const { inputs, ...form } = createForm< typeof formSchema>(formSchema, updatedAppConfig);
|
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
const data = form.validate();
|
const data = form.validate();
|
||||||
@@ -42,15 +42,15 @@
|
|||||||
isLoading = true;
|
isLoading = true;
|
||||||
await callback({
|
await callback({
|
||||||
...data,
|
...data,
|
||||||
emailEnabled: 'true'
|
emailEnabled: true
|
||||||
}).finally(() => (isLoading = false));
|
}).finally(() => (isLoading = false));
|
||||||
toast.success('Email configuration updated successfully');
|
toast.success('Email configuration updated successfully');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onDisable() {
|
async function onDisable() {
|
||||||
await callback({ emailEnabled: 'false' });
|
|
||||||
emailEnabled = false;
|
emailEnabled = false;
|
||||||
|
await callback({ emailEnabled });
|
||||||
toast.success('Email disabled successfully');
|
toast.success('Email disabled successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
<form onsubmit={onSubmit}>
|
<form onsubmit={onSubmit}>
|
||||||
<div class="mt-5 grid grid-cols-2 gap-5">
|
<div class="mt-5 grid grid-cols-2 gap-5">
|
||||||
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
|
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
|
||||||
<FormInput label="SMTP Port" bind:input={$inputs.smtpPort} />
|
<FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} />
|
||||||
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
|
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
|
||||||
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
|
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
|
||||||
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
|
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import FormInput from '$lib/components/form-input.svelte';
|
import FormInput from '$lib/components/form-input.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
import type { AllAppConfig } from '$lib/types/application-configuration';
|
import type { AllAppConfig } from '$lib/types/application-configuration';
|
||||||
import { createForm } from '$lib/utils/form-util';
|
import { createForm } from '$lib/utils/form-util';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
@@ -18,20 +20,16 @@
|
|||||||
|
|
||||||
const updatedAppConfig = {
|
const updatedAppConfig = {
|
||||||
appName: appConfig.appName,
|
appName: appConfig.appName,
|
||||||
sessionDuration: appConfig.sessionDuration
|
sessionDuration: appConfig.sessionDuration,
|
||||||
|
emailsVerified: appConfig.emailsVerified,
|
||||||
|
allowOwnAccountEdit: appConfig.allowOwnAccountEdit
|
||||||
};
|
};
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
appName: z.string().min(2).max(30),
|
appName: z.string().min(2).max(30),
|
||||||
sessionDuration: z.string().refine(
|
sessionDuration: z.number().min(1).max(43200),
|
||||||
(val) => {
|
emailsVerified: z.boolean(),
|
||||||
const num = Number(val);
|
allowOwnAccountEdit: z.boolean()
|
||||||
return Number.isInteger(num) && num >= 1 && num <= 43200;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: 'Session duration must be between 1 and 43200 minutes'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
|
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
|
||||||
@@ -49,9 +47,32 @@
|
|||||||
<FormInput label="Application Name" bind:input={$inputs.appName} />
|
<FormInput label="Application Name" bind:input={$inputs.appName} />
|
||||||
<FormInput
|
<FormInput
|
||||||
label="Session Duration"
|
label="Session Duration"
|
||||||
|
type="number"
|
||||||
description="The duration of a session in minutes before the user has to sign in again."
|
description="The duration of a session in minutes before the user has to sign in again."
|
||||||
bind:input={$inputs.sessionDuration}
|
bind:input={$inputs.sessionDuration}
|
||||||
/>
|
/>
|
||||||
|
<div class="items-top mt-5 flex space-x-2">
|
||||||
|
<Checkbox id="admin-privileges" bind:checked={$inputs.allowOwnAccountEdit.value} />
|
||||||
|
<div class="grid gap-1.5 leading-none">
|
||||||
|
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none">
|
||||||
|
Enable Self-Account Editing
|
||||||
|
</Label>
|
||||||
|
<p class="text-muted-foreground text-[0.8rem]">
|
||||||
|
Whether the users should be able to edit their own account details.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="items-top mt-5 flex space-x-2">
|
||||||
|
<Checkbox id="admin-privileges" bind:checked={$inputs.emailsVerified.value} />
|
||||||
|
<div class="grid gap-1.5 leading-none">
|
||||||
|
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none">
|
||||||
|
Emails Verified
|
||||||
|
</Label>
|
||||||
|
<p class="text-muted-foreground text-[0.8rem]">
|
||||||
|
Whether the user's email should be marked as verified for the OIDC clients.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 flex justify-end">
|
<div class="mt-5 flex justify-end">
|
||||||
<Button {isLoading} type="submit">Save</Button>
|
<Button {isLoading} type="submit">Save</Button>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const limit = 5;
|
const limit = 20;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div {...restProps}>
|
<div {...restProps}>
|
||||||
@@ -25,15 +25,15 @@
|
|||||||
{#each callbackURLs as _, i}
|
{#each callbackURLs as _, i}
|
||||||
<div class="flex gap-x-2">
|
<div class="flex gap-x-2">
|
||||||
<Input data-testid={`callback-url-${i + 1}`} bind:value={callbackURLs[i]} />
|
<Input data-testid={`callback-url-${i + 1}`} bind:value={callbackURLs[i]} />
|
||||||
{#if callbackURLs.length > 1}
|
{#if callbackURLs.length > 1}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
on:click={() => callbackURLs = callbackURLs.filter((_, index) => index !== i)}
|
on:click={() => (callbackURLs = callbackURLs.filter((_, index) => index !== i))}
|
||||||
>
|
>
|
||||||
<LucideMinus class="h-4 w-4" />
|
<LucideMinus class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
class="mt-2"
|
class="mt-2"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
on:click={() => callbackURLs = [...callbackURLs, '']}
|
on:click={() => (callbackURLs = [...callbackURLs, ''])}
|
||||||
>
|
>
|
||||||
<LucidePlus class="mr-1 h-4 w-4" />
|
<LucidePlus class="mr-1 h-4 w-4" />
|
||||||
Add another
|
Add another
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import CustomClaimsInput from '$lib/components/custom-claims-input.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import CustomClaimService from '$lib/services/custom-claim-service';
|
||||||
import UserGroupService from '$lib/services/user-group-service';
|
import UserGroupService from '$lib/services/user-group-service';
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
import type { UserGroupCreate } from '$lib/types/user-group.type';
|
import type { UserGroupCreate } from '$lib/types/user-group.type';
|
||||||
@@ -18,6 +20,7 @@
|
|||||||
|
|
||||||
const userGroupService = new UserGroupService();
|
const userGroupService = new UserGroupService();
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
|
const customClaimService = new CustomClaimService();
|
||||||
|
|
||||||
async function updateUserGroup(updatedUserGroup: UserGroupCreate) {
|
async function updateUserGroup(updatedUserGroup: UserGroupCreate) {
|
||||||
let success = true;
|
let success = true;
|
||||||
@@ -40,6 +43,15 @@
|
|||||||
axiosErrorToast(e);
|
axiosErrorToast(e);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateCustomClaims() {
|
||||||
|
await customClaimService
|
||||||
|
.updateUserGroupCustomClaims(userGroup.id, userGroup.customClaims)
|
||||||
|
.then(() => toast.success('Custom claims updated successfully'))
|
||||||
|
.catch((e) => {
|
||||||
|
axiosErrorToast(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -53,7 +65,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>Meta data</Card.Title>
|
<Card.Title>General</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
|
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
@@ -76,3 +88,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Custom Claims</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
Custom claims are key-value pairs that can be used to store additional information about a
|
||||||
|
user. These claims will be included in the ID token if the scope "profile" is requested.
|
||||||
|
Custom claims defined on the user will be prioritized if there are conflicts.
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<CustomClaimsInput bind:customClaims={userGroup.customClaims} />
|
||||||
|
<div class="mt-5 flex justify-end">
|
||||||
|
<Button onclick={updateCustomClaims} type="submit">Save</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import CustomClaimService from '$lib/services/custom-claim-service';
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
import type { UserCreate } from '$lib/types/user.type';
|
import type { UserCreate } from '$lib/types/user.type';
|
||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import { LucideChevronLeft } from 'lucide-svelte';
|
import { LucideChevronLeft } from 'lucide-svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import CustomClaimsInput from '../../../../../lib/components/custom-claims-input.svelte';
|
||||||
import UserForm from '../user-form.svelte';
|
import UserForm from '../user-form.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
let user = $state(data);
|
let user = $state(data);
|
||||||
|
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
|
const customClaimService = new CustomClaimService();
|
||||||
|
|
||||||
async function updateUser(updatedUser: UserCreate) {
|
async function updateUser(updatedUser: UserCreate) {
|
||||||
let success = true;
|
let success = true;
|
||||||
@@ -24,6 +28,15 @@
|
|||||||
|
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateCustomClaims() {
|
||||||
|
await customClaimService
|
||||||
|
.updateUserCustomClaims(user.id, user.customClaims)
|
||||||
|
.then(() => toast.success('Custom claims updated successfully'))
|
||||||
|
.catch((e) => {
|
||||||
|
axiosErrorToast(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -37,10 +50,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>{user.firstName} {user.lastName}</Card.Title>
|
<Card.Title>General</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
|
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
<UserForm existingUser={user} callback={updateUser} />
|
<UserForm existingUser={user} callback={updateUser} />
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Custom Claims</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
Custom claims are key-value pairs that can be used to store additional information about a
|
||||||
|
user. These claims will be included in the ID token if the scope "profile" is requested.
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<CustomClaimsInput bind:customClaims={user.customClaims} />
|
||||||
|
<div class="mt-5 flex justify-end">
|
||||||
|
<Button onclick={updateCustomClaims} type="submit">Save</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|||||||
@@ -1,22 +1,51 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Dialog from '$lib/components/ui/dialog';
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
import Input from '$lib/components/ui/input/input.svelte';
|
import Input from '$lib/components/ui/input/input.svelte';
|
||||||
import Label from '$lib/components/ui/label/label.svelte';
|
import Label from '$lib/components/ui/label/label.svelte';
|
||||||
|
import * as Select from '$lib/components/ui/select/index.js';
|
||||||
|
import UserService from '$lib/services/user-service';
|
||||||
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
oneTimeLink = $bindable()
|
userId = $bindable()
|
||||||
}: {
|
}: {
|
||||||
oneTimeLink: string | null;
|
userId: string | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const userService = new UserService();
|
||||||
|
|
||||||
|
let oneTimeLink: string | null = $state(null);
|
||||||
|
let selectedExpiration: keyof typeof availableExpirations = $state('1 hour');
|
||||||
|
|
||||||
|
let availableExpirations = {
|
||||||
|
'1 hour': 60 * 60,
|
||||||
|
'12 hours': 60 * 60 * 12,
|
||||||
|
'1 day': 60 * 60 * 24,
|
||||||
|
'1 week': 60 * 60 * 24 * 7,
|
||||||
|
'1 month': 60 * 60 * 24 * 30
|
||||||
|
};
|
||||||
|
|
||||||
|
async function createOneTimeAccessToken() {
|
||||||
|
try {
|
||||||
|
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
|
||||||
|
const token = await userService.createOneTimeAccessToken(userId!, expiration);
|
||||||
|
oneTimeLink = `${$page.url.origin}/login/${token}`;
|
||||||
|
} catch (e) {
|
||||||
|
axiosErrorToast(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onOpenChange(open: boolean) {
|
function onOpenChange(open: boolean) {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
oneTimeLink = null;
|
oneTimeLink = null;
|
||||||
|
userId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root open={!!oneTimeLink} {onOpenChange}>
|
<Dialog.Root open={!!userId} {onOpenChange}>
|
||||||
<Dialog.Content class="max-w-md">
|
<Dialog.Content class="max-w-md">
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>One Time Link</Dialog.Title>
|
<Dialog.Title>One Time Link</Dialog.Title>
|
||||||
@@ -25,9 +54,36 @@
|
|||||||
have lost it.</Dialog.Description
|
have lost it.</Dialog.Description
|
||||||
>
|
>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
<div>
|
{#if oneTimeLink === null}
|
||||||
<Label for="one-time-link">One Time Link</Label>
|
<div>
|
||||||
|
<Label for="expiration">Expiration</Label>
|
||||||
|
<Select.Root
|
||||||
|
selected={{
|
||||||
|
label: Object.keys(availableExpirations)[0],
|
||||||
|
value: Object.keys(availableExpirations)[0]
|
||||||
|
}}
|
||||||
|
onSelectedChange={(v) =>
|
||||||
|
(selectedExpiration = v!.value as keyof typeof availableExpirations)}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="h-9 ">
|
||||||
|
<Select.Value>{selectedExpiration}</Select.Value>
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each Object.keys(availableExpirations) as key}
|
||||||
|
<Select.Item value={key}>{key}</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onclick={() => createOneTimeAccessToken()}
|
||||||
|
disabled={!selectedExpiration}
|
||||||
|
>
|
||||||
|
Generate Link
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Label for="one-time-link" class="sr-only">One Time Link</Label>
|
||||||
<Input id="one-time-link" value={oneTimeLink} readonly />
|
<Input id="one-time-link" value={oneTimeLink} readonly />
|
||||||
</div>
|
{/if}
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { goto } from '$app/navigation';
|
||||||
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
||||||
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
|
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
|
||||||
import { Badge } from '$lib/components/ui/badge/index';
|
import { Badge } from '$lib/components/ui/badge/index';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { buttonVariants } from '$lib/components/ui/button';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
import * as Table from '$lib/components/ui/table';
|
import * as Table from '$lib/components/ui/table';
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
users = initialUsers;
|
users = initialUsers;
|
||||||
});
|
});
|
||||||
|
|
||||||
let oneTimeLink = $state<string | null>(null);
|
let userIdToCreateOneTimeLink: string | null = $state(null);;
|
||||||
|
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
|
|
||||||
@@ -48,15 +48,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createOneTimeAccessToken(userId: string) {
|
|
||||||
try {
|
|
||||||
const token = await userService.createOneTimeAccessToken(userId);
|
|
||||||
oneTimeLink = `${$page.url.origin}/login/${token}`;
|
|
||||||
} catch (e) {
|
|
||||||
axiosErrorToast(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AdvancedTable
|
<AdvancedTable
|
||||||
@@ -82,22 +73,20 @@
|
|||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger asChild let:builder>
|
<DropdownMenu.Trigger class={buttonVariants({ variant: 'ghost', size: 'icon' })}>
|
||||||
<Button aria-haspopup="true" size="icon" variant="ghost" builders={[builder]}>
|
<Ellipsis class="h-4 w-4" />
|
||||||
<Ellipsis class="h-4 w-4" />
|
<span class="sr-only">Toggle menu</span>
|
||||||
<span class="sr-only">Toggle menu</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content align="end">
|
<DropdownMenu.Content align="end">
|
||||||
<DropdownMenu.Item on:click={() => createOneTimeAccessToken(item.id)}
|
<DropdownMenu.Item onclick={() => (userIdToCreateOneTimeLink = item.id)}
|
||||||
><LucideLink class="mr-2 h-4 w-4" />One-time link</DropdownMenu.Item
|
><LucideLink class="mr-2 h-4 w-4" />One-time link</DropdownMenu.Item
|
||||||
>
|
>
|
||||||
<DropdownMenu.Item href="/settings/admin/users/{item.id}"
|
<DropdownMenu.Item onclick={() => goto(`/settings/admin/users/${item.id}`)}
|
||||||
><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item
|
><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item
|
||||||
>
|
>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
class="text-red-500 focus:!text-red-700"
|
class="text-red-500 focus:!text-red-700"
|
||||||
on:click={() => deleteUser(item)}
|
onclick={() => deleteUser(item)}
|
||||||
><LucideTrash class="mr-2 h-4 w-4" />Delete</DropdownMenu.Item
|
><LucideTrash class="mr-2 h-4 w-4" />Delete</DropdownMenu.Item
|
||||||
>
|
>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
@@ -106,4 +95,4 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</AdvancedTable>
|
</AdvancedTable>
|
||||||
|
|
||||||
<OneTimeLinkModal {oneTimeLink} />
|
<OneTimeLinkModal userId={userIdToCreateOneTimeLink} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import adapter from '@sveltejs/adapter-node';
|
import adapter from '@sveltejs/adapter-node';
|
||||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
import packageJson from "./package.json" assert { type: "json" };
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
@@ -12,6 +13,9 @@ const config = {
|
|||||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||||
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
|
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
|
||||||
adapter: adapter(),
|
adapter: adapter(),
|
||||||
|
version: {
|
||||||
|
name: packageJson.version,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ test('Update account details fails with already taken email', async ({ page }) =
|
|||||||
|
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('status')).toHaveText('Email is already taken');
|
await expect(page.getByRole('status')).toHaveText('Email is already in use');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Update account details fails with already taken username', async ({ page }) => {
|
test('Update account details fails with already taken username', async ({ page }) => {
|
||||||
@@ -34,7 +34,7 @@ test('Update account details fails with already taken username', async ({ page }
|
|||||||
|
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('status')).toHaveText('Username is already taken');
|
await expect(page.getByRole('status')).toHaveText('Username is already in use');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Add passkey to an account', async ({ page }) => {
|
test('Add passkey to an account', async ({ page }) => {
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ test('Create user group', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('status')).toHaveText('User group created successfully');
|
await expect(page.getByRole('status')).toHaveText('User group created successfully');
|
||||||
expect(page.url()).toMatch(/\/settings\/admin\/user-groups\/[a-f0-9-]+/);
|
|
||||||
|
await page.waitForURL('/settings/admin/user-groups/*');
|
||||||
|
|
||||||
await expect(page.getByLabel('Friendly Name')).toHaveValue(group.friendlyName);
|
await expect(page.getByLabel('Friendly Name')).toHaveValue(group.friendlyName);
|
||||||
await expect(page.getByLabel('Name', { exact: true })).toHaveValue(group.name);
|
await expect(page.getByLabel('Name', { exact: true })).toHaveValue(group.name);
|
||||||
@@ -72,3 +73,39 @@ test('Delete user group', async ({ page }) => {
|
|||||||
await expect(page.getByRole('status')).toHaveText('User group deleted successfully');
|
await expect(page.getByRole('status')).toHaveText('User group deleted successfully');
|
||||||
await expect(page.getByRole('row', { name: group.name })).not.toBeVisible();
|
await expect(page.getByRole('row', { name: group.name })).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Update user group custom claims', async ({ page }) => {
|
||||||
|
await page.goto(`/settings/admin/user-groups/${userGroups.designers.id}`);
|
||||||
|
|
||||||
|
// Add two custom claims
|
||||||
|
await page.getByRole('button', { name: 'Add custom claim' }).click();
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Key').fill('customClaim1');
|
||||||
|
await page.getByPlaceholder('Value').fill('customClaim1_value');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Add another' }).click();
|
||||||
|
await page.getByPlaceholder('Key').nth(1).fill('customClaim2');
|
||||||
|
await page.getByPlaceholder('Value').nth(1).fill('customClaim2_value');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save' }).nth(2).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('status')).toHaveText('Custom claims updated successfully');
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Check if custom claims are saved
|
||||||
|
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim1');
|
||||||
|
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim1_value');
|
||||||
|
await expect(page.getByPlaceholder('Key').nth(1)).toHaveValue('customClaim2');
|
||||||
|
await expect(page.getByPlaceholder('Value').nth(1)).toHaveValue('customClaim2_value');
|
||||||
|
|
||||||
|
// Remove one custom claim
|
||||||
|
await page.getByLabel('Remove custom claim').first().click();
|
||||||
|
await page.getByRole('button', { name: 'Save' }).nth(2).click();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Check if custom claim is removed
|
||||||
|
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim2');
|
||||||
|
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim2_value');
|
||||||
|
});
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ test('Create user fails with already taken email', async ({ page }) => {
|
|||||||
await page.getByLabel('Username').fill(user.username);
|
await page.getByLabel('Username').fill(user.username);
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('status')).toHaveText('Email is already taken');
|
await expect(page.getByRole('status')).toHaveText('Email is already in use');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Create user fails with already taken username', async ({ page }) => {
|
test('Create user fails with already taken username', async ({ page }) => {
|
||||||
@@ -47,7 +47,7 @@ test('Create user fails with already taken username', async ({ page }) => {
|
|||||||
await page.getByLabel('Username').fill(users.tim.username);
|
await page.getByLabel('Username').fill(users.tim.username);
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('status')).toHaveText('Username is already taken');
|
await expect(page.getByRole('status')).toHaveText('Username is already in use');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Create one time access token', async ({ page }) => {
|
test('Create one time access token', async ({ page }) => {
|
||||||
@@ -57,8 +57,13 @@ test('Create one time access token', async ({ page }) => {
|
|||||||
.getByRole('row', { name: `${users.craig.firstname} ${users.craig.lastname}` })
|
.getByRole('row', { name: `${users.craig.firstname} ${users.craig.lastname}` })
|
||||||
.getByRole('button')
|
.getByRole('button')
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
await page.getByRole('menuitem', { name: 'One-time link' }).click();
|
await page.getByRole('menuitem', { name: 'One-time link' }).click();
|
||||||
|
|
||||||
|
await page.getByLabel('One Time Link').getByRole('combobox').click();
|
||||||
|
await page.getByRole('option', { name: '12 hours' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Generate Link' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('textbox', { name: 'One Time Link' })).toHaveValue(
|
await expect(page.getByRole('textbox', { name: 'One Time Link' })).toHaveValue(
|
||||||
/http:\/\/localhost\/login\/.*/
|
/http:\/\/localhost\/login\/.*/
|
||||||
);
|
);
|
||||||
@@ -95,7 +100,7 @@ test('Update user', async ({ page }) => {
|
|||||||
await page.getByLabel('Last name').fill('Apple');
|
await page.getByLabel('Last name').fill('Apple');
|
||||||
await page.getByLabel('Email').fill('crack.apple@test.com');
|
await page.getByLabel('Email').fill('crack.apple@test.com');
|
||||||
await page.getByLabel('Username').fill('crack');
|
await page.getByLabel('Username').fill('crack');
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||||
|
|
||||||
await expect(page.getByRole('status')).toHaveText('User updated successfully');
|
await expect(page.getByRole('status')).toHaveText('User updated successfully');
|
||||||
});
|
});
|
||||||
@@ -112,9 +117,9 @@ test('Update user fails with already taken email', async ({ page }) => {
|
|||||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||||
|
|
||||||
await page.getByLabel('Email').fill(users.tim.email);
|
await page.getByLabel('Email').fill(users.tim.email);
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||||
|
|
||||||
await expect(page.getByRole('status')).toHaveText('Email is already taken');
|
await expect(page.getByRole('status')).toHaveText('Email is already in use');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Update user fails with already taken username', async ({ page }) => {
|
test('Update user fails with already taken username', async ({ page }) => {
|
||||||
@@ -129,7 +134,43 @@ test('Update user fails with already taken username', async ({ page }) => {
|
|||||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||||
|
|
||||||
await page.getByLabel('Username').fill(users.tim.username);
|
await page.getByLabel('Username').fill(users.tim.username);
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||||
|
|
||||||
await expect(page.getByRole('status')).toHaveText('Username is already taken');
|
await expect(page.getByRole('status')).toHaveText('Username is already in use');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Update user custom claims', async ({ page }) => {
|
||||||
|
await page.goto(`/settings/admin/users/${users.craig.id}`);
|
||||||
|
|
||||||
|
// Add two custom claims
|
||||||
|
await page.getByRole('button', { name: 'Add custom claim' }).click();
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Key').fill('customClaim1');
|
||||||
|
await page.getByPlaceholder('Value').fill('customClaim1_value');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Add another' }).click();
|
||||||
|
await page.getByPlaceholder('Key').nth(1).fill('customClaim2');
|
||||||
|
await page.getByPlaceholder('Value').nth(1).fill('customClaim2_value');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('status')).toHaveText('Custom claims updated successfully');
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Check if custom claims are saved
|
||||||
|
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim1');
|
||||||
|
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim1_value');
|
||||||
|
await expect(page.getByPlaceholder('Key').nth(1)).toHaveValue('customClaim2');
|
||||||
|
await expect(page.getByPlaceholder('Value').nth(1)).toHaveValue('customClaim2_value');
|
||||||
|
|
||||||
|
// Remove one custom claim
|
||||||
|
await page.getByLabel('Remove custom claim').first().click();
|
||||||
|
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Check if custom claim is removed
|
||||||
|
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim2');
|
||||||
|
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim2_value');
|
||||||
});
|
});
|
||||||
|
|||||||
75
scripts/create-one-time-access-token.sh
Normal file
75
scripts/create-one-time-access-token.sh
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Default database path
|
||||||
|
DB_PATH="./backend/data/pocket-id.db"
|
||||||
|
|
||||||
|
# Parse command-line arguments for the -d flag (database path)
|
||||||
|
while getopts ":d:" opt; do
|
||||||
|
case $opt in
|
||||||
|
d)
|
||||||
|
DB_PATH="$OPTARG"
|
||||||
|
;;
|
||||||
|
\?)
|
||||||
|
echo "Invalid option -$OPTARG" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
shift $((OPTIND - 1))
|
||||||
|
|
||||||
|
# Ensure username or email is provided as a parameter
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
echo "Usage: $0 [-d <database_path>] <username or email>"
|
||||||
|
echo " -d Specify the database path (optional, defaults to ./backend/data/pocket-id.db)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
USER_IDENTIFIER="$1"
|
||||||
|
|
||||||
|
# Check and try to install the required commands
|
||||||
|
check_and_install() {
|
||||||
|
local cmd=$1
|
||||||
|
local pkg=$2
|
||||||
|
|
||||||
|
if ! command -v "$cmd" &>/dev/null; then
|
||||||
|
if command -v apk &>/dev/null; then
|
||||||
|
echo "$cmd not found. Installing..."
|
||||||
|
apk add "$pkg" --no-cache
|
||||||
|
else
|
||||||
|
echo "$cmd is not installed, please install it manually."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_and_install sqlite3 sqlite
|
||||||
|
check_and_install uuidgen uuidgen
|
||||||
|
|
||||||
|
# Generate a 16-character alphanumeric secret token
|
||||||
|
SECRET_TOKEN=$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16)
|
||||||
|
|
||||||
|
# Get the current Unix timestamp for creation and expiration (1 hour from now)
|
||||||
|
CREATED_AT=$(date +%s)
|
||||||
|
EXPIRES_AT=$((CREATED_AT + 3600))
|
||||||
|
|
||||||
|
# Retrieve user_id from the users table based on username or email
|
||||||
|
USER_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM users WHERE username='$USER_IDENTIFIER' OR email='$USER_IDENTIFIER';")
|
||||||
|
|
||||||
|
# Check if user exists
|
||||||
|
if [ -z "$USER_ID" ]; then
|
||||||
|
echo "User not found for username/email: $USER_IDENTIFIER"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Insert the one-time token into the one_time_access_tokens table
|
||||||
|
sqlite3 "$DB_PATH" <<EOF
|
||||||
|
INSERT INTO one_time_access_tokens (id, created_at, token, expires_at, user_id)
|
||||||
|
VALUES ('$(uuidgen)', '$CREATED_AT', '$SECRET_TOKEN', '$EXPIRES_AT', '$USER_ID');
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "A one-time access token valid for 1 hour has been created for \"$USER_IDENTIFIER\"."
|
||||||
|
echo "Use the following URL to sign in once: ${PUBLIC_APP_URL:=https://<your-pocket-id-domain>}/login/$SECRET_TOKEN"
|
||||||
|
else
|
||||||
|
echo "Error creating access token."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -6,7 +6,7 @@ increment_version() {
|
|||||||
local version=$1
|
local version=$1
|
||||||
local part=$2
|
local part=$2
|
||||||
|
|
||||||
IFS='.' read -r -a parts <<< "$version"
|
IFS='.' read -r -a parts <<<"$version"
|
||||||
if [ "$part" == "minor" ]; then
|
if [ "$part" == "minor" ]; then
|
||||||
parts[1]=$((parts[1] + 1))
|
parts[1]=$((parts[1] + 1))
|
||||||
parts[2]=0
|
parts[2]=0
|
||||||
@@ -30,12 +30,15 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Update the .version file with the new version
|
# Update the .version file with the new version
|
||||||
echo $NEW_VERSION > .version
|
echo $NEW_VERSION >.version
|
||||||
git add .version
|
git add .version
|
||||||
|
|
||||||
|
# Update version in frontend/package.json
|
||||||
|
jq --arg new_version "$NEW_VERSION" '.version = $new_version' frontend/package.json >frontend/package_tmp.json && mv frontend/package_tmp.json frontend/package.json
|
||||||
|
git add frontend/package.json
|
||||||
|
|
||||||
# Check if conventional-changelog is installed, if not install it
|
# Check if conventional-changelog is installed, if not install it
|
||||||
if ! command -v conventional-changelog &> /dev/null
|
if ! command -v conventional-changelog &>/dev/null; then
|
||||||
then
|
|
||||||
echo "conventional-changelog not found, installing..."
|
echo "conventional-changelog not found, installing..."
|
||||||
npm install -g conventional-changelog-cli
|
npm install -g conventional-changelog-cli
|
||||||
fi
|
fi
|
||||||
@@ -55,4 +58,4 @@ git tag "v$NEW_VERSION"
|
|||||||
git push
|
git push
|
||||||
git push --tags
|
git push --tags
|
||||||
|
|
||||||
echo "Release process complete. New version: $NEW_VERSION"
|
echo "Release process complete. New version: $NEW_VERSION"
|
||||||
Reference in New Issue
Block a user