mirror of
https://github.com/btouchard/ackify-ce.git
synced 2026-02-08 14:58:36 -06:00
feat(admin): add tenant configuration UI with hot-reload support
Add admin settings page allowing runtime configuration of: - SMTP settings with connection testing - OIDC/OAuth2 authentication with validation - S3 storage configuration with connectivity check Backend includes config service with atomic hot-reload, encrypted secrets storage, and environment seeding on startup.
This commit is contained in:
836
backend/internal/application/services/config_service.go
Normal file
836
backend/internal/application/services/config_service.go
Normal file
@@ -0,0 +1,836 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/btouchard/ackify-ce/backend/internal/domain/models"
|
||||
"github.com/btouchard/ackify-ce/backend/pkg/config"
|
||||
"github.com/btouchard/ackify-ce/backend/pkg/logger"
|
||||
mail "github.com/go-mail/mail/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoAuthMethod = errors.New("at least one authentication method must be enabled")
|
||||
ErrMagicLinkNeedsSMTP = errors.New("MagicLink requires SMTP to be configured")
|
||||
ErrOIDCNeedsURLs = errors.New("custom OIDC provider requires auth, token, and userinfo URLs")
|
||||
ErrInvalidCategory = errors.New("invalid configuration category")
|
||||
)
|
||||
|
||||
// configRepository defines the interface for config storage
|
||||
type configRepository interface {
|
||||
GetByCategory(ctx context.Context, category models.ConfigCategory) (*models.TenantConfig, error)
|
||||
GetAll(ctx context.Context) ([]*models.TenantConfig, error)
|
||||
Upsert(ctx context.Context, category models.ConfigCategory, config json.RawMessage, secrets []byte, updatedBy string) error
|
||||
IsSeeded(ctx context.Context) (bool, error)
|
||||
MarkSeeded(ctx context.Context) error
|
||||
DeleteAll(ctx context.Context) error
|
||||
GetLatestUpdatedAt(ctx context.Context) (time.Time, error)
|
||||
}
|
||||
|
||||
// ConfigService manages application configuration with hot-reload support
|
||||
type ConfigService struct {
|
||||
repo configRepository
|
||||
encryptionKey []byte
|
||||
envConfig *config.Config
|
||||
|
||||
currentConfig atomic.Value // *models.MutableConfig
|
||||
|
||||
subscribersMu sync.RWMutex
|
||||
subscribers []chan<- models.MutableConfig
|
||||
}
|
||||
|
||||
// NewConfigService creates a new configuration service
|
||||
func NewConfigService(repo configRepository, envConfig *config.Config, encryptionKey []byte) *ConfigService {
|
||||
svc := &ConfigService{
|
||||
repo: repo,
|
||||
envConfig: envConfig,
|
||||
encryptionKey: encryptionKey,
|
||||
subscribers: make([]chan<- models.MutableConfig, 0),
|
||||
}
|
||||
svc.currentConfig.Store(&models.MutableConfig{})
|
||||
return svc
|
||||
}
|
||||
|
||||
// Initialize loads config from DB or seeds from ENV on first start
|
||||
func (s *ConfigService) Initialize(ctx context.Context) error {
|
||||
seeded, err := s.repo.IsSeeded(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if seeded: %w", err)
|
||||
}
|
||||
|
||||
if !seeded {
|
||||
logger.Logger.Info("First startup: seeding configuration from environment")
|
||||
if err := s.seedFromENV(ctx); err != nil {
|
||||
return fmt.Errorf("failed to seed config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return s.reload(ctx)
|
||||
}
|
||||
|
||||
// GetConfig returns the current config (lock-free read)
|
||||
func (s *ConfigService) GetConfig() *models.MutableConfig {
|
||||
return s.currentConfig.Load().(*models.MutableConfig)
|
||||
}
|
||||
|
||||
// UpdateSection updates a specific config section
|
||||
func (s *ConfigService) UpdateSection(ctx context.Context, category models.ConfigCategory, input json.RawMessage, updatedBy string) error {
|
||||
if !category.IsValid() {
|
||||
return ErrInvalidCategory
|
||||
}
|
||||
|
||||
// Parse the input to validate structure
|
||||
if err := s.validateSection(category, input); err != nil {
|
||||
return fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Get current config to check cross-category validation
|
||||
currentConfig := s.GetConfig()
|
||||
|
||||
// Apply the update temporarily to check cross-category validation
|
||||
tempConfig := *currentConfig
|
||||
if err := s.applyUpdateToConfig(&tempConfig, category, input); err != nil {
|
||||
return fmt.Errorf("failed to apply update: %w", err)
|
||||
}
|
||||
|
||||
// Validate cross-category rules
|
||||
if err := s.validateCrossCategory(&tempConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract and encrypt secrets
|
||||
configWithoutSecrets, encryptedSecrets, err := s.processSecrets(category, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to process secrets: %w", err)
|
||||
}
|
||||
|
||||
// Store in DB
|
||||
if err := s.repo.Upsert(ctx, category, configWithoutSecrets, encryptedSecrets, updatedBy); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
// Hot-reload
|
||||
return s.reload(ctx)
|
||||
}
|
||||
|
||||
// ResetFromENV resets config to current ENV values
|
||||
func (s *ConfigService) ResetFromENV(ctx context.Context, updatedBy string) error {
|
||||
// Delete all existing config
|
||||
if err := s.repo.DeleteAll(ctx); err != nil {
|
||||
return fmt.Errorf("failed to delete existing config: %w", err)
|
||||
}
|
||||
|
||||
// Seed from ENV
|
||||
if err := s.seedFromENV(ctx); err != nil {
|
||||
return fmt.Errorf("failed to seed from ENV: %w", err)
|
||||
}
|
||||
|
||||
// Reload
|
||||
return s.reload(ctx)
|
||||
}
|
||||
|
||||
// Subscribe registers a channel to receive config updates
|
||||
func (s *ConfigService) Subscribe() <-chan models.MutableConfig {
|
||||
ch := make(chan models.MutableConfig, 1)
|
||||
s.subscribersMu.Lock()
|
||||
s.subscribers = append(s.subscribers, ch)
|
||||
s.subscribersMu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
// CloseAllSubscribers closes all subscriber channels (used during shutdown)
|
||||
func (s *ConfigService) CloseAllSubscribers() {
|
||||
s.subscribersMu.Lock()
|
||||
defer s.subscribersMu.Unlock()
|
||||
|
||||
for _, ch := range s.subscribers {
|
||||
close(ch)
|
||||
}
|
||||
s.subscribers = nil
|
||||
}
|
||||
|
||||
// --- Test Connection Methods ---
|
||||
|
||||
// TestSMTP tests SMTP connection
|
||||
func (s *ConfigService) TestSMTP(ctx context.Context, cfg models.SMTPConfig) error {
|
||||
if cfg.Host == "" {
|
||||
return errors.New("SMTP host is required")
|
||||
}
|
||||
|
||||
// Handle masked password - use current config's password
|
||||
if models.IsSecretMasked(cfg.Password) {
|
||||
current := s.GetConfig()
|
||||
cfg.Password = current.SMTP.Password
|
||||
}
|
||||
|
||||
d := mail.NewDialer(cfg.Host, cfg.Port, cfg.Username, cfg.Password)
|
||||
|
||||
if cfg.TLS {
|
||||
d.SSL = true
|
||||
d.TLSConfig = &tls.Config{
|
||||
ServerName: cfg.Host,
|
||||
InsecureSkipVerify: cfg.InsecureSkipVerify,
|
||||
}
|
||||
} else if cfg.StartTLS {
|
||||
d.TLSConfig = &tls.Config{
|
||||
ServerName: cfg.Host,
|
||||
InsecureSkipVerify: cfg.InsecureSkipVerify,
|
||||
}
|
||||
d.StartTLSPolicy = mail.MandatoryStartTLS
|
||||
}
|
||||
|
||||
timeout, err := time.ParseDuration(cfg.Timeout)
|
||||
if err != nil {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
d.Timeout = timeout
|
||||
|
||||
// Try to connect
|
||||
closer, err := d.Dial()
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP connection failed: %w", err)
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestS3 tests S3 connection
|
||||
func (s *ConfigService) TestS3(ctx context.Context, cfg models.StorageConfig) error {
|
||||
if cfg.Type != "s3" {
|
||||
return errors.New("storage type must be 's3' for S3 test")
|
||||
}
|
||||
if cfg.S3Bucket == "" {
|
||||
return errors.New("S3 bucket is required")
|
||||
}
|
||||
|
||||
// Handle masked secret key - use current config's key
|
||||
if models.IsSecretMasked(cfg.S3SecretKey) {
|
||||
current := s.GetConfig()
|
||||
cfg.S3SecretKey = current.Storage.S3SecretKey
|
||||
}
|
||||
|
||||
// Build AWS config
|
||||
opts := []func(*awsconfig.LoadOptions) error{
|
||||
awsconfig.WithRegion(cfg.S3Region),
|
||||
}
|
||||
|
||||
if cfg.S3AccessKey != "" && cfg.S3SecretKey != "" {
|
||||
opts = append(opts, awsconfig.WithCredentialsProvider(
|
||||
credentials.NewStaticCredentialsProvider(cfg.S3AccessKey, cfg.S3SecretKey, ""),
|
||||
))
|
||||
}
|
||||
|
||||
awsCfg, err := awsconfig.LoadDefaultConfig(ctx, opts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load AWS config: %w", err)
|
||||
}
|
||||
|
||||
// Create S3 client
|
||||
s3Opts := []func(*s3.Options){}
|
||||
if cfg.S3Endpoint != "" {
|
||||
s3Opts = append(s3Opts, func(o *s3.Options) {
|
||||
o.BaseEndpoint = aws.String(cfg.S3Endpoint)
|
||||
o.UsePathStyle = true
|
||||
})
|
||||
}
|
||||
|
||||
client := s3.NewFromConfig(awsCfg, s3Opts...)
|
||||
|
||||
// Test bucket access
|
||||
_, err = client.HeadBucket(ctx, &s3.HeadBucketInput{
|
||||
Bucket: aws.String(cfg.S3Bucket),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("S3 bucket access failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestOIDC tests OIDC configuration by fetching the well-known endpoint
|
||||
func (s *ConfigService) TestOIDC(ctx context.Context, cfg models.OIDCConfig) error {
|
||||
if !cfg.Enabled {
|
||||
return errors.New("OIDC is not enabled")
|
||||
}
|
||||
|
||||
// Handle masked client secret - use current config's secret
|
||||
if models.IsSecretMasked(cfg.ClientSecret) {
|
||||
current := s.GetConfig()
|
||||
cfg.ClientSecret = current.OIDC.ClientSecret
|
||||
}
|
||||
|
||||
// Determine the well-known URL based on provider
|
||||
var wellKnownURL string
|
||||
switch cfg.Provider {
|
||||
case "google":
|
||||
wellKnownURL = "https://accounts.google.com/.well-known/openid-configuration"
|
||||
case "github":
|
||||
// GitHub doesn't have a standard OIDC discovery endpoint, just validate URLs exist
|
||||
if cfg.ClientID == "" || cfg.ClientSecret == "" {
|
||||
return errors.New("GitHub OAuth requires client_id and client_secret")
|
||||
}
|
||||
return nil
|
||||
case "gitlab":
|
||||
baseURL := "https://gitlab.com"
|
||||
if cfg.AuthURL != "" && strings.Contains(cfg.AuthURL, "gitlab") {
|
||||
// Extract base URL from auth URL
|
||||
parts := strings.Split(cfg.AuthURL, "/oauth")
|
||||
if len(parts) > 0 {
|
||||
baseURL = parts[0]
|
||||
}
|
||||
}
|
||||
wellKnownURL = baseURL + "/.well-known/openid-configuration"
|
||||
case "custom":
|
||||
// For custom providers, validate that required URLs are present
|
||||
if cfg.AuthURL == "" || cfg.TokenURL == "" || cfg.UserInfoURL == "" {
|
||||
return ErrOIDCNeedsURLs
|
||||
}
|
||||
// Try to derive issuer from auth URL
|
||||
parts := strings.Split(cfg.AuthURL, "/")
|
||||
if len(parts) >= 3 {
|
||||
issuer := strings.Join(parts[:3], "/")
|
||||
wellKnownURL = issuer + "/.well-known/openid-configuration"
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown OIDC provider: %s", cfg.Provider)
|
||||
}
|
||||
|
||||
if wellKnownURL == "" {
|
||||
return nil // No well-known to check
|
||||
}
|
||||
|
||||
// Fetch well-known endpoint
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnownURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch OIDC discovery endpoint: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("OIDC discovery endpoint returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Parse response to validate it's a valid OIDC configuration
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var discovery struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserinfoEndpoint string `json:"userinfo_endpoint"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &discovery); err != nil {
|
||||
return fmt.Errorf("invalid OIDC discovery response: %w", err)
|
||||
}
|
||||
|
||||
if discovery.AuthorizationEndpoint == "" || discovery.TokenEndpoint == "" {
|
||||
return errors.New("OIDC discovery missing required endpoints")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Internal Methods ---
|
||||
|
||||
// seedFromENV seeds configuration from environment variables
|
||||
func (s *ConfigService) seedFromENV(ctx context.Context) error {
|
||||
// General config
|
||||
general := models.GeneralConfig{
|
||||
Organisation: s.envConfig.App.Organisation,
|
||||
OnlyAdminCanCreate: s.envConfig.App.OnlyAdminCanCreate,
|
||||
}
|
||||
if err := s.upsertSection(ctx, models.ConfigCategoryGeneral, general, nil, "system"); err != nil {
|
||||
return fmt.Errorf("failed to seed general config: %w", err)
|
||||
}
|
||||
|
||||
// OIDC config
|
||||
oidc := models.OIDCConfig{
|
||||
Enabled: s.envConfig.Auth.OAuthEnabled,
|
||||
Provider: s.detectOAuthProvider(),
|
||||
ClientID: s.envConfig.OAuth.ClientID,
|
||||
ClientSecret: s.envConfig.OAuth.ClientSecret,
|
||||
AuthURL: s.envConfig.OAuth.AuthURL,
|
||||
TokenURL: s.envConfig.OAuth.TokenURL,
|
||||
UserInfoURL: s.envConfig.OAuth.UserInfoURL,
|
||||
LogoutURL: s.envConfig.OAuth.LogoutURL,
|
||||
Scopes: s.envConfig.OAuth.Scopes,
|
||||
AllowedDomain: s.envConfig.OAuth.AllowedDomain,
|
||||
AutoLogin: s.envConfig.OAuth.AutoLogin,
|
||||
}
|
||||
oidcSecrets := models.OIDCSecrets{ClientSecret: s.envConfig.OAuth.ClientSecret}
|
||||
if err := s.upsertSection(ctx, models.ConfigCategoryOIDC, oidc, oidcSecrets, "system"); err != nil {
|
||||
return fmt.Errorf("failed to seed OIDC config: %w", err)
|
||||
}
|
||||
|
||||
// MagicLink config
|
||||
magicLink := models.MagicLinkConfig{
|
||||
Enabled: s.envConfig.Auth.MagicLinkEnabled,
|
||||
}
|
||||
if err := s.upsertSection(ctx, models.ConfigCategoryMagicLink, magicLink, nil, "system"); err != nil {
|
||||
return fmt.Errorf("failed to seed MagicLink config: %w", err)
|
||||
}
|
||||
|
||||
// SMTP config
|
||||
smtp := models.SMTPConfig{
|
||||
Host: s.envConfig.Mail.Host,
|
||||
Port: s.envConfig.Mail.Port,
|
||||
Username: s.envConfig.Mail.Username,
|
||||
Password: s.envConfig.Mail.Password,
|
||||
TLS: s.envConfig.Mail.TLS,
|
||||
StartTLS: s.envConfig.Mail.StartTLS,
|
||||
InsecureSkipVerify: s.envConfig.Mail.InsecureSkipVerify,
|
||||
Timeout: s.envConfig.Mail.Timeout,
|
||||
From: s.envConfig.Mail.From,
|
||||
FromName: s.envConfig.Mail.FromName,
|
||||
SubjectPrefix: s.envConfig.Mail.SubjectPrefix,
|
||||
}
|
||||
smtpSecrets := models.SMTPSecrets{Password: s.envConfig.Mail.Password}
|
||||
if err := s.upsertSection(ctx, models.ConfigCategorySMTP, smtp, smtpSecrets, "system"); err != nil {
|
||||
return fmt.Errorf("failed to seed SMTP config: %w", err)
|
||||
}
|
||||
|
||||
// Storage config
|
||||
storage := models.StorageConfig{
|
||||
Type: s.envConfig.Storage.Type,
|
||||
MaxSizeMB: s.envConfig.Storage.MaxSizeMB,
|
||||
LocalPath: s.envConfig.Storage.LocalPath,
|
||||
S3Endpoint: s.envConfig.Storage.S3Endpoint,
|
||||
S3Bucket: s.envConfig.Storage.S3Bucket,
|
||||
S3AccessKey: s.envConfig.Storage.S3AccessKey,
|
||||
S3SecretKey: s.envConfig.Storage.S3SecretKey,
|
||||
S3Region: s.envConfig.Storage.S3Region,
|
||||
S3UseSSL: s.envConfig.Storage.S3UseSSL,
|
||||
}
|
||||
storageSecrets := models.StorageSecrets{S3SecretKey: s.envConfig.Storage.S3SecretKey}
|
||||
if err := s.upsertSection(ctx, models.ConfigCategoryStorage, storage, storageSecrets, "system"); err != nil {
|
||||
return fmt.Errorf("failed to seed Storage config: %w", err)
|
||||
}
|
||||
|
||||
// Mark as seeded
|
||||
if err := s.repo.MarkSeeded(ctx); err != nil {
|
||||
return fmt.Errorf("failed to mark config as seeded: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// detectOAuthProvider detects the OAuth provider from the configuration
|
||||
func (s *ConfigService) detectOAuthProvider() string {
|
||||
authURL := s.envConfig.OAuth.AuthURL
|
||||
if strings.Contains(authURL, "accounts.google.com") {
|
||||
return "google"
|
||||
}
|
||||
if strings.Contains(authURL, "github.com") {
|
||||
return "github"
|
||||
}
|
||||
if strings.Contains(authURL, "gitlab") {
|
||||
return "gitlab"
|
||||
}
|
||||
if authURL != "" {
|
||||
return "custom"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// upsertSection marshals and stores a config section
|
||||
func (s *ConfigService) upsertSection(ctx context.Context, category models.ConfigCategory, cfg any, secrets any, updatedBy string) error {
|
||||
configJSON, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
// Remove secrets from config JSON
|
||||
configWithoutSecrets := s.removeSecretsFromJSON(category, configJSON)
|
||||
|
||||
var encryptedSecrets []byte
|
||||
if secrets != nil {
|
||||
secretsJSON, err := json.Marshal(secrets)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal secrets: %w", err)
|
||||
}
|
||||
encryptedSecrets, err = s.encryptSecrets(secretsJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt secrets: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return s.repo.Upsert(ctx, category, configWithoutSecrets, encryptedSecrets, updatedBy)
|
||||
}
|
||||
|
||||
// removeSecretsFromJSON removes secret fields from config JSON
|
||||
func (s *ConfigService) removeSecretsFromJSON(category models.ConfigCategory, configJSON json.RawMessage) json.RawMessage {
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal(configJSON, &data); err != nil {
|
||||
return configJSON
|
||||
}
|
||||
|
||||
// Remove secret fields based on category
|
||||
switch category {
|
||||
case models.ConfigCategoryOIDC:
|
||||
delete(data, "client_secret")
|
||||
case models.ConfigCategorySMTP:
|
||||
delete(data, "password")
|
||||
case models.ConfigCategoryStorage:
|
||||
delete(data, "s3_secret_key")
|
||||
}
|
||||
|
||||
result, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return configJSON
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// reload fetches all config from DB and notifies subscribers
|
||||
func (s *ConfigService) reload(ctx context.Context) error {
|
||||
configs, err := s.repo.GetAll(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load configs: %w", err)
|
||||
}
|
||||
|
||||
mutable := &models.MutableConfig{}
|
||||
for _, cfg := range configs {
|
||||
if err := s.populateCategory(mutable, cfg); err != nil {
|
||||
logger.Logger.Warn("Failed to parse config category", "category", cfg.Category, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get latest updated_at
|
||||
updatedAt, err := s.repo.GetLatestUpdatedAt(ctx)
|
||||
if err != nil {
|
||||
logger.Logger.Warn("Failed to get latest updated_at", "error", err)
|
||||
} else {
|
||||
mutable.UpdatedAt = updatedAt
|
||||
}
|
||||
|
||||
// Atomic swap
|
||||
s.currentConfig.Store(mutable)
|
||||
|
||||
// Notify subscribers
|
||||
s.notifySubscribers(*mutable)
|
||||
|
||||
logger.Logger.Info("Configuration reloaded", "updated_at", mutable.UpdatedAt)
|
||||
return nil
|
||||
}
|
||||
|
||||
// populateCategory populates the MutableConfig with data from a TenantConfig
|
||||
func (s *ConfigService) populateCategory(mutable *models.MutableConfig, tc *models.TenantConfig) error {
|
||||
// Decrypt secrets if present
|
||||
var secrets map[string]string
|
||||
if len(tc.SecretsEncrypted) > 0 {
|
||||
decrypted, err := s.decryptSecrets(tc.SecretsEncrypted)
|
||||
if err != nil {
|
||||
logger.Logger.Warn("Failed to decrypt secrets", "category", tc.Category, "error", err)
|
||||
} else {
|
||||
_ = json.Unmarshal(decrypted, &secrets)
|
||||
}
|
||||
}
|
||||
|
||||
switch tc.Category {
|
||||
case models.ConfigCategoryGeneral:
|
||||
var cfg models.GeneralConfig
|
||||
if err := json.Unmarshal(tc.Config, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
mutable.General = cfg
|
||||
|
||||
case models.ConfigCategoryOIDC:
|
||||
var cfg models.OIDCConfig
|
||||
if err := json.Unmarshal(tc.Config, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if secrets != nil {
|
||||
if v, ok := secrets["client_secret"]; ok {
|
||||
cfg.ClientSecret = v
|
||||
}
|
||||
}
|
||||
mutable.OIDC = cfg
|
||||
|
||||
case models.ConfigCategoryMagicLink:
|
||||
var cfg models.MagicLinkConfig
|
||||
if err := json.Unmarshal(tc.Config, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
mutable.MagicLink = cfg
|
||||
|
||||
case models.ConfigCategorySMTP:
|
||||
var cfg models.SMTPConfig
|
||||
if err := json.Unmarshal(tc.Config, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if secrets != nil {
|
||||
if v, ok := secrets["password"]; ok {
|
||||
cfg.Password = v
|
||||
}
|
||||
}
|
||||
mutable.SMTP = cfg
|
||||
|
||||
case models.ConfigCategoryStorage:
|
||||
var cfg models.StorageConfig
|
||||
if err := json.Unmarshal(tc.Config, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if secrets != nil {
|
||||
if v, ok := secrets["s3_secret_key"]; ok {
|
||||
cfg.S3SecretKey = v
|
||||
}
|
||||
}
|
||||
mutable.Storage = cfg
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// notifySubscribers sends config updates to all subscribers
|
||||
func (s *ConfigService) notifySubscribers(cfg models.MutableConfig) {
|
||||
s.subscribersMu.RLock()
|
||||
defer s.subscribersMu.RUnlock()
|
||||
|
||||
for _, ch := range s.subscribers {
|
||||
select {
|
||||
case ch <- cfg:
|
||||
default:
|
||||
// Channel full, skip this update
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateSection validates a single config section
|
||||
func (s *ConfigService) validateSection(category models.ConfigCategory, input json.RawMessage) error {
|
||||
switch category {
|
||||
case models.ConfigCategoryGeneral:
|
||||
var cfg models.GeneralConfig
|
||||
return json.Unmarshal(input, &cfg)
|
||||
|
||||
case models.ConfigCategoryOIDC:
|
||||
var cfg models.OIDCConfig
|
||||
if err := json.Unmarshal(input, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Enabled && cfg.Provider == "custom" {
|
||||
if cfg.AuthURL == "" || cfg.TokenURL == "" || cfg.UserInfoURL == "" {
|
||||
return ErrOIDCNeedsURLs
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
case models.ConfigCategoryMagicLink:
|
||||
var cfg models.MagicLinkConfig
|
||||
return json.Unmarshal(input, &cfg)
|
||||
|
||||
case models.ConfigCategorySMTP:
|
||||
var cfg models.SMTPConfig
|
||||
return json.Unmarshal(input, &cfg)
|
||||
|
||||
case models.ConfigCategoryStorage:
|
||||
var cfg models.StorageConfig
|
||||
if err := json.Unmarshal(input, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Type == "s3" && cfg.S3Bucket == "" {
|
||||
return errors.New("S3 bucket is required when storage type is 's3'")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return ErrInvalidCategory
|
||||
}
|
||||
|
||||
// applyUpdateToConfig applies an update to a MutableConfig for validation
|
||||
func (s *ConfigService) applyUpdateToConfig(cfg *models.MutableConfig, category models.ConfigCategory, input json.RawMessage) error {
|
||||
switch category {
|
||||
case models.ConfigCategoryGeneral:
|
||||
return json.Unmarshal(input, &cfg.General)
|
||||
case models.ConfigCategoryOIDC:
|
||||
var oidc models.OIDCConfig
|
||||
if err := json.Unmarshal(input, &oidc); err != nil {
|
||||
return err
|
||||
}
|
||||
// Preserve existing secret if masked
|
||||
if models.IsSecretMasked(oidc.ClientSecret) {
|
||||
oidc.ClientSecret = cfg.OIDC.ClientSecret
|
||||
}
|
||||
cfg.OIDC = oidc
|
||||
return nil
|
||||
case models.ConfigCategoryMagicLink:
|
||||
return json.Unmarshal(input, &cfg.MagicLink)
|
||||
case models.ConfigCategorySMTP:
|
||||
var smtp models.SMTPConfig
|
||||
if err := json.Unmarshal(input, &smtp); err != nil {
|
||||
return err
|
||||
}
|
||||
// Preserve existing secret if masked
|
||||
if models.IsSecretMasked(smtp.Password) {
|
||||
smtp.Password = cfg.SMTP.Password
|
||||
}
|
||||
cfg.SMTP = smtp
|
||||
return nil
|
||||
case models.ConfigCategoryStorage:
|
||||
var storage models.StorageConfig
|
||||
if err := json.Unmarshal(input, &storage); err != nil {
|
||||
return err
|
||||
}
|
||||
// Preserve existing secret if masked
|
||||
if models.IsSecretMasked(storage.S3SecretKey) {
|
||||
storage.S3SecretKey = cfg.Storage.S3SecretKey
|
||||
}
|
||||
cfg.Storage = storage
|
||||
return nil
|
||||
}
|
||||
return ErrInvalidCategory
|
||||
}
|
||||
|
||||
// validateCrossCategory validates cross-category rules
|
||||
func (s *ConfigService) validateCrossCategory(cfg *models.MutableConfig) error {
|
||||
// At least one auth method must be enabled
|
||||
if !cfg.HasAtLeastOneAuthMethod() {
|
||||
return ErrNoAuthMethod
|
||||
}
|
||||
|
||||
// MagicLink requires SMTP
|
||||
if !cfg.MagicLinkRequiresSMTP() {
|
||||
return ErrMagicLinkNeedsSMTP
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processSecrets extracts and encrypts secrets from input
|
||||
func (s *ConfigService) processSecrets(category models.ConfigCategory, input json.RawMessage) (json.RawMessage, []byte, error) {
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal(input, &data); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
secrets := make(map[string]string)
|
||||
currentConfig := s.GetConfig()
|
||||
|
||||
switch category {
|
||||
case models.ConfigCategoryOIDC:
|
||||
if secret, ok := data["client_secret"].(string); ok && secret != "" {
|
||||
if models.IsSecretMasked(secret) {
|
||||
secrets["client_secret"] = currentConfig.OIDC.ClientSecret
|
||||
} else {
|
||||
secrets["client_secret"] = secret
|
||||
}
|
||||
}
|
||||
delete(data, "client_secret")
|
||||
|
||||
case models.ConfigCategorySMTP:
|
||||
if secret, ok := data["password"].(string); ok && secret != "" {
|
||||
if models.IsSecretMasked(secret) {
|
||||
secrets["password"] = currentConfig.SMTP.Password
|
||||
} else {
|
||||
secrets["password"] = secret
|
||||
}
|
||||
}
|
||||
delete(data, "password")
|
||||
|
||||
case models.ConfigCategoryStorage:
|
||||
if secret, ok := data["s3_secret_key"].(string); ok && secret != "" {
|
||||
if models.IsSecretMasked(secret) {
|
||||
secrets["s3_secret_key"] = currentConfig.Storage.S3SecretKey
|
||||
} else {
|
||||
secrets["s3_secret_key"] = secret
|
||||
}
|
||||
}
|
||||
delete(data, "s3_secret_key")
|
||||
}
|
||||
|
||||
configWithoutSecrets, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var encryptedSecrets []byte
|
||||
if len(secrets) > 0 {
|
||||
secretsJSON, err := json.Marshal(secrets)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
encryptedSecrets, err = s.encryptSecrets(secretsJSON)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return configWithoutSecrets, encryptedSecrets, nil
|
||||
}
|
||||
|
||||
// encryptSecrets encrypts secrets using AES-256-GCM
|
||||
func (s *ConfigService) encryptSecrets(plaintext []byte) ([]byte, error) {
|
||||
if len(s.encryptionKey) < 32 {
|
||||
return nil, errors.New("encryption key too short")
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(s.encryptionKey[:32])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return gcm.Seal(nonce, nonce, plaintext, nil), nil
|
||||
}
|
||||
|
||||
// decryptSecrets decrypts secrets using AES-256-GCM
|
||||
func (s *ConfigService) decryptSecrets(ciphertext []byte) ([]byte, error) {
|
||||
if len(s.encryptionKey) < 32 {
|
||||
return nil, errors.New("encryption key too short")
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(s.encryptionKey[:32])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(ciphertext) < nonceSize {
|
||||
return nil, errors.New("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||
return gcm.Open(nil, nonce, ciphertext, nil)
|
||||
}
|
||||
658
backend/internal/application/services/config_service_test.go
Normal file
658
backend/internal/application/services/config_service_test.go
Normal file
@@ -0,0 +1,658 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/btouchard/ackify-ce/backend/internal/domain/models"
|
||||
"github.com/btouchard/ackify-ce/backend/pkg/config"
|
||||
)
|
||||
|
||||
// fakeConfigRepository is a mock implementation of configRepository
|
||||
type fakeConfigRepository struct {
|
||||
configs map[models.ConfigCategory]*models.TenantConfig
|
||||
seeded bool
|
||||
shouldFailGet bool
|
||||
shouldFailGetAll bool
|
||||
shouldFailUpsert bool
|
||||
shouldFailSeeded bool
|
||||
shouldFailMark bool
|
||||
shouldFailDelete bool
|
||||
}
|
||||
|
||||
func newFakeConfigRepository() *fakeConfigRepository {
|
||||
return &fakeConfigRepository{
|
||||
configs: make(map[models.ConfigCategory]*models.TenantConfig),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeConfigRepository) GetByCategory(_ context.Context, category models.ConfigCategory) (*models.TenantConfig, error) {
|
||||
if f.shouldFailGet {
|
||||
return nil, errors.New("repository get failed")
|
||||
}
|
||||
tc, ok := f.configs[category]
|
||||
if !ok {
|
||||
return nil, errors.New("config not found")
|
||||
}
|
||||
return tc, nil
|
||||
}
|
||||
|
||||
func (f *fakeConfigRepository) GetAll(_ context.Context) ([]*models.TenantConfig, error) {
|
||||
if f.shouldFailGetAll {
|
||||
return nil, errors.New("repository get all failed")
|
||||
}
|
||||
result := make([]*models.TenantConfig, 0, len(f.configs))
|
||||
for _, tc := range f.configs {
|
||||
result = append(result, tc)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (f *fakeConfigRepository) Upsert(_ context.Context, category models.ConfigCategory, cfg json.RawMessage, secrets []byte, updatedBy string) error {
|
||||
if f.shouldFailUpsert {
|
||||
return errors.New("repository upsert failed")
|
||||
}
|
||||
f.configs[category] = &models.TenantConfig{
|
||||
Category: category,
|
||||
Config: cfg,
|
||||
SecretsEncrypted: secrets,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeConfigRepository) IsSeeded(_ context.Context) (bool, error) {
|
||||
if f.shouldFailSeeded {
|
||||
return false, errors.New("repository is seeded failed")
|
||||
}
|
||||
return f.seeded, nil
|
||||
}
|
||||
|
||||
func (f *fakeConfigRepository) MarkSeeded(_ context.Context) error {
|
||||
if f.shouldFailMark {
|
||||
return errors.New("repository mark seeded failed")
|
||||
}
|
||||
f.seeded = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeConfigRepository) DeleteAll(_ context.Context) error {
|
||||
if f.shouldFailDelete {
|
||||
return errors.New("repository delete all failed")
|
||||
}
|
||||
f.configs = make(map[models.ConfigCategory]*models.TenantConfig)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeConfigRepository) GetLatestUpdatedAt(_ context.Context) (time.Time, error) {
|
||||
var latest time.Time
|
||||
for _, tc := range f.configs {
|
||||
if tc.UpdatedAt.After(latest) {
|
||||
latest = tc.UpdatedAt
|
||||
}
|
||||
}
|
||||
return latest, nil
|
||||
}
|
||||
|
||||
// createTestConfigService creates a ConfigService with a fake repository for testing
|
||||
func createTestConfigService() (*ConfigService, *fakeConfigRepository) {
|
||||
repo := newFakeConfigRepository()
|
||||
envConfig := &config.Config{
|
||||
App: config.AppConfig{
|
||||
Organisation: "Test Org",
|
||||
OnlyAdminCanCreate: false,
|
||||
},
|
||||
Auth: config.AuthConfig{
|
||||
OAuthEnabled: true,
|
||||
MagicLinkEnabled: false,
|
||||
},
|
||||
OAuth: config.OAuthConfig{
|
||||
ClientID: "test-client-id",
|
||||
ClientSecret: "test-client-secret",
|
||||
AuthURL: "https://accounts.google.com/o/oauth2/auth",
|
||||
TokenURL: "https://oauth2.googleapis.com/token",
|
||||
UserInfoURL: "https://openidconnect.googleapis.com/v1/userinfo",
|
||||
Scopes: []string{"openid", "email", "profile"},
|
||||
AllowedDomain: "@example.com",
|
||||
},
|
||||
Mail: config.MailConfig{
|
||||
Host: "smtp.example.com",
|
||||
Port: 587,
|
||||
Username: "test@example.com",
|
||||
Password: "smtp-password",
|
||||
TLS: false,
|
||||
StartTLS: true,
|
||||
From: "noreply@example.com",
|
||||
FromName: "Test App",
|
||||
Timeout: "10s",
|
||||
},
|
||||
Storage: config.StorageConfig{
|
||||
Type: "local",
|
||||
MaxSizeMB: 50,
|
||||
LocalPath: "/data/documents",
|
||||
},
|
||||
}
|
||||
encryptionKey := make([]byte, 32)
|
||||
for i := range encryptionKey {
|
||||
encryptionKey[i] = byte(i)
|
||||
}
|
||||
|
||||
svc := NewConfigService(repo, envConfig, encryptionKey)
|
||||
return svc, repo
|
||||
}
|
||||
|
||||
func TestNewConfigService(t *testing.T) {
|
||||
svc, _ := createTestConfigService()
|
||||
if svc == nil {
|
||||
t.Fatal("expected non-nil ConfigService")
|
||||
}
|
||||
|
||||
cfg := svc.GetConfig()
|
||||
if cfg == nil {
|
||||
t.Fatal("expected non-nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigService_Initialize_FirstStartup(t *testing.T) {
|
||||
svc, repo := createTestConfigService()
|
||||
ctx := context.Background()
|
||||
|
||||
err := svc.Initialize(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify config was seeded
|
||||
if !repo.seeded {
|
||||
t.Error("expected config to be marked as seeded")
|
||||
}
|
||||
|
||||
// Verify configs were created
|
||||
if len(repo.configs) == 0 {
|
||||
t.Error("expected configs to be created")
|
||||
}
|
||||
|
||||
// Verify we can get the config
|
||||
cfg := svc.GetConfig()
|
||||
if cfg.General.Organisation != "Test Org" {
|
||||
t.Errorf("expected organisation 'Test Org', got '%s'", cfg.General.Organisation)
|
||||
}
|
||||
if !cfg.OIDC.Enabled {
|
||||
t.Error("expected OIDC to be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigService_Initialize_AlreadySeeded(t *testing.T) {
|
||||
svc, repo := createTestConfigService()
|
||||
ctx := context.Background()
|
||||
|
||||
// Pre-seed with existing config
|
||||
repo.seeded = true
|
||||
generalCfg, _ := json.Marshal(models.GeneralConfig{
|
||||
Organisation: "Existing Org",
|
||||
OnlyAdminCanCreate: true,
|
||||
})
|
||||
repo.configs[models.ConfigCategoryGeneral] = &models.TenantConfig{
|
||||
Category: models.ConfigCategoryGeneral,
|
||||
Config: generalCfg,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
err := svc.Initialize(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify it loaded from DB, not ENV
|
||||
cfg := svc.GetConfig()
|
||||
if cfg.General.Organisation != "Existing Org" {
|
||||
t.Errorf("expected organisation 'Existing Org', got '%s'", cfg.General.Organisation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigService_UpdateSection_General(t *testing.T) {
|
||||
svc, repo := createTestConfigService()
|
||||
ctx := context.Background()
|
||||
|
||||
// First initialize
|
||||
_ = svc.Initialize(ctx)
|
||||
|
||||
// Update general config
|
||||
input := json.RawMessage(`{"organisation": "Updated Org", "only_admin_can_create": true}`)
|
||||
err := svc.UpdateSection(ctx, models.ConfigCategoryGeneral, input, "admin@test.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateSection failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify update
|
||||
cfg := svc.GetConfig()
|
||||
if cfg.General.Organisation != "Updated Org" {
|
||||
t.Errorf("expected organisation 'Updated Org', got '%s'", cfg.General.Organisation)
|
||||
}
|
||||
if !cfg.General.OnlyAdminCanCreate {
|
||||
t.Error("expected OnlyAdminCanCreate to be true")
|
||||
}
|
||||
|
||||
// Verify it was saved to repo
|
||||
tc, err := repo.GetByCategory(ctx, models.ConfigCategoryGeneral)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByCategory failed: %v", err)
|
||||
}
|
||||
var savedCfg models.GeneralConfig
|
||||
_ = json.Unmarshal(tc.Config, &savedCfg)
|
||||
if savedCfg.Organisation != "Updated Org" {
|
||||
t.Errorf("expected saved organisation 'Updated Org', got '%s'", savedCfg.Organisation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigService_UpdateSection_InvalidCategory(t *testing.T) {
|
||||
svc, _ := createTestConfigService()
|
||||
ctx := context.Background()
|
||||
|
||||
_ = svc.Initialize(ctx)
|
||||
|
||||
input := json.RawMessage(`{"foo": "bar"}`)
|
||||
err := svc.UpdateSection(ctx, "invalid", input, "admin@test.com")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid category")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigService_UpdateSection_ValidationError(t *testing.T) {
|
||||
svc, _ := createTestConfigService()
|
||||
ctx := context.Background()
|
||||
|
||||
_ = svc.Initialize(ctx)
|
||||
|
||||
// Disable both auth methods - should fail validation
|
||||
oidcInput := json.RawMessage(`{"enabled": false, "provider": ""}`)
|
||||
err := svc.UpdateSection(ctx, models.ConfigCategoryOIDC, oidcInput, "admin@test.com")
|
||||
if err == nil {
|
||||
t.Error("expected error when disabling all auth methods")
|
||||
}
|
||||
if !errors.Is(err, ErrNoAuthMethod) {
|
||||
t.Errorf("expected ErrNoAuthMethod, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigService_UpdateSection_MagicLinkRequiresSMTP(t *testing.T) {
|
||||
svc, repo := createTestConfigService()
|
||||
ctx := context.Background()
|
||||
|
||||
_ = svc.Initialize(ctx)
|
||||
|
||||
// Clear SMTP config
|
||||
emptySMTP, _ := json.Marshal(models.SMTPConfig{})
|
||||
repo.configs[models.ConfigCategorySMTP] = &models.TenantConfig{
|
||||
Category: models.ConfigCategorySMTP,
|
||||
Config: emptySMTP,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
_ = svc.reload(ctx)
|
||||
|
||||
// Try to enable MagicLink without SMTP
|
||||
input := json.RawMessage(`{"enabled": true}`)
|
||||
err := svc.UpdateSection(ctx, models.ConfigCategoryMagicLink, input, "admin@test.com")
|
||||
if err == nil {
|
||||
t.Error("expected error when enabling MagicLink without SMTP")
|
||||
}
|
||||
if !errors.Is(err, ErrMagicLinkNeedsSMTP) {
|
||||
t.Errorf("expected ErrMagicLinkNeedsSMTP, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigService_UpdateSection_OIDCCustomRequiresURLs(t *testing.T) {
|
||||
svc, _ := createTestConfigService()
|
||||
ctx := context.Background()
|
||||
|
||||
_ = svc.Initialize(ctx)
|
||||
|
||||
// Enable custom OIDC without URLs
|
||||
input := json.RawMessage(`{"enabled": true, "provider": "custom", "client_id": "test"}`)
|
||||
err := svc.UpdateSection(ctx, models.ConfigCategoryOIDC, input, "admin@test.com")
|
||||
if err == nil {
|
||||
t.Error("expected error when enabling custom OIDC without URLs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigService_ResetFromENV(t *testing.T) {
|
||||
svc, repo := createTestConfigService()
|
||||
ctx := context.Background()
|
||||
|
||||
_ = svc.Initialize(ctx)
|
||||
|
||||
// Modify config
|
||||
input := json.RawMessage(`{"organisation": "Modified Org", "only_admin_can_create": true}`)
|
||||
_ = svc.UpdateSection(ctx, models.ConfigCategoryGeneral, input, "admin@test.com")
|
||||
|
||||
cfg := svc.GetConfig()
|
||||
if cfg.General.Organisation != "Modified Org" {
|
||||
t.Fatalf("expected 'Modified Org', got '%s'", cfg.General.Organisation)
|
||||
}
|
||||
|
||||
// Reset from ENV
|
||||
err := svc.ResetFromENV(ctx, "admin@test.com")
|
||||
if err != nil {
|
||||
t.Fatalf("ResetFromENV failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify it was reset to ENV values
|
||||
cfg = svc.GetConfig()
|
||||
if cfg.General.Organisation != "Test Org" {
|
||||
t.Errorf("expected organisation 'Test Org' after reset, got '%s'", cfg.General.Organisation)
|
||||
}
|
||||
|
||||
// Verify repo was cleared and reseeded
|
||||
if len(repo.configs) == 0 {
|
||||
t.Error("expected configs to be present after reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigService_Subscribe(t *testing.T) {
|
||||
svc, _ := createTestConfigService()
|
||||
ctx := context.Background()
|
||||
|
||||
_ = svc.Initialize(ctx)
|
||||
|
||||
// Subscribe
|
||||
ch := svc.Subscribe()
|
||||
if ch == nil {
|
||||
t.Fatal("expected non-nil channel")
|
||||
}
|
||||
|
||||
// Update config - should trigger notification
|
||||
go func() {
|
||||
input := json.RawMessage(`{"organisation": "Notified Org", "only_admin_can_create": false}`)
|
||||
_ = svc.UpdateSection(ctx, models.ConfigCategoryGeneral, input, "admin@test.com")
|
||||
}()
|
||||
|
||||
// Wait for notification
|
||||
select {
|
||||
case cfg := <-ch:
|
||||
if cfg.General.Organisation != "Notified Org" {
|
||||
t.Errorf("expected 'Notified Org', got '%s'", cfg.General.Organisation)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("timeout waiting for config notification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigService_CloseAllSubscribers(t *testing.T) {
|
||||
svc, _ := createTestConfigService()
|
||||
ctx := context.Background()
|
||||
|
||||
_ = svc.Initialize(ctx)
|
||||
|
||||
ch := svc.Subscribe()
|
||||
svc.CloseAllSubscribers()
|
||||
|
||||
// Channel should be closed
|
||||
_, ok := <-ch
|
||||
if ok {
|
||||
t.Error("expected channel to be closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigService_EncryptDecryptSecrets(t *testing.T) {
|
||||
svc, _ := createTestConfigService()
|
||||
|
||||
plaintext := []byte(`{"password":"secret123"}`)
|
||||
|
||||
encrypted, err := svc.encryptSecrets(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("encryptSecrets failed: %v", err)
|
||||
}
|
||||
|
||||
if len(encrypted) == 0 {
|
||||
t.Error("expected non-empty encrypted data")
|
||||
}
|
||||
|
||||
decrypted, err := svc.decryptSecrets(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("decryptSecrets failed: %v", err)
|
||||
}
|
||||
|
||||
if string(decrypted) != string(plaintext) {
|
||||
t.Errorf("expected '%s', got '%s'", plaintext, decrypted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigService_ValidateSection_Storage(t *testing.T) {
|
||||
svc, _ := createTestConfigService()
|
||||
ctx := context.Background()
|
||||
|
||||
_ = svc.Initialize(ctx)
|
||||
|
||||
// S3 without bucket should fail
|
||||
input := json.RawMessage(`{"type": "s3", "max_size_mb": 50, "s3_endpoint": "s3.amazonaws.com"}`)
|
||||
err := svc.UpdateSection(ctx, models.ConfigCategoryStorage, input, "admin@test.com")
|
||||
if err == nil {
|
||||
t.Error("expected error when S3 type without bucket")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigService_UpdateSection_PreserveMaskedSecrets(t *testing.T) {
|
||||
svc, _ := createTestConfigService()
|
||||
ctx := context.Background()
|
||||
|
||||
_ = svc.Initialize(ctx)
|
||||
|
||||
// First set a secret
|
||||
input := json.RawMessage(`{"enabled": true, "provider": "google", "client_id": "id123", "client_secret": "secret123"}`)
|
||||
err := svc.UpdateSection(ctx, models.ConfigCategoryOIDC, input, "admin@test.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateSection failed: %v", err)
|
||||
}
|
||||
|
||||
cfg := svc.GetConfig()
|
||||
if cfg.OIDC.ClientSecret != "secret123" {
|
||||
t.Fatalf("expected secret to be stored")
|
||||
}
|
||||
|
||||
// Update with masked secret - should preserve original
|
||||
maskedInput := json.RawMessage(`{"enabled": true, "provider": "google", "client_id": "id123", "client_secret": "********"}`)
|
||||
err = svc.UpdateSection(ctx, models.ConfigCategoryOIDC, maskedInput, "admin@test.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateSection with masked secret failed: %v", err)
|
||||
}
|
||||
|
||||
cfg = svc.GetConfig()
|
||||
if cfg.OIDC.ClientSecret != "secret123" {
|
||||
t.Errorf("expected secret to be preserved, got '%s'", cfg.OIDC.ClientSecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigService_DetectOAuthProvider(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
authURL string
|
||||
expected string
|
||||
}{
|
||||
{"Google", "https://accounts.google.com/o/oauth2/auth", "google"},
|
||||
{"GitHub", "https://github.com/login/oauth/authorize", "github"},
|
||||
{"GitLab", "https://gitlab.com/oauth/authorize", "gitlab"},
|
||||
{"Custom", "https://auth.custom.com/authorize", "custom"},
|
||||
{"Empty", "", ""},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
repo := newFakeConfigRepository()
|
||||
envConfig := &config.Config{
|
||||
OAuth: config.OAuthConfig{
|
||||
AuthURL: tc.authURL,
|
||||
},
|
||||
}
|
||||
svc := NewConfigService(repo, envConfig, make([]byte, 32))
|
||||
result := svc.detectOAuthProvider()
|
||||
if result != tc.expected {
|
||||
t.Errorf("expected '%s', got '%s'", tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigService_Initialize_RepoError(t *testing.T) {
|
||||
svc, repo := createTestConfigService()
|
||||
repo.shouldFailSeeded = true
|
||||
ctx := context.Background()
|
||||
|
||||
err := svc.Initialize(ctx)
|
||||
if err == nil {
|
||||
t.Error("expected error when repo fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigService_UpdateSection_RepoError(t *testing.T) {
|
||||
svc, repo := createTestConfigService()
|
||||
ctx := context.Background()
|
||||
|
||||
_ = svc.Initialize(ctx)
|
||||
repo.shouldFailUpsert = true
|
||||
|
||||
input := json.RawMessage(`{"organisation": "Test", "only_admin_can_create": false}`)
|
||||
err := svc.UpdateSection(ctx, models.ConfigCategoryGeneral, input, "admin@test.com")
|
||||
if err == nil {
|
||||
t.Error("expected error when repo fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigService_ResetFromENV_DeleteError(t *testing.T) {
|
||||
svc, repo := createTestConfigService()
|
||||
ctx := context.Background()
|
||||
|
||||
_ = svc.Initialize(ctx)
|
||||
repo.shouldFailDelete = true
|
||||
|
||||
err := svc.ResetFromENV(ctx, "admin@test.com")
|
||||
if err == nil {
|
||||
t.Error("expected error when delete fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigCategory_IsValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
category models.ConfigCategory
|
||||
valid bool
|
||||
}{
|
||||
{models.ConfigCategoryGeneral, true},
|
||||
{models.ConfigCategoryOIDC, true},
|
||||
{models.ConfigCategoryMagicLink, true},
|
||||
{models.ConfigCategorySMTP, true},
|
||||
{models.ConfigCategoryStorage, true},
|
||||
{"invalid", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(string(tc.category), func(t *testing.T) {
|
||||
result := tc.category.IsValid()
|
||||
if result != tc.valid {
|
||||
t.Errorf("expected %v, got %v", tc.valid, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutableConfig_HasAtLeastOneAuthMethod(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
oidcEnabled bool
|
||||
magicEnabled bool
|
||||
expected bool
|
||||
}{
|
||||
{"Both enabled", true, true, true},
|
||||
{"Only OIDC", true, false, true},
|
||||
{"Only MagicLink", false, true, true},
|
||||
{"Neither", false, false, false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := &models.MutableConfig{
|
||||
OIDC: models.OIDCConfig{Enabled: tc.oidcEnabled},
|
||||
MagicLink: models.MagicLinkConfig{Enabled: tc.magicEnabled},
|
||||
}
|
||||
result := cfg.HasAtLeastOneAuthMethod()
|
||||
if result != tc.expected {
|
||||
t.Errorf("expected %v, got %v", tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutableConfig_MagicLinkRequiresSMTP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
magicEnabled bool
|
||||
smtpHost string
|
||||
expected bool
|
||||
}{
|
||||
{"MagicLink disabled", false, "", true},
|
||||
{"MagicLink enabled with SMTP", true, "smtp.test.com", true},
|
||||
{"MagicLink enabled without SMTP", true, "", false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := &models.MutableConfig{
|
||||
MagicLink: models.MagicLinkConfig{Enabled: tc.magicEnabled},
|
||||
SMTP: models.SMTPConfig{Host: tc.smtpHost},
|
||||
}
|
||||
result := cfg.MagicLinkRequiresSMTP()
|
||||
if result != tc.expected {
|
||||
t.Errorf("expected %v, got %v", tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutableConfig_MaskSecrets(t *testing.T) {
|
||||
cfg := &models.MutableConfig{
|
||||
OIDC: models.OIDCConfig{ClientSecret: "secret123"},
|
||||
SMTP: models.SMTPConfig{Password: "smtppass"},
|
||||
Storage: models.StorageConfig{S3SecretKey: "s3key"},
|
||||
}
|
||||
|
||||
masked := cfg.MaskSecrets()
|
||||
|
||||
if masked.OIDC.ClientSecret != models.SecretMask {
|
||||
t.Errorf("expected OIDC secret to be masked")
|
||||
}
|
||||
if masked.SMTP.Password != models.SecretMask {
|
||||
t.Errorf("expected SMTP password to be masked")
|
||||
}
|
||||
if masked.Storage.S3SecretKey != models.SecretMask {
|
||||
t.Errorf("expected S3 secret to be masked")
|
||||
}
|
||||
|
||||
// Original should be unchanged
|
||||
if cfg.OIDC.ClientSecret != "secret123" {
|
||||
t.Errorf("original OIDC secret should be unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSecretMasked(t *testing.T) {
|
||||
tests := []struct {
|
||||
value string
|
||||
expected bool
|
||||
}{
|
||||
{models.SecretMask, true},
|
||||
{"********", true},
|
||||
{"secret123", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.value, func(t *testing.T) {
|
||||
result := models.IsSecretMasked(tc.value)
|
||||
if result != tc.expected {
|
||||
t.Errorf("expected %v, got %v", tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
188
backend/internal/domain/models/tenant_config.go
Normal file
188
backend/internal/domain/models/tenant_config.go
Normal file
@@ -0,0 +1,188 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ConfigCategory represents the category of configuration
|
||||
type ConfigCategory string
|
||||
|
||||
const (
|
||||
ConfigCategoryGeneral ConfigCategory = "general"
|
||||
ConfigCategoryOIDC ConfigCategory = "oidc"
|
||||
ConfigCategoryMagicLink ConfigCategory = "magiclink"
|
||||
ConfigCategorySMTP ConfigCategory = "smtp"
|
||||
ConfigCategoryStorage ConfigCategory = "storage"
|
||||
)
|
||||
|
||||
// AllConfigCategories returns all valid configuration categories
|
||||
func AllConfigCategories() []ConfigCategory {
|
||||
return []ConfigCategory{
|
||||
ConfigCategoryGeneral,
|
||||
ConfigCategoryOIDC,
|
||||
ConfigCategoryMagicLink,
|
||||
ConfigCategorySMTP,
|
||||
ConfigCategoryStorage,
|
||||
}
|
||||
}
|
||||
|
||||
// IsValid checks if the category is valid
|
||||
func (c ConfigCategory) IsValid() bool {
|
||||
switch c {
|
||||
case ConfigCategoryGeneral, ConfigCategoryOIDC, ConfigCategoryMagicLink,
|
||||
ConfigCategorySMTP, ConfigCategoryStorage:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TenantConfig represents a configuration section stored in the database
|
||||
type TenantConfig struct {
|
||||
ID int64 `json:"id" db:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
|
||||
Category ConfigCategory `json:"category" db:"category"`
|
||||
Config json.RawMessage `json:"config" db:"config"`
|
||||
SecretsEncrypted []byte `json:"-" db:"secrets_encrypted"`
|
||||
Version int `json:"version" db:"version"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
UpdatedBy *string `json:"updated_by,omitempty" db:"updated_by"`
|
||||
}
|
||||
|
||||
// GeneralConfig holds general application settings
|
||||
type GeneralConfig struct {
|
||||
Organisation string `json:"organisation"`
|
||||
OnlyAdminCanCreate bool `json:"only_admin_can_create"`
|
||||
}
|
||||
|
||||
// OIDCConfig holds OIDC/OAuth2 authentication settings
|
||||
type OIDCConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Provider string `json:"provider"` // google, github, gitlab, custom
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
AuthURL string `json:"auth_url,omitempty"`
|
||||
TokenURL string `json:"token_url,omitempty"`
|
||||
UserInfoURL string `json:"userinfo_url,omitempty"`
|
||||
LogoutURL string `json:"logout_url,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
AllowedDomain string `json:"allowed_domain,omitempty"`
|
||||
AutoLogin bool `json:"auto_login"`
|
||||
}
|
||||
|
||||
// OIDCSecrets holds the secret fields for OIDC config
|
||||
type OIDCSecrets struct {
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
}
|
||||
|
||||
// MagicLinkConfig holds MagicLink authentication settings
|
||||
type MagicLinkConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// SMTPConfig holds SMTP email settings
|
||||
type SMTPConfig struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
TLS bool `json:"tls"`
|
||||
StartTLS bool `json:"starttls"`
|
||||
InsecureSkipVerify bool `json:"insecure_skip_verify"`
|
||||
Timeout string `json:"timeout"`
|
||||
From string `json:"from"`
|
||||
FromName string `json:"from_name"`
|
||||
SubjectPrefix string `json:"subject_prefix,omitempty"`
|
||||
}
|
||||
|
||||
// SMTPSecrets holds the secret fields for SMTP config
|
||||
type SMTPSecrets struct {
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
// IsConfigured returns true if SMTP is properly configured
|
||||
func (c *SMTPConfig) IsConfigured() bool {
|
||||
return c.Host != "" && c.From != ""
|
||||
}
|
||||
|
||||
// StorageConfig holds document storage settings
|
||||
type StorageConfig struct {
|
||||
Type string `json:"type"` // "", "local", "s3"
|
||||
MaxSizeMB int64 `json:"max_size_mb"`
|
||||
LocalPath string `json:"local_path,omitempty"`
|
||||
S3Endpoint string `json:"s3_endpoint,omitempty"`
|
||||
S3Bucket string `json:"s3_bucket,omitempty"`
|
||||
S3AccessKey string `json:"s3_access_key,omitempty"`
|
||||
S3SecretKey string `json:"s3_secret_key,omitempty"`
|
||||
S3Region string `json:"s3_region,omitempty"`
|
||||
S3UseSSL bool `json:"s3_use_ssl"`
|
||||
}
|
||||
|
||||
// StorageSecrets holds the secret fields for Storage config
|
||||
type StorageSecrets struct {
|
||||
S3SecretKey string `json:"s3_secret_key,omitempty"`
|
||||
}
|
||||
|
||||
// IsEnabled returns true if storage is enabled
|
||||
func (c *StorageConfig) IsEnabled() bool {
|
||||
return c.Type == "local" || c.Type == "s3"
|
||||
}
|
||||
|
||||
// MutableConfig combines all mutable configuration sections
|
||||
type MutableConfig struct {
|
||||
General GeneralConfig `json:"general"`
|
||||
OIDC OIDCConfig `json:"oidc"`
|
||||
MagicLink MagicLinkConfig `json:"magiclink"`
|
||||
SMTP SMTPConfig `json:"smtp"`
|
||||
Storage StorageConfig `json:"storage"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ConfigSecrets holds all encrypted secrets
|
||||
type ConfigSecrets struct {
|
||||
OIDCClientSecret string `json:"oidc_client_secret,omitempty"`
|
||||
SMTPPassword string `json:"smtp_password,omitempty"`
|
||||
S3SecretKey string `json:"s3_secret_key,omitempty"`
|
||||
}
|
||||
|
||||
// HasAtLeastOneAuthMethod validates that at least one auth method is enabled
|
||||
func (c *MutableConfig) HasAtLeastOneAuthMethod() bool {
|
||||
return c.OIDC.Enabled || c.MagicLink.Enabled
|
||||
}
|
||||
|
||||
// MagicLinkRequiresSMTP validates that MagicLink has SMTP configured
|
||||
func (c *MutableConfig) MagicLinkRequiresSMTP() bool {
|
||||
if !c.MagicLink.Enabled {
|
||||
return true
|
||||
}
|
||||
return c.SMTP.IsConfigured()
|
||||
}
|
||||
|
||||
// SecretMask is the value returned for masked secrets
|
||||
const SecretMask = "********"
|
||||
|
||||
// MaskSecrets returns a copy of MutableConfig with secrets masked
|
||||
func (c *MutableConfig) MaskSecrets() *MutableConfig {
|
||||
masked := *c
|
||||
|
||||
if masked.OIDC.ClientSecret != "" {
|
||||
masked.OIDC.ClientSecret = SecretMask
|
||||
}
|
||||
if masked.SMTP.Password != "" {
|
||||
masked.SMTP.Password = SecretMask
|
||||
}
|
||||
if masked.Storage.S3SecretKey != "" {
|
||||
masked.Storage.S3SecretKey = SecretMask
|
||||
}
|
||||
|
||||
return &masked
|
||||
}
|
||||
|
||||
// IsSecretMasked checks if a value is the secret mask
|
||||
func IsSecretMasked(value string) bool {
|
||||
return value == SecretMask
|
||||
}
|
||||
203
backend/internal/infrastructure/database/config_repository.go
Normal file
203
backend/internal/infrastructure/database/config_repository.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/btouchard/ackify-ce/backend/internal/domain/models"
|
||||
"github.com/btouchard/ackify-ce/backend/internal/infrastructure/dbctx"
|
||||
"github.com/btouchard/ackify-ce/backend/internal/infrastructure/tenant"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ConfigRepository struct {
|
||||
db *sql.DB
|
||||
tenants tenant.Provider
|
||||
}
|
||||
|
||||
func NewConfigRepository(db *sql.DB, tenants tenant.Provider) *ConfigRepository {
|
||||
return &ConfigRepository{db: db, tenants: tenants}
|
||||
}
|
||||
|
||||
// GetByCategory retrieves a configuration section by category
|
||||
func (r *ConfigRepository) GetByCategory(ctx context.Context, category models.ConfigCategory) (*models.TenantConfig, error) {
|
||||
query := `
|
||||
SELECT id, tenant_id, category, config, secrets_encrypted, version, created_at, updated_at, updated_by
|
||||
FROM tenant_config
|
||||
WHERE category = $1
|
||||
`
|
||||
cfg := &models.TenantConfig{}
|
||||
var updatedBy sql.NullString
|
||||
var secretsEncrypted []byte
|
||||
|
||||
err := dbctx.GetQuerier(ctx, r.db).QueryRowContext(ctx, query, string(category)).Scan(
|
||||
&cfg.ID, &cfg.TenantID, &cfg.Category, &cfg.Config, &secretsEncrypted,
|
||||
&cfg.Version, &cfg.CreatedAt, &cfg.UpdatedAt, &updatedBy,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get config by category: %w", err)
|
||||
}
|
||||
|
||||
cfg.SecretsEncrypted = secretsEncrypted
|
||||
if updatedBy.Valid {
|
||||
cfg.UpdatedBy = &updatedBy.String
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// GetAll retrieves all configuration sections for the current tenant
|
||||
func (r *ConfigRepository) GetAll(ctx context.Context) ([]*models.TenantConfig, error) {
|
||||
query := `
|
||||
SELECT id, tenant_id, category, config, secrets_encrypted, version, created_at, updated_at, updated_by
|
||||
FROM tenant_config
|
||||
ORDER BY category
|
||||
`
|
||||
rows, err := dbctx.GetQuerier(ctx, r.db).QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list configs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var configs []*models.TenantConfig
|
||||
for rows.Next() {
|
||||
cfg := &models.TenantConfig{}
|
||||
var updatedBy sql.NullString
|
||||
var secretsEncrypted []byte
|
||||
|
||||
if err := rows.Scan(
|
||||
&cfg.ID, &cfg.TenantID, &cfg.Category, &cfg.Config, &secretsEncrypted,
|
||||
&cfg.Version, &cfg.CreatedAt, &cfg.UpdatedAt, &updatedBy,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan config row: %w", err)
|
||||
}
|
||||
|
||||
cfg.SecretsEncrypted = secretsEncrypted
|
||||
if updatedBy.Valid {
|
||||
cfg.UpdatedBy = &updatedBy.String
|
||||
}
|
||||
configs = append(configs, cfg)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating config rows: %w", err)
|
||||
}
|
||||
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
// Upsert creates or updates a configuration section
|
||||
func (r *ConfigRepository) Upsert(ctx context.Context, category models.ConfigCategory, config json.RawMessage, secrets []byte, updatedBy string) error {
|
||||
tenantID, err := r.tenants.CurrentTenant(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get tenant: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO tenant_config (tenant_id, category, config, secrets_encrypted, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (tenant_id, category)
|
||||
DO UPDATE SET
|
||||
config = EXCLUDED.config,
|
||||
secrets_encrypted = COALESCE(EXCLUDED.secrets_encrypted, tenant_config.secrets_encrypted),
|
||||
updated_by = EXCLUDED.updated_by
|
||||
`
|
||||
|
||||
var secretsArg interface{}
|
||||
if len(secrets) > 0 {
|
||||
secretsArg = secrets
|
||||
}
|
||||
|
||||
_, err = dbctx.GetQuerier(ctx, r.db).ExecContext(ctx, query,
|
||||
tenantID, string(category), config, secretsArg, updatedBy,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upsert config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsSeeded checks if configuration has been seeded from environment variables
|
||||
func (r *ConfigRepository) IsSeeded(ctx context.Context) (bool, error) {
|
||||
tenantID, err := r.tenants.CurrentTenant(ctx)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get tenant: %w", err)
|
||||
}
|
||||
|
||||
query := `SELECT config_seeded_at FROM instance_metadata WHERE id = $1`
|
||||
var seededAt sql.NullTime
|
||||
err = r.db.QueryRowContext(ctx, query, tenantID).Scan(&seededAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check seeded status: %w", err)
|
||||
}
|
||||
|
||||
return seededAt.Valid, nil
|
||||
}
|
||||
|
||||
// MarkSeeded marks configuration as seeded from environment variables
|
||||
func (r *ConfigRepository) MarkSeeded(ctx context.Context) error {
|
||||
tenantID, err := r.tenants.CurrentTenant(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get tenant: %w", err)
|
||||
}
|
||||
|
||||
query := `UPDATE instance_metadata SET config_seeded_at = NOW() WHERE id = $1`
|
||||
_, err = r.db.ExecContext(ctx, query, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to mark config as seeded: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearSeeded clears the seeded flag (for reset functionality)
|
||||
func (r *ConfigRepository) ClearSeeded(ctx context.Context) error {
|
||||
tenantID, err := r.tenants.CurrentTenant(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get tenant: %w", err)
|
||||
}
|
||||
|
||||
query := `UPDATE instance_metadata SET config_seeded_at = NULL WHERE id = $1`
|
||||
_, err = r.db.ExecContext(ctx, query, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to clear seeded status: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAll removes all configuration for the current tenant (for reset)
|
||||
func (r *ConfigRepository) DeleteAll(ctx context.Context) error {
|
||||
query := `DELETE FROM tenant_config`
|
||||
_, err := dbctx.GetQuerier(ctx, r.db).ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete all configs: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTenantID returns the current tenant ID
|
||||
func (r *ConfigRepository) GetTenantID(ctx context.Context) (uuid.UUID, error) {
|
||||
return r.tenants.CurrentTenant(ctx)
|
||||
}
|
||||
|
||||
// GetLatestUpdatedAt returns the most recent updated_at across all config sections
|
||||
func (r *ConfigRepository) GetLatestUpdatedAt(ctx context.Context) (time.Time, error) {
|
||||
query := `SELECT COALESCE(MAX(updated_at), NOW()) FROM tenant_config`
|
||||
var updatedAt time.Time
|
||||
err := dbctx.GetQuerier(ctx, r.db).QueryRowContext(ctx, query).Scan(&updatedAt)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("failed to get latest updated_at: %w", err)
|
||||
}
|
||||
return updatedAt, nil
|
||||
}
|
||||
247
backend/internal/presentation/api/admin/settings_handler.go
Normal file
247
backend/internal/presentation/api/admin/settings_handler.go
Normal file
@@ -0,0 +1,247 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/btouchard/ackify-ce/backend/internal/domain/models"
|
||||
"github.com/btouchard/ackify-ce/backend/internal/presentation/api/shared"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// configService defines the interface for configuration management
|
||||
type configService interface {
|
||||
GetConfig() *models.MutableConfig
|
||||
UpdateSection(ctx context.Context, category models.ConfigCategory, input json.RawMessage, updatedBy string) error
|
||||
TestSMTP(ctx context.Context, cfg models.SMTPConfig) error
|
||||
TestS3(ctx context.Context, cfg models.StorageConfig) error
|
||||
TestOIDC(ctx context.Context, cfg models.OIDCConfig) error
|
||||
ResetFromENV(ctx context.Context, updatedBy string) error
|
||||
}
|
||||
|
||||
// SettingsHandler handles admin settings endpoints
|
||||
type SettingsHandler struct {
|
||||
configService configService
|
||||
}
|
||||
|
||||
// NewSettingsHandler creates a new settings handler
|
||||
func NewSettingsHandler(configService configService) *SettingsHandler {
|
||||
return &SettingsHandler{configService: configService}
|
||||
}
|
||||
|
||||
// SettingsResponse represents the full settings response
|
||||
type SettingsResponse struct {
|
||||
General models.GeneralConfig `json:"general"`
|
||||
OIDC OIDCResponse `json:"oidc"`
|
||||
MagicLink models.MagicLinkConfig `json:"magiclink"`
|
||||
SMTP SMTPResponse `json:"smtp"`
|
||||
Storage StorageResponse `json:"storage"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// OIDCResponse is OIDCConfig with masked secrets
|
||||
type OIDCResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Provider string `json:"provider"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
AuthURL string `json:"auth_url,omitempty"`
|
||||
TokenURL string `json:"token_url,omitempty"`
|
||||
UserInfoURL string `json:"userinfo_url,omitempty"`
|
||||
LogoutURL string `json:"logout_url,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
AllowedDomain string `json:"allowed_domain,omitempty"`
|
||||
AutoLogin bool `json:"auto_login"`
|
||||
}
|
||||
|
||||
// SMTPResponse is SMTPConfig with masked secrets
|
||||
type SMTPResponse struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
TLS bool `json:"tls"`
|
||||
StartTLS bool `json:"starttls"`
|
||||
InsecureSkipVerify bool `json:"insecure_skip_verify"`
|
||||
Timeout string `json:"timeout"`
|
||||
From string `json:"from"`
|
||||
FromName string `json:"from_name"`
|
||||
SubjectPrefix string `json:"subject_prefix,omitempty"`
|
||||
}
|
||||
|
||||
// StorageResponse is StorageConfig with masked secrets
|
||||
type StorageResponse struct {
|
||||
Type string `json:"type"`
|
||||
MaxSizeMB int64 `json:"max_size_mb"`
|
||||
LocalPath string `json:"local_path,omitempty"`
|
||||
S3Endpoint string `json:"s3_endpoint,omitempty"`
|
||||
S3Bucket string `json:"s3_bucket,omitempty"`
|
||||
S3AccessKey string `json:"s3_access_key,omitempty"`
|
||||
S3SecretKey string `json:"s3_secret_key,omitempty"`
|
||||
S3Region string `json:"s3_region,omitempty"`
|
||||
S3UseSSL bool `json:"s3_use_ssl"`
|
||||
}
|
||||
|
||||
// HandleGetSettings handles GET /api/v1/admin/settings
|
||||
func (h *SettingsHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := h.configService.GetConfig()
|
||||
|
||||
// Build response with masked secrets
|
||||
response := SettingsResponse{
|
||||
General: cfg.General,
|
||||
OIDC: OIDCResponse{
|
||||
Enabled: cfg.OIDC.Enabled,
|
||||
Provider: cfg.OIDC.Provider,
|
||||
ClientID: cfg.OIDC.ClientID,
|
||||
ClientSecret: maskSecret(cfg.OIDC.ClientSecret),
|
||||
AuthURL: cfg.OIDC.AuthURL,
|
||||
TokenURL: cfg.OIDC.TokenURL,
|
||||
UserInfoURL: cfg.OIDC.UserInfoURL,
|
||||
LogoutURL: cfg.OIDC.LogoutURL,
|
||||
Scopes: cfg.OIDC.Scopes,
|
||||
AllowedDomain: cfg.OIDC.AllowedDomain,
|
||||
AutoLogin: cfg.OIDC.AutoLogin,
|
||||
},
|
||||
MagicLink: cfg.MagicLink,
|
||||
SMTP: SMTPResponse{
|
||||
Host: cfg.SMTP.Host,
|
||||
Port: cfg.SMTP.Port,
|
||||
Username: cfg.SMTP.Username,
|
||||
Password: maskSecret(cfg.SMTP.Password),
|
||||
TLS: cfg.SMTP.TLS,
|
||||
StartTLS: cfg.SMTP.StartTLS,
|
||||
InsecureSkipVerify: cfg.SMTP.InsecureSkipVerify,
|
||||
Timeout: cfg.SMTP.Timeout,
|
||||
From: cfg.SMTP.From,
|
||||
FromName: cfg.SMTP.FromName,
|
||||
SubjectPrefix: cfg.SMTP.SubjectPrefix,
|
||||
},
|
||||
Storage: StorageResponse{
|
||||
Type: cfg.Storage.Type,
|
||||
MaxSizeMB: cfg.Storage.MaxSizeMB,
|
||||
LocalPath: cfg.Storage.LocalPath,
|
||||
S3Endpoint: cfg.Storage.S3Endpoint,
|
||||
S3Bucket: cfg.Storage.S3Bucket,
|
||||
S3AccessKey: cfg.Storage.S3AccessKey,
|
||||
S3SecretKey: maskSecret(cfg.Storage.S3SecretKey),
|
||||
S3Region: cfg.Storage.S3Region,
|
||||
S3UseSSL: cfg.Storage.S3UseSSL,
|
||||
},
|
||||
UpdatedAt: cfg.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
|
||||
shared.WriteJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// HandleUpdateSection handles PUT /api/v1/admin/settings/{section}
|
||||
func (h *SettingsHandler) HandleUpdateSection(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
section := chi.URLParam(r, "section")
|
||||
|
||||
category, err := parseCategory(section)
|
||||
if err != nil {
|
||||
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Invalid section: "+section, nil)
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := shared.GetUserFromContext(ctx)
|
||||
if !ok || user == nil {
|
||||
shared.WriteUnauthorized(w, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
var input json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Invalid JSON: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.configService.UpdateSection(ctx, category, input, user.Email); err != nil {
|
||||
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
shared.WriteJSON(w, http.StatusOK, map[string]string{"message": "Configuration updated"})
|
||||
}
|
||||
|
||||
// HandleTestConnection handles POST /api/v1/admin/settings/test/{type}
|
||||
func (h *SettingsHandler) HandleTestConnection(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
testType := chi.URLParam(r, "type")
|
||||
|
||||
var err error
|
||||
switch testType {
|
||||
case "smtp":
|
||||
var cfg models.SMTPConfig
|
||||
if decErr := json.NewDecoder(r.Body).Decode(&cfg); decErr != nil {
|
||||
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Invalid SMTP config: "+decErr.Error(), nil)
|
||||
return
|
||||
}
|
||||
err = h.configService.TestSMTP(ctx, cfg)
|
||||
|
||||
case "s3":
|
||||
var cfg models.StorageConfig
|
||||
if decErr := json.NewDecoder(r.Body).Decode(&cfg); decErr != nil {
|
||||
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Invalid storage config: "+decErr.Error(), nil)
|
||||
return
|
||||
}
|
||||
err = h.configService.TestS3(ctx, cfg)
|
||||
|
||||
case "oidc":
|
||||
var cfg models.OIDCConfig
|
||||
if decErr := json.NewDecoder(r.Body).Decode(&cfg); decErr != nil {
|
||||
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Invalid OIDC config: "+decErr.Error(), nil)
|
||||
return
|
||||
}
|
||||
err = h.configService.TestOIDC(ctx, cfg)
|
||||
|
||||
default:
|
||||
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Unknown test type: "+testType, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
shared.WriteJSON(w, http.StatusOK, map[string]string{"message": "Connection successful"})
|
||||
}
|
||||
|
||||
// HandleResetFromENV handles POST /api/v1/admin/settings/reset
|
||||
func (h *SettingsHandler) HandleResetFromENV(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
user, ok := shared.GetUserFromContext(ctx)
|
||||
if !ok || user == nil {
|
||||
shared.WriteUnauthorized(w, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.configService.ResetFromENV(ctx, user.Email); err != nil {
|
||||
shared.WriteError(w, http.StatusInternalServerError, shared.ErrCodeInternal, "Reset failed: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
shared.WriteJSON(w, http.StatusOK, map[string]string{"message": "Configuration reset from environment"})
|
||||
}
|
||||
|
||||
// parseCategory converts a string to a ConfigCategory
|
||||
func parseCategory(s string) (models.ConfigCategory, error) {
|
||||
category := models.ConfigCategory(s)
|
||||
if !category.IsValid() {
|
||||
return "", errors.New("invalid category")
|
||||
}
|
||||
return category, nil
|
||||
}
|
||||
|
||||
// maskSecret returns the mask if the secret is set, empty string otherwise
|
||||
func maskSecret(secret string) string {
|
||||
if secret == "" {
|
||||
return ""
|
||||
}
|
||||
return models.SecretMask
|
||||
}
|
||||
378
backend/internal/presentation/api/admin/settings_handler_test.go
Normal file
378
backend/internal/presentation/api/admin/settings_handler_test.go
Normal file
@@ -0,0 +1,378 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package admin_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/btouchard/ackify-ce/backend/internal/application/services"
|
||||
"github.com/btouchard/ackify-ce/backend/internal/domain/models"
|
||||
"github.com/btouchard/ackify-ce/backend/internal/infrastructure/database"
|
||||
"github.com/btouchard/ackify-ce/backend/internal/presentation/api/admin"
|
||||
"github.com/btouchard/ackify-ce/backend/internal/presentation/api/shared"
|
||||
"github.com/btouchard/ackify-ce/backend/pkg/config"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func setupConfigTestDB(t *testing.T) *database.TestDB {
|
||||
testDB := database.SetupTestDB(t)
|
||||
return testDB
|
||||
}
|
||||
|
||||
func createTestConfigService(t *testing.T, testDB *database.TestDB) *services.ConfigService {
|
||||
configRepo := database.NewConfigRepository(testDB.DB, testDB.TenantProvider)
|
||||
|
||||
envConfig := &config.Config{
|
||||
App: config.AppConfig{
|
||||
Organisation: "Test Org",
|
||||
OnlyAdminCanCreate: false,
|
||||
},
|
||||
Auth: config.AuthConfig{
|
||||
OAuthEnabled: true,
|
||||
MagicLinkEnabled: false,
|
||||
},
|
||||
OAuth: config.OAuthConfig{
|
||||
ClientID: "test-client-id",
|
||||
ClientSecret: "test-client-secret",
|
||||
AuthURL: "https://accounts.google.com/o/oauth2/auth",
|
||||
TokenURL: "https://oauth2.googleapis.com/token",
|
||||
UserInfoURL: "https://openidconnect.googleapis.com/v1/userinfo",
|
||||
Scopes: []string{"openid", "email", "profile"},
|
||||
},
|
||||
Mail: config.MailConfig{
|
||||
Host: "smtp.example.com",
|
||||
Port: 587,
|
||||
Username: "test@example.com",
|
||||
Password: "smtp-password",
|
||||
TLS: false,
|
||||
StartTLS: true,
|
||||
From: "noreply@example.com",
|
||||
FromName: "Test App",
|
||||
Timeout: "10s",
|
||||
},
|
||||
Storage: config.StorageConfig{
|
||||
Type: "local",
|
||||
MaxSizeMB: 50,
|
||||
LocalPath: "/data/documents",
|
||||
},
|
||||
}
|
||||
|
||||
encryptionKey := make([]byte, 32)
|
||||
for i := range encryptionKey {
|
||||
encryptionKey[i] = byte(i)
|
||||
}
|
||||
|
||||
svc := services.NewConfigService(configRepo, envConfig, encryptionKey)
|
||||
|
||||
ctx := context.Background()
|
||||
if err := svc.Initialize(ctx); err != nil {
|
||||
t.Fatalf("Failed to initialize config service: %v", err)
|
||||
}
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
func createTestUser() *models.User {
|
||||
return &models.User{
|
||||
Sub: "test-admin-sub",
|
||||
Email: "admin@example.com",
|
||||
Name: "Test Admin",
|
||||
IsAdmin: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsHandler_GetSettings(t *testing.T) {
|
||||
testDB := setupConfigTestDB(t)
|
||||
configService := createTestConfigService(t, testDB)
|
||||
handler := admin.NewSettingsHandler(configService)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetSettings(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Data struct {
|
||||
General models.GeneralConfig `json:"general"`
|
||||
OIDC admin.OIDCResponse `json:"oidc"`
|
||||
MagicLink models.MagicLinkConfig `json:"magiclink"`
|
||||
SMTP admin.SMTPResponse `json:"smtp"`
|
||||
Storage admin.StorageResponse `json:"storage"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
// Verify general config
|
||||
if response.Data.General.Organisation != "Test Org" {
|
||||
t.Errorf("Expected organisation 'Test Org', got '%s'", response.Data.General.Organisation)
|
||||
}
|
||||
|
||||
// Verify OIDC config - secret should be masked
|
||||
if response.Data.OIDC.ClientSecret != models.SecretMask {
|
||||
t.Errorf("Expected OIDC client_secret to be masked, got '%s'", response.Data.OIDC.ClientSecret)
|
||||
}
|
||||
|
||||
// Verify SMTP config - password should be masked
|
||||
if response.Data.SMTP.Password != models.SecretMask {
|
||||
t.Errorf("Expected SMTP password to be masked, got '%s'", response.Data.SMTP.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsHandler_UpdateSection_General(t *testing.T) {
|
||||
testDB := setupConfigTestDB(t)
|
||||
configService := createTestConfigService(t, testDB)
|
||||
handler := admin.NewSettingsHandler(configService)
|
||||
|
||||
// Create request with user context
|
||||
body := `{"organisation": "Updated Org", "only_admin_can_create": true}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings/general", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Add chi URL params
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("section", "general")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
|
||||
// Add user context
|
||||
user := createTestUser()
|
||||
req = req.WithContext(shared.SetUserInContext(req.Context(), user))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.HandleUpdateSection(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify the update
|
||||
cfg := configService.GetConfig()
|
||||
if cfg.General.Organisation != "Updated Org" {
|
||||
t.Errorf("Expected organisation 'Updated Org', got '%s'", cfg.General.Organisation)
|
||||
}
|
||||
if !cfg.General.OnlyAdminCanCreate {
|
||||
t.Error("Expected OnlyAdminCanCreate to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsHandler_UpdateSection_InvalidSection(t *testing.T) {
|
||||
testDB := setupConfigTestDB(t)
|
||||
configService := createTestConfigService(t, testDB)
|
||||
handler := admin.NewSettingsHandler(configService)
|
||||
|
||||
body := `{"foo": "bar"}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings/invalid", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("section", "invalid")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
|
||||
user := createTestUser()
|
||||
req = req.WithContext(shared.SetUserInContext(req.Context(), user))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.HandleUpdateSection(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsHandler_UpdateSection_ValidationError(t *testing.T) {
|
||||
testDB := setupConfigTestDB(t)
|
||||
configService := createTestConfigService(t, testDB)
|
||||
handler := admin.NewSettingsHandler(configService)
|
||||
|
||||
// Try to disable all auth methods
|
||||
body := `{"enabled": false, "provider": ""}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings/oidc", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("section", "oidc")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
|
||||
user := createTestUser()
|
||||
req = req.WithContext(shared.SetUserInContext(req.Context(), user))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.HandleUpdateSection(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("Expected status 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsHandler_UpdateSection_NoAuth(t *testing.T) {
|
||||
testDB := setupConfigTestDB(t)
|
||||
configService := createTestConfigService(t, testDB)
|
||||
handler := admin.NewSettingsHandler(configService)
|
||||
|
||||
body := `{"organisation": "Test"}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings/general", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("section", "general")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
|
||||
// No user context
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.HandleUpdateSection(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("Expected status 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsHandler_UpdateSection_InvalidJSON(t *testing.T) {
|
||||
testDB := setupConfigTestDB(t)
|
||||
configService := createTestConfigService(t, testDB)
|
||||
handler := admin.NewSettingsHandler(configService)
|
||||
|
||||
body := `{invalid json}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings/general", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("section", "general")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
|
||||
user := createTestUser()
|
||||
req = req.WithContext(shared.SetUserInContext(req.Context(), user))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.HandleUpdateSection(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsHandler_ResetFromENV(t *testing.T) {
|
||||
testDB := setupConfigTestDB(t)
|
||||
configService := createTestConfigService(t, testDB)
|
||||
handler := admin.NewSettingsHandler(configService)
|
||||
|
||||
// First modify the config
|
||||
ctx := context.Background()
|
||||
input := json.RawMessage(`{"organisation": "Modified Org", "only_admin_can_create": true}`)
|
||||
_ = configService.UpdateSection(ctx, models.ConfigCategoryGeneral, input, "admin@test.com")
|
||||
|
||||
// Verify modification
|
||||
cfg := configService.GetConfig()
|
||||
if cfg.General.Organisation != "Modified Org" {
|
||||
t.Fatalf("Setup failed: expected 'Modified Org'")
|
||||
}
|
||||
|
||||
// Call reset
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/settings/reset", nil)
|
||||
user := createTestUser()
|
||||
req = req.WithContext(shared.SetUserInContext(req.Context(), user))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.HandleResetFromENV(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify reset to ENV values
|
||||
cfg = configService.GetConfig()
|
||||
if cfg.General.Organisation != "Test Org" {
|
||||
t.Errorf("Expected organisation 'Test Org' after reset, got '%s'", cfg.General.Organisation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsHandler_ResetFromENV_NoAuth(t *testing.T) {
|
||||
testDB := setupConfigTestDB(t)
|
||||
configService := createTestConfigService(t, testDB)
|
||||
handler := admin.NewSettingsHandler(configService)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/settings/reset", nil)
|
||||
// No user context
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.HandleResetFromENV(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("Expected status 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsHandler_TestConnection_InvalidType(t *testing.T) {
|
||||
testDB := setupConfigTestDB(t)
|
||||
configService := createTestConfigService(t, testDB)
|
||||
handler := admin.NewSettingsHandler(configService)
|
||||
|
||||
body := `{}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/settings/test/invalid", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("type", "invalid")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.HandleTestConnection(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsHandler_UpdateSection_PreserveMaskedSecrets(t *testing.T) {
|
||||
testDB := setupConfigTestDB(t)
|
||||
configService := createTestConfigService(t, testDB)
|
||||
handler := admin.NewSettingsHandler(configService)
|
||||
|
||||
// Verify initial secret exists
|
||||
cfg := configService.GetConfig()
|
||||
if cfg.OIDC.ClientSecret != "test-client-secret" {
|
||||
t.Fatalf("Initial secret not set")
|
||||
}
|
||||
|
||||
// Update with masked secret
|
||||
body := `{"enabled": true, "provider": "google", "client_id": "new-id", "client_secret": "********"}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings/oidc", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("section", "oidc")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
|
||||
user := createTestUser()
|
||||
req = req.WithContext(shared.SetUserInContext(req.Context(), user))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.HandleUpdateSection(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify original secret is preserved
|
||||
cfg = configService.GetConfig()
|
||||
if cfg.OIDC.ClientSecret != "test-client-secret" {
|
||||
t.Errorf("Expected secret to be preserved, got '%s'", cfg.OIDC.ClientSecret)
|
||||
}
|
||||
if cfg.OIDC.ClientID != "new-id" {
|
||||
t.Errorf("Expected client_id to be updated, got '%s'", cfg.OIDC.ClientID)
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,16 @@ type webhookService interface {
|
||||
ListDeliveries(ctx context.Context, webhookID int64, limit, offset int) ([]*models.WebhookDelivery, error)
|
||||
}
|
||||
|
||||
// configService defines configuration management operations
|
||||
type configService interface {
|
||||
GetConfig() *models.MutableConfig
|
||||
UpdateSection(ctx context.Context, category models.ConfigCategory, input json.RawMessage, updatedBy string) error
|
||||
TestSMTP(ctx context.Context, cfg models.SMTPConfig) error
|
||||
TestS3(ctx context.Context, cfg models.StorageConfig) error
|
||||
TestOIDC(ctx context.Context, cfg models.OIDCConfig) error
|
||||
ResetFromENV(ctx context.Context, updatedBy string) error
|
||||
}
|
||||
|
||||
// RouterConfig holds configuration for the API router
|
||||
type RouterConfig struct {
|
||||
// Database for RLS middleware
|
||||
@@ -118,6 +128,7 @@ type RouterConfig struct {
|
||||
ReminderService reminderService
|
||||
WebhookService webhookService
|
||||
WebhookPublisher webhookPublisher
|
||||
ConfigService configService
|
||||
|
||||
// Storage
|
||||
StorageProvider storage.Provider // Optional, for document file storage
|
||||
@@ -351,6 +362,17 @@ func NewRouter(cfg RouterConfig) *chi.Mux {
|
||||
r.Delete("/{id}", webhooksHandler.HandleDeleteWebhook)
|
||||
r.Get("/{id}/deliveries", webhooksHandler.HandleListDeliveries)
|
||||
})
|
||||
|
||||
// Settings management (configuration)
|
||||
if cfg.ConfigService != nil {
|
||||
settingsHandler := apiAdmin.NewSettingsHandler(cfg.ConfigService)
|
||||
r.Route("/settings", func(r chi.Router) {
|
||||
r.Get("/", settingsHandler.HandleGetSettings)
|
||||
r.Put("/{section}", settingsHandler.HandleUpdateSection)
|
||||
r.Post("/test/{type}", settingsHandler.HandleTestConnection)
|
||||
r.Post("/reset", settingsHandler.HandleResetFromENV)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
24
backend/migrations/0019_add_tenant_config.down.sql
Normal file
24
backend/migrations/0019_add_tenant_config.down.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-- Rollback: Remove Tenant Configuration Storage
|
||||
|
||||
-- Revoke permissions
|
||||
REVOKE SELECT, INSERT, UPDATE, DELETE ON tenant_config FROM ackify_app;
|
||||
REVOKE USAGE, SELECT ON SEQUENCE tenant_config_id_seq FROM ackify_app;
|
||||
REVOKE UPDATE (config_seeded_at) ON instance_metadata FROM ackify_app;
|
||||
|
||||
-- Drop RLS policy
|
||||
DROP POLICY IF EXISTS tenant_isolation_tenant_config ON tenant_config;
|
||||
|
||||
-- Drop triggers
|
||||
DROP TRIGGER IF EXISTS tr_tenant_config_tenant_id_immutable ON tenant_config;
|
||||
DROP TRIGGER IF EXISTS tr_tenant_config_update_timestamp ON tenant_config;
|
||||
|
||||
-- Drop function
|
||||
DROP FUNCTION IF EXISTS update_tenant_config_timestamp();
|
||||
|
||||
-- Remove column from instance_metadata
|
||||
ALTER TABLE instance_metadata DROP COLUMN IF EXISTS config_seeded_at;
|
||||
|
||||
-- Drop table
|
||||
DROP TABLE IF EXISTS tenant_config;
|
||||
80
backend/migrations/0019_add_tenant_config.up.sql
Normal file
80
backend/migrations/0019_add_tenant_config.up.sql
Normal file
@@ -0,0 +1,80 @@
|
||||
-- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-- ============================================================================
|
||||
-- Migration: Add Tenant Configuration Storage
|
||||
-- ============================================================================
|
||||
-- This migration creates a table for storing tenant-specific configuration
|
||||
-- with support for:
|
||||
-- - Category-based JSONB storage for flexibility
|
||||
-- - Encrypted secrets storage (separate from main config)
|
||||
-- - Optimistic locking via version field
|
||||
-- - Audit trail (updated_by, updated_at)
|
||||
-- - Tenant isolation via RLS
|
||||
-- ============================================================================
|
||||
|
||||
-- Step 1: Create tenant_config table
|
||||
CREATE TABLE tenant_config (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL,
|
||||
category TEXT NOT NULL CHECK (category IN ('general', 'oidc', 'magiclink', 'smtp', 'storage')),
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
secrets_encrypted BYTEA,
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by TEXT,
|
||||
UNIQUE(tenant_id, category)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE tenant_config IS 'Stores tenant-specific configuration with JSONB per category';
|
||||
COMMENT ON COLUMN tenant_config.tenant_id IS 'Tenant identifier (references instance_metadata.id)';
|
||||
COMMENT ON COLUMN tenant_config.category IS 'Configuration category: general, oidc, magiclink, smtp, storage';
|
||||
COMMENT ON COLUMN tenant_config.config IS 'JSONB configuration data (secrets excluded)';
|
||||
COMMENT ON COLUMN tenant_config.secrets_encrypted IS 'AES-256-GCM encrypted secrets blob';
|
||||
COMMENT ON COLUMN tenant_config.version IS 'Optimistic locking version (incremented on each update)';
|
||||
COMMENT ON COLUMN tenant_config.updated_by IS 'Email of the user who last updated this config';
|
||||
|
||||
-- Step 2: Add indexes
|
||||
CREATE INDEX idx_tenant_config_tenant_category ON tenant_config(tenant_id, category);
|
||||
|
||||
-- Step 3: Add config_seeded_at column to instance_metadata
|
||||
ALTER TABLE instance_metadata ADD COLUMN IF NOT EXISTS config_seeded_at TIMESTAMPTZ;
|
||||
|
||||
COMMENT ON COLUMN instance_metadata.config_seeded_at IS 'Timestamp when configuration was first seeded from environment variables';
|
||||
|
||||
-- Step 4: Create trigger for automatic updated_at and version increment
|
||||
CREATE OR REPLACE FUNCTION update_tenant_config_timestamp()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
NEW.version = OLD.version + 1;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
COMMENT ON FUNCTION update_tenant_config_timestamp() IS 'Automatically updates updated_at and increments version on tenant_config updates';
|
||||
|
||||
CREATE TRIGGER tr_tenant_config_update_timestamp
|
||||
BEFORE UPDATE ON tenant_config
|
||||
FOR EACH ROW EXECUTE FUNCTION update_tenant_config_timestamp();
|
||||
|
||||
-- Step 5: Add tenant_id immutability trigger
|
||||
CREATE TRIGGER tr_tenant_config_tenant_id_immutable
|
||||
BEFORE UPDATE ON tenant_config
|
||||
FOR EACH ROW EXECUTE FUNCTION prevent_tenant_id_modification();
|
||||
|
||||
-- Step 6: Enable Row Level Security
|
||||
ALTER TABLE tenant_config ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tenant_config FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation_tenant_config ON tenant_config;
|
||||
CREATE POLICY tenant_isolation_tenant_config ON tenant_config
|
||||
USING (tenant_id = current_tenant_id())
|
||||
WITH CHECK (tenant_id = current_tenant_id());
|
||||
|
||||
-- Step 7: Grant permissions to ackify_app role
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON tenant_config TO ackify_app;
|
||||
GRANT USAGE, SELECT ON SEQUENCE tenant_config_id_seq TO ackify_app;
|
||||
|
||||
-- Step 8: Grant UPDATE on instance_metadata for config_seeded_at column
|
||||
GRANT UPDATE (config_seeded_at) ON instance_metadata TO ackify_app;
|
||||
@@ -84,6 +84,7 @@ type ServerBuilder struct {
|
||||
adminService *services.AdminService
|
||||
webhookService *services.WebhookService
|
||||
reminderService *services.ReminderAsyncService
|
||||
configService *services.ConfigService
|
||||
|
||||
// Flags
|
||||
oauthEnabled bool
|
||||
@@ -200,6 +201,12 @@ func (b *ServerBuilder) WithReminderService(service *services.ReminderAsyncServi
|
||||
return b
|
||||
}
|
||||
|
||||
// WithConfigService injects a configuration service.
|
||||
func (b *ServerBuilder) WithConfigService(service *services.ConfigService) *ServerBuilder {
|
||||
b.configService = service
|
||||
return b
|
||||
}
|
||||
|
||||
// Build constructs the server with all dependencies.
|
||||
func (b *ServerBuilder) Build(ctx context.Context) (*Server, error) {
|
||||
if err := b.validateProviders(); err != nil {
|
||||
@@ -229,6 +236,7 @@ func (b *ServerBuilder) Build(ctx context.Context) (*Server, error) {
|
||||
}
|
||||
|
||||
b.initializeCoreServices(repos)
|
||||
b.initializeConfigService(ctx, repos)
|
||||
magicLinkWorker := b.initializeMagicLinkService(ctx, repos)
|
||||
b.initializeReminderService(repos)
|
||||
|
||||
@@ -334,6 +342,7 @@ type repositories struct {
|
||||
webhookDelivery *database.WebhookDeliveryRepository
|
||||
magicLink services.MagicLinkRepository // Interface, not concrete type
|
||||
oauthSession *database.OAuthSessionRepository
|
||||
config *database.ConfigRepository
|
||||
}
|
||||
|
||||
// createRepositories creates all repository instances.
|
||||
@@ -348,6 +357,7 @@ func (b *ServerBuilder) createRepositories() *repositories {
|
||||
webhookDelivery: database.NewWebhookDeliveryRepository(b.db, b.tenantProvider),
|
||||
magicLink: database.NewMagicLinkRepository(b.db),
|
||||
oauthSession: database.NewOAuthSessionRepository(b.db, b.tenantProvider),
|
||||
config: database.NewConfigRepository(b.db, b.tenantProvider),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,6 +443,25 @@ func (b *ServerBuilder) initializeCoreServices(repos *repositories) {
|
||||
}
|
||||
}
|
||||
|
||||
// initializeConfigService initializes the configuration service with hot-reload.
|
||||
func (b *ServerBuilder) initializeConfigService(ctx context.Context, repos *repositories) {
|
||||
if b.configService == nil {
|
||||
encryptionKey := b.cfg.OAuth.CookieSecret
|
||||
b.configService = services.NewConfigService(repos.config, b.cfg, encryptionKey)
|
||||
|
||||
// Initialize will seed from ENV on first start or load from DB
|
||||
// Must use tenant context for RLS to work correctly
|
||||
err := tenant.WithTenantContextFromProvider(ctx, b.db, b.tenantProvider, func(txCtx context.Context) error {
|
||||
return b.configService.Initialize(txCtx)
|
||||
})
|
||||
if err != nil {
|
||||
logger.Logger.Warn("Failed to initialize config service, using ENV config", "error", err)
|
||||
return
|
||||
}
|
||||
logger.Logger.Info("Configuration service initialized")
|
||||
}
|
||||
}
|
||||
|
||||
// initializeMagicLinkService initializes MagicLink service and cleanup worker.
|
||||
func (b *ServerBuilder) initializeMagicLinkService(ctx context.Context, repos *repositories) *workers.MagicLinkCleanupWorker {
|
||||
if b.magicLinkService == nil {
|
||||
@@ -521,6 +550,7 @@ func (b *ServerBuilder) buildRouter(repos *repositories, whPublisher *services.W
|
||||
DocumentRateLimit: b.cfg.App.DocumentRateLimit,
|
||||
GeneralRateLimit: b.cfg.App.GeneralRateLimit,
|
||||
ImportMaxSigners: b.cfg.App.ImportMaxSigners,
|
||||
ConfigService: b.configService,
|
||||
}
|
||||
apiRouter := api.NewRouter(apiConfig)
|
||||
router.Mount("/api/v1", apiRouter)
|
||||
|
||||
543
webapp/cypress/e2e/16-admin-settings.cy.ts
Normal file
543
webapp/cypress/e2e/16-admin-settings.cy.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
/// <reference types="cypress" />
|
||||
|
||||
describe('Test 16: Admin Settings Configuration', () => {
|
||||
beforeEach(() => {
|
||||
cy.clearCookies()
|
||||
})
|
||||
|
||||
// Helper to save settings and wait for success message
|
||||
const saveAndWaitForSuccess = () => {
|
||||
cy.get('button').contains('Save').click()
|
||||
cy.contains('saved successfully', { timeout: 10000 }).should('be.visible')
|
||||
}
|
||||
|
||||
// Helper to navigate to a settings section
|
||||
const navigateToSection = (sectionName: string) => {
|
||||
cy.contains('button', sectionName).click()
|
||||
}
|
||||
|
||||
describe('Basic Navigation and Access', () => {
|
||||
it('should allow admin to access settings page', () => {
|
||||
cy.loginAsAdmin()
|
||||
cy.visit('/admin/settings')
|
||||
cy.url({ timeout: 10000 }).should('include', '/admin/settings')
|
||||
|
||||
// Verify page elements
|
||||
cy.contains('Settings', { timeout: 10000 }).should('be.visible')
|
||||
|
||||
// Verify sidebar navigation sections are present
|
||||
cy.contains('button', 'General').should('be.visible')
|
||||
cy.contains('button', 'OAuth / OIDC').should('be.visible')
|
||||
cy.contains('button', 'Magic Link').should('be.visible')
|
||||
cy.contains('button', 'Email (SMTP)').should('be.visible')
|
||||
cy.contains('button', 'Storage').should('be.visible')
|
||||
})
|
||||
|
||||
it('should prevent non-admin from accessing settings', () => {
|
||||
cy.loginViaMagicLink('user@test.com')
|
||||
cy.visit('/admin/settings', { failOnStatusCode: false })
|
||||
|
||||
// Should redirect away from admin settings
|
||||
cy.url({ timeout: 10000 }).should('not.include', '/admin/settings')
|
||||
})
|
||||
|
||||
it('should navigate to settings from admin dashboard', () => {
|
||||
cy.loginAsAdmin()
|
||||
cy.visit('/admin')
|
||||
|
||||
// Click on Settings link/button in the dashboard (router-link wrapped button)
|
||||
cy.get('a[href="/admin/settings"]').click()
|
||||
|
||||
// Should navigate to settings page
|
||||
cy.url({ timeout: 10000 }).should('include', '/admin/settings')
|
||||
cy.contains('Settings', { timeout: 10000 }).should('be.visible')
|
||||
})
|
||||
})
|
||||
|
||||
describe('General Settings Section', () => {
|
||||
beforeEach(() => {
|
||||
cy.loginAsAdmin()
|
||||
cy.visit('/admin/settings')
|
||||
cy.contains('General', { timeout: 10000 }).should('be.visible')
|
||||
})
|
||||
|
||||
it('should display general settings form', () => {
|
||||
// General section should be visible by default
|
||||
cy.contains('h2', 'General', { timeout: 10000 }).should('be.visible')
|
||||
|
||||
// Verify form fields are present
|
||||
cy.get('[data-testid="organisation"]').should('exist')
|
||||
cy.get('[data-testid="only_admin_can_create"]').should('exist')
|
||||
})
|
||||
|
||||
it('should update organisation name and persist after reload', () => {
|
||||
const newOrg = 'E2E Test Org ' + Date.now()
|
||||
|
||||
// Update organisation name
|
||||
cy.get('[data-testid="organisation"]').clear().type(newOrg)
|
||||
|
||||
// Save settings
|
||||
saveAndWaitForSuccess()
|
||||
|
||||
// Reload and verify persistence
|
||||
cy.reload()
|
||||
cy.get('[data-testid="organisation"]', { timeout: 10000 }).should('have.value', newOrg)
|
||||
|
||||
// Restore original value
|
||||
cy.get('[data-testid="organisation"]').clear().type('Ackify Test')
|
||||
saveAndWaitForSuccess()
|
||||
})
|
||||
|
||||
it('should toggle only_admin_can_create setting', () => {
|
||||
// Get initial state
|
||||
cy.get('[data-testid="only_admin_can_create"]').then(($checkbox) => {
|
||||
const wasChecked = $checkbox.is(':checked')
|
||||
|
||||
// Toggle the checkbox
|
||||
if (wasChecked) {
|
||||
cy.get('[data-testid="only_admin_can_create"]').uncheck()
|
||||
} else {
|
||||
cy.get('[data-testid="only_admin_can_create"]').check()
|
||||
}
|
||||
|
||||
saveAndWaitForSuccess()
|
||||
|
||||
// Reload and verify the change persisted
|
||||
cy.reload()
|
||||
cy.get('[data-testid="only_admin_can_create"]', { timeout: 10000 }).should(
|
||||
wasChecked ? 'not.be.checked' : 'be.checked'
|
||||
)
|
||||
|
||||
// Restore original state
|
||||
if (wasChecked) {
|
||||
cy.get('[data-testid="only_admin_can_create"]').check()
|
||||
} else {
|
||||
cy.get('[data-testid="only_admin_can_create"]').uncheck()
|
||||
}
|
||||
saveAndWaitForSuccess()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('OAuth/OIDC Settings Section', () => {
|
||||
beforeEach(() => {
|
||||
cy.loginAsAdmin()
|
||||
cy.visit('/admin/settings')
|
||||
navigateToSection('OAuth / OIDC')
|
||||
cy.contains('h2', 'OAuth / OIDC', { timeout: 10000 }).should('be.visible')
|
||||
})
|
||||
|
||||
it('should display OIDC form fields', () => {
|
||||
cy.get('[data-testid="oidc_enabled"]').should('exist')
|
||||
|
||||
// Enable OIDC to see more fields
|
||||
cy.get('[data-testid="oidc_enabled"]').check()
|
||||
cy.get('[data-testid="oidc_provider"]').should('be.visible')
|
||||
cy.get('[data-testid="oidc_client_id"]').should('be.visible')
|
||||
cy.get('[data-testid="oidc_client_secret"]').should('be.visible')
|
||||
})
|
||||
|
||||
it('should show custom provider URLs when custom is selected', () => {
|
||||
cy.get('[data-testid="oidc_enabled"]').check()
|
||||
cy.get('[data-testid="oidc_provider"]').select('custom')
|
||||
|
||||
// Custom URL fields should appear
|
||||
cy.get('[data-testid="oidc_auth_url"]').should('be.visible')
|
||||
cy.get('[data-testid="oidc_token_url"]').should('be.visible')
|
||||
cy.get('[data-testid="oidc_userinfo_url"]').should('be.visible')
|
||||
})
|
||||
|
||||
it('should mask client secret', () => {
|
||||
cy.get('[data-testid="oidc_enabled"]').check()
|
||||
|
||||
// Client secret input should be password type (masked)
|
||||
cy.get('[data-testid="oidc_client_secret"]').should('have.attr', 'type', 'password')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Magic Link Settings Section', () => {
|
||||
beforeEach(() => {
|
||||
cy.loginAsAdmin()
|
||||
cy.visit('/admin/settings')
|
||||
navigateToSection('Magic Link')
|
||||
cy.contains('h2', 'Magic Link', { timeout: 10000 }).should('be.visible')
|
||||
})
|
||||
|
||||
it('should display Magic Link form fields', () => {
|
||||
cy.get('[data-testid="magiclink_enabled"]').should('exist')
|
||||
})
|
||||
|
||||
it('should toggle Magic Link enabled setting', () => {
|
||||
cy.get('[data-testid="magiclink_enabled"]').then(($checkbox) => {
|
||||
const wasEnabled = $checkbox.is(':checked')
|
||||
|
||||
// Toggle
|
||||
if (wasEnabled) {
|
||||
cy.get('[data-testid="magiclink_enabled"]').uncheck()
|
||||
} else {
|
||||
cy.get('[data-testid="magiclink_enabled"]').check()
|
||||
}
|
||||
|
||||
saveAndWaitForSuccess()
|
||||
|
||||
// Reload and verify
|
||||
cy.reload()
|
||||
navigateToSection('Magic Link')
|
||||
cy.get('[data-testid="magiclink_enabled"]', { timeout: 10000 }).should(
|
||||
wasEnabled ? 'not.be.checked' : 'be.checked'
|
||||
)
|
||||
|
||||
// Restore
|
||||
if (wasEnabled) {
|
||||
cy.get('[data-testid="magiclink_enabled"]').check()
|
||||
} else {
|
||||
cy.get('[data-testid="magiclink_enabled"]').uncheck()
|
||||
}
|
||||
saveAndWaitForSuccess()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SMTP Settings Section', () => {
|
||||
beforeEach(() => {
|
||||
cy.loginAsAdmin()
|
||||
cy.visit('/admin/settings')
|
||||
navigateToSection('Email (SMTP)')
|
||||
cy.contains('h2', 'Email (SMTP)', { timeout: 10000 }).should('be.visible')
|
||||
})
|
||||
|
||||
it('should display SMTP form fields', () => {
|
||||
cy.get('[data-testid="smtp_host"]').should('exist')
|
||||
cy.get('[data-testid="smtp_port"]').should('exist')
|
||||
cy.get('[data-testid="smtp_from"]').should('exist')
|
||||
cy.get('[data-testid="smtp_from_name"]').should('exist')
|
||||
cy.get('[data-testid="smtp_tls"]').should('exist')
|
||||
cy.get('[data-testid="smtp_starttls"]').should('exist')
|
||||
})
|
||||
|
||||
it('should update SMTP port and persist', () => {
|
||||
// Get current port value
|
||||
cy.get('[data-testid="smtp_port"]').invoke('val').then((originalPort) => {
|
||||
const newPort = '2525'
|
||||
|
||||
cy.get('[data-testid="smtp_port"]').clear().type(newPort)
|
||||
saveAndWaitForSuccess()
|
||||
|
||||
// Reload and verify
|
||||
cy.reload()
|
||||
navigateToSection('Email (SMTP)')
|
||||
cy.get('[data-testid="smtp_port"]', { timeout: 10000 }).should('have.value', newPort)
|
||||
|
||||
// Restore original
|
||||
cy.get('[data-testid="smtp_port"]').clear().type(String(originalPort))
|
||||
saveAndWaitForSuccess()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Storage Settings Section', () => {
|
||||
beforeEach(() => {
|
||||
cy.loginAsAdmin()
|
||||
cy.visit('/admin/settings')
|
||||
navigateToSection('Storage')
|
||||
cy.contains('h2', 'Storage', { timeout: 10000 }).should('be.visible')
|
||||
})
|
||||
|
||||
it('should display storage form fields', () => {
|
||||
cy.get('[data-testid="storage_type"]').should('exist')
|
||||
cy.get('[data-testid="storage_max_size_mb"]').should('exist')
|
||||
})
|
||||
|
||||
it('should show local path when storage type is local', () => {
|
||||
cy.get('[data-testid="storage_type"]').select('local')
|
||||
cy.get('[data-testid="storage_local_path"]').should('be.visible')
|
||||
})
|
||||
|
||||
it('should show S3 fields when storage type is s3', () => {
|
||||
cy.get('[data-testid="storage_type"]').select('s3')
|
||||
|
||||
// S3 fields should appear
|
||||
cy.get('[data-testid="storage_s3_endpoint"]').should('be.visible')
|
||||
cy.get('[data-testid="storage_s3_bucket"]').should('be.visible')
|
||||
cy.get('[data-testid="storage_s3_access_key"]').should('be.visible')
|
||||
cy.get('[data-testid="storage_s3_secret_key"]').should('be.visible')
|
||||
cy.get('[data-testid="storage_s3_region"]').should('be.visible')
|
||||
cy.get('[data-testid="s3_use_ssl"]').should('be.visible')
|
||||
})
|
||||
|
||||
it('should update max size and persist', () => {
|
||||
cy.get('[data-testid="storage_max_size_mb"]').invoke('val').then((originalSize) => {
|
||||
const newSize = '100'
|
||||
|
||||
cy.get('[data-testid="storage_max_size_mb"]').clear().type(newSize)
|
||||
saveAndWaitForSuccess()
|
||||
|
||||
// Reload and verify
|
||||
cy.reload()
|
||||
navigateToSection('Storage')
|
||||
cy.get('[data-testid="storage_max_size_mb"]', { timeout: 10000 }).should('have.value', newSize)
|
||||
|
||||
// Restore original
|
||||
cy.get('[data-testid="storage_max_size_mb"]').clear().type(String(originalSize))
|
||||
saveAndWaitForSuccess()
|
||||
})
|
||||
})
|
||||
|
||||
it('should maintain selected section after save', () => {
|
||||
// We're already on Storage section
|
||||
cy.get('[data-testid="storage_max_size_mb"]').clear().type('75')
|
||||
saveAndWaitForSuccess()
|
||||
|
||||
// Should still be on Storage section
|
||||
cy.contains('h2', 'Storage').should('be.visible')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Reset from ENV', () => {
|
||||
beforeEach(() => {
|
||||
cy.loginAsAdmin()
|
||||
cy.visit('/admin/settings')
|
||||
})
|
||||
|
||||
it('should show reset confirmation dialog', () => {
|
||||
cy.contains('button', 'Reset from ENV').click()
|
||||
|
||||
// Confirmation modal should appear
|
||||
cy.contains('Reset Settings?', { timeout: 10000 }).should('be.visible')
|
||||
cy.contains('reset all settings').should('be.visible')
|
||||
|
||||
// Cancel the reset
|
||||
cy.contains('button', 'Cancel').click()
|
||||
|
||||
// Modal should be closed
|
||||
cy.contains('Reset Settings?').should('not.exist')
|
||||
})
|
||||
|
||||
it('should reset settings to ENV values when confirmed', () => {
|
||||
// First, modify a setting
|
||||
cy.get('[data-testid="organisation"]').clear()
|
||||
cy.get('[data-testid="organisation"]').type('Modified Org Name')
|
||||
saveAndWaitForSuccess()
|
||||
|
||||
// Now reset from ENV
|
||||
cy.contains('button', 'Reset from ENV').click()
|
||||
cy.contains('Reset Settings?', { timeout: 10000 }).should('be.visible')
|
||||
|
||||
// Confirm reset - click the amber button inside the modal (not the Reset from ENV button)
|
||||
cy.get('.bg-amber-600').click()
|
||||
|
||||
// Wait for reset success
|
||||
cy.contains('reset', { matchCase: false, timeout: 10000 }).should('be.visible')
|
||||
|
||||
// Organisation should be back to ENV value
|
||||
cy.get('[data-testid="organisation"]', { timeout: 10000 }).should('have.value', 'Ackify Test')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Full Flow: Auth Settings affect Login Page', () => {
|
||||
it('should hide MagicLink option on login page when disabled', () => {
|
||||
// Login as admin
|
||||
cy.loginAsAdmin()
|
||||
|
||||
// Go to settings and disable MagicLink
|
||||
cy.visit('/admin/settings')
|
||||
navigateToSection('Magic Link')
|
||||
cy.contains('h2', 'Magic Link', { timeout: 10000 }).should('be.visible')
|
||||
|
||||
// Remember current state and disable
|
||||
cy.get('[data-testid="magiclink_enabled"]').then(($checkbox) => {
|
||||
const wasEnabled = $checkbox.is(':checked')
|
||||
|
||||
if (wasEnabled) {
|
||||
cy.get('[data-testid="magiclink_enabled"]').uncheck()
|
||||
saveAndWaitForSuccess()
|
||||
|
||||
// Logout
|
||||
cy.logout()
|
||||
|
||||
// Visit auth page (fresh load to get new window variables)
|
||||
cy.visit('/auth')
|
||||
|
||||
// MagicLink card should NOT be visible
|
||||
cy.contains('Send Magic Link').should('not.exist')
|
||||
|
||||
// OAuth should still be visible (if enabled)
|
||||
// Note: OAuth button might auto-redirect if only method available
|
||||
|
||||
// Re-enable MagicLink via API for other tests
|
||||
cy.loginAsAdmin()
|
||||
cy.visit('/admin/settings')
|
||||
navigateToSection('Magic Link')
|
||||
cy.get('[data-testid="magiclink_enabled"]').check()
|
||||
saveAndWaitForSuccess()
|
||||
} else {
|
||||
// MagicLink was already disabled, enable it first then run test
|
||||
cy.get('[data-testid="magiclink_enabled"]').check()
|
||||
saveAndWaitForSuccess()
|
||||
|
||||
cy.logout()
|
||||
cy.visit('/auth')
|
||||
|
||||
// MagicLink should be visible now
|
||||
cy.contains('Send Magic Link', { timeout: 10000 }).should('be.visible')
|
||||
|
||||
// Disable it
|
||||
cy.loginAsAdmin()
|
||||
cy.visit('/admin/settings')
|
||||
navigateToSection('Magic Link')
|
||||
cy.get('[data-testid="magiclink_enabled"]').uncheck()
|
||||
saveAndWaitForSuccess()
|
||||
|
||||
cy.logout()
|
||||
cy.visit('/auth')
|
||||
|
||||
// MagicLink should NOT be visible
|
||||
cy.contains('Send Magic Link').should('not.exist')
|
||||
|
||||
// Re-enable for other tests
|
||||
cy.loginAsAdmin()
|
||||
cy.visit('/admin/settings')
|
||||
navigateToSection('Magic Link')
|
||||
cy.get('[data-testid="magiclink_enabled"]').check()
|
||||
saveAndWaitForSuccess()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide OAuth option on login page when disabled', () => {
|
||||
cy.loginAsAdmin()
|
||||
|
||||
// Go to settings and check OIDC status
|
||||
cy.visit('/admin/settings')
|
||||
navigateToSection('OAuth / OIDC')
|
||||
cy.contains('h2', 'OAuth / OIDC', { timeout: 10000 }).should('be.visible')
|
||||
|
||||
cy.get('[data-testid="oidc_enabled"]').then(($checkbox) => {
|
||||
const wasEnabled = $checkbox.is(':checked')
|
||||
|
||||
if (wasEnabled) {
|
||||
// Disable OAuth
|
||||
cy.get('[data-testid="oidc_enabled"]').uncheck()
|
||||
saveAndWaitForSuccess()
|
||||
|
||||
cy.logout()
|
||||
cy.visit('/auth')
|
||||
|
||||
// OAuth login button should NOT be visible
|
||||
cy.contains('Continue with OAuth').should('not.exist')
|
||||
|
||||
// MagicLink should still work
|
||||
cy.contains('Send Magic Link', { timeout: 10000 }).should('be.visible')
|
||||
|
||||
// Re-enable OAuth
|
||||
cy.loginAsAdmin()
|
||||
cy.visit('/admin/settings')
|
||||
navigateToSection('OAuth / OIDC')
|
||||
cy.get('[data-testid="oidc_enabled"]').check()
|
||||
saveAndWaitForSuccess()
|
||||
} else {
|
||||
// OAuth was disabled, enable it first
|
||||
cy.get('[data-testid="oidc_enabled"]').check()
|
||||
// Fill in required fields for custom provider
|
||||
cy.get('[data-testid="oidc_provider"]').select('custom')
|
||||
cy.get('[data-testid="oidc_client_id"]').clear().type('test_client_id')
|
||||
cy.get('[data-testid="oidc_client_secret"]').clear().type('test_client_secret')
|
||||
cy.get('[data-testid="oidc_auth_url"]').clear().type('https://auth.url.com/auth')
|
||||
cy.get('[data-testid="oidc_token_url"]').clear().type('https://auth.url.com/token')
|
||||
cy.get('[data-testid="oidc_userinfo_url"]').clear().type('https://auth.url.com/userinfo')
|
||||
saveAndWaitForSuccess()
|
||||
|
||||
cy.logout()
|
||||
cy.visit('/auth')
|
||||
|
||||
// OAuth should be visible
|
||||
cy.contains('Continue with OAuth', { timeout: 10000 }).should('be.visible')
|
||||
|
||||
// Disable it
|
||||
cy.loginAsAdmin()
|
||||
cy.visit('/admin/settings')
|
||||
navigateToSection('OAuth / OIDC')
|
||||
cy.get('[data-testid="oidc_enabled"]').uncheck()
|
||||
saveAndWaitForSuccess()
|
||||
|
||||
cy.logout()
|
||||
cy.visit('/auth')
|
||||
|
||||
// OAuth should NOT be visible
|
||||
cy.contains('Continue with OAuth').should('not.exist')
|
||||
|
||||
// Don't re-enable as it was originally disabled
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should show both auth methods when both are enabled', () => {
|
||||
cy.loginAsAdmin()
|
||||
cy.visit('/admin/settings')
|
||||
|
||||
// Ensure MagicLink is enabled
|
||||
navigateToSection('Magic Link')
|
||||
cy.get('[data-testid="magiclink_enabled"]').check()
|
||||
saveAndWaitForSuccess()
|
||||
|
||||
// Ensure OAuth is enabled
|
||||
navigateToSection('OAuth / OIDC')
|
||||
cy.get('[data-testid="oidc_enabled"]').check()
|
||||
// If provider not set, select custom and fill required fields
|
||||
cy.get('[data-testid="oidc_provider"]').then(($select) => {
|
||||
if (!$select.val()) {
|
||||
cy.get('[data-testid="oidc_provider"]').select('custom')
|
||||
cy.get('[data-testid="oidc_client_id"]').clear().type('test_client_id')
|
||||
cy.get('[data-testid="oidc_client_secret"]').clear().type('test_client_secret')
|
||||
cy.get('[data-testid="oidc_auth_url"]').clear().type('https://auth.url.com/auth')
|
||||
cy.get('[data-testid="oidc_token_url"]').clear().type('https://auth.url.com/token')
|
||||
cy.get('[data-testid="oidc_userinfo_url"]').clear().type('https://auth.url.com/userinfo')
|
||||
}
|
||||
})
|
||||
saveAndWaitForSuccess()
|
||||
|
||||
cy.logout()
|
||||
cy.visit('/auth')
|
||||
|
||||
// Both auth methods should be visible
|
||||
cy.contains('Continue with OAuth', { timeout: 10000 }).should('be.visible')
|
||||
cy.contains('Send Magic Link').should('be.visible')
|
||||
})
|
||||
|
||||
// Note: Test for "no auth method available" is not feasible in e2e
|
||||
// because disabling both auth methods would prevent re-login to restore state.
|
||||
// This scenario should be tested at the unit/integration level.
|
||||
})
|
||||
|
||||
describe('Validation Errors', () => {
|
||||
beforeEach(() => {
|
||||
cy.loginAsAdmin()
|
||||
cy.visit('/admin/settings')
|
||||
})
|
||||
|
||||
it('should show validation error for OIDC without required fields', () => {
|
||||
navigateToSection('OAuth / OIDC')
|
||||
cy.contains('h2', 'OAuth / OIDC', { timeout: 10000 }).should('be.visible')
|
||||
|
||||
// Enable OIDC
|
||||
cy.get('[data-testid="oidc_enabled"]').check()
|
||||
|
||||
// Select custom provider
|
||||
cy.get('[data-testid="oidc_provider"]').select('custom')
|
||||
|
||||
// Fill only client_id, leave URLs empty
|
||||
cy.get('[data-testid="oidc_client_id"]').clear()
|
||||
cy.get('[data-testid="oidc_client_id"]').type('test-id')
|
||||
cy.get('[data-testid="oidc_auth_url"]').clear()
|
||||
cy.get('[data-testid="oidc_token_url"]').clear()
|
||||
cy.get('[data-testid="oidc_userinfo_url"]').clear()
|
||||
|
||||
// Try to save
|
||||
cy.get('button').contains('Save').click()
|
||||
|
||||
// Should show validation error (red alert box with error icon)
|
||||
cy.get('.bg-red-50, .bg-red-900\\/20', { timeout: 10000 }).should('be.visible')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Menu, X, ChevronDown, LogOut, Shield } from 'lucide-vue-next'
|
||||
import { Menu, X, ChevronDown, LogOut, Shield, FileText, Settings, Webhook } from 'lucide-vue-next'
|
||||
import ThemeToggle from './ThemeToggle.vue'
|
||||
import LanguageSelect from './LanguageSelect.vue'
|
||||
import AppLogo from '@/components/AppLogo.vue'
|
||||
@@ -17,6 +17,7 @@ const router = useRouter()
|
||||
|
||||
const mobileMenuOpen = ref(false)
|
||||
const userMenuOpen = ref(false)
|
||||
const adminMenuOpen = ref(false)
|
||||
|
||||
const isAuthenticated = computed(() => authStore.isAuthenticated)
|
||||
const isAdmin = computed(() => authStore.isAdmin)
|
||||
@@ -89,6 +90,14 @@ const closeMobileMenu = () => {
|
||||
const closeUserMenu = () => {
|
||||
userMenuOpen.value = false
|
||||
}
|
||||
|
||||
const toggleAdminMenu = () => {
|
||||
adminMenuOpen.value = !adminMenuOpen.value
|
||||
}
|
||||
|
||||
const closeAdminMenu = () => {
|
||||
adminMenuOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -144,6 +153,76 @@ const closeUserMenu = () => {
|
||||
>
|
||||
{{ t('nav.myDocuments') }}
|
||||
</router-link>
|
||||
|
||||
<!-- Admin dropdown - admin only -->
|
||||
<div v-if="isAuthenticated && isAdmin" class="relative">
|
||||
<button
|
||||
@click="toggleAdminMenu"
|
||||
:class="[
|
||||
'flex items-center space-x-1 px-3 py-2 text-sm font-medium rounded-lg transition-colors',
|
||||
route.path.startsWith('/admin')
|
||||
? 'text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-900/30'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
]"
|
||||
aria-haspopup="true"
|
||||
:aria-expanded="adminMenuOpen"
|
||||
>
|
||||
<Shield :size="16" />
|
||||
<span>{{ t('nav.administration') }}</span>
|
||||
<ChevronDown :size="14" />
|
||||
</button>
|
||||
|
||||
<!-- Admin dropdown menu -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="adminMenuOpen"
|
||||
@click.stop
|
||||
v-click-outside="closeAdminMenu"
|
||||
class="absolute left-0 mt-2 w-48 origin-top-left bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 shadow-lg focus:outline-none"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
>
|
||||
<div class="p-2">
|
||||
<router-link
|
||||
to="/admin"
|
||||
@click="adminMenuOpen = false"
|
||||
class="flex items-center space-x-2 rounded-lg px-3 py-2 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<FileText :size="16" />
|
||||
<span>{{ t('nav.adminMenu.allDocuments') }}</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/admin/settings"
|
||||
@click="adminMenuOpen = false"
|
||||
class="flex items-center space-x-2 rounded-lg px-3 py-2 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<Settings :size="16" />
|
||||
<span>{{ t('nav.adminMenu.settings') }}</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/admin/webhooks"
|
||||
@click="adminMenuOpen = false"
|
||||
class="flex items-center space-x-2 rounded-lg px-3 py-2 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<Webhook :size="16" />
|
||||
<span>{{ t('nav.adminMenu.webhooks') }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side: Language + Theme + Auth -->
|
||||
@@ -192,19 +271,6 @@ const closeUserMenu = () => {
|
||||
</div>
|
||||
|
||||
<!-- Menu items -->
|
||||
<router-link
|
||||
v-if="isAdmin"
|
||||
to="/admin"
|
||||
@click="userMenuOpen = false"
|
||||
class="flex items-center space-x-2 rounded-lg px-3 py-2 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<Shield :size="16" />
|
||||
<span>{{ t('nav.administration') }}</span>
|
||||
</router-link>
|
||||
|
||||
<div v-if="isAdmin" class="border-t border-slate-100 dark:border-slate-700 my-2"></div>
|
||||
|
||||
<button
|
||||
@click="logout"
|
||||
class="flex w-full items-center space-x-2 rounded-lg px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
@@ -295,19 +361,53 @@ const closeUserMenu = () => {
|
||||
{{ t('nav.myDocuments') }}
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-if="isAdmin"
|
||||
to="/admin"
|
||||
@click="closeMobileMenu"
|
||||
:class="[
|
||||
'block rounded-lg px-3 py-2.5 text-base font-medium transition-colors',
|
||||
isActive('/admin') || route.path.startsWith('/admin')
|
||||
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800'
|
||||
]"
|
||||
>
|
||||
{{ t('nav.admin') }}
|
||||
</router-link>
|
||||
<!-- Admin section -->
|
||||
<template v-if="isAdmin">
|
||||
<div class="border-t border-slate-200 dark:border-slate-700 pt-3 mt-3">
|
||||
<p class="px-3 py-1 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
{{ t('nav.administration') }}
|
||||
</p>
|
||||
<router-link
|
||||
to="/admin"
|
||||
@click="closeMobileMenu"
|
||||
:class="[
|
||||
'flex items-center space-x-2 rounded-lg px-3 py-2.5 text-base font-medium transition-colors',
|
||||
isActive('/admin') && !route.path.startsWith('/admin/')
|
||||
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800'
|
||||
]"
|
||||
>
|
||||
<FileText :size="18" />
|
||||
<span>{{ t('nav.adminMenu.allDocuments') }}</span>
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/admin/settings"
|
||||
@click="closeMobileMenu"
|
||||
:class="[
|
||||
'flex items-center space-x-2 rounded-lg px-3 py-2.5 text-base font-medium transition-colors',
|
||||
route.path.startsWith('/admin/settings')
|
||||
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800'
|
||||
]"
|
||||
>
|
||||
<Settings :size="18" />
|
||||
<span>{{ t('nav.adminMenu.settings') }}</span>
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/admin/webhooks"
|
||||
@click="closeMobileMenu"
|
||||
:class="[
|
||||
'flex items-center space-x-2 rounded-lg px-3 py-2.5 text-base font-medium transition-colors',
|
||||
route.path.startsWith('/admin/webhooks')
|
||||
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800'
|
||||
]"
|
||||
>
|
||||
<Webhook :size="18" />
|
||||
<span>{{ t('nav.adminMenu.webhooks') }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- User section -->
|
||||
<div class="border-t border-slate-200 dark:border-slate-700 pt-3 mt-3">
|
||||
|
||||
@@ -3,15 +3,20 @@
|
||||
"name": "Ackify"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Accueil",
|
||||
"myConfirmations": "Mes confirmations",
|
||||
"myDocuments": "Mes documents",
|
||||
"home": "Startseite",
|
||||
"myConfirmations": "Meine Bestätigungen",
|
||||
"myDocuments": "Meine Dokumente",
|
||||
"admin": "Admin",
|
||||
"administration": "Administration",
|
||||
"login": "Se connecter",
|
||||
"logout": "Déconnexion",
|
||||
"mobileMenu": "Menu mobile",
|
||||
"mainNavigation": "Navigation principale"
|
||||
"adminMenu": {
|
||||
"allDocuments": "Alle Dokumente",
|
||||
"settings": "Einstellungen",
|
||||
"webhooks": "Webhooks"
|
||||
},
|
||||
"login": "Anmelden",
|
||||
"logout": "Abmelden",
|
||||
"mobileMenu": "Mobiles Menü",
|
||||
"mainNavigation": "Hauptnavigation"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Changer de thème"
|
||||
@@ -342,6 +347,152 @@
|
||||
"pageOf": "Page {current} sur {total}"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"subtitle": "Konfigurieren Sie Ihre Ackify-Instanz",
|
||||
"manage": "Einstellungen",
|
||||
"loading": "Einstellungen werden geladen...",
|
||||
"lastUpdated": "Letzte Aktualisierung: {date}",
|
||||
"saveSuccess": "Einstellungen erfolgreich gespeichert",
|
||||
"saveError": "Fehler beim Speichern der Einstellungen",
|
||||
"testSuccess": "Verbindungstest erfolgreich",
|
||||
"testError": "Verbindungstest fehlgeschlagen",
|
||||
"resetSuccess": "Einstellungen aus Umgebung zurückgesetzt",
|
||||
"resetError": "Fehler beim Zurücksetzen der Einstellungen",
|
||||
"sections": {
|
||||
"general": "Allgemein",
|
||||
"oidc": "OAuth / OIDC",
|
||||
"magiclink": "Magic Link",
|
||||
"smtp": "E-Mail (SMTP)",
|
||||
"storage": "Speicher"
|
||||
},
|
||||
"general": {
|
||||
"title": "Allgemeine Einstellungen",
|
||||
"description": "Grundkonfiguration Ihrer Ackify-Instanz",
|
||||
"organisation": "Organisationsname",
|
||||
"organisationPlaceholder": "Meine Firma",
|
||||
"organisationHelper": "Wird in E-Mails und Oberfläche angezeigt",
|
||||
"onlyAdminCanCreate": "Nur Admins können Dokumente erstellen",
|
||||
"onlyAdminCanCreateHelper": "Wenn aktiviert, können nur Administratoren neue Dokumente erstellen"
|
||||
},
|
||||
"oidc": {
|
||||
"title": "OAuth / OIDC Konfiguration",
|
||||
"description": "OAuth 2.0 / OpenID Connect Authentifizierung konfigurieren",
|
||||
"enabled": "OAuth-Authentifizierung aktivieren",
|
||||
"provider": "Anbieter",
|
||||
"providerPlaceholder": "Anbieter auswählen",
|
||||
"providers": {
|
||||
"google": "Google",
|
||||
"github": "GitHub",
|
||||
"gitlab": "GitLab",
|
||||
"custom": "Benutzerdefiniert"
|
||||
},
|
||||
"clientId": "Client-ID",
|
||||
"clientIdPlaceholder": "Geben Sie Ihre Client-ID ein",
|
||||
"clientSecret": "Client-Secret",
|
||||
"clientSecretPlaceholder": "Geben Sie Ihr Client-Secret ein",
|
||||
"clientSecretHelper": "Leer lassen, um den bestehenden Wert beizubehalten",
|
||||
"authUrl": "Autorisierungs-URL",
|
||||
"authUrlPlaceholder": "https://provider.com/oauth/authorize",
|
||||
"tokenUrl": "Token-URL",
|
||||
"tokenUrlPlaceholder": "https://provider.com/oauth/token",
|
||||
"userinfoUrl": "Userinfo-URL",
|
||||
"userinfoUrlPlaceholder": "https://provider.com/userinfo",
|
||||
"logoutUrl": "Logout-URL (optional)",
|
||||
"logoutUrlPlaceholder": "https://provider.com/logout",
|
||||
"scopes": "Scopes",
|
||||
"scopesPlaceholder": "openid email profile",
|
||||
"scopesHelper": "Durch Leerzeichen getrennte Liste von OAuth-Scopes",
|
||||
"allowedDomain": "Erlaubte Domain (optional)",
|
||||
"allowedDomainPlaceholder": "@firma.com",
|
||||
"allowedDomainHelper": "Auf bestimmte E-Mail-Domain beschränken",
|
||||
"autoLogin": "Auto-Login",
|
||||
"autoLoginHelper": "Automatisch zu OAuth weiterleiten, wenn Sitzung existiert",
|
||||
"testConnection": "Verbindung testen"
|
||||
},
|
||||
"magiclink": {
|
||||
"title": "Magic Link Konfiguration",
|
||||
"description": "Passwortlose E-Mail-Authentifizierung konfigurieren",
|
||||
"enabled": "Magic Link Authentifizierung aktivieren",
|
||||
"enabledHelper": "Erfordert konfiguriertes SMTP"
|
||||
},
|
||||
"smtp": {
|
||||
"title": "E-Mail (SMTP) Konfiguration",
|
||||
"description": "SMTP-Server für E-Mail-Versand konfigurieren",
|
||||
"host": "SMTP-Host",
|
||||
"hostPlaceholder": "smtp.beispiel.com",
|
||||
"port": "Port",
|
||||
"portPlaceholder": "587",
|
||||
"username": "Benutzername",
|
||||
"usernamePlaceholder": "user@beispiel.com",
|
||||
"password": "Passwort",
|
||||
"passwordPlaceholder": "Passwort eingeben",
|
||||
"passwordHelper": "Leer lassen, um den bestehenden Wert beizubehalten",
|
||||
"tls": "TLS verwenden",
|
||||
"tlsHelper": "Implizite TLS-Verbindung verwenden",
|
||||
"starttls": "STARTTLS verwenden",
|
||||
"starttlsHelper": "Verbindung auf TLS upgraden",
|
||||
"insecureSkipVerify": "Zertifikatsprüfung überspringen",
|
||||
"insecureSkipVerifyHelper": "Nicht für Produktion empfohlen",
|
||||
"timeout": "Timeout",
|
||||
"timeoutPlaceholder": "10s",
|
||||
"from": "Absenderadresse",
|
||||
"fromPlaceholder": "noreply@beispiel.com",
|
||||
"fromName": "Absendername",
|
||||
"fromNamePlaceholder": "Ackify",
|
||||
"subjectPrefix": "Betreff-Präfix (optional)",
|
||||
"subjectPrefixPlaceholder": "[Ackify]",
|
||||
"testConnection": "SMTP testen"
|
||||
},
|
||||
"storage": {
|
||||
"title": "Speicherkonfiguration",
|
||||
"description": "Dokumentenspeicher-Backend konfigurieren",
|
||||
"type": "Speichertyp",
|
||||
"types": {
|
||||
"none": "Deaktiviert",
|
||||
"local": "Lokales Dateisystem",
|
||||
"s3": "S3 / MinIO"
|
||||
},
|
||||
"maxSizeMb": "Max. Dateigröße (MB)",
|
||||
"maxSizeMbHelper": "Maximale Upload-Dateigröße in Megabytes",
|
||||
"localPath": "Lokaler Pfad",
|
||||
"localPathPlaceholder": "/data/documents",
|
||||
"localPathHelper": "Verzeichnispfad zum Speichern von Dateien",
|
||||
"s3Endpoint": "S3-Endpoint",
|
||||
"s3EndpointPlaceholder": "s3.amazonaws.com oder minio.beispiel.com",
|
||||
"s3Bucket": "Bucket-Name",
|
||||
"s3BucketPlaceholder": "ackify-documents",
|
||||
"s3AccessKey": "Zugriffsschlüssel",
|
||||
"s3AccessKeyPlaceholder": "AKIAIOSFODNN7EXAMPLE",
|
||||
"s3SecretKey": "Geheimschlüssel",
|
||||
"s3SecretKeyPlaceholder": "Geheimschlüssel eingeben",
|
||||
"s3SecretKeyHelper": "Leer lassen, um den bestehenden Wert beizubehalten",
|
||||
"s3Region": "Region",
|
||||
"s3RegionPlaceholder": "us-east-1",
|
||||
"s3UseSsl": "SSL verwenden",
|
||||
"s3UseSslHelper": "HTTPS für S3-Verbindung verwenden",
|
||||
"testConnection": "S3-Verbindung testen"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Speichern",
|
||||
"saving": "Speichern...",
|
||||
"test": "Testen",
|
||||
"testing": "Testen...",
|
||||
"reset": "Aus ENV zurücksetzen",
|
||||
"resetting": "Zurücksetzen..."
|
||||
},
|
||||
"resetConfirm": {
|
||||
"title": "Einstellungen zurücksetzen?",
|
||||
"message": "Dies setzt alle Einstellungen auf die Werte aus den Umgebungsvariablen zurück. Alle in der Datenbank gespeicherten Konfigurationen werden überschrieben.",
|
||||
"confirm": "Zurücksetzen",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"validation": {
|
||||
"authRequired": "Mindestens eine Authentifizierungsmethode (OAuth oder Magic Link) muss aktiviert sein",
|
||||
"magiclinkRequiresSmtp": "Magic Link erfordert konfiguriertes SMTP",
|
||||
"oidcCustomRequiresUrls": "Benutzerdefinierter OIDC-Anbieter erfordert auth_url, token_url und userinfo_url"
|
||||
}
|
||||
},
|
||||
"webhooks": {
|
||||
"title": "Webhooks",
|
||||
"subtitle": "Configurer les notifications vers des applications tierces",
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
"myDocuments": "My documents",
|
||||
"admin": "Admin",
|
||||
"administration": "Administration",
|
||||
"adminMenu": {
|
||||
"allDocuments": "All documents",
|
||||
"settings": "Settings",
|
||||
"webhooks": "Webhooks"
|
||||
},
|
||||
"login": "Sign in",
|
||||
"logout": "Sign out",
|
||||
"mobileMenu": "Mobile menu",
|
||||
@@ -342,6 +347,152 @@
|
||||
"pageOf": "Page {current} of {total}"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"subtitle": "Configure your Ackify instance",
|
||||
"manage": "Settings",
|
||||
"loading": "Loading settings...",
|
||||
"lastUpdated": "Last updated: {date}",
|
||||
"saveSuccess": "Settings saved successfully",
|
||||
"saveError": "Failed to save settings",
|
||||
"testSuccess": "Connection test successful",
|
||||
"testError": "Connection test failed",
|
||||
"resetSuccess": "Settings reset from environment",
|
||||
"resetError": "Failed to reset settings",
|
||||
"sections": {
|
||||
"general": "General",
|
||||
"oidc": "OAuth / OIDC",
|
||||
"magiclink": "Magic Link",
|
||||
"smtp": "Email (SMTP)",
|
||||
"storage": "Storage"
|
||||
},
|
||||
"general": {
|
||||
"title": "General Settings",
|
||||
"description": "Basic configuration for your Ackify instance",
|
||||
"organisation": "Organisation name",
|
||||
"organisationPlaceholder": "My Company",
|
||||
"organisationHelper": "Displayed in emails and interface",
|
||||
"onlyAdminCanCreate": "Only admins can create documents",
|
||||
"onlyAdminCanCreateHelper": "When enabled, only administrators can create new documents"
|
||||
},
|
||||
"oidc": {
|
||||
"title": "OAuth / OIDC Configuration",
|
||||
"description": "Configure OAuth 2.0 / OpenID Connect authentication",
|
||||
"enabled": "Enable OAuth authentication",
|
||||
"provider": "Provider",
|
||||
"providerPlaceholder": "Select a provider",
|
||||
"providers": {
|
||||
"google": "Google",
|
||||
"github": "GitHub",
|
||||
"gitlab": "GitLab",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"clientId": "Client ID",
|
||||
"clientIdPlaceholder": "Enter your client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"clientSecretPlaceholder": "Enter your client secret",
|
||||
"clientSecretHelper": "Leave empty to keep existing value",
|
||||
"authUrl": "Authorization URL",
|
||||
"authUrlPlaceholder": "https://provider.com/oauth/authorize",
|
||||
"tokenUrl": "Token URL",
|
||||
"tokenUrlPlaceholder": "https://provider.com/oauth/token",
|
||||
"userinfoUrl": "Userinfo URL",
|
||||
"userinfoUrlPlaceholder": "https://provider.com/userinfo",
|
||||
"logoutUrl": "Logout URL (optional)",
|
||||
"logoutUrlPlaceholder": "https://provider.com/logout",
|
||||
"scopes": "Scopes",
|
||||
"scopesPlaceholder": "openid email profile",
|
||||
"scopesHelper": "Space-separated list of OAuth scopes",
|
||||
"allowedDomain": "Allowed domain (optional)",
|
||||
"allowedDomainPlaceholder": "@company.com",
|
||||
"allowedDomainHelper": "Restrict to specific email domain",
|
||||
"autoLogin": "Auto login",
|
||||
"autoLoginHelper": "Automatically redirect to OAuth if session exists",
|
||||
"testConnection": "Test Connection"
|
||||
},
|
||||
"magiclink": {
|
||||
"title": "Magic Link Configuration",
|
||||
"description": "Configure passwordless email authentication",
|
||||
"enabled": "Enable Magic Link authentication",
|
||||
"enabledHelper": "Requires SMTP to be configured"
|
||||
},
|
||||
"smtp": {
|
||||
"title": "Email (SMTP) Configuration",
|
||||
"description": "Configure SMTP server for sending emails",
|
||||
"host": "SMTP Host",
|
||||
"hostPlaceholder": "smtp.example.com",
|
||||
"port": "Port",
|
||||
"portPlaceholder": "587",
|
||||
"username": "Username",
|
||||
"usernamePlaceholder": "user@example.com",
|
||||
"password": "Password",
|
||||
"passwordPlaceholder": "Enter password",
|
||||
"passwordHelper": "Leave empty to keep existing value",
|
||||
"tls": "Use TLS",
|
||||
"tlsHelper": "Use implicit TLS connection",
|
||||
"starttls": "Use STARTTLS",
|
||||
"starttlsHelper": "Upgrade connection to TLS",
|
||||
"insecureSkipVerify": "Skip certificate verification",
|
||||
"insecureSkipVerifyHelper": "Not recommended for production",
|
||||
"timeout": "Timeout",
|
||||
"timeoutPlaceholder": "10s",
|
||||
"from": "From address",
|
||||
"fromPlaceholder": "noreply@example.com",
|
||||
"fromName": "From name",
|
||||
"fromNamePlaceholder": "Ackify",
|
||||
"subjectPrefix": "Subject prefix (optional)",
|
||||
"subjectPrefixPlaceholder": "[Ackify]",
|
||||
"testConnection": "Test SMTP"
|
||||
},
|
||||
"storage": {
|
||||
"title": "Storage Configuration",
|
||||
"description": "Configure document storage backend",
|
||||
"type": "Storage type",
|
||||
"types": {
|
||||
"none": "Disabled",
|
||||
"local": "Local filesystem",
|
||||
"s3": "S3 / MinIO"
|
||||
},
|
||||
"maxSizeMb": "Max file size (MB)",
|
||||
"maxSizeMbHelper": "Maximum upload file size in megabytes",
|
||||
"localPath": "Local path",
|
||||
"localPathPlaceholder": "/data/documents",
|
||||
"localPathHelper": "Directory path for storing files",
|
||||
"s3Endpoint": "S3 Endpoint",
|
||||
"s3EndpointPlaceholder": "s3.amazonaws.com or minio.example.com",
|
||||
"s3Bucket": "Bucket name",
|
||||
"s3BucketPlaceholder": "ackify-documents",
|
||||
"s3AccessKey": "Access Key",
|
||||
"s3AccessKeyPlaceholder": "AKIAIOSFODNN7EXAMPLE",
|
||||
"s3SecretKey": "Secret Key",
|
||||
"s3SecretKeyPlaceholder": "Enter secret key",
|
||||
"s3SecretKeyHelper": "Leave empty to keep existing value",
|
||||
"s3Region": "Region",
|
||||
"s3RegionPlaceholder": "us-east-1",
|
||||
"s3UseSsl": "Use SSL",
|
||||
"s3UseSslHelper": "Use HTTPS for S3 connection",
|
||||
"testConnection": "Test S3 Connection"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"test": "Test",
|
||||
"testing": "Testing...",
|
||||
"reset": "Reset from ENV",
|
||||
"resetting": "Resetting..."
|
||||
},
|
||||
"resetConfirm": {
|
||||
"title": "Reset Settings?",
|
||||
"message": "This will reset all settings to their values from environment variables. Any configuration saved in the database will be overwritten.",
|
||||
"confirm": "Reset",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"validation": {
|
||||
"authRequired": "At least one authentication method (OAuth or Magic Link) must be enabled",
|
||||
"magiclinkRequiresSmtp": "Magic Link requires SMTP to be configured",
|
||||
"oidcCustomRequiresUrls": "Custom OIDC provider requires auth_url, token_url and userinfo_url"
|
||||
}
|
||||
},
|
||||
"webhooks": {
|
||||
"title": "Webhooks",
|
||||
"subtitle": "Configure notifications to third-party applications",
|
||||
|
||||
@@ -3,15 +3,20 @@
|
||||
"name": "Ackify"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Accueil",
|
||||
"myConfirmations": "Mes confirmations",
|
||||
"myDocuments": "Mes documents",
|
||||
"home": "Inicio",
|
||||
"myConfirmations": "Mis confirmaciones",
|
||||
"myDocuments": "Mis documentos",
|
||||
"admin": "Admin",
|
||||
"administration": "Administration",
|
||||
"login": "Se connecter",
|
||||
"logout": "Déconnexion",
|
||||
"mobileMenu": "Menu mobile",
|
||||
"mainNavigation": "Navigation principale"
|
||||
"administration": "Administración",
|
||||
"adminMenu": {
|
||||
"allDocuments": "Todos los documentos",
|
||||
"settings": "Configuración",
|
||||
"webhooks": "Webhooks"
|
||||
},
|
||||
"login": "Iniciar sesión",
|
||||
"logout": "Cerrar sesión",
|
||||
"mobileMenu": "Menú móvil",
|
||||
"mainNavigation": "Navegación principal"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Changer de thème"
|
||||
@@ -342,6 +347,152 @@
|
||||
"pageOf": "Page {current} sur {total}"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configuración",
|
||||
"subtitle": "Configure su instancia de Ackify",
|
||||
"manage": "Configuración",
|
||||
"loading": "Cargando configuración...",
|
||||
"lastUpdated": "Última actualización: {date}",
|
||||
"saveSuccess": "Configuración guardada correctamente",
|
||||
"saveError": "Error al guardar la configuración",
|
||||
"testSuccess": "Prueba de conexión exitosa",
|
||||
"testError": "Error en la prueba de conexión",
|
||||
"resetSuccess": "Configuración restablecida desde el entorno",
|
||||
"resetError": "Error al restablecer la configuración",
|
||||
"sections": {
|
||||
"general": "General",
|
||||
"oidc": "OAuth / OIDC",
|
||||
"magiclink": "Magic Link",
|
||||
"smtp": "Email (SMTP)",
|
||||
"storage": "Almacenamiento"
|
||||
},
|
||||
"general": {
|
||||
"title": "Configuración general",
|
||||
"description": "Configuración básica de su instancia Ackify",
|
||||
"organisation": "Nombre de la organización",
|
||||
"organisationPlaceholder": "Mi Empresa",
|
||||
"organisationHelper": "Se muestra en emails e interfaz",
|
||||
"onlyAdminCanCreate": "Solo los admins pueden crear documentos",
|
||||
"onlyAdminCanCreateHelper": "Cuando está activado, solo los administradores pueden crear nuevos documentos"
|
||||
},
|
||||
"oidc": {
|
||||
"title": "Configuración OAuth / OIDC",
|
||||
"description": "Configurar autenticación OAuth 2.0 / OpenID Connect",
|
||||
"enabled": "Habilitar autenticación OAuth",
|
||||
"provider": "Proveedor",
|
||||
"providerPlaceholder": "Seleccionar proveedor",
|
||||
"providers": {
|
||||
"google": "Google",
|
||||
"github": "GitHub",
|
||||
"gitlab": "GitLab",
|
||||
"custom": "Personalizado"
|
||||
},
|
||||
"clientId": "Client ID",
|
||||
"clientIdPlaceholder": "Ingrese su client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"clientSecretPlaceholder": "Ingrese su client secret",
|
||||
"clientSecretHelper": "Dejar vacío para mantener el valor existente",
|
||||
"authUrl": "URL de autorización",
|
||||
"authUrlPlaceholder": "https://provider.com/oauth/authorize",
|
||||
"tokenUrl": "URL del token",
|
||||
"tokenUrlPlaceholder": "https://provider.com/oauth/token",
|
||||
"userinfoUrl": "URL userinfo",
|
||||
"userinfoUrlPlaceholder": "https://provider.com/userinfo",
|
||||
"logoutUrl": "URL de cierre de sesión (opcional)",
|
||||
"logoutUrlPlaceholder": "https://provider.com/logout",
|
||||
"scopes": "Scopes",
|
||||
"scopesPlaceholder": "openid email profile",
|
||||
"scopesHelper": "Lista de scopes OAuth separados por espacios",
|
||||
"allowedDomain": "Dominio permitido (opcional)",
|
||||
"allowedDomainPlaceholder": "@empresa.com",
|
||||
"allowedDomainHelper": "Restringir a un dominio de email específico",
|
||||
"autoLogin": "Inicio de sesión automático",
|
||||
"autoLoginHelper": "Redirigir automáticamente a OAuth si existe sesión",
|
||||
"testConnection": "Probar conexión"
|
||||
},
|
||||
"magiclink": {
|
||||
"title": "Configuración Magic Link",
|
||||
"description": "Configurar autenticación sin contraseña por email",
|
||||
"enabled": "Habilitar autenticación Magic Link",
|
||||
"enabledHelper": "Requiere que SMTP esté configurado"
|
||||
},
|
||||
"smtp": {
|
||||
"title": "Configuración Email (SMTP)",
|
||||
"description": "Configurar servidor SMTP para envío de emails",
|
||||
"host": "Host SMTP",
|
||||
"hostPlaceholder": "smtp.ejemplo.com",
|
||||
"port": "Puerto",
|
||||
"portPlaceholder": "587",
|
||||
"username": "Usuario",
|
||||
"usernamePlaceholder": "user@ejemplo.com",
|
||||
"password": "Contraseña",
|
||||
"passwordPlaceholder": "Ingrese contraseña",
|
||||
"passwordHelper": "Dejar vacío para mantener el valor existente",
|
||||
"tls": "Usar TLS",
|
||||
"tlsHelper": "Usar conexión TLS implícita",
|
||||
"starttls": "Usar STARTTLS",
|
||||
"starttlsHelper": "Actualizar conexión a TLS",
|
||||
"insecureSkipVerify": "Omitir verificación de certificado",
|
||||
"insecureSkipVerifyHelper": "No recomendado en producción",
|
||||
"timeout": "Tiempo de espera",
|
||||
"timeoutPlaceholder": "10s",
|
||||
"from": "Dirección de envío",
|
||||
"fromPlaceholder": "noreply@ejemplo.com",
|
||||
"fromName": "Nombre del remitente",
|
||||
"fromNamePlaceholder": "Ackify",
|
||||
"subjectPrefix": "Prefijo del asunto (opcional)",
|
||||
"subjectPrefixPlaceholder": "[Ackify]",
|
||||
"testConnection": "Probar SMTP"
|
||||
},
|
||||
"storage": {
|
||||
"title": "Configuración de almacenamiento",
|
||||
"description": "Configurar backend de almacenamiento de documentos",
|
||||
"type": "Tipo de almacenamiento",
|
||||
"types": {
|
||||
"none": "Deshabilitado",
|
||||
"local": "Sistema de archivos local",
|
||||
"s3": "S3 / MinIO"
|
||||
},
|
||||
"maxSizeMb": "Tamaño máx. de archivo (MB)",
|
||||
"maxSizeMbHelper": "Tamaño máximo de carga de archivos en megabytes",
|
||||
"localPath": "Ruta local",
|
||||
"localPathPlaceholder": "/data/documents",
|
||||
"localPathHelper": "Ruta del directorio para almacenar archivos",
|
||||
"s3Endpoint": "Endpoint S3",
|
||||
"s3EndpointPlaceholder": "s3.amazonaws.com o minio.ejemplo.com",
|
||||
"s3Bucket": "Nombre del bucket",
|
||||
"s3BucketPlaceholder": "ackify-documents",
|
||||
"s3AccessKey": "Clave de acceso",
|
||||
"s3AccessKeyPlaceholder": "AKIAIOSFODNN7EXAMPLE",
|
||||
"s3SecretKey": "Clave secreta",
|
||||
"s3SecretKeyPlaceholder": "Ingrese clave secreta",
|
||||
"s3SecretKeyHelper": "Dejar vacío para mantener el valor existente",
|
||||
"s3Region": "Región",
|
||||
"s3RegionPlaceholder": "us-east-1",
|
||||
"s3UseSsl": "Usar SSL",
|
||||
"s3UseSslHelper": "Usar HTTPS para conexión S3",
|
||||
"testConnection": "Probar conexión S3"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Guardar",
|
||||
"saving": "Guardando...",
|
||||
"test": "Probar",
|
||||
"testing": "Probando...",
|
||||
"reset": "Restablecer desde ENV",
|
||||
"resetting": "Restableciendo..."
|
||||
},
|
||||
"resetConfirm": {
|
||||
"title": "¿Restablecer configuración?",
|
||||
"message": "Esto restablecerá toda la configuración a los valores de las variables de entorno. Cualquier configuración guardada en la base de datos será sobrescrita.",
|
||||
"confirm": "Restablecer",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"validation": {
|
||||
"authRequired": "Al menos un método de autenticación (OAuth o Magic Link) debe estar habilitado",
|
||||
"magiclinkRequiresSmtp": "Magic Link requiere que SMTP esté configurado",
|
||||
"oidcCustomRequiresUrls": "El proveedor OIDC personalizado requiere auth_url, token_url y userinfo_url"
|
||||
}
|
||||
},
|
||||
"webhooks": {
|
||||
"title": "Webhooks",
|
||||
"subtitle": "Configurer les notifications vers des applications tierces",
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
"myDocuments": "Mes documents",
|
||||
"admin": "Admin",
|
||||
"administration": "Administration",
|
||||
"adminMenu": {
|
||||
"allDocuments": "Tous les documents",
|
||||
"settings": "Paramètres",
|
||||
"webhooks": "Webhooks"
|
||||
},
|
||||
"login": "Se connecter",
|
||||
"logout": "Déconnexion",
|
||||
"mobileMenu": "Menu mobile",
|
||||
@@ -342,6 +347,152 @@
|
||||
"pageOf": "Page {current} sur {total}"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"subtitle": "Configurez votre instance Ackify",
|
||||
"manage": "Paramètres",
|
||||
"loading": "Chargement des paramètres...",
|
||||
"lastUpdated": "Dernière mise à jour : {date}",
|
||||
"saveSuccess": "Paramètres enregistrés avec succès",
|
||||
"saveError": "Échec de l'enregistrement des paramètres",
|
||||
"testSuccess": "Test de connexion réussi",
|
||||
"testError": "Échec du test de connexion",
|
||||
"resetSuccess": "Paramètres réinitialisés depuis l'environnement",
|
||||
"resetError": "Échec de la réinitialisation des paramètres",
|
||||
"sections": {
|
||||
"general": "Général",
|
||||
"oidc": "OAuth / OIDC",
|
||||
"magiclink": "Magic Link",
|
||||
"smtp": "Email (SMTP)",
|
||||
"storage": "Stockage"
|
||||
},
|
||||
"general": {
|
||||
"title": "Paramètres généraux",
|
||||
"description": "Configuration de base de votre instance Ackify",
|
||||
"organisation": "Nom de l'organisation",
|
||||
"organisationPlaceholder": "Mon Entreprise",
|
||||
"organisationHelper": "Affiché dans les emails et l'interface",
|
||||
"onlyAdminCanCreate": "Seuls les admins peuvent créer des documents",
|
||||
"onlyAdminCanCreateHelper": "Lorsque activé, seuls les administrateurs peuvent créer de nouveaux documents"
|
||||
},
|
||||
"oidc": {
|
||||
"title": "Configuration OAuth / OIDC",
|
||||
"description": "Configurer l'authentification OAuth 2.0 / OpenID Connect",
|
||||
"enabled": "Activer l'authentification OAuth",
|
||||
"provider": "Fournisseur",
|
||||
"providerPlaceholder": "Sélectionner un fournisseur",
|
||||
"providers": {
|
||||
"google": "Google",
|
||||
"github": "GitHub",
|
||||
"gitlab": "GitLab",
|
||||
"custom": "Personnalisé"
|
||||
},
|
||||
"clientId": "Client ID",
|
||||
"clientIdPlaceholder": "Entrez votre client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"clientSecretPlaceholder": "Entrez votre client secret",
|
||||
"clientSecretHelper": "Laissez vide pour conserver la valeur existante",
|
||||
"authUrl": "URL d'autorisation",
|
||||
"authUrlPlaceholder": "https://provider.com/oauth/authorize",
|
||||
"tokenUrl": "URL du token",
|
||||
"tokenUrlPlaceholder": "https://provider.com/oauth/token",
|
||||
"userinfoUrl": "URL userinfo",
|
||||
"userinfoUrlPlaceholder": "https://provider.com/userinfo",
|
||||
"logoutUrl": "URL de déconnexion (optionnel)",
|
||||
"logoutUrlPlaceholder": "https://provider.com/logout",
|
||||
"scopes": "Scopes",
|
||||
"scopesPlaceholder": "openid email profile",
|
||||
"scopesHelper": "Liste des scopes OAuth séparés par des espaces",
|
||||
"allowedDomain": "Domaine autorisé (optionnel)",
|
||||
"allowedDomainPlaceholder": "@entreprise.com",
|
||||
"allowedDomainHelper": "Restreindre à un domaine email spécifique",
|
||||
"autoLogin": "Connexion automatique",
|
||||
"autoLoginHelper": "Rediriger automatiquement vers OAuth si une session existe",
|
||||
"testConnection": "Tester la connexion"
|
||||
},
|
||||
"magiclink": {
|
||||
"title": "Configuration Magic Link",
|
||||
"description": "Configurer l'authentification sans mot de passe par email",
|
||||
"enabled": "Activer l'authentification Magic Link",
|
||||
"enabledHelper": "Nécessite que SMTP soit configuré"
|
||||
},
|
||||
"smtp": {
|
||||
"title": "Configuration Email (SMTP)",
|
||||
"description": "Configurer le serveur SMTP pour l'envoi d'emails",
|
||||
"host": "Hôte SMTP",
|
||||
"hostPlaceholder": "smtp.exemple.com",
|
||||
"port": "Port",
|
||||
"portPlaceholder": "587",
|
||||
"username": "Nom d'utilisateur",
|
||||
"usernamePlaceholder": "user@exemple.com",
|
||||
"password": "Mot de passe",
|
||||
"passwordPlaceholder": "Entrez le mot de passe",
|
||||
"passwordHelper": "Laissez vide pour conserver la valeur existante",
|
||||
"tls": "Utiliser TLS",
|
||||
"tlsHelper": "Utiliser une connexion TLS implicite",
|
||||
"starttls": "Utiliser STARTTLS",
|
||||
"starttlsHelper": "Mettre à niveau la connexion vers TLS",
|
||||
"insecureSkipVerify": "Ignorer la vérification du certificat",
|
||||
"insecureSkipVerifyHelper": "Non recommandé en production",
|
||||
"timeout": "Délai d'attente",
|
||||
"timeoutPlaceholder": "10s",
|
||||
"from": "Adresse d'expédition",
|
||||
"fromPlaceholder": "noreply@exemple.com",
|
||||
"fromName": "Nom d'expéditeur",
|
||||
"fromNamePlaceholder": "Ackify",
|
||||
"subjectPrefix": "Préfixe du sujet (optionnel)",
|
||||
"subjectPrefixPlaceholder": "[Ackify]",
|
||||
"testConnection": "Tester SMTP"
|
||||
},
|
||||
"storage": {
|
||||
"title": "Configuration du stockage",
|
||||
"description": "Configurer le backend de stockage des documents",
|
||||
"type": "Type de stockage",
|
||||
"types": {
|
||||
"none": "Désactivé",
|
||||
"local": "Système de fichiers local",
|
||||
"s3": "S3 / MinIO"
|
||||
},
|
||||
"maxSizeMb": "Taille max des fichiers (Mo)",
|
||||
"maxSizeMbHelper": "Taille maximale des fichiers uploadés en mégaoctets",
|
||||
"localPath": "Chemin local",
|
||||
"localPathPlaceholder": "/data/documents",
|
||||
"localPathHelper": "Chemin du répertoire pour stocker les fichiers",
|
||||
"s3Endpoint": "Endpoint S3",
|
||||
"s3EndpointPlaceholder": "s3.amazonaws.com ou minio.exemple.com",
|
||||
"s3Bucket": "Nom du bucket",
|
||||
"s3BucketPlaceholder": "ackify-documents",
|
||||
"s3AccessKey": "Clé d'accès",
|
||||
"s3AccessKeyPlaceholder": "AKIAIOSFODNN7EXAMPLE",
|
||||
"s3SecretKey": "Clé secrète",
|
||||
"s3SecretKeyPlaceholder": "Entrez la clé secrète",
|
||||
"s3SecretKeyHelper": "Laissez vide pour conserver la valeur existante",
|
||||
"s3Region": "Région",
|
||||
"s3RegionPlaceholder": "us-east-1",
|
||||
"s3UseSsl": "Utiliser SSL",
|
||||
"s3UseSslHelper": "Utiliser HTTPS pour la connexion S3",
|
||||
"testConnection": "Tester la connexion S3"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement...",
|
||||
"test": "Tester",
|
||||
"testing": "Test en cours...",
|
||||
"reset": "Réinitialiser depuis ENV",
|
||||
"resetting": "Réinitialisation..."
|
||||
},
|
||||
"resetConfirm": {
|
||||
"title": "Réinitialiser les paramètres ?",
|
||||
"message": "Cela réinitialisera tous les paramètres à leurs valeurs des variables d'environnement. Toute configuration enregistrée en base de données sera écrasée.",
|
||||
"confirm": "Réinitialiser",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"validation": {
|
||||
"authRequired": "Au moins une méthode d'authentification (OAuth ou Magic Link) doit être activée",
|
||||
"magiclinkRequiresSmtp": "Magic Link nécessite que SMTP soit configuré",
|
||||
"oidcCustomRequiresUrls": "Un fournisseur OIDC personnalisé nécessite auth_url, token_url et userinfo_url"
|
||||
}
|
||||
},
|
||||
"webhooks": {
|
||||
"title": "Webhooks",
|
||||
"subtitle": "Configurer les notifications vers des applications tierces",
|
||||
|
||||
@@ -3,15 +3,20 @@
|
||||
"name": "Ackify"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Accueil",
|
||||
"myConfirmations": "Mes confirmations",
|
||||
"myDocuments": "Mes documents",
|
||||
"home": "Home",
|
||||
"myConfirmations": "Le mie conferme",
|
||||
"myDocuments": "I miei documenti",
|
||||
"admin": "Admin",
|
||||
"administration": "Administration",
|
||||
"login": "Se connecter",
|
||||
"logout": "Déconnexion",
|
||||
"administration": "Amministrazione",
|
||||
"adminMenu": {
|
||||
"allDocuments": "Tutti i documenti",
|
||||
"settings": "Impostazioni",
|
||||
"webhooks": "Webhooks"
|
||||
},
|
||||
"login": "Accedi",
|
||||
"logout": "Esci",
|
||||
"mobileMenu": "Menu mobile",
|
||||
"mainNavigation": "Navigation principale"
|
||||
"mainNavigation": "Navigazione principale"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Changer de thème"
|
||||
@@ -342,6 +347,152 @@
|
||||
"pageOf": "Page {current} sur {total}"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni",
|
||||
"subtitle": "Configura la tua istanza Ackify",
|
||||
"manage": "Impostazioni",
|
||||
"loading": "Caricamento impostazioni...",
|
||||
"lastUpdated": "Ultimo aggiornamento: {date}",
|
||||
"saveSuccess": "Impostazioni salvate con successo",
|
||||
"saveError": "Errore nel salvataggio delle impostazioni",
|
||||
"testSuccess": "Test di connessione riuscito",
|
||||
"testError": "Test di connessione fallito",
|
||||
"resetSuccess": "Impostazioni ripristinate dall'ambiente",
|
||||
"resetError": "Errore nel ripristino delle impostazioni",
|
||||
"sections": {
|
||||
"general": "Generale",
|
||||
"oidc": "OAuth / OIDC",
|
||||
"magiclink": "Magic Link",
|
||||
"smtp": "Email (SMTP)",
|
||||
"storage": "Archiviazione"
|
||||
},
|
||||
"general": {
|
||||
"title": "Impostazioni generali",
|
||||
"description": "Configurazione di base della tua istanza Ackify",
|
||||
"organisation": "Nome organizzazione",
|
||||
"organisationPlaceholder": "La Mia Azienda",
|
||||
"organisationHelper": "Visualizzato nelle email e nell'interfaccia",
|
||||
"onlyAdminCanCreate": "Solo gli admin possono creare documenti",
|
||||
"onlyAdminCanCreateHelper": "Quando abilitato, solo gli amministratori possono creare nuovi documenti"
|
||||
},
|
||||
"oidc": {
|
||||
"title": "Configurazione OAuth / OIDC",
|
||||
"description": "Configura l'autenticazione OAuth 2.0 / OpenID Connect",
|
||||
"enabled": "Abilita autenticazione OAuth",
|
||||
"provider": "Provider",
|
||||
"providerPlaceholder": "Seleziona un provider",
|
||||
"providers": {
|
||||
"google": "Google",
|
||||
"github": "GitHub",
|
||||
"gitlab": "GitLab",
|
||||
"custom": "Personalizzato"
|
||||
},
|
||||
"clientId": "Client ID",
|
||||
"clientIdPlaceholder": "Inserisci il tuo client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"clientSecretPlaceholder": "Inserisci il tuo client secret",
|
||||
"clientSecretHelper": "Lascia vuoto per mantenere il valore esistente",
|
||||
"authUrl": "URL di autorizzazione",
|
||||
"authUrlPlaceholder": "https://provider.com/oauth/authorize",
|
||||
"tokenUrl": "URL del token",
|
||||
"tokenUrlPlaceholder": "https://provider.com/oauth/token",
|
||||
"userinfoUrl": "URL userinfo",
|
||||
"userinfoUrlPlaceholder": "https://provider.com/userinfo",
|
||||
"logoutUrl": "URL di logout (opzionale)",
|
||||
"logoutUrlPlaceholder": "https://provider.com/logout",
|
||||
"scopes": "Scopes",
|
||||
"scopesPlaceholder": "openid email profile",
|
||||
"scopesHelper": "Lista di scopes OAuth separati da spazi",
|
||||
"allowedDomain": "Dominio consentito (opzionale)",
|
||||
"allowedDomainPlaceholder": "@azienda.com",
|
||||
"allowedDomainHelper": "Limita a un dominio email specifico",
|
||||
"autoLogin": "Login automatico",
|
||||
"autoLoginHelper": "Reindirizza automaticamente a OAuth se esiste una sessione",
|
||||
"testConnection": "Testa connessione"
|
||||
},
|
||||
"magiclink": {
|
||||
"title": "Configurazione Magic Link",
|
||||
"description": "Configura l'autenticazione senza password via email",
|
||||
"enabled": "Abilita autenticazione Magic Link",
|
||||
"enabledHelper": "Richiede SMTP configurato"
|
||||
},
|
||||
"smtp": {
|
||||
"title": "Configurazione Email (SMTP)",
|
||||
"description": "Configura il server SMTP per l'invio di email",
|
||||
"host": "Host SMTP",
|
||||
"hostPlaceholder": "smtp.esempio.com",
|
||||
"port": "Porta",
|
||||
"portPlaceholder": "587",
|
||||
"username": "Nome utente",
|
||||
"usernamePlaceholder": "user@esempio.com",
|
||||
"password": "Password",
|
||||
"passwordPlaceholder": "Inserisci password",
|
||||
"passwordHelper": "Lascia vuoto per mantenere il valore esistente",
|
||||
"tls": "Usa TLS",
|
||||
"tlsHelper": "Usa connessione TLS implicita",
|
||||
"starttls": "Usa STARTTLS",
|
||||
"starttlsHelper": "Aggiorna connessione a TLS",
|
||||
"insecureSkipVerify": "Salta verifica certificato",
|
||||
"insecureSkipVerifyHelper": "Non raccomandato in produzione",
|
||||
"timeout": "Timeout",
|
||||
"timeoutPlaceholder": "10s",
|
||||
"from": "Indirizzo mittente",
|
||||
"fromPlaceholder": "noreply@esempio.com",
|
||||
"fromName": "Nome mittente",
|
||||
"fromNamePlaceholder": "Ackify",
|
||||
"subjectPrefix": "Prefisso oggetto (opzionale)",
|
||||
"subjectPrefixPlaceholder": "[Ackify]",
|
||||
"testConnection": "Testa SMTP"
|
||||
},
|
||||
"storage": {
|
||||
"title": "Configurazione archiviazione",
|
||||
"description": "Configura il backend di archiviazione documenti",
|
||||
"type": "Tipo di archiviazione",
|
||||
"types": {
|
||||
"none": "Disabilitato",
|
||||
"local": "Filesystem locale",
|
||||
"s3": "S3 / MinIO"
|
||||
},
|
||||
"maxSizeMb": "Dimensione max file (MB)",
|
||||
"maxSizeMbHelper": "Dimensione massima file caricati in megabytes",
|
||||
"localPath": "Percorso locale",
|
||||
"localPathPlaceholder": "/data/documents",
|
||||
"localPathHelper": "Percorso directory per archiviare i file",
|
||||
"s3Endpoint": "Endpoint S3",
|
||||
"s3EndpointPlaceholder": "s3.amazonaws.com o minio.esempio.com",
|
||||
"s3Bucket": "Nome bucket",
|
||||
"s3BucketPlaceholder": "ackify-documents",
|
||||
"s3AccessKey": "Chiave di accesso",
|
||||
"s3AccessKeyPlaceholder": "AKIAIOSFODNN7EXAMPLE",
|
||||
"s3SecretKey": "Chiave segreta",
|
||||
"s3SecretKeyPlaceholder": "Inserisci chiave segreta",
|
||||
"s3SecretKeyHelper": "Lascia vuoto per mantenere il valore esistente",
|
||||
"s3Region": "Regione",
|
||||
"s3RegionPlaceholder": "us-east-1",
|
||||
"s3UseSsl": "Usa SSL",
|
||||
"s3UseSslHelper": "Usa HTTPS per connessione S3",
|
||||
"testConnection": "Testa connessione S3"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Salva",
|
||||
"saving": "Salvataggio...",
|
||||
"test": "Testa",
|
||||
"testing": "Test in corso...",
|
||||
"reset": "Ripristina da ENV",
|
||||
"resetting": "Ripristino..."
|
||||
},
|
||||
"resetConfirm": {
|
||||
"title": "Ripristinare le impostazioni?",
|
||||
"message": "Questo ripristinerà tutte le impostazioni ai valori delle variabili d'ambiente. Qualsiasi configurazione salvata nel database verrà sovrascritta.",
|
||||
"confirm": "Ripristina",
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
"validation": {
|
||||
"authRequired": "Almeno un metodo di autenticazione (OAuth o Magic Link) deve essere abilitato",
|
||||
"magiclinkRequiresSmtp": "Magic Link richiede SMTP configurato",
|
||||
"oidcCustomRequiresUrls": "Il provider OIDC personalizzato richiede auth_url, token_url e userinfo_url"
|
||||
}
|
||||
},
|
||||
"webhooks": {
|
||||
"title": "Webhooks",
|
||||
"subtitle": "Configurer les notifications vers des applications tierces",
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Loader2,
|
||||
Search,
|
||||
Webhook,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
AlertCircle,
|
||||
@@ -199,6 +200,12 @@ onMounted(() => {
|
||||
<span class="hidden sm:inline">{{ t('admin.webhooks.manage') }}</span>
|
||||
</button>
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'admin-settings' }">
|
||||
<button class="inline-flex items-center gap-2 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-4 py-2.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors">
|
||||
<Settings :size="16" />
|
||||
<span class="hidden sm:inline">{{ t('admin.settings.manage') }}</span>
|
||||
</button>
|
||||
</router-link>
|
||||
<button
|
||||
@click="loadDocuments()"
|
||||
:disabled="loading || searching"
|
||||
|
||||
579
webapp/src/pages/admin/AdminSettings.vue
Normal file
579
webapp/src/pages/admin/AdminSettings.vue
Normal file
@@ -0,0 +1,579 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePageTitle } from '@/composables/usePageTitle'
|
||||
import {
|
||||
getSettings,
|
||||
updateSection,
|
||||
testConnection,
|
||||
resetFromENV,
|
||||
isSecretMasked,
|
||||
getOIDCProviderURLs,
|
||||
type SettingsResponse,
|
||||
type GeneralConfig,
|
||||
type OIDCConfig,
|
||||
type SMTPConfig,
|
||||
type StorageConfig,
|
||||
type ConfigSection
|
||||
} from '@/services/settings'
|
||||
import { extractError } from '@/services/http'
|
||||
import {
|
||||
Settings,
|
||||
Shield,
|
||||
Mail,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
Save,
|
||||
RefreshCw,
|
||||
TestTube2,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
ChevronRight,
|
||||
Link
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const { t } = useI18n()
|
||||
usePageTitle('admin.settings.title')
|
||||
|
||||
// State
|
||||
const settings = ref<SettingsResponse | null>(null)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const testing = ref<string | null>(null)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const activeSection = ref<ConfigSection>('general')
|
||||
const showResetConfirm = ref(false)
|
||||
|
||||
// Edit states for each section
|
||||
const editGeneral = ref<GeneralConfig>({ organisation: '', only_admin_can_create: false })
|
||||
const editOIDC = ref<OIDCConfig>({
|
||||
enabled: false, provider: '', client_id: '', client_secret: '',
|
||||
auth_url: '', token_url: '', userinfo_url: '', logout_url: '',
|
||||
scopes: [], allowed_domain: '', auto_login: false
|
||||
})
|
||||
const editMagicLink = ref<{ enabled: boolean }>({ enabled: false })
|
||||
const editSMTP = ref<SMTPConfig>({
|
||||
host: '', port: 587, username: '', password: '',
|
||||
tls: false, starttls: true, insecure_skip_verify: false,
|
||||
timeout: '10s', from: '', from_name: '', subject_prefix: ''
|
||||
})
|
||||
const editStorage = ref<StorageConfig>({
|
||||
type: '', max_size_mb: 50, local_path: '/data/documents',
|
||||
s3_endpoint: '', s3_bucket: '', s3_access_key: '', s3_secret_key: '',
|
||||
s3_region: 'us-east-1', s3_use_ssl: true
|
||||
})
|
||||
|
||||
// Section navigation
|
||||
const sections = computed(() => [
|
||||
{ id: 'general' as ConfigSection, icon: Settings, label: t('admin.settings.sections.general') },
|
||||
{ id: 'oidc' as ConfigSection, icon: Shield, label: t('admin.settings.sections.oidc') },
|
||||
{ id: 'magiclink' as ConfigSection, icon: Link, label: t('admin.settings.sections.magiclink') },
|
||||
{ id: 'smtp' as ConfigSection, icon: Mail, label: t('admin.settings.sections.smtp') },
|
||||
{ id: 'storage' as ConfigSection, icon: HardDrive, label: t('admin.settings.sections.storage') }
|
||||
])
|
||||
|
||||
// Load settings
|
||||
async function loadSettings() {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
const response = await getSettings()
|
||||
settings.value = response.data
|
||||
// Initialize edit states
|
||||
editGeneral.value = { ...response.data.general }
|
||||
editOIDC.value = { ...response.data.oidc }
|
||||
editMagicLink.value = { enabled: response.data.magiclink.enabled }
|
||||
editSMTP.value = { ...response.data.smtp }
|
||||
editStorage.value = { ...response.data.storage }
|
||||
} catch (err) {
|
||||
error.value = extractError(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Save section
|
||||
async function saveSection(section: ConfigSection) {
|
||||
try {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
let config: any
|
||||
switch (section) {
|
||||
case 'general': config = editGeneral.value; break
|
||||
case 'oidc': config = editOIDC.value; break
|
||||
case 'magiclink': config = editMagicLink.value; break
|
||||
case 'smtp': config = editSMTP.value; break
|
||||
case 'storage': config = editStorage.value; break
|
||||
}
|
||||
|
||||
await updateSection(section, config)
|
||||
success.value = t('admin.settings.saveSuccess')
|
||||
await loadSettings()
|
||||
setTimeout(() => success.value = '', 3000)
|
||||
} catch (err) {
|
||||
error.value = extractError(err)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Test connection
|
||||
async function testConnectionHandler(type: 'smtp' | 's3' | 'oidc') {
|
||||
try {
|
||||
testing.value = type
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
let config: any
|
||||
switch (type) {
|
||||
case 'smtp': config = editSMTP.value; break
|
||||
case 's3': config = editStorage.value; break
|
||||
case 'oidc': config = editOIDC.value; break
|
||||
}
|
||||
|
||||
await testConnection(type, config)
|
||||
success.value = t('admin.settings.testSuccess')
|
||||
setTimeout(() => success.value = '', 3000)
|
||||
} catch (err) {
|
||||
error.value = extractError(err)
|
||||
} finally {
|
||||
testing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Reset from ENV
|
||||
async function handleReset() {
|
||||
try {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
await resetFromENV()
|
||||
success.value = t('admin.settings.resetSuccess')
|
||||
await loadSettings()
|
||||
showResetConfirm.value = false
|
||||
setTimeout(() => success.value = '', 3000)
|
||||
} catch (err) {
|
||||
error.value = extractError(err)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// OIDC provider change handler
|
||||
function onOIDCProviderChange() {
|
||||
const provider = editOIDC.value.provider
|
||||
if (provider && provider !== 'custom') {
|
||||
const urls = getOIDCProviderURLs(provider)
|
||||
editOIDC.value = { ...editOIDC.value, ...urls }
|
||||
}
|
||||
}
|
||||
|
||||
// Check if password field has value (masked or real)
|
||||
function hasSecretValue(value: string): boolean {
|
||||
return value !== '' && !isSecretMasked(value) || isSecretMasked(value)
|
||||
}
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="flex items-center gap-2 text-sm mb-6">
|
||||
<router-link to="/admin" class="text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors">
|
||||
{{ t('admin.title') }}
|
||||
</router-link>
|
||||
<ChevronRight :size="16" class="text-slate-300 dark:text-slate-600" />
|
||||
<span class="text-slate-900 dark:text-slate-100 font-medium">
|
||||
{{ t('admin.settings.title') }}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6 sm:mb-8">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 sm:w-14 sm:h-14 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<Settings class="w-6 h-6 sm:w-7 sm:h-7 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl sm:text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{{ t('admin.settings.title') }}
|
||||
</h1>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||
{{ t('admin.settings.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="showResetConfirm = true"
|
||||
class="w-full sm:w-auto inline-flex items-center justify-center gap-2 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 font-medium rounded-lg px-4 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<RefreshCw :size="18" />
|
||||
{{ t('admin.settings.actions.reset') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Alerts -->
|
||||
<div v-if="error" class="mb-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<AlertCircle :size="20" class="text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<p class="text-red-700 dark:text-red-400 text-sm">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="success" class="mb-6 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-xl p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<CheckCircle :size="20" class="text-emerald-600 dark:text-emerald-400 flex-shrink-0 mt-0.5" />
|
||||
<p class="text-emerald-700 dark:text-emerald-400 text-sm">{{ success }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center gap-3 py-24">
|
||||
<Loader2 :size="32" class="animate-spin text-blue-600" />
|
||||
<span class="text-slate-500">{{ t('common.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div v-else class="flex flex-col lg:flex-row gap-6">
|
||||
<!-- Sidebar Navigation -->
|
||||
<nav class="lg:w-64 flex-shrink-0">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-2">
|
||||
<ul class="space-y-1">
|
||||
<li v-for="section in sections" :key="section.id">
|
||||
<button
|
||||
@click="activeSection = section.id"
|
||||
:class="[
|
||||
'w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors text-sm font-medium',
|
||||
activeSection === section.id
|
||||
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700'
|
||||
]"
|
||||
>
|
||||
<component :is="section.icon" :size="20" />
|
||||
{{ section.label }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex-1 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<!-- General Section -->
|
||||
<div v-if="activeSection === 'general'" class="p-6">
|
||||
<h2 class="text-lg font-semibold text-slate-900 dark:text-white mb-6">
|
||||
{{ t('admin.settings.sections.general') }}
|
||||
</h2>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label for="organisation" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
{{ t('admin.settings.general.organisation') }}
|
||||
</label>
|
||||
<input
|
||||
id="organisation"
|
||||
data-testid="organisation"
|
||||
v-model="editGeneral.organisation"
|
||||
type="text"
|
||||
class="w-full px-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
v-model="editGeneral.only_admin_can_create"
|
||||
type="checkbox"
|
||||
id="only_admin_can_create"
|
||||
data-testid="only_admin_can_create"
|
||||
class="w-5 h-5 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<label for="only_admin_can_create" class="text-sm text-slate-700 dark:text-slate-300">
|
||||
{{ t('admin.settings.general.onlyAdminCanCreate') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 flex justify-end">
|
||||
<button
|
||||
@click="saveSection('general')"
|
||||
:disabled="saving"
|
||||
class="inline-flex items-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg px-6 py-2.5 transition-colors"
|
||||
>
|
||||
<Loader2 v-if="saving" :size="18" class="animate-spin" />
|
||||
<Save v-else :size="18" />
|
||||
{{ t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OIDC Section -->
|
||||
<div v-if="activeSection === 'oidc'" class="p-6">
|
||||
<h2 class="text-lg font-semibold text-slate-900 dark:text-white mb-6">
|
||||
{{ t('admin.settings.sections.oidc') }}
|
||||
</h2>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
v-model="editOIDC.enabled"
|
||||
type="checkbox"
|
||||
id="oidc_enabled"
|
||||
data-testid="oidc_enabled"
|
||||
class="w-5 h-5 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<label for="oidc_enabled" class="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{{ t('admin.settings.oidc.enabled') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="editOIDC.enabled" class="space-y-6">
|
||||
<div>
|
||||
<label for="oidc_provider" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
{{ t('admin.settings.oidc.provider') }}
|
||||
</label>
|
||||
<select
|
||||
id="oidc_provider"
|
||||
data-testid="oidc_provider"
|
||||
v-model="editOIDC.provider"
|
||||
@change="onOIDCProviderChange"
|
||||
class="w-full px-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">{{ t('admin.settings.oidc.providerPlaceholder') }}</option>
|
||||
<option value="google">{{ t('admin.settings.oidc.providers.google') }}</option>
|
||||
<option value="github">{{ t('admin.settings.oidc.providers.github') }}</option>
|
||||
<option value="gitlab">{{ t('admin.settings.oidc.providers.gitlab') }}</option>
|
||||
<option value="custom">{{ t('admin.settings.oidc.providers.custom') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="oidc_client_id" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
{{ t('admin.settings.oidc.clientId') }}
|
||||
</label>
|
||||
<input id="oidc_client_id" data-testid="oidc_client_id" v-model="editOIDC.client_id" type="text" class="w-full px-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="oidc_client_secret" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
{{ t('admin.settings.oidc.clientSecret') }}
|
||||
</label>
|
||||
<input id="oidc_client_secret" data-testid="oidc_client_secret" v-model="editOIDC.client_secret" type="password" :placeholder="hasSecretValue(editOIDC.client_secret) ? '********' : ''" class="w-full px-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="editOIDC.provider === 'custom'" class="space-y-4">
|
||||
<div>
|
||||
<label for="oidc_auth_url" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">{{ t('admin.settings.oidc.authUrl') }}</label>
|
||||
<input id="oidc_auth_url" data-testid="oidc_auth_url" v-model="editOIDC.auth_url" type="url" class="w-full px-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="oidc_token_url" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">{{ t('admin.settings.oidc.tokenUrl') }}</label>
|
||||
<input id="oidc_token_url" data-testid="oidc_token_url" v-model="editOIDC.token_url" type="url" class="w-full px-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="oidc_userinfo_url" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">{{ t('admin.settings.oidc.userinfoUrl') }}</label>
|
||||
<input id="oidc_userinfo_url" data-testid="oidc_userinfo_url" v-model="editOIDC.userinfo_url" type="url" class="w-full px-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
{{ t('admin.settings.oidc.allowedDomain') }}
|
||||
</label>
|
||||
<input v-model="editOIDC.allowed_domain" type="text" placeholder="@company.com" class="w-full px-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input v-model="editOIDC.auto_login" type="checkbox" id="oidc_auto_login" data-testid="oidc_auto_login" class="w-5 h-5 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500" />
|
||||
<label for="oidc_auto_login" class="text-sm text-slate-700 dark:text-slate-300">{{ t('admin.settings.oidc.autoLogin') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 flex flex-wrap gap-3 justify-end">
|
||||
<button
|
||||
v-if="editOIDC.enabled"
|
||||
@click="testConnectionHandler('oidc')"
|
||||
:disabled="testing === 'oidc'"
|
||||
class="inline-flex items-center gap-2 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 disabled:opacity-50 text-slate-700 dark:text-slate-300 font-medium rounded-lg px-4 py-2.5 transition-colors"
|
||||
>
|
||||
<Loader2 v-if="testing === 'oidc'" :size="18" class="animate-spin" />
|
||||
<TestTube2 v-else :size="18" />
|
||||
{{ t('admin.settings.oidc.testConnection') }}
|
||||
</button>
|
||||
<button @click="saveSection('oidc')" :disabled="saving" class="inline-flex items-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg px-6 py-2.5 transition-colors">
|
||||
<Loader2 v-if="saving" :size="18" class="animate-spin" />
|
||||
<Save v-else :size="18" />
|
||||
{{ t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MagicLink Section -->
|
||||
<div v-if="activeSection === 'magiclink'" class="p-6">
|
||||
<h2 class="text-lg font-semibold text-slate-900 dark:text-white mb-6">{{ t('admin.settings.sections.magiclink') }}</h2>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<input v-model="editMagicLink.enabled" type="checkbox" id="magiclink_enabled" data-testid="magiclink_enabled" class="w-5 h-5 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500" />
|
||||
<label for="magiclink_enabled" class="text-sm font-medium text-slate-700 dark:text-slate-300">{{ t('admin.settings.magiclink.enabled') }}</label>
|
||||
</div>
|
||||
<p v-if="editMagicLink.enabled && !editSMTP.host" class="text-amber-600 dark:text-amber-400 text-sm">{{ t('admin.settings.validation.magiclinkRequiresSmtp') }}</p>
|
||||
</div>
|
||||
<div class="mt-8 flex justify-end">
|
||||
<button @click="saveSection('magiclink')" :disabled="saving" class="inline-flex items-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg px-6 py-2.5 transition-colors">
|
||||
<Loader2 v-if="saving" :size="18" class="animate-spin" />
|
||||
<Save v-else :size="18" />
|
||||
{{ t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SMTP Section -->
|
||||
<div v-if="activeSection === 'smtp'" class="p-6">
|
||||
<h2 class="text-lg font-semibold text-slate-900 dark:text-white mb-6">{{ t('admin.settings.sections.smtp') }}</h2>
|
||||
<div class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="smtp_host" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">{{ t('admin.settings.smtp.host') }}</label>
|
||||
<input id="smtp_host" data-testid="smtp_host" v-model="editSMTP.host" type="text" placeholder="smtp.example.com" class="w-full px-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="smtp_port" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">{{ t('admin.settings.smtp.port') }}</label>
|
||||
<input id="smtp_port" data-testid="smtp_port" v-model.number="editSMTP.port" type="number" class="w-full px-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="smtp_username" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">{{ t('admin.settings.smtp.username') }}</label>
|
||||
<input id="smtp_username" data-testid="smtp_username" v-model="editSMTP.username" type="text" class="w-full px-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="smtp_password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">{{ t('admin.settings.smtp.password') }}</label>
|
||||
<input id="smtp_password" data-testid="smtp_password" v-model="editSMTP.password" type="password" :placeholder="hasSecretValue(editSMTP.password) ? '********' : ''" class="w-full px-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="smtp_from" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">{{ t('admin.settings.smtp.from') }}</label>
|
||||
<input id="smtp_from" data-testid="smtp_from" v-model="editSMTP.from" type="email" placeholder="noreply@example.com" class="w-full px-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="smtp_from_name" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">{{ t('admin.settings.smtp.fromName') }}</label>
|
||||
<input id="smtp_from_name" data-testid="smtp_from_name" v-model="editSMTP.from_name" type="text" class="w-full px-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<input v-model="editSMTP.tls" type="checkbox" id="smtp_tls" data-testid="smtp_tls" class="w-5 h-5 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500" />
|
||||
<label for="smtp_tls" class="text-sm text-slate-700 dark:text-slate-300">{{ t('admin.settings.smtp.tls') }}</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<input v-model="editSMTP.starttls" type="checkbox" id="smtp_starttls" data-testid="smtp_starttls" class="w-5 h-5 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500" />
|
||||
<label for="smtp_starttls" class="text-sm text-slate-700 dark:text-slate-300">{{ t('admin.settings.smtp.starttls') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 flex flex-wrap gap-3 justify-end">
|
||||
<button @click="testConnectionHandler('smtp')" :disabled="testing === 'smtp' || !editSMTP.host" class="inline-flex items-center gap-2 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 disabled:opacity-50 text-slate-700 dark:text-slate-300 font-medium rounded-lg px-4 py-2.5 transition-colors">
|
||||
<Loader2 v-if="testing === 'smtp'" :size="18" class="animate-spin" />
|
||||
<TestTube2 v-else :size="18" />
|
||||
{{ t('admin.settings.smtp.testConnection') }}
|
||||
</button>
|
||||
<button @click="saveSection('smtp')" :disabled="saving" class="inline-flex items-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg px-6 py-2.5 transition-colors">
|
||||
<Loader2 v-if="saving" :size="18" class="animate-spin" />
|
||||
<Save v-else :size="18" />
|
||||
{{ t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Section -->
|
||||
<div v-if="activeSection === 'storage'" class="p-6">
|
||||
<h2 class="text-lg font-semibold text-slate-900 dark:text-white mb-6">{{ t('admin.settings.sections.storage') }}</h2>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label for="storage_type" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">{{ t('admin.settings.storage.type') }}</label>
|
||||
<select id="storage_type" data-testid="storage_type" v-model="editStorage.type" class="w-full px-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">{{ t('admin.settings.storage.types.none') }}</option>
|
||||
<option value="local">{{ t('admin.settings.storage.types.local') }}</option>
|
||||
<option value="s3">{{ t('admin.settings.storage.types.s3') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="storage_max_size_mb" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">{{ t('admin.settings.storage.maxSizeMb') }}</label>
|
||||
<input id="storage_max_size_mb" data-testid="storage_max_size_mb" v-model.number="editStorage.max_size_mb" type="number" min="1" class="w-full px-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div v-if="editStorage.type === 'local'">
|
||||
<label for="storage_local_path" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">{{ t('admin.settings.storage.localPath') }}</label>
|
||||
<input id="storage_local_path" data-testid="storage_local_path" v-model="editStorage.local_path" type="text" class="w-full px-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div v-if="editStorage.type === 's3'" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="storage_s3_endpoint" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">{{ t('admin.settings.storage.s3Endpoint') }}</label>
|
||||
<input id="storage_s3_endpoint" data-testid="storage_s3_endpoint" v-model="editStorage.s3_endpoint" type="text" placeholder="https://s3.amazonaws.com" class="w-full px-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="storage_s3_bucket" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">{{ t('admin.settings.storage.s3Bucket') }} *</label>
|
||||
<input id="storage_s3_bucket" data-testid="storage_s3_bucket" v-model="editStorage.s3_bucket" type="text" class="w-full px-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="storage_s3_access_key" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">{{ t('admin.settings.storage.s3AccessKey') }}</label>
|
||||
<input id="storage_s3_access_key" data-testid="storage_s3_access_key" v-model="editStorage.s3_access_key" type="text" class="w-full px-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="storage_s3_secret_key" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">{{ t('admin.settings.storage.s3SecretKey') }}</label>
|
||||
<input id="storage_s3_secret_key" data-testid="storage_s3_secret_key" v-model="editStorage.s3_secret_key" type="password" :placeholder="hasSecretValue(editStorage.s3_secret_key || '') ? '********' : ''" class="w-full px-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="storage_s3_region" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">{{ t('admin.settings.storage.s3Region') }}</label>
|
||||
<input id="storage_s3_region" data-testid="storage_s3_region" v-model="editStorage.s3_region" type="text" class="w-full px-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3 pt-8">
|
||||
<input v-model="editStorage.s3_use_ssl" type="checkbox" id="s3_use_ssl" data-testid="s3_use_ssl" class="w-5 h-5 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500" />
|
||||
<label for="s3_use_ssl" class="text-sm text-slate-700 dark:text-slate-300">{{ t('admin.settings.storage.s3UseSsl') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 flex flex-wrap gap-3 justify-end">
|
||||
<button v-if="editStorage.type === 's3'" @click="testConnectionHandler('s3')" :disabled="testing === 's3' || !editStorage.s3_bucket" class="inline-flex items-center gap-2 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 disabled:opacity-50 text-slate-700 dark:text-slate-300 font-medium rounded-lg px-4 py-2.5 transition-colors">
|
||||
<Loader2 v-if="testing === 's3'" :size="18" class="animate-spin" />
|
||||
<TestTube2 v-else :size="18" />
|
||||
{{ t('admin.settings.storage.testConnection') }}
|
||||
</button>
|
||||
<button @click="saveSection('storage')" :disabled="saving" class="inline-flex items-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg px-6 py-2.5 transition-colors">
|
||||
<Loader2 v-if="saving" :size="18" class="animate-spin" />
|
||||
<Save v-else :size="18" />
|
||||
{{ t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset Confirmation Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showResetConfirm" class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="fixed inset-0 bg-black/50" @click="showResetConfirm = false"></div>
|
||||
<div class="relative bg-white dark:bg-slate-800 rounded-xl shadow-xl max-w-md w-full p-6">
|
||||
<h3 class="text-lg font-semibold text-slate-900 dark:text-white mb-2">{{ t('admin.settings.resetConfirm.title') }}</h3>
|
||||
<p class="text-slate-600 dark:text-slate-400 mb-6">{{ t('admin.settings.resetConfirm.message') }}</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="showResetConfirm = false" class="px-4 py-2 text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button @click="handleReset" :disabled="saving" class="px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-lg transition-colors disabled:opacity-50">
|
||||
<Loader2 v-if="saving" :size="18" class="animate-spin inline mr-2" />
|
||||
{{ t('admin.settings.resetConfirm.confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
@@ -11,6 +11,7 @@ const AdminDashboard = () => import('@/pages/admin/AdminDashboard.vue')
|
||||
const AdminDocumentDetail = () => import('@/pages/admin/AdminDocumentDetail.vue')
|
||||
const AdminWebhooks = () => import('@/pages/admin/AdminWebhooks.vue')
|
||||
const AdminWebhookEdit = () => import('@/pages/admin/AdminWebhookEdit.vue')
|
||||
const AdminSettings = () => import('@/pages/admin/AdminSettings.vue')
|
||||
const EmbedPage = () => import('@/pages/EmbedPage.vue')
|
||||
const NotFoundPage = () => import('@/pages/NotFoundPage.vue')
|
||||
|
||||
@@ -75,6 +76,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: AdminDocumentDetail,
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/admin/settings',
|
||||
name: 'admin-settings',
|
||||
component: AdminSettings,
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/embed',
|
||||
name: 'embed',
|
||||
|
||||
231
webapp/src/services/settings.ts
Normal file
231
webapp/src/services/settings.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import http, { type ApiResponse } from './http'
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface GeneralConfig {
|
||||
organisation: string
|
||||
only_admin_can_create: boolean
|
||||
}
|
||||
|
||||
export interface OIDCConfig {
|
||||
enabled: boolean
|
||||
provider: 'google' | 'github' | 'gitlab' | 'custom' | ''
|
||||
client_id: string
|
||||
client_secret: string // "********" if set, empty if not
|
||||
auth_url?: string
|
||||
token_url?: string
|
||||
userinfo_url?: string
|
||||
logout_url?: string
|
||||
scopes?: string[]
|
||||
allowed_domain?: string
|
||||
auto_login: boolean
|
||||
}
|
||||
|
||||
export interface MagicLinkConfig {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface SMTPConfig {
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
password: string // "********" if set, empty if not
|
||||
tls: boolean
|
||||
starttls: boolean
|
||||
insecure_skip_verify: boolean
|
||||
timeout: string
|
||||
from: string
|
||||
from_name: string
|
||||
subject_prefix?: string
|
||||
}
|
||||
|
||||
export interface StorageConfig {
|
||||
type: '' | 'local' | 's3'
|
||||
max_size_mb: number
|
||||
local_path?: string
|
||||
s3_endpoint?: string
|
||||
s3_bucket?: string
|
||||
s3_access_key?: string
|
||||
s3_secret_key?: string // "********" if set, empty if not
|
||||
s3_region?: string
|
||||
s3_use_ssl: boolean
|
||||
}
|
||||
|
||||
export interface SettingsResponse {
|
||||
general: GeneralConfig
|
||||
oidc: OIDCConfig
|
||||
magiclink: MagicLinkConfig
|
||||
smtp: SMTPConfig
|
||||
storage: StorageConfig
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type ConfigSection =
|
||||
| 'general'
|
||||
| 'oidc'
|
||||
| 'magiclink'
|
||||
| 'smtp'
|
||||
| 'storage'
|
||||
|
||||
// ============================================================================
|
||||
// API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all settings (secrets are masked with "********")
|
||||
*/
|
||||
export async function getSettings(): Promise<ApiResponse<SettingsResponse>> {
|
||||
const response = await http.get('/admin/settings')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific settings section
|
||||
* @param section - The section to update (general, oidc, magiclink, smtp, storage)
|
||||
* @param config - The new configuration for the section
|
||||
*/
|
||||
export async function updateSection<T>(
|
||||
section: ConfigSection,
|
||||
config: T
|
||||
): Promise<ApiResponse<{ message: string }>> {
|
||||
const response = await http.put(`/admin/settings/${section}`, config)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a connection (SMTP, S3, or OIDC)
|
||||
* @param type - The type of connection to test (smtp, s3, oidc)
|
||||
* @param config - The configuration to test
|
||||
*/
|
||||
export async function testConnection(
|
||||
type: 'smtp' | 's3' | 'oidc',
|
||||
config: SMTPConfig | StorageConfig | OIDCConfig
|
||||
): Promise<ApiResponse<{ message: string }>> {
|
||||
const response = await http.post(`/admin/settings/test/${type}`, config)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all settings from environment variables
|
||||
*/
|
||||
export async function resetFromENV(): Promise<ApiResponse<{ message: string }>> {
|
||||
const response = await http.post('/admin/settings/reset', {})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if a value is a masked secret
|
||||
*/
|
||||
export function isSecretMasked(value: string): boolean {
|
||||
return value === '********'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if SMTP is configured (has host and from)
|
||||
*/
|
||||
export function isSMTPConfigured(config: SMTPConfig): boolean {
|
||||
return config.host !== '' && config.from !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if storage is enabled
|
||||
*/
|
||||
export function isStorageEnabled(config: StorageConfig): boolean {
|
||||
return config.type === 'local' || config.type === 's3'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default values for a new SMTP config
|
||||
*/
|
||||
export function getDefaultSMTPConfig(): SMTPConfig {
|
||||
return {
|
||||
host: '',
|
||||
port: 587,
|
||||
username: '',
|
||||
password: '',
|
||||
tls: false,
|
||||
starttls: true,
|
||||
insecure_skip_verify: false,
|
||||
timeout: '10s',
|
||||
from: '',
|
||||
from_name: '',
|
||||
subject_prefix: ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default values for a new storage config
|
||||
*/
|
||||
export function getDefaultStorageConfig(): StorageConfig {
|
||||
return {
|
||||
type: '',
|
||||
max_size_mb: 50,
|
||||
local_path: '/data/documents',
|
||||
s3_endpoint: '',
|
||||
s3_bucket: '',
|
||||
s3_access_key: '',
|
||||
s3_secret_key: '',
|
||||
s3_region: 'us-east-1',
|
||||
s3_use_ssl: true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default values for a new OIDC config
|
||||
*/
|
||||
export function getDefaultOIDCConfig(): OIDCConfig {
|
||||
return {
|
||||
enabled: false,
|
||||
provider: '',
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
auth_url: '',
|
||||
token_url: '',
|
||||
userinfo_url: '',
|
||||
logout_url: '',
|
||||
scopes: ['openid', 'email', 'profile'],
|
||||
allowed_domain: '',
|
||||
auto_login: false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OIDC provider URLs for well-known providers
|
||||
*/
|
||||
export function getOIDCProviderURLs(provider: string): Partial<OIDCConfig> {
|
||||
switch (provider) {
|
||||
case 'google':
|
||||
return {
|
||||
auth_url: 'https://accounts.google.com/o/oauth2/auth',
|
||||
token_url: 'https://oauth2.googleapis.com/token',
|
||||
userinfo_url: 'https://openidconnect.googleapis.com/v1/userinfo',
|
||||
logout_url: 'https://accounts.google.com/Logout',
|
||||
scopes: ['openid', 'email', 'profile']
|
||||
}
|
||||
case 'github':
|
||||
return {
|
||||
auth_url: 'https://github.com/login/oauth/authorize',
|
||||
token_url: 'https://github.com/login/oauth/access_token',
|
||||
userinfo_url: 'https://api.github.com/user',
|
||||
logout_url: 'https://github.com/logout',
|
||||
scopes: ['user:email', 'read:user']
|
||||
}
|
||||
case 'gitlab':
|
||||
return {
|
||||
auth_url: 'https://gitlab.com/oauth/authorize',
|
||||
token_url: 'https://gitlab.com/oauth/token',
|
||||
userinfo_url: 'https://gitlab.com/api/v4/user',
|
||||
logout_url: 'https://gitlab.com/users/sign_out',
|
||||
scopes: ['read_user', 'profile']
|
||||
}
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user