mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-14 09:13:21 +03:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9370292fe5 | ||
|
|
46eef1fcb7 | ||
|
|
e784093342 | ||
|
|
653d948f73 | ||
|
|
a1302ef7bf | ||
|
|
5f44fef85f | ||
|
|
3613ac261c | ||
|
|
760c8e83bb | ||
|
|
3f29325f45 | ||
|
|
aca2240a50 | ||
|
|
de45398903 | ||
|
|
3d3fb4d855 | ||
|
|
725388fcc7 | ||
|
|
ad1d3560f9 | ||
|
|
becfc0004a | ||
|
|
376d747616 | ||
|
|
5b9f4d7326 |
@@ -11,18 +11,36 @@ jobs:
|
|||||||
- name: checkout code
|
- name: checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
ghcr.io/${{ github.repository }}
|
||||||
|
${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}},prefix=v
|
||||||
|
type=semver,pattern={{major}}.{{minor}},prefix=v
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to Docker registry
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: 'Login to GitHub Container Registry'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{github.repository_owner}}
|
||||||
|
password: ${{secrets.GITHUB_TOKEN}}
|
||||||
|
|
||||||
- name: Download GeoLite2 City database
|
- name: Download GeoLite2 City database
|
||||||
run: MAXMIND_LICENSE_KEY=${{ secrets.MAXMIND_LICENSE_KEY }} sh scripts/download-ip-database.sh
|
run: MAXMIND_LICENSE_KEY=${{ secrets.MAXMIND_LICENSE_KEY }} sh scripts/download-ip-database.sh
|
||||||
|
|
||||||
@@ -32,6 +50,7 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: stonith404/pocket-id:latest,stonith404/pocket-id:${{ github.ref_name }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -1,3 +1,42 @@
|
|||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.14.0...v) (2024-11-21)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add option to skip TLS certificate check and ability to send test email ([653d948](https://github.com/stonith404/pocket-id/commit/653d948f73b61e6d1fd3484398fef1a2a37e6d92))
|
||||||
|
* add PKCE support ([3613ac2](https://github.com/stonith404/pocket-id/commit/3613ac261cf65a2db0620ff16dc6df239f6e5ecd))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* mobile layout overflow on application configuration page ([e784093](https://github.com/stonith404/pocket-id/commit/e784093342f9977ea08cac65ff0c3de4d2644872))
|
||||||
|
|
||||||
|
## [](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)
|
## [](https://github.com/stonith404/pocket-id/compare/v0.12.0...v) (2024-10-31)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ RUN CGO_ENABLED=1 GOOS=linux go build -o /app/backend/pocket-id-backend .
|
|||||||
|
|
||||||
# Stage 3: Production Image
|
# Stage 3: Production Image
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
RUN apk add --no-cache caddy
|
# Delete default node user
|
||||||
|
RUN deluser --remove-home node
|
||||||
|
|
||||||
|
RUN apk add --no-cache caddy su-exec
|
||||||
COPY ./reverse-proxy /etc/caddy/
|
COPY ./reverse-proxy /etc/caddy/
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -41,5 +44,5 @@ RUN chmod +x ./scripts/*.sh
|
|||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
ENV APP_ENV=production
|
ENV APP_ENV=production
|
||||||
|
|
||||||
# Use a shell form to run both the frontend and backend
|
ENTRYPOINT ["sh", "./scripts/docker/create-user.sh"]
|
||||||
CMD ["sh", "./scripts/docker-entrypoint.sh"]
|
CMD ["sh", "./scripts/docker/entrypoint.sh"]
|
||||||
@@ -11,7 +11,7 @@ Additionally, what makes Pocket ID special is that it only supports [passkey](ht
|
|||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Pocket ID is in its early stages and may contain bugs.
|
> Pocket ID is in its early stages and may contain bugs. There might be OIDC features that are not yet implemented. If you encounter any issues, please open an issue.
|
||||||
|
|
||||||
### Before you start
|
### Before you start
|
||||||
|
|
||||||
@@ -150,6 +150,7 @@ docker compose up -d
|
|||||||
| ---------------------- | ----------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------- | ----------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. |
|
| `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. |
|
||||||
| `TRUST_PROXY` | `false` | yes | Whether the app is behind a reverse proxy. |
|
| `TRUST_PROXY` | `false` | yes | Whether the app is behind a reverse proxy. |
|
||||||
|
| `PUID` and `PGID` | `1000` | yes | The user and group ID of the user who should run Pocket ID inside the Docker container and owns the files that are mounted with the volume. You can get the `PUID` and `GUID` of your user on your host machine by using the command `id`. For more information see [this article](https://docs.linuxserver.io/general/understanding-puid-and-pgid/#using-the-variables). |
|
||||||
| `DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. |
|
| `DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. |
|
||||||
| `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. |
|
| `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. |
|
||||||
| `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. |
|
| `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. |
|
||||||
|
|||||||
11
backend/email-templates/test_html.tmpl
Normal file
11
backend/email-templates/test_html.tmpl
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{{ define "base" -}}
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">
|
||||||
|
<img src="{{ .LogoURL }}" alt="Pocket ID"/>
|
||||||
|
<h1>{{ .AppName }}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>This is a test email.</p>
|
||||||
|
</div>
|
||||||
|
{{ end -}}
|
||||||
3
backend/email-templates/test_text.tmpl
Normal file
3
backend/email-templates/test_text.tmpl
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{{ define "base" -}}
|
||||||
|
This is a test email.
|
||||||
|
{{ end -}}
|
||||||
@@ -30,7 +30,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
|||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
templateDir := os.DirFS(common.EnvConfig.EmailTemplatesPath)
|
templateDir := os.DirFS(common.EnvConfig.EmailTemplatesPath)
|
||||||
emailService, err := service.NewEmailService(appConfigService, templateDir)
|
emailService, err := service.NewEmailService(appConfigService, db, templateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Unable to create email service: %s", err)
|
log.Fatalf("Unable to create email service: %s", err)
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@ 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)
|
||||||
customClaimService := service.NewCustomClaimService(db)
|
customClaimService := service.NewCustomClaimService(db)
|
||||||
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
|
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
|
||||||
testService := service.NewTestService(db, appConfigService)
|
testService := service.NewTestService(db, appConfigService)
|
||||||
@@ -58,7 +58,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
|||||||
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, appConfigService)
|
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
|
||||||
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService)
|
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService, emailService)
|
||||||
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
|
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
|
||||||
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
|
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
|
||||||
controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)
|
controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyR
|
|||||||
type ClientIdOrSecretNotProvidedError struct{}
|
type ClientIdOrSecretNotProvidedError struct{}
|
||||||
|
|
||||||
func (e *ClientIdOrSecretNotProvidedError) Error() string {
|
func (e *ClientIdOrSecretNotProvidedError) Error() string {
|
||||||
return "Client id and secret not provided"
|
return "Client id or secret not provided"
|
||||||
}
|
}
|
||||||
func (e *ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
|
func (e *ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
@@ -146,3 +146,17 @@ func (e *AccountEditNotAllowedError) Error() string {
|
|||||||
return "You are not allowed to edit your account"
|
return "You are not allowed to edit your account"
|
||||||
}
|
}
|
||||||
func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
|
func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
|
||||||
|
|
||||||
|
type OidcInvalidCodeVerifierError struct{}
|
||||||
|
|
||||||
|
func (e *OidcInvalidCodeVerifierError) Error() string {
|
||||||
|
return "Invalid code verifier"
|
||||||
|
}
|
||||||
|
func (e *OidcInvalidCodeVerifierError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type OidcMissingCodeChallengeError struct{}
|
||||||
|
|
||||||
|
func (e *OidcMissingCodeChallengeError) Error() string {
|
||||||
|
return "Missing code challenge"
|
||||||
|
}
|
||||||
|
func (e *OidcMissingCodeChallengeError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ import (
|
|||||||
func NewAppConfigController(
|
func NewAppConfigController(
|
||||||
group *gin.RouterGroup,
|
group *gin.RouterGroup,
|
||||||
jwtAuthMiddleware *middleware.JwtAuthMiddleware,
|
jwtAuthMiddleware *middleware.JwtAuthMiddleware,
|
||||||
appConfigService *service.AppConfigService) {
|
appConfigService *service.AppConfigService,
|
||||||
|
emailService *service.EmailService,
|
||||||
|
) {
|
||||||
|
|
||||||
acc := &AppConfigController{
|
acc := &AppConfigController{
|
||||||
appConfigService: appConfigService,
|
appConfigService: appConfigService,
|
||||||
|
emailService: emailService,
|
||||||
}
|
}
|
||||||
group.GET("/application-configuration", acc.listAppConfigHandler)
|
group.GET("/application-configuration", acc.listAppConfigHandler)
|
||||||
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
|
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
|
||||||
@@ -29,10 +32,13 @@ func NewAppConfigController(
|
|||||||
group.PUT("/application-configuration/logo", jwtAuthMiddleware.Add(true), acc.updateLogoHandler)
|
group.PUT("/application-configuration/logo", jwtAuthMiddleware.Add(true), acc.updateLogoHandler)
|
||||||
group.PUT("/application-configuration/favicon", jwtAuthMiddleware.Add(true), acc.updateFaviconHandler)
|
group.PUT("/application-configuration/favicon", jwtAuthMiddleware.Add(true), acc.updateFaviconHandler)
|
||||||
group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler)
|
group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler)
|
||||||
|
|
||||||
|
group.POST("/application-configuration/test-email", jwtAuthMiddleware.Add(true), acc.testEmailHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppConfigController struct {
|
type AppConfigController struct {
|
||||||
appConfigService *service.AppConfigService
|
appConfigService *service.AppConfigService
|
||||||
|
emailService *service.EmailService
|
||||||
}
|
}
|
||||||
|
|
||||||
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
||||||
@@ -175,3 +181,13 @@ func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, ol
|
|||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
|
||||||
|
err := acc.emailService.SendTestEmail()
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package controller
|
|||||||
|
|
||||||
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/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"
|
||||||
@@ -80,7 +79,10 @@ func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
||||||
var input dto.OidcIdTokenDto
|
// Disable cors for this endpoint
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
var input dto.OidcCreateTokensDto
|
||||||
|
|
||||||
if err := c.ShouldBind(&input); err != nil {
|
if err := c.ShouldBind(&input); err != nil {
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
@@ -91,16 +93,11 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
|||||||
clientSecret := input.ClientSecret
|
clientSecret := input.ClientSecret
|
||||||
|
|
||||||
// Client id and secret can also be passed over the Authorization header
|
// Client id and secret can also be passed over the Authorization header
|
||||||
if clientID == "" || clientSecret == "" {
|
if clientID == "" && clientSecret == "" {
|
||||||
var ok bool
|
clientID, clientSecret, _ = c.Request.BasicAuth()
|
||||||
clientID, clientSecret, ok = c.Request.BasicAuth()
|
|
||||||
if !ok {
|
|
||||||
c.Error(&common.ClientIdOrSecretNotProvidedError{})
|
|
||||||
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, input.CodeVerifier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
|
|||||||
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 {
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -22,4 +22,5 @@ type AppConfigUpdateDto struct {
|
|||||||
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
|
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
|
||||||
SmtpUser string `json:"smtpUser"`
|
SmtpUser string `json:"smtpUser"`
|
||||||
SmtpPassword string `json:"smtpPassword"`
|
SmtpPassword string `json:"smtpPassword"`
|
||||||
|
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -9,19 +9,23 @@ type PublicOidcClientDto struct {
|
|||||||
type OidcClientDto struct {
|
type OidcClientDto struct {
|
||||||
PublicOidcClientDto
|
PublicOidcClientDto
|
||||||
CallbackURLs []string `json:"callbackURLs"`
|
CallbackURLs []string `json:"callbackURLs"`
|
||||||
|
IsPublic bool `json:"isPublic"`
|
||||||
CreatedBy UserDto `json:"createdBy"`
|
CreatedBy UserDto `json:"createdBy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcClientCreateDto struct {
|
type OidcClientCreateDto struct {
|
||||||
Name string `json:"name" binding:"required,max=50"`
|
Name string `json:"name" binding:"required,max=50"`
|
||||||
CallbackURLs []string `json:"callbackURLs" binding:"required,urlList"`
|
CallbackURLs []string `json:"callbackURLs" binding:"required,urlList"`
|
||||||
|
IsPublic bool `json:"isPublic"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthorizeOidcClientRequestDto struct {
|
type AuthorizeOidcClientRequestDto struct {
|
||||||
ClientID string `json:"clientID" binding:"required"`
|
ClientID string `json:"clientID" binding:"required"`
|
||||||
Scope string `json:"scope" binding:"required"`
|
Scope string `json:"scope" binding:"required"`
|
||||||
CallbackURL string `json:"callbackURL"`
|
CallbackURL string `json:"callbackURL"`
|
||||||
Nonce string `json:"nonce"`
|
Nonce string `json:"nonce"`
|
||||||
|
CodeChallenge string `json:"codeChallenge"`
|
||||||
|
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthorizeOidcClientResponseDto struct {
|
type AuthorizeOidcClientResponseDto struct {
|
||||||
@@ -29,9 +33,10 @@ type AuthorizeOidcClientResponseDto struct {
|
|||||||
CallbackURL string `json:"callbackURL"`
|
CallbackURL string `json:"callbackURL"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcIdTokenDto struct {
|
type OidcCreateTokensDto struct {
|
||||||
GrantType string `form:"grant_type" binding:"required"`
|
GrantType string `form:"grant_type" binding:"required"`
|
||||||
Code string `form:"code" binding:"required"`
|
Code string `form:"code" binding:"required"`
|
||||||
ClientID string `form:"client_id"`
|
ClientID string `form:"client_id"`
|
||||||
ClientSecret string `form:"client_secret"`
|
ClientSecret string `form:"client_secret"`
|
||||||
|
CodeVerifier string `form:"code_verifier"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +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"`
|
||||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||||
Users []UserDto `json:"users"`
|
Users []UserDto `json:"users"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
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"`
|
||||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||||
UserCount int64 `json:"userCount"`
|
UserCount int64 `json:"userCount"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserGroupCreateDto struct {
|
type UserGroupCreateDto struct {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-contrib/cors"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CorsMiddleware struct{}
|
type CorsMiddleware struct{}
|
||||||
@@ -15,10 +12,22 @@ func NewCorsMiddleware() *CorsMiddleware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *CorsMiddleware) Add() gin.HandlerFunc {
|
func (m *CorsMiddleware) Add() gin.HandlerFunc {
|
||||||
return cors.New(cors.Config{
|
return func(c *gin.Context) {
|
||||||
AllowOrigins: []string{common.EnvConfig.AppURL},
|
// Allow all origins for the token endpoint
|
||||||
AllowMethods: []string{"*"},
|
if c.FullPath() == "/api/oidc/token" {
|
||||||
AllowHeaders: []string{"*"},
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
MaxAge: 12 * time.Hour,
|
} else {
|
||||||
})
|
c.Writer.Header().Set("Access-Control-Allow-Origin", common.EnvConfig.AppURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Headers", "*")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT")
|
||||||
|
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ func (m *FileSizeLimitMiddleware) Add(maxSize int64) gin.HandlerFunc {
|
|||||||
if err := c.Request.ParseMultipartForm(maxSize); err != nil {
|
if err := c.Request.ParseMultipartForm(maxSize); err != nil {
|
||||||
err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)}
|
err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)}
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
c.Error(&common.NotSignedInError{})
|
c.Error(&common.NotSignedInError{})
|
||||||
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,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() {
|
||||||
c.Error(&common.TooManyRequestsError{})
|
c.Error(&common.TooManyRequestsError{})
|
||||||
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ type AppConfig struct {
|
|||||||
LogoLightImageType AppConfigVariable
|
LogoLightImageType AppConfigVariable
|
||||||
LogoDarkImageType AppConfigVariable
|
LogoDarkImageType AppConfigVariable
|
||||||
|
|
||||||
EmailEnabled AppConfigVariable
|
EmailEnabled AppConfigVariable
|
||||||
SmtpHost AppConfigVariable
|
SmtpHost AppConfigVariable
|
||||||
SmtpPort AppConfigVariable
|
SmtpPort AppConfigVariable
|
||||||
SmtpFrom AppConfigVariable
|
SmtpFrom AppConfigVariable
|
||||||
SmtpUser AppConfigVariable
|
SmtpUser AppConfigVariable
|
||||||
SmtpPassword AppConfigVariable
|
SmtpPassword AppConfigVariable
|
||||||
|
SmtpSkipCertVerify 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
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ type UserAuthorizedOidcClient struct {
|
|||||||
type OidcAuthorizationCode struct {
|
type OidcAuthorizationCode struct {
|
||||||
Base
|
Base
|
||||||
|
|
||||||
Code string
|
Code string
|
||||||
Scope string
|
Scope string
|
||||||
Nonce string
|
Nonce string
|
||||||
ExpiresAt datatype.DateTime
|
CodeChallenge *string
|
||||||
|
CodeChallengeMethodSha256 *bool
|
||||||
|
ExpiresAt datatype.DateTime
|
||||||
|
|
||||||
UserID string
|
UserID string
|
||||||
User User
|
User User
|
||||||
@@ -39,6 +41,7 @@ type OidcClient struct {
|
|||||||
CallbackURLs CallbackURLs
|
CallbackURLs CallbackURLs
|
||||||
ImageType *string
|
ImageType *string
|
||||||
HasLogo bool `gorm:"-"`
|
HasLogo bool `gorm:"-"`
|
||||||
|
IsPublic bool
|
||||||
|
|
||||||
CreatedByID string
|
CreatedByID string
|
||||||
CreatedBy User
|
CreatedBy User
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
|
|||||||
return descriptors
|
return descriptors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u User) FullName() string { return u.FirstName + " " + u.LastName }
|
||||||
|
|
||||||
type OneTimeAccessToken struct {
|
type OneTimeAccessToken struct {
|
||||||
Base
|
Base
|
||||||
Token string
|
Token string
|
||||||
|
|||||||
@@ -95,6 +95,11 @@ var defaultDbConfig = model.AppConfig{
|
|||||||
Key: "smtpPassword",
|
Key: "smtpPassword",
|
||||||
Type: "string",
|
Type: "string",
|
||||||
},
|
},
|
||||||
|
SmtpSkipCertVerify: model.AppConfigVariable{
|
||||||
|
Key: "smtpSkipCertVerify",
|
||||||
|
Type: "bool",
|
||||||
|
DefaultValue: "false",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
|
func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
||||||
|
"gorm.io/gorm"
|
||||||
htemplate "html/template"
|
htemplate "html/template"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"mime/quotedprintable"
|
"mime/quotedprintable"
|
||||||
|
"net"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
ttemplate "text/template"
|
ttemplate "text/template"
|
||||||
@@ -17,11 +21,12 @@ import (
|
|||||||
|
|
||||||
type EmailService struct {
|
type EmailService struct {
|
||||||
appConfigService *AppConfigService
|
appConfigService *AppConfigService
|
||||||
|
db *gorm.DB
|
||||||
htmlTemplates map[string]*htemplate.Template
|
htmlTemplates map[string]*htemplate.Template
|
||||||
textTemplates map[string]*ttemplate.Template
|
textTemplates map[string]*ttemplate.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEmailService(appConfigService *AppConfigService, templateDir fs.FS) (*EmailService, error) {
|
func NewEmailService(appConfigService *AppConfigService, db *gorm.DB, templateDir fs.FS) (*EmailService, error) {
|
||||||
htmlTemplates, err := email.PrepareHTMLTemplates(templateDir, emailTemplatesPaths)
|
htmlTemplates, err := email.PrepareHTMLTemplates(templateDir, emailTemplatesPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("prepare html templates: %w", err)
|
return nil, fmt.Errorf("prepare html templates: %w", err)
|
||||||
@@ -34,11 +39,25 @@ func NewEmailService(appConfigService *AppConfigService, templateDir fs.FS) (*Em
|
|||||||
|
|
||||||
return &EmailService{
|
return &EmailService{
|
||||||
appConfigService: appConfigService,
|
appConfigService: appConfigService,
|
||||||
|
db: db,
|
||||||
htmlTemplates: htmlTemplates,
|
htmlTemplates: htmlTemplates,
|
||||||
textTemplates: textTemplates,
|
textTemplates: textTemplates,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (srv *EmailService) SendTestEmail() error {
|
||||||
|
var user model.User
|
||||||
|
if err := srv.db.First(&user).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return SendEmail(srv,
|
||||||
|
email.Address{
|
||||||
|
Email: user.Email,
|
||||||
|
Name: user.FullName(),
|
||||||
|
}, TestTemplate, nil)
|
||||||
|
}
|
||||||
|
|
||||||
func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error {
|
func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error {
|
||||||
// Check if SMTP settings are set
|
// Check if SMTP settings are set
|
||||||
if srv.appConfigService.DbConfig.EmailEnabled.Value != "true" {
|
if srv.appConfigService.DbConfig.EmailEnabled.Value != "true" {
|
||||||
@@ -71,26 +90,100 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
|
|||||||
)
|
)
|
||||||
c.Body(body)
|
c.Body(body)
|
||||||
|
|
||||||
// Set up the authentication information.
|
// Set up the TLS configuration
|
||||||
|
tlsConfig := &tls.Config{
|
||||||
|
InsecureSkipVerify: srv.appConfigService.DbConfig.SmtpSkipCertVerify.Value == "true",
|
||||||
|
ServerName: srv.appConfigService.DbConfig.SmtpHost.Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to the SMTP server
|
||||||
|
port := srv.appConfigService.DbConfig.SmtpPort.Value
|
||||||
|
var client *smtp.Client
|
||||||
|
if port == "465" {
|
||||||
|
client, err = srv.connectToSmtpServerUsingImplicitTLS(
|
||||||
|
srv.appConfigService.DbConfig.SmtpHost.Value+":"+port,
|
||||||
|
tlsConfig,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
client, err = srv.connectToSmtpServerUsingStartTLS(
|
||||||
|
srv.appConfigService.DbConfig.SmtpHost.Value+":"+port,
|
||||||
|
tlsConfig,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
defer client.Quit()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up the authentication
|
||||||
auth := smtp.PlainAuth("",
|
auth := smtp.PlainAuth("",
|
||||||
srv.appConfigService.DbConfig.SmtpUser.Value,
|
srv.appConfigService.DbConfig.SmtpUser.Value,
|
||||||
srv.appConfigService.DbConfig.SmtpPassword.Value,
|
srv.appConfigService.DbConfig.SmtpPassword.Value,
|
||||||
srv.appConfigService.DbConfig.SmtpHost.Value,
|
srv.appConfigService.DbConfig.SmtpHost.Value,
|
||||||
)
|
)
|
||||||
|
if err := client.Auth(auth); err != nil {
|
||||||
// Send the email
|
return fmt.Errorf("failed to authenticate SMTP client: %w", err)
|
||||||
err = smtp.SendMail(
|
|
||||||
srv.appConfigService.DbConfig.SmtpHost.Value+":"+srv.appConfigService.DbConfig.SmtpPort.Value,
|
|
||||||
auth,
|
|
||||||
srv.appConfigService.DbConfig.SmtpFrom.Value,
|
|
||||||
[]string{toEmail.Email},
|
|
||||||
[]byte(c.String()),
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to send email: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send the email
|
||||||
|
if err := srv.sendEmailContent(client, toEmail, c); err != nil {
|
||||||
|
return fmt.Errorf("send email content: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *EmailService) connectToSmtpServerUsingImplicitTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
|
||||||
|
conn, err := tls.Dial("tcp", serverAddr, tlsConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *EmailService) connectToSmtpServerUsingStartTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
|
||||||
|
conn, err := net.Dial("tcp", serverAddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.StartTLS(tlsConfig); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to start TLS: %w", err)
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *EmailService) sendEmailContent(client *smtp.Client, toEmail email.Address, c *email.Composer) error {
|
||||||
|
if err := client.Mail(srv.appConfigService.DbConfig.SmtpFrom.Value); err != nil {
|
||||||
|
return fmt.Errorf("failed to set sender: %w", err)
|
||||||
|
}
|
||||||
|
if err := client.Rcpt(toEmail.Email); err != nil {
|
||||||
|
return fmt.Errorf("failed to set recipient: %w", err)
|
||||||
|
}
|
||||||
|
w, err := client.Data()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to start data: %w", err)
|
||||||
|
}
|
||||||
|
_, err = w.Write([]byte(c.String()))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write email data: %w", err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return fmt.Errorf("failed to close data writer: %w", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ var NewLoginTemplate = email.Template[NewLoginTemplateData]{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var TestTemplate = email.Template[struct{}]{
|
||||||
|
Path: "test",
|
||||||
|
Title: func(data *email.TemplateData[struct{}]) string {
|
||||||
|
return "Test email"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
type NewLoginTemplateData struct {
|
type NewLoginTemplateData struct {
|
||||||
IPAddress string
|
IPAddress string
|
||||||
Country string
|
Country string
|
||||||
@@ -36,4 +43,4 @@ type NewLoginTemplateData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// this is list of all template paths used for preloading templates
|
// this is list of all template paths used for preloading templates
|
||||||
var emailTemplatesPaths = []string{NewLoginTemplate.Path}
|
var emailTemplatesPaths = []string{NewLoginTemplate.Path, TestTemplate.Path}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
@@ -39,16 +41,20 @@ func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID,
|
|||||||
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
|
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
|
||||||
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.Client.IsPublic && input.CodeChallenge == "" {
|
||||||
|
return "", "", &common.OidcMissingCodeChallengeError{}
|
||||||
|
}
|
||||||
|
|
||||||
if userAuthorizedOIDCClient.Scope != input.Scope {
|
if userAuthorizedOIDCClient.Scope != input.Scope {
|
||||||
return "", "", &common.OidcMissingAuthorizationError{}
|
return "", "", &common.OidcMissingAuthorizationError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
callbackURL, err := getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
|
callbackURL, err := s.getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
|
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
@@ -64,7 +70,11 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
|
|||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
callbackURL, err := getCallbackURL(client, input.CallbackURL)
|
if client.IsPublic && input.CodeChallenge == "" {
|
||||||
|
return "", "", &common.OidcMissingCodeChallengeError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
callbackURL, err := s.getCallbackURL(client, input.CallbackURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
@@ -83,7 +93,7 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
|
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
@@ -93,31 +103,41 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
|
|||||||
return code, callbackURL, nil
|
return code, callbackURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) {
|
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret, codeVerifier string) (string, string, error) {
|
||||||
if grantType != "authorization_code" {
|
if grantType != "authorization_code" {
|
||||||
return "", "", &common.OidcGrantTypeNotSupportedError{}
|
return "", "", &common.OidcGrantTypeNotSupportedError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
if clientID == "" || clientSecret == "" {
|
|
||||||
return "", "", &common.OidcMissingClientCredentialsError{}
|
|
||||||
}
|
|
||||||
|
|
||||||
var client model.OidcClient
|
var client model.OidcClient
|
||||||
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
|
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
|
// Verify the client secret if the client is not public
|
||||||
if err != nil {
|
if !client.IsPublic {
|
||||||
return "", "", &common.OidcClientSecretInvalidError{}
|
if clientID == "" || clientSecret == "" {
|
||||||
|
return "", "", &common.OidcMissingClientCredentialsError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
|
||||||
|
if err != nil {
|
||||||
|
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.OidcInvalidAuthorizationCodeError{}
|
return "", "", &common.OidcInvalidAuthorizationCodeError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the client is public, the code verifier must match the code challenge
|
||||||
|
if client.IsPublic {
|
||||||
|
if !s.validateCodeVerifier(codeVerifier, *authorizationCodeMetaData.CodeChallenge, *authorizationCodeMetaData.CodeChallengeMethodSha256) {
|
||||||
|
return "", "", &common.OidcInvalidCodeVerifierError{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
|
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
|
||||||
return "", "", &common.OidcInvalidAuthorizationCodeError{}
|
return "", "", &common.OidcInvalidAuthorizationCodeError{}
|
||||||
}
|
}
|
||||||
@@ -186,6 +206,7 @@ func (s *OidcService) UpdateClient(clientID string, input dto.OidcClientCreateDt
|
|||||||
|
|
||||||
client.Name = input.Name
|
client.Name = input.Name
|
||||||
client.CallbackURLs = input.CallbackURLs
|
client.CallbackURLs = input.CallbackURLs
|
||||||
|
client.IsPublic = input.IsPublic
|
||||||
|
|
||||||
if err := s.db.Save(&client).Error; err != nil {
|
if err := s.db.Save(&client).Error; err != nil {
|
||||||
return model.OidcClient{}, err
|
return model.OidcClient{}, err
|
||||||
@@ -331,7 +352,7 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
|
|||||||
profileClaims := map[string]interface{}{
|
profileClaims := map[string]interface{}{
|
||||||
"given_name": user.FirstName,
|
"given_name": user.FirstName,
|
||||||
"family_name": user.LastName,
|
"family_name": user.LastName,
|
||||||
"name": user.FirstName + " " + user.LastName,
|
"name": user.FullName(),
|
||||||
"preferred_username": user.Username,
|
"preferred_username": user.Username,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,19 +379,23 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
|
|||||||
return claims, nil
|
return claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string) (string, error) {
|
func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string) (string, error) {
|
||||||
randomString, err := utils.GenerateRandomAlphanumericString(32)
|
randomString, err := utils.GenerateRandomAlphanumericString(32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
codeChallengeMethodSha256 := strings.ToUpper(codeChallengeMethod) == "S256"
|
||||||
|
|
||||||
oidcAuthorizationCode := model.OidcAuthorizationCode{
|
oidcAuthorizationCode := model.OidcAuthorizationCode{
|
||||||
ExpiresAt: datatype.DateTime(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,
|
||||||
Scope: scope,
|
Scope: scope,
|
||||||
Nonce: nonce,
|
Nonce: nonce,
|
||||||
|
CodeChallenge: &codeChallenge,
|
||||||
|
CodeChallengeMethodSha256: &codeChallengeMethodSha256,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.Create(&oidcAuthorizationCode).Error; err != nil {
|
if err := s.db.Create(&oidcAuthorizationCode).Error; err != nil {
|
||||||
@@ -380,7 +405,23 @@ func (s *OidcService) createAuthorizationCode(clientID string, userID string, sc
|
|||||||
return randomString, nil
|
return randomString, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackURL string, err error) {
|
func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, codeChallengeMethodSha256 bool) bool {
|
||||||
|
if !codeChallengeMethodSha256 {
|
||||||
|
return codeVerifier == codeChallenge
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute SHA-256 hash of the codeVerifier
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte(codeVerifier))
|
||||||
|
codeVerifierHash := h.Sum(nil)
|
||||||
|
|
||||||
|
// Base64 URL encode the verifier hash
|
||||||
|
encodedVerifierHash := base64.RawURLEncoding.EncodeToString(codeVerifierHash)
|
||||||
|
|
||||||
|
return encodedVerifierHash == codeChallenge
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackURL string, err error) {
|
||||||
if inputCallbackURL == "" {
|
if inputCallbackURL == "" {
|
||||||
return client.CallbackURLs[0], nil
|
return client.CallbackURLs[0], nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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) {
|
||||||
@@ -88,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
|
||||||
@@ -104,6 +105,8 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
3
backend/migrations/20241115131129_pkce.down.sql
Normal file
3
backend/migrations/20241115131129_pkce.down.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE oidc_authorization_codes DROP COLUMN code_challenge;
|
||||||
|
ALTER TABLE oidc_authorization_codes DROP COLUMN code_challenge_method_sha256;
|
||||||
|
ALTER TABLE oidc_clients DROP COLUMN is_public;
|
||||||
3
backend/migrations/20241115131129_pkce.up.sql
Normal file
3
backend/migrations/20241115131129_pkce.up.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE oidc_authorization_codes ADD COLUMN code_challenge TEXT;
|
||||||
|
ALTER TABLE oidc_authorization_codes ADD COLUMN code_challenge_method_sha256 NUMERIC;
|
||||||
|
ALTER TABLE oidc_clients ADD COLUMN is_public BOOLEAN DEFAULT FALSE;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pocket-id-frontend",
|
"name": "pocket-id-frontend",
|
||||||
"version": "0.13.0",
|
"version": "0.15.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --port 3000",
|
"dev": "vite dev --port 3000",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
25
frontend/src/lib/components/checkbox-with-label.svelte
Normal file
25
frontend/src/lib/components/checkbox-with-label.svelte
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Checkbox } from './ui/checkbox';
|
||||||
|
import { Label } from './ui/label';
|
||||||
|
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
checked = $bindable(),
|
||||||
|
label,
|
||||||
|
description
|
||||||
|
}: { id: string; checked: boolean; label: string; description?: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="items-top mt-5 flex space-x-2">
|
||||||
|
<Checkbox {id} bind:checked />
|
||||||
|
<div class="grid gap-1.5 leading-none">
|
||||||
|
<Label for={id} class="mb-0 text-sm font-medium leading-none">
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
{#if description}
|
||||||
|
<p class="text-muted-foreground text-[0.8rem]">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
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>
|
||||||
@@ -53,6 +53,10 @@ 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 sendTestEmail() {
|
||||||
|
await this.api.post('/application-configuration/test-email');
|
||||||
|
}
|
||||||
|
|
||||||
async getVersionInformation() {
|
async getVersionInformation() {
|
||||||
const response = (
|
const response = (
|
||||||
await axios.get('https://api.github.com/repos/stonith404/pocket-id/releases/latest')
|
await axios.get('https://api.github.com/repos/stonith404/pocket-id/releases/latest')
|
||||||
|
|||||||
@@ -3,23 +3,27 @@ import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
|
|||||||
import APIService from './api-service';
|
import APIService from './api-service';
|
||||||
|
|
||||||
class OidcService extends APIService {
|
class OidcService extends APIService {
|
||||||
async authorize(clientId: string, scope: string, callbackURL: string, nonce?: string) {
|
async authorize(clientId: string, scope: string, callbackURL: string, nonce?: string, codeChallenge?: string, codeChallengeMethod?: string) {
|
||||||
const res = await this.api.post('/oidc/authorize', {
|
const res = await this.api.post('/oidc/authorize', {
|
||||||
scope,
|
scope,
|
||||||
nonce,
|
nonce,
|
||||||
callbackURL,
|
callbackURL,
|
||||||
clientId
|
clientId,
|
||||||
|
codeChallenge,
|
||||||
|
codeChallengeMethod
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.data as AuthorizeResponse;
|
return res.data as AuthorizeResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
async authorizeNewClient(clientId: string, scope: string, callbackURL: string, nonce?: string) {
|
async authorizeNewClient(clientId: string, scope: string, callbackURL: string, nonce?: string, codeChallenge?: string, codeChallengeMethod?: string) {
|
||||||
const res = await this.api.post('/oidc/authorize/new-client', {
|
const res = await this.api.post('/oidc/authorize/new-client', {
|
||||||
scope,
|
scope,
|
||||||
nonce,
|
nonce,
|
||||||
callbackURL,
|
callbackURL,
|
||||||
clientId
|
clientId,
|
||||||
|
codeChallenge,
|
||||||
|
codeChallengeMethod
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.data as AuthorizeResponse;
|
return res.data as AuthorizeResponse;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type AllAppConfig = AppConfig & {
|
|||||||
smtpFrom: string;
|
smtpFrom: string;
|
||||||
smtpUser: string;
|
smtpUser: string;
|
||||||
smtpPassword: string;
|
smtpPassword: string;
|
||||||
|
smtpSkipCertVerify: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppConfigRawResponse = {
|
export type AppConfigRawResponse = {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type OidcClient = {
|
|||||||
logoURL: string;
|
logoURL: string;
|
||||||
callbackURLs: [string, ...string[]];
|
callbackURLs: [string, ...string[]];
|
||||||
hasLogo: boolean;
|
hasLogo: boolean;
|
||||||
|
isPublic: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
|
export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
|
|||||||
nonce: url.searchParams.get('nonce') || undefined,
|
nonce: url.searchParams.get('nonce') || undefined,
|
||||||
state: url.searchParams.get('state')!,
|
state: url.searchParams.get('state')!,
|
||||||
callbackURL: url.searchParams.get('redirect_uri')!,
|
callbackURL: url.searchParams.get('redirect_uri')!,
|
||||||
client
|
client,
|
||||||
|
codeChallenge: url.searchParams.get('code_challenge')!,
|
||||||
|
codeChallengeMethod: url.searchParams.get('code_challenge_method')!
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
let authorizationRequired = false;
|
let authorizationRequired = false;
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
let { scope, nonce, client, state, callbackURL } = data;
|
let { scope, nonce, client, state, callbackURL, codeChallenge, codeChallengeMethod } = data;
|
||||||
|
|
||||||
async function authorize() {
|
async function authorize() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
await oidService
|
await oidService
|
||||||
.authorize(client!.id, scope, callbackURL, nonce)
|
.authorize(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
|
||||||
.then(async ({ code, callbackURL }) => {
|
.then(async ({ code, callbackURL }) => {
|
||||||
onSuccess(code, callbackURL);
|
onSuccess(code, callbackURL);
|
||||||
});
|
});
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
isLoading = true;
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
await oidService
|
await oidService
|
||||||
.authorizeNewClient(client!.id, scope, callbackURL, nonce)
|
.authorizeNewClient(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
|
||||||
.then(async ({ code, callbackURL }) => {
|
.then(async ({ code, callbackURL }) => {
|
||||||
onSuccess(code, callbackURL);
|
onSuccess(code, callbackURL);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,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' },
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
||||||
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 AppConfigService from '$lib/services/app-config-service';
|
||||||
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';
|
||||||
@@ -14,7 +16,9 @@
|
|||||||
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
|
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let isLoading = $state(false);
|
const appConfigService = new AppConfigService();
|
||||||
|
|
||||||
|
let isSendingTestEmail = $state(false);
|
||||||
let emailEnabled = $state(appConfig.emailEnabled);
|
let emailEnabled = $state(appConfig.emailEnabled);
|
||||||
|
|
||||||
const updatedAppConfig = {
|
const updatedAppConfig = {
|
||||||
@@ -23,7 +27,8 @@
|
|||||||
smtpPort: appConfig.smtpPort,
|
smtpPort: appConfig.smtpPort,
|
||||||
smtpUser: appConfig.smtpUser,
|
smtpUser: appConfig.smtpUser,
|
||||||
smtpPassword: appConfig.smtpPassword,
|
smtpPassword: appConfig.smtpPassword,
|
||||||
smtpFrom: appConfig.smtpFrom
|
smtpFrom: appConfig.smtpFrom,
|
||||||
|
smtpSkipCertVerify: appConfig.smtpSkipCertVerify
|
||||||
};
|
};
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
@@ -31,19 +36,20 @@
|
|||||||
smtpPort: z.number().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(),
|
||||||
|
smtpSkipCertVerify: z.boolean()
|
||||||
});
|
});
|
||||||
|
|
||||||
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
|
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
|
console.log('submit');
|
||||||
const data = form.validate();
|
const data = form.validate();
|
||||||
if (!data) return false;
|
if (!data) return false;
|
||||||
isLoading = true;
|
|
||||||
await callback({
|
await callback({
|
||||||
...data,
|
...data,
|
||||||
emailEnabled: true
|
emailEnabled: true
|
||||||
}).finally(() => (isLoading = false));
|
});
|
||||||
toast.success('Email configuration updated successfully');
|
toast.success('Email configuration updated successfully');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -59,22 +65,43 @@
|
|||||||
emailEnabled = true;
|
emailEnabled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onTestEmail() {
|
||||||
|
isSendingTestEmail = true;
|
||||||
|
await appConfigService
|
||||||
|
.sendTestEmail()
|
||||||
|
.then(() => toast.success('Test email sent successfully to your Email address.'))
|
||||||
|
.catch(() =>
|
||||||
|
toast.error('Failed to send test email. Check the server logs for more information.')
|
||||||
|
)
|
||||||
|
.finally(() => (isSendingTestEmail = false));
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form onsubmit={onSubmit}>
|
<form onsubmit={onSubmit}>
|
||||||
<div class="mt-5 grid grid-cols-2 gap-5">
|
<div class="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||||
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
|
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
|
||||||
<FormInput label="SMTP Port" type="number" 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} />
|
||||||
|
<CheckboxWithLabel
|
||||||
|
id="skip-cert-verify"
|
||||||
|
label="Skip Certificate Verification"
|
||||||
|
description="This can be useful for self-signed certificates."
|
||||||
|
bind:checked={$inputs.smtpSkipCertVerify.value}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 flex justify-end gap-3">
|
<div class="mt-8 flex flex-wrap justify-end gap-3">
|
||||||
{#if emailEnabled}
|
{#if emailEnabled}
|
||||||
<Button variant="secondary" onclick={onDisable}>Disable</Button>
|
<Button variant="secondary" onclick={onDisable}>Disable</Button>
|
||||||
<Button {isLoading} onclick={onSubmit} type="submit">Save</Button>
|
<Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
|
||||||
|
>Send Test Email</Button
|
||||||
|
>
|
||||||
|
|
||||||
|
<Button onclick={onSubmit} type="submit">Save</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<Button {isLoading} onclick={onEnable} type="submit">Enable</Button>
|
<Button onclick={onEnable}>Enable</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
||||||
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 { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
@@ -51,28 +52,18 @@
|
|||||||
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">
|
<CheckboxWithLabel
|
||||||
<Checkbox id="admin-privileges" bind:checked={$inputs.allowOwnAccountEdit.value} />
|
id="self-account-editing"
|
||||||
<div class="grid gap-1.5 leading-none">
|
label="Enable Self-Account Editing"
|
||||||
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none">
|
description="Whether the users should be able to edit their own account details."
|
||||||
Enable Self-Account Editing
|
bind:checked={$inputs.allowOwnAccountEdit.value}
|
||||||
</Label>
|
/>
|
||||||
<p class="text-muted-foreground text-[0.8rem]">
|
<CheckboxWithLabel
|
||||||
Whether the user should be able to edit their own account details.
|
id="emails-verified"
|
||||||
</p>
|
label="Emails Verified"
|
||||||
</div>
|
description="Whether the user's email should be marked as verified for the OIDC clients."
|
||||||
</div>
|
bind:checked={$inputs.emailsVerified.value}
|
||||||
<div class="items-top mt-5 flex space-x-2">
|
/>
|
||||||
<Checkbox id="admin-privileges" bind:checked={$inputs.emailsVerified.value} />
|
|
||||||
<div class="grid gap-1.5 leading-none">
|
|
||||||
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none">
|
|
||||||
Emails Verified
|
|
||||||
</Label>
|
|
||||||
<p class="text-muted-foreground text-[0.8rem]">
|
|
||||||
Whether the user's email should be marked as verified for the OIDC clients.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 flex justify-end">
|
<div class="mt-5 flex justify-end">
|
||||||
<Button {isLoading} type="submit">Save</Button>
|
<Button {isLoading} type="submit">Save</Button>
|
||||||
|
|||||||
@@ -26,7 +26,8 @@
|
|||||||
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
|
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
|
||||||
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
|
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
|
||||||
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
|
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
|
||||||
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`
|
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
|
||||||
|
PKCE: client.isPublic ? 'Enabled' : 'Disabled'
|
||||||
};
|
};
|
||||||
|
|
||||||
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
|
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
|
||||||
@@ -34,6 +35,8 @@
|
|||||||
const dataPromise = oidcService.updateClient(client.id, updatedClient);
|
const dataPromise = oidcService.updateClient(client.id, updatedClient);
|
||||||
const imagePromise = oidcService.updateClientLogo(client, updatedClient.logo);
|
const imagePromise = oidcService.updateClientLogo(client, updatedClient.logo);
|
||||||
|
|
||||||
|
client.isPublic = updatedClient.isPublic;
|
||||||
|
|
||||||
await Promise.all([dataPromise, imagePromise])
|
await Promise.all([dataPromise, imagePromise])
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success('OIDC client updated successfully');
|
toast.success('OIDC client updated successfully');
|
||||||
@@ -93,27 +96,29 @@
|
|||||||
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
|
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
|
||||||
</CopyToClipboard>
|
</CopyToClipboard>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2 mt-1 flex items-center">
|
{#if !client.isPublic}
|
||||||
<Label class="w-44">Client secret</Label>
|
<div class="mb-2 mt-1 flex items-center">
|
||||||
{#if $clientSecretStore}
|
<Label class="w-44">Client secret</Label>
|
||||||
<CopyToClipboard value={$clientSecretStore}>
|
{#if $clientSecretStore}
|
||||||
<span class="text-muted-foreground text-sm" data-testid="client-secret">
|
<CopyToClipboard value={$clientSecretStore}>
|
||||||
{$clientSecretStore}
|
<span class="text-muted-foreground text-sm" data-testid="client-secret">
|
||||||
</span>
|
{$clientSecretStore}
|
||||||
</CopyToClipboard>
|
</span>
|
||||||
{:else}
|
</CopyToClipboard>
|
||||||
<span class="text-muted-foreground text-sm" data-testid="client-secret"
|
{:else}
|
||||||
>••••••••••••••••••••••••••••••••</span
|
<span class="text-muted-foreground text-sm" data-testid="client-secret"
|
||||||
>
|
>••••••••••••••••••••••••••••••••</span
|
||||||
<Button
|
>
|
||||||
class="ml-2"
|
<Button
|
||||||
onclick={createClientSecret}
|
class="ml-2"
|
||||||
size="sm"
|
onclick={createClientSecret}
|
||||||
variant="ghost"
|
size="sm"
|
||||||
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
|
variant="ghost"
|
||||||
>
|
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
|
||||||
{/if}
|
>
|
||||||
</div>
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if showAllDetails}
|
{#if showAllDetails}
|
||||||
<div transition:slide>
|
<div transition:slide>
|
||||||
{#each Object.entries(setupDetails) as [key, value]}
|
{#each Object.entries(setupDetails) as [key, value]}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
||||||
import FileInput from '$lib/components/file-input.svelte';
|
import FileInput from '$lib/components/file-input.svelte';
|
||||||
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';
|
||||||
@@ -28,12 +29,14 @@
|
|||||||
|
|
||||||
const client: OidcClientCreate = {
|
const client: OidcClientCreate = {
|
||||||
name: existingClient?.name || '',
|
name: existingClient?.name || '',
|
||||||
callbackURLs: existingClient?.callbackURLs || [""]
|
callbackURLs: existingClient?.callbackURLs || [''],
|
||||||
|
isPublic: existingClient?.isPublic || false
|
||||||
};
|
};
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().min(2).max(50),
|
name: z.string().min(2).max(50),
|
||||||
callbackURLs: z.array(z.string().url()).nonempty()
|
callbackURLs: z.array(z.string().url()).nonempty(),
|
||||||
|
isPublic: z.boolean()
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormSchema = typeof formSchema;
|
type FormSchema = typeof formSchema;
|
||||||
@@ -71,15 +74,21 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form onsubmit={onSubmit}>
|
<form onsubmit={onSubmit}>
|
||||||
<div class="flex flex-col gap-3 sm:flex-row">
|
<div class="grid grid-cols-2 gap-3 sm:flex-row">
|
||||||
<FormInput label="Name" class="w-full" bind:input={$inputs.name} />
|
<FormInput label="Name" class="w-full" bind:input={$inputs.name} />
|
||||||
<OidcCallbackUrlInput
|
<OidcCallbackUrlInput
|
||||||
class="w-full"
|
class="w-full"
|
||||||
bind:callbackURLs={$inputs.callbackURLs.value}
|
bind:callbackURLs={$inputs.callbackURLs.value}
|
||||||
bind:error={$inputs.callbackURLs.error}
|
bind:error={$inputs.callbackURLs.error}
|
||||||
/>
|
/>
|
||||||
|
<CheckboxWithLabel
|
||||||
|
id="public-client"
|
||||||
|
label="Public Client"
|
||||||
|
description="Public clients do not have a client secret and use PKCE instead. Enable this if your client is a SPA or mobile app."
|
||||||
|
bind:checked={$inputs.isPublic.value}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3">
|
<div class="mt-8">
|
||||||
<Label for="logo">Logo</Label>
|
<Label for="logo">Logo</Label>
|
||||||
<div class="mt-2 flex items-end gap-3">
|
<div class="mt-2 flex items-end gap-3">
|
||||||
{#if logoDataURL}
|
{#if logoDataURL}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
||||||
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 { UserCreate } from '$lib/types/user.type';
|
import type { UserCreate } from '$lib/types/user.type';
|
||||||
import { createForm } from '$lib/utils/form-util';
|
import { createForm } from '$lib/utils/form-util';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -70,15 +69,12 @@
|
|||||||
<FormInput label="Username" bind:input={$inputs.username} />
|
<FormInput label="Username" bind:input={$inputs.username} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="items-top mt-5 flex space-x-2">
|
<CheckboxWithLabel
|
||||||
<Checkbox id="admin-privileges" bind:checked={$inputs.isAdmin.value} />
|
id="admin-privileges"
|
||||||
<div class="grid gap-1.5 leading-none">
|
label="Admin Privileges"
|
||||||
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none">
|
description="Admins have full access to the admin panel."
|
||||||
Admin Privileges
|
bind:checked={$inputs.isAdmin.value}
|
||||||
</Label>
|
/>
|
||||||
<p class="text-muted-foreground text-[0.8rem]">Admins have full access to the admin panel.</p>
|
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
28
scripts/docker/create-user.sh
Normal file
28
scripts/docker/create-user.sh
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
|
||||||
|
echo "Creating user and group..."
|
||||||
|
|
||||||
|
PUID=${PUID:-1000}
|
||||||
|
PGID=${PGID:-1000}
|
||||||
|
|
||||||
|
# Check if the group with PGID exists; if not, create it
|
||||||
|
if ! getent group pocket-id-group > /dev/null 2>&1; then
|
||||||
|
addgroup -g "$PGID" pocket-id-group
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if a user with PUID exists; if not, create it
|
||||||
|
if ! id -u pocket-id > /dev/null 2>&1; then
|
||||||
|
if ! getent passwd "$PUID" > /dev/null 2>&1; then
|
||||||
|
adduser -u "$PUID" -G pocket-id-group pocket-id
|
||||||
|
else
|
||||||
|
# If a user with the PUID already exists, use that user
|
||||||
|
existing_user=$(getent passwd "$PUID" | cut -d: -f1)
|
||||||
|
echo "Using existing user: $existing_user"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Change ownership of the /app directory
|
||||||
|
mkdir -p /app/backend/data
|
||||||
|
find /app/backend/data \( ! -group "${PGID}" -o ! -user "${PUID}" \) -exec chown "${PUID}:${PGID}" {} +
|
||||||
|
|
||||||
|
# Switch to the non-root user
|
||||||
|
exec su-exec "$PUID:$PGID" "$@"
|
||||||
Reference in New Issue
Block a user