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:
Benjamin
2026-01-12 22:46:04 +01:00
parent a272cc7de9
commit 9b28f78ce9
21 changed files with 4937 additions and 49 deletions

View 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)
}

View 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)
}
})
}
}

View 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
}

View 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
}

View 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
}

View 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)
}
}

View File

@@ -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)
})
}
})
})

View 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;

View 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;

View File

@@ -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)

View 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')
})
})
})

View File

@@ -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">

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"

View 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>

View File

@@ -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',

View 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 {}
}
}