Compare commits

...

4 Commits

Author SHA1 Message Date
Elias Schneider
82e475a923 release: 0.23.0 2025-01-03 16:34:23 +01:00
Elias Schneider
2d31fc2cc9 feat: use same table component for OIDC client list as all other lists 2025-01-03 16:19:15 +01:00
Elias Schneider
adcf3ddc66 feat: add PKCE for non public clients 2025-01-03 16:15:10 +01:00
Elias Schneider
785200de61 chore: include static assets in binary 2025-01-03 15:12:07 +01:00
60 changed files with 176 additions and 169 deletions

View File

@@ -1 +1 @@
0.22.0 0.23.0

View File

@@ -1,3 +1,11 @@
## [](https://github.com/stonith404/pocket-id/compare/v0.22.0...v) (2025-01-03)
### Features
* add PKCE for non public clients ([adcf3dd](https://github.com/stonith404/pocket-id/commit/adcf3ddc6682794e136a454ef9e69ddd130626a8))
* use same table component for OIDC client list as all other lists ([2d31fc2](https://github.com/stonith404/pocket-id/commit/2d31fc2cc9201bb93d296faae622f52c6dcdfebc))
## [](https://github.com/stonith404/pocket-id/compare/v0.21.0...v) (2025-01-01) ## [](https://github.com/stonith404/pocket-id/compare/v0.21.0...v) (2025-01-01)

View File

@@ -33,9 +33,6 @@ COPY --from=frontend-builder /app/frontend/node_modules ./frontend/node_modules
COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json
COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend
COPY --from=backend-builder /app/backend/migrations ./backend/migrations
COPY --from=backend-builder /app/backend/email-templates ./backend/email-templates
COPY --from=backend-builder /app/backend/images ./backend/images
COPY ./scripts ./scripts COPY ./scripts ./scripts
RUN chmod +x ./scripts/*.sh RUN chmod +x ./scripts/*.sh

View File

@@ -3,8 +3,10 @@ package bootstrap
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" "github.com/stonith404/pocket-id/backend/internal/utils"
"github.com/stonith404/pocket-id/backend/resources"
"log" "log"
"os" "os"
"path"
"strings" "strings"
) )
@@ -12,7 +14,7 @@ import (
func initApplicationImages() { func initApplicationImages() {
dirPath := common.EnvConfig.UploadPath + "/application-images" dirPath := common.EnvConfig.UploadPath + "/application-images"
sourceFiles, err := os.ReadDir("./images") sourceFiles, err := resources.FS.ReadDir("images")
if err != nil && !os.IsNotExist(err) { if err != nil && !os.IsNotExist(err) {
log.Fatalf("Error reading directory: %v", err) log.Fatalf("Error reading directory: %v", err)
} }
@@ -27,10 +29,10 @@ func initApplicationImages() {
if sourceFile.IsDir() || imageAlreadyExists(sourceFile.Name(), destinationFiles) { if sourceFile.IsDir() || imageAlreadyExists(sourceFile.Name(), destinationFiles) {
continue continue
} }
srcFilePath := "./images/" + sourceFile.Name() srcFilePath := path.Join("images", sourceFile.Name())
destFilePath := dirPath + "/" + sourceFile.Name() destFilePath := path.Join(dirPath, sourceFile.Name())
err := utils.CopyFile(srcFilePath, destFilePath) err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
if err != nil { if err != nil {
log.Fatalf("Error copying file: %v", err) log.Fatalf("Error copying file: %v", err)
} }

View File

@@ -7,7 +7,9 @@ import (
"github.com/golang-migrate/migrate/v4/database" "github.com/golang-migrate/migrate/v4/database"
postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres" postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres"
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3" sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/resources"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
@@ -42,20 +44,31 @@ func newDatabase() (db *gorm.DB) {
} }
// Run migrations // Run migrations
m, err := migrate.NewWithDatabaseInstance( if err := migrateDatabase(driver); err != nil {
"file://migrations/"+string(common.EnvConfig.DbProvider), log.Fatalf("failed to run migrations: %v", err)
"pocket-id", driver, }
)
return db
}
func migrateDatabase(driver database.Driver) error {
// Use the embedded migrations
source, err := iofs.New(resources.FS, "migrations/"+string(common.EnvConfig.DbProvider))
if err != nil { if err != nil {
log.Fatalf("failed to create migration instance: %v", err) return fmt.Errorf("failed to create embedded migration source: %v", err)
}
m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver)
if err != nil {
return fmt.Errorf("failed to create migration instance: %v", err)
} }
err = m.Up() err = m.Up()
if err != nil && !errors.Is(err, migrate.ErrNoChange) { if err != nil && !errors.Is(err, migrate.ErrNoChange) {
log.Fatalf("failed to apply migrations: %v", err) return fmt.Errorf("failed to apply migrations: %v", err)
} }
return db return nil
} }
func connectDatabase() (db *gorm.DB, err error) { func connectDatabase() (db *gorm.DB, err error) {

View File

@@ -2,7 +2,6 @@ package bootstrap
import ( import (
"log" "log"
"os"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -29,8 +28,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
r.Use(gin.Logger()) r.Use(gin.Logger())
// Initialize services // Initialize services
templateDir := os.DirFS(common.EnvConfig.EmailTemplatesPath) emailService, err := service.NewEmailService(appConfigService, db)
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)
} }

View File

@@ -22,7 +22,6 @@ type EnvConfigSchema struct {
UploadPath string `env:"UPLOAD_PATH"` UploadPath string `env:"UPLOAD_PATH"`
Port string `env:"BACKEND_PORT"` Port string `env:"BACKEND_PORT"`
Host string `env:"HOST"` Host string `env:"HOST"`
EmailTemplatesPath string `env:"EMAIL_TEMPLATES_PATH"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"` MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"` GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
} }
@@ -36,7 +35,6 @@ var EnvConfig = &EnvConfigSchema{
AppURL: "http://localhost", AppURL: "http://localhost",
Port: "8080", Port: "8080",
Host: "localhost", Host: "localhost",
EmailTemplatesPath: "./email-templates",
MaxMindLicenseKey: "", MaxMindLicenseKey: "",
GeoLiteDBPath: "data/GeoLite2-City.mmdb", GeoLiteDBPath: "data/GeoLite2-City.mmdb",
} }

View File

@@ -10,6 +10,7 @@ type OidcClientDto struct {
PublicOidcClientDto PublicOidcClientDto
CallbackURLs []string `json:"callbackURLs"` CallbackURLs []string `json:"callbackURLs"`
IsPublic bool `json:"isPublic"` IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
CreatedBy UserDto `json:"createdBy"` CreatedBy UserDto `json:"createdBy"`
} }
@@ -17,6 +18,7 @@ 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"` IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
} }
type AuthorizeOidcClientRequestDto struct { type AuthorizeOidcClientRequestDto struct {

View File

@@ -42,6 +42,7 @@ type OidcClient struct {
ImageType *string ImageType *string
HasLogo bool `gorm:"-"` HasLogo bool `gorm:"-"`
IsPublic bool IsPublic bool
PkceEnabled bool
CreatedByID string CreatedByID string
CreatedBy User CreatedBy User

View File

@@ -10,7 +10,6 @@ import (
"github.com/stonith404/pocket-id/backend/internal/utils/email" "github.com/stonith404/pocket-id/backend/internal/utils/email"
"gorm.io/gorm" "gorm.io/gorm"
htemplate "html/template" htemplate "html/template"
"io/fs"
"mime/multipart" "mime/multipart"
"mime/quotedprintable" "mime/quotedprintable"
"net" "net"
@@ -26,13 +25,13 @@ type EmailService struct {
textTemplates map[string]*ttemplate.Template textTemplates map[string]*ttemplate.Template
} }
func NewEmailService(appConfigService *AppConfigService, db *gorm.DB, templateDir fs.FS) (*EmailService, error) { func NewEmailService(appConfigService *AppConfigService, db *gorm.DB) (*EmailService, error) {
htmlTemplates, err := email.PrepareHTMLTemplates(templateDir, emailTemplatesPaths) htmlTemplates, err := email.PrepareHTMLTemplates(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)
} }
textTemplates, err := email.PrepareTextTemplates(templateDir, emailTemplatesPaths) textTemplates, err := email.PrepareTextTemplates(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)
} }

View File

@@ -131,8 +131,8 @@ func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret, code
return "", "", &common.OidcInvalidAuthorizationCodeError{} return "", "", &common.OidcInvalidAuthorizationCodeError{}
} }
// If the client is public, the code verifier must match the code challenge // If the client is public or PKCE is enabled, the code verifier must match the code challenge
if client.IsPublic { if client.IsPublic || client.PkceEnabled {
if !s.validateCodeVerifier(codeVerifier, *authorizationCodeMetaData.CodeChallenge, *authorizationCodeMetaData.CodeChallengeMethodSha256) { if !s.validateCodeVerifier(codeVerifier, *authorizationCodeMetaData.CodeChallenge, *authorizationCodeMetaData.CodeChallengeMethodSha256) {
return "", "", &common.OidcInvalidCodeVerifierError{} return "", "", &common.OidcInvalidCodeVerifierError{}
} }
@@ -189,6 +189,8 @@ func (s *OidcService) CreateClient(input dto.OidcClientCreateDto, userID string)
Name: input.Name, Name: input.Name,
CallbackURLs: input.CallbackURLs, CallbackURLs: input.CallbackURLs,
CreatedByID: userID, CreatedByID: userID,
IsPublic: input.IsPublic,
PkceEnabled: input.IsPublic || input.PkceEnabled,
} }
if err := s.db.Create(&client).Error; err != nil { if err := s.db.Create(&client).Error; err != nil {
@@ -207,6 +209,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 client.IsPublic = input.IsPublic
client.PkceEnabled = input.IsPublic || input.PkceEnabled
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
@@ -406,6 +409,10 @@ func (s *OidcService) createAuthorizationCode(clientID string, userID string, sc
} }
func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, codeChallengeMethodSha256 bool) bool { func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, codeChallengeMethodSha256 bool) bool {
if codeVerifier == "" || codeChallenge == "" {
return false
}
if !codeChallengeMethodSha256 { if !codeChallengeMethodSha256 {
return codeVerifier == codeChallenge return codeVerifier == codeChallenge
} }

View File

@@ -7,8 +7,10 @@ import (
"fmt" "fmt"
"github.com/fxamacker/cbor/v2" "github.com/fxamacker/cbor/v2"
"github.com/stonith404/pocket-id/backend/internal/model/types" "github.com/stonith404/pocket-id/backend/internal/model/types"
"github.com/stonith404/pocket-id/backend/resources"
"log" "log"
"os" "os"
"path/filepath"
"time" "time"
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
@@ -245,11 +247,21 @@ func (s *TestService) ResetApplicationImages() error {
return err return err
} }
if err := utils.CopyDirectory("./images", common.EnvConfig.UploadPath+"/application-images"); err != nil { files, err := resources.FS.ReadDir("images")
log.Printf("Error copying directory: %v", err) if err != nil {
return err return err
} }
for _, file := range files {
srcFilePath := filepath.Join("images", file.Name())
destFilePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", file.Name())
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
if err != nil {
return err
}
}
return nil return nil
} }

View File

@@ -2,6 +2,7 @@ package email
import ( import (
"fmt" "fmt"
"github.com/stonith404/pocket-id/backend/resources"
htemplate "html/template" htemplate "html/template"
"io/fs" "io/fs"
"path" "path"
@@ -35,36 +36,37 @@ type pareseable[V any] interface {
ParseFS(fs.FS, ...string) (V, error) ParseFS(fs.FS, ...string) (V, error)
} }
func prepareTemplate[V pareseable[V]](template string, rootTemplate clonable[V], templateDir fs.FS, suffix string) (V, error) { func prepareTemplate[V pareseable[V]](templateFS fs.FS, template string, rootTemplate clonable[V], suffix string) (V, error) {
tmpl, err := rootTemplate.Clone() tmpl, err := rootTemplate.Clone()
if err != nil { if err != nil {
return *new(V), fmt.Errorf("clone root html template: %w", err) return *new(V), fmt.Errorf("clone root template: %w", err)
} }
filename := fmt.Sprintf("%s%s", template, suffix) filename := fmt.Sprintf("%s%s", template, suffix)
_, err = tmpl.ParseFS(templateDir, filename) templatePath := path.Join("email-templates", filename)
_, err = tmpl.ParseFS(templateFS, templatePath)
if err != nil { if err != nil {
return *new(V), fmt.Errorf("parsing html template '%s': %w", template, err) return *new(V), fmt.Errorf("parsing template '%s': %w", template, err)
} }
return tmpl, nil return tmpl, nil
} }
func PrepareTextTemplates(templateDir fs.FS, templates []string) (map[string]*ttemplate.Template, error) { func PrepareTextTemplates(templates []string) (map[string]*ttemplate.Template, error) {
components := path.Join(templateComponentsDir, "*_text.tmpl") components := path.Join("email-templates", "components", "*_text.tmpl")
rootTmpl, err := ttemplate.ParseFS(templateDir, components) rootTmpl, err := ttemplate.ParseFS(resources.FS, components)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err) return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err)
} }
var textTemplates = make(map[string]*ttemplate.Template, len(templates)) textTemplates := make(map[string]*ttemplate.Template, len(templates))
for _, tmpl := range templates { for _, tmpl := range templates {
rootTmplClone, err := rootTmpl.Clone() rootTmplClone, err := rootTmpl.Clone()
if err != nil { if err != nil {
return nil, fmt.Errorf("clone root template: %w", err) return nil, fmt.Errorf("clone root template: %w", err)
} }
textTemplates[tmpl], err = prepareTemplate[*ttemplate.Template](tmpl, rootTmplClone, templateDir, "_text.tmpl") textTemplates[tmpl], err = prepareTemplate[*ttemplate.Template](resources.FS, tmpl, rootTmplClone, "_text.tmpl")
if err != nil { if err != nil {
return nil, fmt.Errorf("parse '%s': %w", tmpl, err) return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
} }
@@ -73,21 +75,21 @@ func PrepareTextTemplates(templateDir fs.FS, templates []string) (map[string]*tt
return textTemplates, nil return textTemplates, nil
} }
func PrepareHTMLTemplates(templateDir fs.FS, templates []string) (map[string]*htemplate.Template, error) { func PrepareHTMLTemplates(templates []string) (map[string]*htemplate.Template, error) {
components := path.Join(templateComponentsDir, "*_html.tmpl") components := path.Join("email-templates", "components", "*_html.tmpl")
rootTmpl, err := htemplate.ParseFS(templateDir, components) rootTmpl, err := htemplate.ParseFS(resources.FS, components)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err) return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err)
} }
var htmlTemplates = make(map[string]*htemplate.Template, len(templates)) htmlTemplates := make(map[string]*htemplate.Template, len(templates))
for _, tmpl := range templates { for _, tmpl := range templates {
rootTmplClone, err := rootTmpl.Clone() rootTmplClone, err := rootTmpl.Clone()
if err != nil { if err != nil {
return nil, fmt.Errorf("clone root template: %w", err) return nil, fmt.Errorf("clone root template: %w", err)
} }
htmlTemplates[tmpl], err = prepareTemplate[*htemplate.Template](tmpl, rootTmplClone, templateDir, "_html.tmpl") htmlTemplates[tmpl], err = prepareTemplate[*htemplate.Template](resources.FS, tmpl, rootTmplClone, "_html.tmpl")
if err != nil { if err != nil {
return nil, fmt.Errorf("parse '%s': %w", tmpl, err) return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
} }

View File

@@ -1,6 +1,7 @@
package utils package utils
import ( import (
"github.com/stonith404/pocket-id/backend/resources"
"io" "io"
"mime/multipart" "mime/multipart"
"os" "os"
@@ -28,27 +29,8 @@ func GetImageMimeType(ext string) string {
} }
} }
func CopyDirectory(srcDir, destDir string) error { func CopyEmbeddedFileToDisk(srcFilePath, destFilePath string) error {
files, err := os.ReadDir(srcDir) srcFile, err := resources.FS.Open(srcFilePath)
if err != nil {
return err
}
for _, file := range files {
srcFilePath := filepath.Join(srcDir, file.Name())
destFilePath := filepath.Join(destDir, file.Name())
err := CopyFile(srcFilePath, destFilePath)
if err != nil {
return err
}
}
return nil
}
func CopyFile(srcFilePath, destFilePath string) error {
srcFile, err := os.Open(srcFilePath)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -0,0 +1,8 @@
package resources
import "embed"
// Embedded file systems for the project
//go:embed email-templates images migrations
var FS embed.FS

View File

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 3.7 MiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 539 B

After

Width:  |  Height:  |  Size: 539 B

View File

Before

Width:  |  Height:  |  Size: 434 B

After

Width:  |  Height:  |  Size: 434 B

View File

Before

Width:  |  Height:  |  Size: 434 B

After

Width:  |  Height:  |  Size: 434 B

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients DROP COLUMN pkce_enabled;

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients ADD COLUMN pkce_enabled BOOLEAN DEFAULT FALSE;

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients DROP COLUMN pkce_enabled;

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients ADD COLUMN pkce_enabled BOOLEAN DEFAULT FALSE;

View File

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

View File

@@ -6,12 +6,26 @@
id, id,
checked = $bindable(), checked = $bindable(),
label, label,
description description,
}: { id: string; checked: boolean; label: string; description?: string } = $props(); disabled = false,
onCheckedChange
}: {
id: string;
checked: boolean;
label: string;
description?: string;
disabled?: boolean;
onCheckedChange?: (checked: boolean) => void;
} = $props();
</script> </script>
<div class="items-top mt-5 flex space-x-2"> <div class="items-top mt-5 flex space-x-2">
<Checkbox {id} bind:checked /> <Checkbox
{id}
{disabled}
onCheckedChange={(v) => onCheckedChange && onCheckedChange(v == true)}
bind:checked
/>
<div class="grid gap-1.5 leading-none"> <div class="grid gap-1.5 leading-none">
<Label for={id} class="mb-0 text-sm font-medium leading-none"> <Label for={id} class="mb-0 text-sm font-medium leading-none">
{label} {label}

View File

@@ -19,7 +19,7 @@
> >
<div class="flex h-16 items-center"> <div class="flex h-16 items-center">
{#if !isAuthPage} {#if !isAuthPage}
<Logo class="mr-3 h-10 w-10" /> <Logo class="mr-3 h-8 w-8" />
<h1 class="text-lg font-medium" data-testid="application-name"> <h1 class="text-lg font-medium" data-testid="application-name">
{$appConfigStore.appName} {$appConfigStore.appName}
</h1> </h1>

View File

@@ -5,6 +5,7 @@ export type OidcClient = {
callbackURLs: [string, ...string[]]; callbackURLs: [string, ...string[]];
hasLogo: boolean; hasLogo: boolean;
isPublic: boolean; isPublic: boolean;
pkceEnabled: boolean;
}; };
export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>; export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;

View File

@@ -80,11 +80,19 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
}); });
} }
function setValue(key: keyof z.infer<T>, value: z.infer<T>[keyof z.infer<T>]) {
inputsStore.update((inputs) => {
inputs[key].value = value;
return inputs;
});
}
return { return {
schema, schema,
inputs: inputsStore, inputs: inputsStore,
data, data,
validate, validate,
setValue,
reset reset
}; };
} }

View File

@@ -10,7 +10,7 @@
OidcClientCreateWithLogo OidcClientCreateWithLogo
} from '$lib/types/oidc.type'; } from '$lib/types/oidc.type';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { z } from 'zod'; import { set, z } from 'zod';
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte'; import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
let { let {
@@ -30,13 +30,15 @@
const client: OidcClientCreate = { const client: OidcClientCreate = {
name: existingClient?.name || '', name: existingClient?.name || '',
callbackURLs: existingClient?.callbackURLs || [''], callbackURLs: existingClient?.callbackURLs || [''],
isPublic: existingClient?.isPublic || false isPublic: existingClient?.isPublic || false,
pkceEnabled: existingClient?.isPublic == true || existingClient?.pkceEnabled || 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() isPublic: z.boolean(),
pkceEnabled: z.boolean()
}); });
type FormSchema = typeof formSchema; type FormSchema = typeof formSchema;
@@ -85,8 +87,19 @@
id="public-client" id="public-client"
label="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." description="Public clients do not have a client secret and use PKCE instead. Enable this if your client is a SPA or mobile app."
onCheckedChange={(v) => {
console.log(v)
if (v == true) form.setValue('pkceEnabled', true);
}}
bind:checked={$inputs.isPublic.value} bind:checked={$inputs.isPublic.value}
/> />
<CheckboxWithLabel
id="pkce"
label="PKCE"
description="Public Key Code Exchange is a security feature to prevent CSRF and authorization code interception attacks."
disabled={$inputs.isPublic.value}
bind:checked={$inputs.pkceEnabled.value}
/>
</div> </div>
<div class="mt-8"> <div class="mt-8">
<Label for="logo">Logo</Label> <Label for="logo">Logo</Label>

View File

@@ -1,13 +1,11 @@
<script lang="ts"> <script lang="ts">
import AdvancedTable from '$lib/components/advanced-table.svelte';
import { openConfirmDialog } from '$lib/components/confirm-dialog/'; import { openConfirmDialog } from '$lib/components/confirm-dialog/';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Pagination from '$lib/components/ui/pagination';
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import OIDCService from '$lib/services/oidc-service'; import OIDCService from '$lib/services/oidc-service';
import type { OidcClient } from '$lib/types/oidc.type'; import type { OidcClient } from '$lib/types/oidc.type';
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type'; import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
import { debounced } from '$lib/utils/debounce-util';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { LucidePencil, LucideTrash } from 'lucide-svelte'; import { LucidePencil, LucideTrash } from 'lucide-svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
@@ -29,10 +27,6 @@
}); });
let search = $state(''); let search = $state('');
const debouncedSearch = debounced(async (searchValue: string) => {
clients = await oidcService.listClients(searchValue, pagination);
}, 400);
async function deleteClient(client: OidcClient) { async function deleteClient(client: OidcClient) {
openConfirmDialog({ openConfirmDialog({
title: `Delete ${client.name}`, title: `Delete ${client.name}`,
@@ -52,98 +46,42 @@
} }
}); });
} }
async function fetchItems(search: string, page: number, limit: number) {
return oidcService.listClients(search, { page, limit });
}
</script> </script>
<Input <AdvancedTable
type="search" items={clients}
placeholder="Search clients" {fetchItems}
bind:value={search} columns={['Logo', 'Name', { label: 'Actions', hidden: true }]}
on:input={(e) => debouncedSearch((e.target as HTMLInputElement).value)} >
/> {#snippet rows({ item })}
<Table.Root> <Table.Cell class="w-8 font-medium">
<Table.Header class="sr-only"> {#if item.hasLogo}
<Table.Row> <div class="h-8 w-8">
<Table.Head>Logo</Table.Head> <img
<Table.Head>Name</Table.Head> class="m-auto max-h-full max-w-full object-contain"
<Table.Head>Actions</Table.Head> src="/api/oidc/clients/{item.id}/logo"
</Table.Row> alt="{item.name} logo"
</Table.Header> />
<Table.Body> </div>
{#if clients.data.length === 0} {/if}
<Table.Row> </Table.Cell>
<Table.Cell colspan={6} class="text-center">No OIDC clients found</Table.Cell> <Table.Cell class="font-medium">{item.name}</Table.Cell>
</Table.Row> <Table.Cell class="flex justify-end gap-1">
{:else} <Button
{#each clients.data as client} href="/settings/admin/oidc-clients/{item.id}"
<Table.Row> size="sm"
<Table.Cell class="w-8 font-medium"> variant="outline"
{#if client.hasLogo} aria-label="Edit"><LucidePencil class="h-3 w-3 " /></Button
<div class="h-8 w-8"> >
<img <Button on:click={() => deleteClient(item)} size="sm" variant="outline" aria-label="Delete"
class="m-auto max-h-full max-w-full object-contain" ><LucideTrash class="h-3 w-3 text-red-500" /></Button
src="/api/oidc/clients/{client.id}/logo" >
alt="{client.name} logo" </Table.Cell>
/> {/snippet}
</div> </AdvancedTable>
{/if}
</Table.Cell>
<Table.Cell class="font-medium">{client.name}</Table.Cell>
<Table.Cell class="flex justify-end gap-1">
<Button
href="/settings/admin/oidc-clients/{client.id}"
size="sm"
variant="outline"
aria-label="Edit"><LucidePencil class="h-3 w-3 " /></Button
>
<Button
on:click={() => deleteClient(client)}
size="sm"
variant="outline"
aria-label="Delete"><LucideTrash class="h-3 w-3 text-red-500" /></Button
>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
{#if clients?.data?.length ?? 0 > 0}
<Pagination.Root
class="mt-5"
count={clients.pagination.totalItems}
perPage={pagination.limit}
onPageChange={async (p) =>
(clients = await oidcService.listClients(search, {
page: p,
limit: pagination.limit
}))}
bind:page={clients.pagination.currentPage}
let:pages
let:currentPage
>
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type === 'ellipsis'}
<Pagination.Item>
<Pagination.Ellipsis />
</Pagination.Item>
{:else}
<Pagination.Item>
<Pagination.Link {page} isActive={clients.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
</Pagination.Root>
{/if}
<OneTimeLinkModal {oneTimeLink} /> <OneTimeLinkModal {oneTimeLink} />