refactor: consolidate dependency injection and improve auth architecture

- Move service initialization (MagicLink, Email, i18n) to main.go
- Change signature lookup from user_sub to email for cross-auth consistency
- Remove OauthService wrapper, simplify auth layer
- Pass parent context to workers for graceful shutdown
- Fix IP extraction from RemoteAddr with port
- Add compact mode to SignatureList component
- Update Cypress tests with new data-testid attributes
This commit is contained in:
Benjamin
2026-01-14 12:34:11 +01:00
parent 2d78294f55
commit fb33fd424d
28 changed files with 414 additions and 891 deletions

View File

@@ -8,12 +8,15 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/btouchard/ackify-ce/backend/internal/application/services"
"github.com/btouchard/ackify-ce/backend/internal/infrastructure/auth"
"github.com/btouchard/ackify-ce/backend/internal/infrastructure/database"
"github.com/btouchard/ackify-ce/backend/internal/infrastructure/email"
"github.com/btouchard/ackify-ce/backend/internal/infrastructure/i18n"
"github.com/btouchard/ackify-ce/backend/internal/infrastructure/tenant"
"github.com/btouchard/ackify-ce/backend/pkg/config"
"github.com/btouchard/ackify-ce/backend/pkg/logger"
@@ -59,6 +62,7 @@ func main() {
// Create repositories needed for auth
oauthSessionRepo := database.NewOAuthSessionRepository(db, tenantProvider)
configRepo := database.NewConfigRepository(db, tenantProvider)
magicLinkRepo := database.NewMagicLinkRepository(db)
// Create ConfigService (needed for dynamic auth config)
encryptionKey := cfg.OAuth.CookieSecret
@@ -72,7 +76,33 @@ func main() {
logger.Logger.Warn("Failed to initialize config service, using ENV config", "error", err)
}
// Create SessionService (always needed for session management)
// Create i18n service
i18nService, err := i18n.NewI18n(getLocalesDir())
if err != nil {
log.Fatalf("Failed to initialize i18n: %v", err)
}
// Create email renderer and sender if SMTP is configured
var emailSender email.Sender
var emailRenderer *email.Renderer
if cfg.Mail.Host != "" {
emailRenderer = email.NewRenderer(getTemplatesDir(), cfg.App.BaseURL, cfg.App.Organisation,
cfg.Mail.FromName, cfg.Mail.From, cfg.Mail.DefaultLocale, i18nService)
emailSender = email.NewSMTPSender(cfg.Mail, emailRenderer)
}
// Create MagicLinkService
magicLinkService := services.NewMagicLinkService(services.MagicLinkServiceConfig{
Repository: magicLinkRepo,
EmailSender: emailSender,
I18n: i18nService,
BaseURL: cfg.App.BaseURL,
AppName: cfg.App.Organisation,
RateLimitPerEmail: cfg.Auth.MagicLinkRateLimitEmail,
RateLimitPerIP: cfg.Auth.MagicLinkRateLimitIP,
})
// Create a SessionService (always needed for session management)
sessionService := auth.NewSessionService(auth.SessionServiceConfig{
CookieSecret: cfg.OAuth.CookieSecret,
SecureCookies: cfg.App.SecureCookies,
@@ -81,10 +111,10 @@ func main() {
// Create DynamicAuthProvider (unified auth for OIDC + MagicLink)
authProvider := webauth.NewDynamicAuthProvider(webauth.DynamicAuthProviderConfig{
ConfigProvider: configService,
SessionService: sessionService,
BaseURL: cfg.App.BaseURL,
// MagicLinkService will be set by ServerBuilder
ConfigProvider: configService,
SessionService: sessionService,
MagicLinkService: magicLinkService,
BaseURL: cfg.App.BaseURL,
})
// Create authorizer
@@ -97,6 +127,10 @@ func main() {
WithAuthProvider(authProvider).
WithAuthorizer(authorizer).
WithConfigService(configService).
WithI18nService(i18nService).
WithEmailSender(emailSender).
WithEmailRenderer(emailRenderer).
WithMagicLinkService(magicLinkService).
Build(ctx)
if err != nil {
log.Fatalf("Failed to create server: %v", err)
@@ -124,3 +158,49 @@ func main() {
log.Println("Community Edition server exited")
}
func getTemplatesDir() string {
if envDir := os.Getenv("ACKIFY_TEMPLATES_DIR"); envDir != "" {
return envDir
}
if execPath, err := os.Executable(); err == nil {
execDir := filepath.Dir(execPath)
defaultDir := filepath.Join(execDir, "templates")
if _, err := os.Stat(defaultDir); err == nil {
return defaultDir
}
}
possiblePaths := []string{"templates", "./templates"}
for _, path := range possiblePaths {
if _, err := os.Stat(path); err == nil {
return path
}
}
return "templates"
}
func getLocalesDir() string {
if envDir := os.Getenv("ACKIFY_LOCALES_DIR"); envDir != "" {
return envDir
}
if execPath, err := os.Executable(); err == nil {
execDir := filepath.Dir(execPath)
defaultDir := filepath.Join(execDir, "locales")
if _, err := os.Stat(defaultDir); err == nil {
return defaultDir
}
}
possiblePaths := []string{"locales", "./locales"}
for _, path := range possiblePaths {
if _, err := os.Stat(path); err == nil {
return path
}
}
return "locales"
}

View File

@@ -18,7 +18,7 @@ type repository interface {
Create(ctx context.Context, signature *models.Signature) error
GetByDocAndUser(ctx context.Context, docID, userSub string) (*models.Signature, error)
GetByDoc(ctx context.Context, docID string) ([]*models.Signature, error)
GetByUser(ctx context.Context, userSub string) ([]*models.Signature, error)
GetByUserEmail(ctx context.Context, userEmail string) ([]*models.Signature, error)
ExistsByDocAndUser(ctx context.Context, docID, userSub string) (bool, error)
CheckUserSignatureStatus(ctx context.Context, docID, userIdentifier string) (bool, error)
GetLastSignature(ctx context.Context, docID string) (*models.Signature, error)
@@ -245,7 +245,7 @@ func (s *SignatureService) GetUserSignatures(ctx context.Context, user *models.U
return nil, models.ErrInvalidUser
}
signatures, err := s.repo.GetByUser(ctx, user.Sub)
signatures, err := s.repo.GetByUserEmail(ctx, user.Email)
if err != nil {
return nil, fmt.Errorf("failed to get user signatures: %w", err)
}

View File

@@ -55,7 +55,7 @@ func (m *mockSignatureRepository) GetByDoc(ctx context.Context, docID string) ([
return nil, nil
}
func (m *mockSignatureRepository) GetByUser(ctx context.Context, userSub string) ([]*models.Signature, error) {
func (m *mockSignatureRepository) GetByUserEmail(ctx context.Context, userEmail string) ([]*models.Signature, error) {
return nil, nil
}

View File

@@ -72,14 +72,14 @@ func (f *fakeRepository) GetByDoc(_ context.Context, docID string) ([]*models.Si
return result, nil
}
func (f *fakeRepository) GetByUser(_ context.Context, userSub string) ([]*models.Signature, error) {
func (f *fakeRepository) GetByUserEmail(_ context.Context, userEmail string) ([]*models.Signature, error) {
if f.shouldFailGet {
return nil, errors.New("repository get failed")
}
var result []*models.Signature
for _, sig := range f.signatures {
if sig.UserSub == userSub {
if sig.UserEmail == userEmail {
result = append(result, sig)
}
}

View File

@@ -3,11 +3,9 @@ package auth
import (
"context"
"net/http"
"time"
"github.com/btouchard/ackify-ce/backend/internal/domain/models"
"github.com/btouchard/ackify-ce/backend/pkg/logger"
)
const sessionName = "ackapp_session"
@@ -20,117 +18,3 @@ type SessionRepository interface {
DeleteBySessionID(ctx context.Context, sessionID string) error
DeleteExpired(ctx context.Context, olderThan time.Duration) (int64, error)
}
// OauthService is a wrapper that composes SessionService and OAuthProvider
// SessionService is ALWAYS present (required for all auth methods)
// OAuthProvider is OPTIONAL (nil if OAuth is disabled)
type OauthService struct {
SessionService *SessionService // ALWAYS present - manages user sessions
OAuthProvider *OAuthProvider // OPTIONAL - nil if OAuth disabled
}
type Config struct {
BaseURL string
ClientID string
ClientSecret string
AuthURL string
TokenURL string
UserInfoURL string
LogoutURL string
Scopes []string
AllowedDomain string
CookieSecret []byte
SecureCookies bool
SessionRepo SessionRepository
}
func NewOAuthService(config Config) *OauthService {
// Create SessionService (ALWAYS required)
sessionService := NewSessionService(SessionServiceConfig{
CookieSecret: config.CookieSecret,
SecureCookies: config.SecureCookies,
SessionRepo: config.SessionRepo,
})
// Create OAuthProvider (only if OAuth is configured)
// For now, we always create it for backward compatibility
// Later, this will be conditional based on config flags
var oauthProvider *OAuthProvider
if config.ClientID != "" && config.ClientSecret != "" {
oauthProvider = NewOAuthProvider(OAuthProviderConfig{
BaseURL: config.BaseURL,
ClientID: config.ClientID,
ClientSecret: config.ClientSecret,
AuthURL: config.AuthURL,
TokenURL: config.TokenURL,
UserInfoURL: config.UserInfoURL,
LogoutURL: config.LogoutURL,
Scopes: config.Scopes,
AllowedDomain: config.AllowedDomain,
SessionSvc: sessionService,
})
logger.Logger.Info("OAuth service configured with OAuth provider")
} else {
logger.Logger.Info("OAuth service configured WITHOUT OAuth provider (session-only mode)")
}
return &OauthService{
SessionService: sessionService,
OAuthProvider: oauthProvider,
}
}
// Session management methods - delegate to SessionService
func (s *OauthService) GetUser(r *http.Request) (*models.User, error) {
return s.SessionService.GetUser(r)
}
func (s *OauthService) SetUser(w http.ResponseWriter, r *http.Request, user *models.User) error {
return s.SessionService.SetUser(w, r, user)
}
func (s *OauthService) Logout(w http.ResponseWriter, r *http.Request) {
s.SessionService.Logout(w, r)
}
// OAuth methods - delegate to OAuthProvider (nil-safe)
func (s *OauthService) GetLogoutURL() string {
if s.OAuthProvider == nil {
return ""
}
return s.OAuthProvider.GetLogoutURL()
}
func (s *OauthService) CreateAuthURL(w http.ResponseWriter, r *http.Request, nextURL string) string {
if s.OAuthProvider == nil {
logger.Logger.Error("CreateAuthURL called but OAuth provider is nil")
return ""
}
return s.OAuthProvider.CreateAuthURL(w, r, nextURL)
}
func (s *OauthService) VerifyState(w http.ResponseWriter, r *http.Request, stateToken string) bool {
if s.OAuthProvider == nil {
logger.Logger.Error("VerifyState called but OAuth provider is nil")
return false
}
return s.OAuthProvider.VerifyState(w, r, stateToken)
}
func (s *OauthService) HandleCallback(ctx context.Context, w http.ResponseWriter, r *http.Request, code, state string) (*models.User, string, error) {
if s.OAuthProvider == nil {
logger.Logger.Error("HandleCallback called but OAuth provider is nil")
return nil, "/", models.ErrUnauthorized
}
return s.OAuthProvider.HandleCallback(ctx, w, r, code, state)
}
func (s *OauthService) IsAllowedDomain(email string) bool {
if s.OAuthProvider == nil {
// If no OAuth provider, allow all domains (used for MagicLink)
return true
}
return s.OAuthProvider.IsAllowedDomain(email)
}

View File

@@ -45,7 +45,7 @@ func DefaultSessionWorkerConfig() SessionWorkerConfig {
}
// NewSessionWorker creates a new OAuth session cleanup worker
func NewSessionWorker(sessionRepo SessionRepository, config SessionWorkerConfig, db *sql.DB, tenants tenant.Provider) *SessionWorker {
func NewSessionWorker(sessionRepo SessionRepository, config SessionWorkerConfig, parentCtx context.Context, db *sql.DB, tenants tenant.Provider) *SessionWorker {
// Apply defaults
if config.CleanupInterval <= 0 {
config.CleanupInterval = 24 * time.Hour
@@ -54,7 +54,7 @@ func NewSessionWorker(sessionRepo SessionRepository, config SessionWorkerConfig,
config.CleanupAge = 37 * 24 * time.Hour
}
ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := context.WithCancel(parentCtx)
return &SessionWorker{
sessionRepo: sessionRepo,

View File

@@ -66,7 +66,7 @@ func TestSessionWorker_StartStop(t *testing.T) {
CleanupAge: 1 * time.Hour,
}
worker := NewSessionWorker(repo, config, nil, &mockTenantProvider{tenantID: uuid.New()})
worker := NewSessionWorker(repo, config, context.Background(), nil, &mockTenantProvider{tenantID: uuid.New()})
// Test starting
err := worker.Start()
@@ -123,7 +123,7 @@ func TestSessionWorker_CleanupSuccess(t *testing.T) {
CleanupAge: 24 * time.Hour,
}
worker := NewSessionWorker(repo, config, nil, &mockTenantProvider{tenantID: uuid.New()})
worker := NewSessionWorker(repo, config, context.Background(), nil, &mockTenantProvider{tenantID: uuid.New()})
err := worker.Start()
if err != nil {
@@ -157,7 +157,7 @@ func TestSessionWorker_CleanupError(t *testing.T) {
CleanupAge: 24 * time.Hour,
}
worker := NewSessionWorker(repo, config, nil, &mockTenantProvider{tenantID: uuid.New()})
worker := NewSessionWorker(repo, config, context.Background(), nil, &mockTenantProvider{tenantID: uuid.New()})
err := worker.Start()
if err != nil {
@@ -191,7 +191,7 @@ func TestSessionWorker_ImmediateCleanupOnStart(t *testing.T) {
CleanupAge: 24 * time.Hour,
}
worker := NewSessionWorker(repo, config, nil, &mockTenantProvider{tenantID: uuid.New()})
worker := NewSessionWorker(repo, config, context.Background(), nil, &mockTenantProvider{tenantID: uuid.New()})
err := worker.Start()
if err != nil {
@@ -238,7 +238,7 @@ func TestSessionWorker_GracefulShutdown(t *testing.T) {
CleanupAge: 1 * time.Hour,
}
worker := NewSessionWorker(repo, config, nil, &mockTenantProvider{tenantID: uuid.New()})
worker := NewSessionWorker(repo, config, context.Background(), nil, &mockTenantProvider{tenantID: uuid.New()})
err := worker.Start()
if err != nil {
@@ -305,7 +305,7 @@ func TestSessionWorker_ContextCancellation(t *testing.T) {
CleanupAge: 1 * time.Hour,
}
worker := NewSessionWorker(repo, config, nil, &mockTenantProvider{tenantID: uuid.New()})
worker := NewSessionWorker(repo, config, context.Background(), nil, &mockTenantProvider{tenantID: uuid.New()})
err := worker.Start()
if err != nil {

View File

@@ -451,14 +451,15 @@ func TestRepository_DeadlockPrevention_Integration(t *testing.T) {
} else {
testDocID := fmt.Sprintf("pattern2-doc-%d", workerID)
testUserSub := fmt.Sprintf("pattern2-user-%d", j)
testUserEmail := fmt.Sprintf("pattern2-user-%d@example.com", j)
_, _ = repo.GetByDoc(ctx, testDocID)
_, _ = repo.GetByUser(ctx, testUserSub)
_, _ = repo.GetByUserEmail(ctx, testUserEmail)
sig := factory.CreateSignatureWithDocAndUser(
testDocID,
testUserSub,
"pattern2@example.com",
testUserEmail,
)
_ = repo.Create(ctx, sig)
}

View File

@@ -252,7 +252,7 @@ func TestRepository_GetByDoc_Integration(t *testing.T) {
}
}
func TestRepository_GetByUser_Integration(t *testing.T) {
func TestRepository_GetByUserEmail_Integration(t *testing.T) {
testDB := SetupTestDB(t)
repo := NewSignatureRepository(testDB.DB, testDB.TenantProvider)
factory := NewSignatureFactory()
@@ -270,33 +270,39 @@ func TestRepository_GetByUser_Integration(t *testing.T) {
tests := []struct {
name string
userSub string
userEmail string
expectedCount int
expectedDocIDs []string
}{
{
name: "get signatures for user with 2 docs",
userSub: "user1",
userEmail: "user1@example.com",
expectedCount: 2,
expectedDocIDs: []string{"doc2", "doc1"}, // Should be ordered by created_at DESC
},
{
name: "get signatures for user with 1 doc",
userSub: "user2",
userEmail: "user2@example.com",
expectedCount: 1,
expectedDocIDs: []string{"doc1"},
},
{
name: "get signatures for non-existent user",
userSub: "non-existent",
userEmail: "non-existent@example.com",
expectedCount: 0,
expectedDocIDs: []string{},
},
{
name: "get signatures case insensitive",
userEmail: "USER1@EXAMPLE.COM",
expectedCount: 2,
expectedDocIDs: []string{"doc2", "doc1"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := repo.GetByUser(ctx, tt.userSub)
result, err := repo.GetByUserEmail(ctx, tt.userEmail)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
@@ -310,10 +316,6 @@ func TestRepository_GetByUser_Integration(t *testing.T) {
if i < len(tt.expectedDocIDs) && sig.DocID != tt.expectedDocIDs[i] {
t.Errorf("Expected DocID %s at position %d, got %s", tt.expectedDocIDs[i], i, sig.DocID)
}
if sig.UserSub != tt.userSub {
t.Errorf("Expected UserSub %s, got %s", tt.userSub, sig.UserSub)
}
}
})
}

View File

@@ -187,20 +187,20 @@ func (r *SignatureRepository) GetByDoc(ctx context.Context, docID string) ([]*mo
return signatures, nil
}
// GetByUser retrieves all signatures created by a specific user, ordered by creation timestamp descending
// GetByUserEmail retrieves all signatures created by a specific user (by email), ordered by creation timestamp descending
// RLS policy automatically filters by tenant_id
func (r *SignatureRepository) GetByUser(ctx context.Context, userSub string) ([]*models.Signature, error) {
func (r *SignatureRepository) GetByUserEmail(ctx context.Context, userEmail string) ([]*models.Signature, error) {
query := `
SELECT s.id, s.tenant_id, s.doc_id, s.user_sub, s.user_email, s.user_name, s.signed_at, s.doc_checksum,
s.payload_hash, s.signature, s.nonce, s.created_at, s.referer, s.prev_hash,
s.hash_version, s.doc_deleted_at, d.title, d.url
FROM signatures s
LEFT JOIN documents d ON s.doc_id = d.doc_id AND s.tenant_id = d.tenant_id
WHERE s.user_sub = $1
WHERE LOWER(s.user_email) = LOWER($1)
ORDER BY s.created_at DESC
`
rows, err := dbctx.GetQuerier(ctx, r.db).QueryContext(ctx, query, userSub)
rows, err := dbctx.GetQuerier(ctx, r.db).QueryContext(ctx, query, userEmail)
if err != nil {
return nil, fmt.Errorf("failed to query user signatures: %w", err)
}

View File

@@ -91,7 +91,7 @@ func DefaultWorkerConfig() WorkerConfig {
}
// NewWorker creates a new email worker
func NewWorker(queueRepo QueueRepository, sender Sender, renderer *Renderer, config WorkerConfig, db *sql.DB, tenants tenant.Provider) *Worker {
func NewWorker(queueRepo QueueRepository, sender Sender, renderer *Renderer, config WorkerConfig, parentCtx context.Context, db *sql.DB, tenants tenant.Provider) *Worker {
// Apply defaults
if config.BatchSize <= 0 {
config.BatchSize = 10
@@ -109,7 +109,7 @@ func NewWorker(queueRepo QueueRepository, sender Sender, renderer *Renderer, con
config.MaxConcurrent = 5
}
ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := context.WithCancel(parentCtx)
return &Worker{
queueRepo: queueRepo,

View File

@@ -66,7 +66,7 @@ type Worker struct {
started bool
}
func NewWorker(repo DeliveryRepository, httpClient HTTPDoer, cfg WorkerConfig, db *sql.DB, tenants tenant.Provider) *Worker {
func NewWorker(repo DeliveryRepository, httpClient HTTPDoer, cfg WorkerConfig, parentCtx context.Context, db *sql.DB, tenants tenant.Provider) *Worker {
if cfg.BatchSize <= 0 {
cfg.BatchSize = 10
}
@@ -85,7 +85,7 @@ func NewWorker(repo DeliveryRepository, httpClient HTTPDoer, cfg WorkerConfig, d
if cfg.RequestTimeout <= 0 {
cfg.RequestTimeout = 10 * time.Second
}
ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := context.WithCancel(parentCtx)
return &Worker{repo: repo, http: httpClient, cfg: cfg, db: db, tenants: tenants, ctx: ctx, cancel: cancel, stopChan: make(chan struct{})}
}

View File

@@ -74,7 +74,7 @@ func TestWorker_ProcessBatch_Success(t *testing.T) {
repo := &fakeDelRepo{}
doer := &fakeDoer{resp: &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok")), Header: http.Header{}}}
tenants := &mockTenantProviderWebhook{tenantID: uuid.New()}
w := NewWorker(repo, doer, DefaultWorkerConfig(), nil, tenants)
w := NewWorker(repo, doer, DefaultWorkerConfig(), context.Background(), nil, tenants)
w.processBatch()
if repo.delivered != 1 {
t.Fatalf("expected delivered=1, got %d", repo.delivered)

View File

@@ -1,378 +0,0 @@
//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

@@ -4,10 +4,12 @@ package auth
import (
"encoding/base64"
"encoding/json"
"net"
"net/http"
"net/url"
"strings"
"github.com/btouchard/ackify-ce/backend/internal/infrastructure/i18n"
"github.com/btouchard/ackify-ce/backend/internal/presentation/api/shared"
"github.com/btouchard/ackify-ce/backend/pkg/logger"
"github.com/btouchard/ackify-ce/backend/pkg/providers"
@@ -240,11 +242,10 @@ func (h *Handler) HandleRequestMagicLink(w http.ResponseWriter, r *http.Request)
req.RedirectTo = "/"
}
ip := r.RemoteAddr
ip := extractIP(r.RemoteAddr)
userAgent := r.UserAgent()
locale := r.Header.Get("Accept-Language")
ctx := r.Context()
locale := i18n.GetLang(ctx)
if err := h.authProvider.RequestMagicLink(ctx, req.Email, req.RedirectTo, ip, userAgent, locale); err != nil {
logger.Logger.Error("Failed to request magic link", "error", err.Error())
// Don't reveal if email exists or not
@@ -272,7 +273,7 @@ func (h *Handler) HandleVerifyMagicLink(w http.ResponseWriter, r *http.Request)
return
}
ip := r.RemoteAddr
ip := extractIP(r.RemoteAddr)
userAgent := r.UserAgent()
ctx := r.Context()
@@ -317,7 +318,7 @@ func (h *Handler) HandleVerifyReminderAuthLink(w http.ResponseWriter, r *http.Re
return
}
ip := r.RemoteAddr
ip := extractIP(r.RemoteAddr)
userAgent := r.UserAgent()
ctx := r.Context()
@@ -328,6 +329,19 @@ func (h *Handler) HandleVerifyReminderAuthLink(w http.ResponseWriter, r *http.Re
return
}
// Create user session from reminder auth result
user := &types.User{
Sub: "reminder:" + result.Email,
Email: result.Email,
Name: result.Email,
}
if err := h.authProvider.SetCurrentUser(w, r, user); err != nil {
logger.Logger.Error("Failed to set user session", "error", err.Error())
http.Redirect(w, r, "/?error=session_error", http.StatusFound)
return
}
redirectTo := result.RedirectTo
if redirectTo == "" && result.DocID != nil {
redirectTo = "/?doc=" + *result.DocID
@@ -338,3 +352,11 @@ func (h *Handler) HandleVerifyReminderAuthLink(w http.ResponseWriter, r *http.Re
http.Redirect(w, r, redirectTo, http.StatusFound)
}
func extractIP(remoteAddr string) string {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
return remoteAddr
}
return host
}

View File

@@ -44,11 +44,5 @@ func (a *SimpleAuthorizer) CanCreateDocument(ctx context.Context, userEmail stri
return a.IsAdmin(ctx, userEmail)
}
// OnlyAdminCanCreate returns whether only administrators can create documents.
// This is provided for backward compatibility with existing code.
func (a *SimpleAuthorizer) OnlyAdminCanCreate() bool {
return a.onlyAdminCanCreate
}
// Compile-time interface check.
var _ web.Authorizer = (*SimpleAuthorizer)(nil)

View File

@@ -73,6 +73,7 @@ type ServerBuilder struct {
// Optional infrastructure
i18nService *i18n.I18n
emailSender email.Sender
emailRenderer *email.Renderer
storageProvider storage.Provider
// Core services (created internally or injected)
@@ -124,6 +125,12 @@ func (b *ServerBuilder) WithEmailSender(sender email.Sender) *ServerBuilder {
return b
}
// WithEmailRenderer injects an email renderer.
func (b *ServerBuilder) WithEmailRenderer(renderer *email.Renderer) *ServerBuilder {
b.emailRenderer = renderer
return b
}
// === Capability Providers ===
// WithAuthProvider injects an authentication provider (REQUIRED).
@@ -210,22 +217,22 @@ func (b *ServerBuilder) Build(ctx context.Context) (*Server, error) {
return nil, err
}
whPublisher, whWorker, err := b.initializeWebhookSystem(repos)
whPublisher, whWorker, err := b.initializeWebhookSystem(ctx, repos)
if err != nil {
return nil, err
}
emailWorker, err := b.initializeEmailWorker(repos, whPublisher)
emailWorker, err := b.initializeEmailWorker(ctx, repos, whPublisher)
if err != nil {
return nil, err
}
b.initializeCoreServices(repos)
b.initializeConfigService(ctx, repos)
magicLinkWorker := b.initializeMagicLinkService(ctx, repos)
magicLinkWorker := b.initializeMagicLinkCleanupWorker(ctx)
b.initializeReminderService(repos)
sessionWorker, err := b.initializeSessionWorker(repos)
sessionWorker, err := b.initializeSessionWorker(ctx, repos)
if err != nil {
return nil, err
}
@@ -275,7 +282,8 @@ func (b *ServerBuilder) setDefaultProviders() {
}
}
// initializeInfrastructure initializes i18n and email sender.
// initializeInfrastructure initializes signer and storage provider.
// i18n and emailSender are expected to be injected from main.go.
func (b *ServerBuilder) initializeInfrastructure() error {
var err error
@@ -286,21 +294,6 @@ func (b *ServerBuilder) initializeInfrastructure() error {
}
}
if b.i18nService == nil {
localesDir := getLocalesDir()
b.i18nService, err = i18n.NewI18n(localesDir)
if err != nil {
return fmt.Errorf("failed to initialize i18n: %w", err)
}
}
if b.emailSender == nil && b.cfg.Mail.Host != "" {
emailTemplatesDir := getTemplatesDir()
renderer := email.NewRenderer(emailTemplatesDir, b.cfg.App.BaseURL, b.cfg.App.Organisation,
b.cfg.Mail.FromName, b.cfg.Mail.From, "fr", b.i18nService)
b.emailSender = email.NewSMTPSender(b.cfg.Mail, renderer)
}
if b.storageProvider == nil && b.cfg.Storage.IsEnabled() {
provider, err := storage.NewProvider(b.cfg.Storage)
if err != nil {
@@ -324,7 +317,6 @@ type repositories struct {
emailQueue *database.EmailQueueRepository
webhook *database.WebhookRepository
webhookDelivery *database.WebhookDeliveryRepository
magicLink services.MagicLinkRepository // Interface, not concrete type
oauthSession *database.OAuthSessionRepository
config *database.ConfigRepository
}
@@ -339,7 +331,6 @@ func (b *ServerBuilder) createRepositories() *repositories {
emailQueue: database.NewEmailQueueRepository(b.db, b.tenantProvider),
webhook: database.NewWebhookRepository(b.db, b.tenantProvider),
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),
}
@@ -369,10 +360,10 @@ func (b *ServerBuilder) initializeTelemetry(ctx context.Context) error {
}
// initializeWebhookSystem initializes webhook publisher and worker.
func (b *ServerBuilder) initializeWebhookSystem(repos *repositories) (*services.WebhookPublisher, *webhook.Worker, error) {
func (b *ServerBuilder) initializeWebhookSystem(ctx context.Context, repos *repositories) (*services.WebhookPublisher, *webhook.Worker, error) {
whPublisher := services.NewWebhookPublisher(repos.webhook, repos.webhookDelivery)
whCfg := webhook.DefaultWorkerConfig()
whWorker := webhook.NewWorker(repos.webhookDelivery, &http.Client{}, whCfg, b.db, b.tenantProvider)
whWorker := webhook.NewWorker(repos.webhookDelivery, &http.Client{}, whCfg, ctx, b.db, b.tenantProvider)
if err := whWorker.Start(); err != nil {
return nil, nil, fmt.Errorf("failed to start webhook worker: %w", err)
@@ -382,15 +373,14 @@ func (b *ServerBuilder) initializeWebhookSystem(repos *repositories) (*services.
}
// initializeEmailWorker initializes email worker for async processing.
func (b *ServerBuilder) initializeEmailWorker(repos *repositories, whPublisher *services.WebhookPublisher) (*email.Worker, error) {
if b.emailSender == nil || b.cfg.Mail.Host == "" {
// emailRenderer is expected to be injected from main.go via WithEmailRenderer().
func (b *ServerBuilder) initializeEmailWorker(ctx context.Context, repos *repositories, whPublisher *services.WebhookPublisher) (*email.Worker, error) {
if b.emailSender == nil || b.cfg.Mail.Host == "" || b.emailRenderer == nil {
return nil, nil
}
renderer := email.NewRenderer(getTemplatesDir(), b.cfg.App.BaseURL, b.cfg.App.Organisation,
b.cfg.Mail.FromName, b.cfg.Mail.From, "fr", b.i18nService)
workerConfig := email.DefaultWorkerConfig()
emailWorker := email.NewWorker(repos.emailQueue, b.emailSender, renderer, workerConfig, b.db, b.tenantProvider)
emailWorker := email.NewWorker(repos.emailQueue, b.emailSender, b.emailRenderer, workerConfig, ctx, b.db, b.tenantProvider)
if whPublisher != nil {
emailWorker.SetPublisher(whPublisher)
@@ -420,43 +410,17 @@ 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")
}
// initializeConfigService is a no-op when configService is injected from main.go.
// Kept for API compatibility.
func (b *ServerBuilder) initializeConfigService(_ context.Context, _ *repositories) {
// configService is expected to be injected and initialized from main.go
}
// initializeMagicLinkService initializes MagicLink service and cleanup worker.
func (b *ServerBuilder) initializeMagicLinkService(ctx context.Context, repos *repositories) *workers.MagicLinkCleanupWorker {
if b.magicLinkService == nil {
b.magicLinkService = services.NewMagicLinkService(services.MagicLinkServiceConfig{
Repository: repos.magicLink,
EmailSender: b.emailSender,
I18n: b.i18nService,
BaseURL: b.cfg.App.BaseURL,
AppName: b.cfg.App.Organisation,
RateLimitPerEmail: b.cfg.Auth.MagicLinkRateLimitEmail,
RateLimitPerIP: b.cfg.Auth.MagicLinkRateLimitIP,
})
}
// Always start cleanup worker - it will clean expired tokens regardless of config
// initializeMagicLinkCleanupWorker starts the cleanup worker for expired magic link tokens.
// magicLinkService is expected to be injected from main.go.
func (b *ServerBuilder) initializeMagicLinkCleanupWorker(ctx context.Context) *workers.MagicLinkCleanupWorker {
magicLinkWorker := workers.NewMagicLinkCleanupWorker(b.magicLinkService, 1*time.Hour, b.db, b.tenantProvider)
go magicLinkWorker.Start(ctx)
return magicLinkWorker
}
@@ -475,13 +439,13 @@ func (b *ServerBuilder) initializeReminderService(repos *repositories) {
}
// initializeSessionWorker initializes OAuth session cleanup worker.
func (b *ServerBuilder) initializeSessionWorker(repos *repositories) (*auth.SessionWorker, error) {
func (b *ServerBuilder) initializeSessionWorker(ctx context.Context, repos *repositories) (*auth.SessionWorker, error) {
if repos.oauthSession == nil {
return nil, nil
}
workerConfig := auth.DefaultSessionWorkerConfig()
sessionWorker := auth.NewSessionWorker(repos.oauthSession, workerConfig, b.db, b.tenantProvider)
sessionWorker := auth.NewSessionWorker(repos.oauthSession, workerConfig, ctx, b.db, b.tenantProvider)
if err := sessionWorker.Start(); err != nil {
return nil, fmt.Errorf("failed to start OAuth session worker: %w", err)
}

View File

@@ -20,8 +20,8 @@ describe('Test 3: Admin - Expected Signers Management', () => {
cy.contains('Administration', { timeout: 10000 }).should('be.visible')
// Step 3: Create new document
cy.get('[data-testid="new-doc-input"]').type(docId)
cy.contains('button', 'Confirm').click()
cy.get('[data-testid="doc-url-input"]').type(docId)
cy.get('[data-testid="submit-button"]').click()
// Step 4: Should redirect to document detail page
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${docId}`)
@@ -63,8 +63,7 @@ describe('Test 3: Admin - Expected Signers Management', () => {
// Step 8: Verify stats
cy.contains('Expected').should('be.visible')
cy.contains('3').should('be.visible') // 3 expected signers
cy.contains('0').should('be.visible') // 0 signed
cy.contains('0%').should('be.visible') // 0% completion rate (nobody signed yet)
cy.contains('Confirmed').parent().should('contain', '0') // 0 confirmed
})
it('should allow admin to remove expected signer', () => {
@@ -74,8 +73,8 @@ describe('Test 3: Admin - Expected Signers Management', () => {
// Create document
const removeDocId = 'test-remove-signer-' + Date.now()
cy.get('[data-testid="new-doc-input"]').type(removeDocId)
cy.get('[data-testid="create-doc-btn"]').click()
cy.get('[data-testid="doc-url-input"]').type(removeDocId)
cy.get('[data-testid="submit-button"]').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${removeDocId}`)

View File

@@ -17,8 +17,8 @@ describe('Test 4: Admin - Email Reminders', () => {
cy.loginAsAdmin()
cy.visit('/admin')
cy.get('[data-testid="new-doc-input"]').type(docId)
cy.contains('button', 'Confirm').click()
cy.get('[data-testid="doc-url-input"]').type(docId)
cy.get('[data-testid="submit-button"]').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${docId}`)
// Step 2: Add 2 expected signers
@@ -57,7 +57,7 @@ describe('Test 4: Admin - Email Reminders', () => {
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${docId}`)
// Step 7: Verify stats (1 signed, 1 pending)
cy.contains('Signed').parent().should('contain', '1')
cy.contains('Confirmed').parent().should('contain', '1')
cy.contains('Pending').parent().should('contain', '1')
// Step 8: Send reminders to all pending
@@ -68,7 +68,7 @@ describe('Test 4: Admin - Email Reminders', () => {
// Confirm in modal
cy.contains('Send reminders', { timeout: 5000 }).should('be.visible')
cy.contains('button', 'Confirm').click({ force: true })
cy.get('[data-testid="confirm-button"]').click({ force: true })
// Step 9: Wait for API call to complete
cy.wait(2000)

View File

@@ -16,8 +16,8 @@ describe('Test 7: Admin - Document Deletion', () => {
cy.loginAsAdmin()
cy.visit('/admin')
cy.get('[data-testid="new-doc-input"]').type(docId)
cy.contains('button', 'Confirm').click()
cy.get('[data-testid="doc-url-input"]').type(docId)
cy.get('[data-testid="submit-button"]').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${docId}`)
// Step 2: Add 2 expected signers
@@ -84,8 +84,8 @@ describe('Test 7: Admin - Document Deletion', () => {
cy.visit('/admin')
const safeDocId = 'safe-doc-' + Date.now()
cy.get('[data-testid="new-doc-input"]').type(safeDocId)
cy.contains('button', 'Confirm').click()
cy.get('[data-testid="doc-url-input"]').type(safeDocId)
cy.get('[data-testid="submit-button"]').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${safeDocId}`)

View File

@@ -48,7 +48,7 @@ describe('Test 8: Admin Route Protection', () => {
// Step 4: Should see admin dashboard
cy.contains('Administration', { timeout: 10000 }).should('be.visible')
cy.contains('Create new document').should('be.visible')
cy.get('[data-testid="doc-url-input"]').should('be.visible')
})
it('should redirect unauthenticated users to auth page', () => {
@@ -72,8 +72,8 @@ describe('Test 8: Admin Route Protection', () => {
// Create document first as admin
cy.loginAsAdmin()
cy.visit('/admin')
cy.get('[data-testid="new-doc-input"]').type(targetDoc)
cy.contains('button', 'Confirm').click()
cy.get('[data-testid="doc-url-input"]').type(targetDoc)
cy.get('[data-testid="submit-button"]').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${targetDoc}`)
// Logout

View File

@@ -19,8 +19,8 @@ describe('Test 9: Complete End-to-End Workflow', () => {
cy.loginAsAdmin()
cy.visit('/admin')
cy.get('[data-testid="new-doc-input"]').type(docId)
cy.contains('button', 'Confirm').click()
cy.get('[data-testid="doc-url-input"]').type(docId)
cy.get('[data-testid="submit-button"]').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${docId}`)
// ===== STEP 2: Admin adds 3 expected signers =====
@@ -36,7 +36,7 @@ describe('Test 9: Complete End-to-End Workflow', () => {
cy.contains(charlie).should('be.visible')
// Verify stats: 0/3 signed (0%)
cy.contains('Signed').parent().should('contain', '0')
cy.contains('Confirmed').parent().should('contain', '0')
cy.contains('Expected').parent().should('contain', '3')
// ===== STEP 3: Admin sends reminders → 3 emails sent =====
@@ -44,8 +44,8 @@ describe('Test 9: Complete End-to-End Workflow', () => {
cy.clearMailbox()
cy.contains('button', 'Send reminders').click()
cy.contains('This action is irreversible', { timeout: 5000 }).should('be.visible')
cy.contains('button', 'Confirm').click()
cy.contains('Send reminders', { timeout: 5000 }).should('be.visible')
cy.get('[data-testid="confirm-button"]').click()
cy.contains(/Reminder.*sent|sent successfully/, { timeout: 10000 }).should('be.visible')
@@ -54,37 +54,14 @@ describe('Test 9: Complete End-to-End Workflow', () => {
cy.waitForEmail(bob, 'Reminder', 15000).should('exist')
cy.waitForEmail(charlie, 'Reminder', 15000).should('exist')
// ===== STEP 4: Alice logs in from email and signs =====
// ===== STEP 4: Alice logs in and signs =====
cy.log('STEP 4: Alice signs the document')
cy.logout()
cy.loginViaMagicLink(alice, `/?doc=${docId}`)
// Get Alice's reminder email and extract auth link
cy.waitForEmail(alice, 'Reminder', 15000).then((message) => {
let body = message.Content?.Body || ''
// Decode quoted-printable encoding first (=\r\n are soft line breaks, =XX are hex)
body = body
.replace(/=\r?\n/g, '') // Remove soft line breaks
.replace(/=([0-9A-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
// Extract reminder auth link from email (reminder-link/verify)
const linkMatch = body.match(/(https?:\/\/[^\s"<]+\/api\/v1\/auth\/reminder-link\/verify\?token=[^\s"<]+)/)
expect(linkMatch).to.not.be.null
const authLink = linkMatch![1]
cy.log('Auth link from email:', authLink)
// Visit auth link directly (it will authenticate and redirect to document)
cy.visit(authLink)
// Should redirect to document
cy.url({ timeout: 10000 }).should('include', `/?doc=${docId}`)
// Alice signs
cy.confirmReading()
cy.contains('Reading confirmed', { timeout: 10000 }).should('be.visible')
})
cy.url({ timeout: 10000 }).should('include', `/?doc=${docId}`)
cy.confirmReading()
cy.contains('Reading confirmed', { timeout: 10000 }).should('be.visible')
// ===== STEP 5: Verify stats: 1/3 signed (33%) =====
cy.log('STEP 5: Verify completion stats after Alice signs')
@@ -92,9 +69,8 @@ describe('Test 9: Complete End-to-End Workflow', () => {
cy.loginAsAdmin()
cy.visit(`/admin/docs/${docId}`)
cy.contains('Signed', { timeout: 10000 }).parent().should('contain', '1')
cy.contains('Confirmed', { timeout: 10000 }).parent().should('contain', '1')
cy.contains('Pending').parent().should('contain', '2')
cy.contains('33%').should('be.visible') // 33% completion
// ===== STEP 6: Bob logs in and signs =====
cy.log('STEP 6: Bob signs the document')
@@ -111,17 +87,16 @@ describe('Test 9: Complete End-to-End Workflow', () => {
cy.loginAsAdmin()
cy.visit(`/admin/docs/${docId}`)
cy.contains('Signed', { timeout: 10000 }).parent().should('contain', '2')
cy.contains('Confirmed', { timeout: 10000 }).parent().should('contain', '2')
cy.contains('Pending').parent().should('contain', '1')
cy.contains('67%').should('be.visible') // 67% completion (2/3 rounded)
// ===== STEP 8: Admin sends new reminder → 1 email (charlie only) =====
cy.log('STEP 8: Admin sends reminder to remaining signer')
cy.clearMailbox()
cy.contains('button', 'Send reminders').click()
cy.contains('This action is irreversible', { timeout: 5000 }).should('be.visible')
cy.contains('button', 'Confirm').click()
cy.contains('Send reminders', { timeout: 5000 }).should('be.visible')
cy.get('[data-testid="confirm-button"]').click()
cy.contains(/Reminder.*sent|sent successfully/, { timeout: 10000 }).should('be.visible')
@@ -162,9 +137,8 @@ describe('Test 9: Complete End-to-End Workflow', () => {
cy.loginAsAdmin()
cy.visit(`/admin/docs/${docId}`)
cy.contains('Signed', { timeout: 10000 }).parent().should('contain', '3')
cy.contains('Confirmed', { timeout: 10000 }).parent().should('contain', '3')
cy.contains('Expected').parent().should('contain', '3')
cy.contains('100%').should('be.visible') // 100% completion
// All signers should show "Confirmed" status
cy.contains('tr', alice).should('contain', 'Confirmed')

View File

@@ -19,8 +19,8 @@ describe('Test 10: Unexpected Signatures Tracking', () => {
cy.loginAsAdmin()
cy.visit('/admin')
cy.get('[data-testid="new-doc-input"]').type(docId)
cy.contains('button', 'Confirm').click()
cy.get('[data-testid="doc-url-input"]').type(docId)
cy.get('[data-testid="submit-button"]').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${docId}`)
// Add alice and bob as expected signers
@@ -35,7 +35,7 @@ describe('Test 10: Unexpected Signatures Tracking', () => {
// Verify initial stats
cy.contains('Expected').parent().should('contain', '2')
cy.contains('Signed').parent().should('contain', '0')
cy.contains('Confirmed').parent().should('contain', '0')
// ===== STEP 2: Charlie (not expected) accesses and signs the document =====
cy.log('STEP 2: Unexpected user (Charlie) signs the document')
@@ -61,7 +61,7 @@ describe('Test 10: Unexpected Signatures Tracking', () => {
// Stats should show: 0/2 expected signed, but there's an unexpected signature
cy.contains('Expected').parent().should('contain', '2')
cy.contains('Signed').parent().should('contain', '0') // 0 expected signed
cy.contains('Confirmed').parent().should('contain', '0') // 0 expected signed
// Unexpected signatures section should exist
cy.contains(/Unexpected|Additional.*confirmations/, { timeout: 10000 }).should('be.visible')
@@ -89,10 +89,9 @@ describe('Test 10: Unexpected Signatures Tracking', () => {
cy.loginAsAdmin()
cy.visit(`/admin/docs/${docId}`)
// Expected stats: 1/2 signed (50%)
cy.contains('Signed', { timeout: 10000 }).parent().should('contain', '1')
// Expected stats: 1/2 signed
cy.contains('Confirmed', { timeout: 10000 }).parent().should('contain', '1')
cy.contains('Expected').parent().should('contain', '2')
cy.contains('50%').should('be.visible')
// Alice should show "Confirmed" in expected section
cy.contains('tr', alice).should('contain', 'Confirmed')
@@ -115,8 +114,8 @@ describe('Test 10: Unexpected Signatures Tracking', () => {
cy.loginAsAdmin()
cy.visit('/admin')
cy.get('[data-testid="new-doc-input"]').type(multiDocId)
cy.contains('button', 'Confirm').click()
cy.get('[data-testid="doc-url-input"]').type(multiDocId)
cy.get('[data-testid="submit-button"]').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${multiDocId}`)

View File

@@ -13,8 +13,8 @@ describe('Test 14: CSV Import Preview', () => {
// Step 1: Login as admin and create document
cy.loginAsAdmin()
cy.visit('/admin')
cy.get('[data-testid="new-doc-input"]').type(testDocId)
cy.contains('button', 'Confirm').click()
cy.get('[data-testid="doc-url-input"]').type(testDocId)
cy.get('[data-testid="submit-button"]').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${testDocId}`)
// Step 2: Click Import CSV button
@@ -108,8 +108,8 @@ david@test.com,David New`
// Step 1: Login and create new document
cy.loginAsAdmin()
cy.visit('/admin')
cy.get('[data-testid="new-doc-input"]').type(invalidCsvDocId)
cy.contains('button', 'Confirm').click()
cy.get('[data-testid="doc-url-input"]').type(invalidCsvDocId)
cy.get('[data-testid="submit-button"]').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${invalidCsvDocId}`)
// Step 2: Click Import CSV button
@@ -154,8 +154,8 @@ missing-domain@,Missing Domain`
// Step 1: Login and create new document
cy.loginAsAdmin()
cy.visit('/admin')
cy.get('[data-testid="new-doc-input"]').type(emptyDocId)
cy.contains('button', 'Confirm').click()
cy.get('[data-testid="doc-url-input"]').type(emptyDocId)
cy.get('[data-testid="submit-button"]').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${emptyDocId}`)
// Step 2: Click Import CSV button
@@ -185,8 +185,8 @@ missing-domain@,Missing Domain`
// Step 1: Login and create new document
cy.loginAsAdmin()
cy.visit('/admin')
cy.get('[data-testid="new-doc-input"]').type(missingColDocId)
cy.contains('button', 'Confirm').click()
cy.get('[data-testid="doc-url-input"]').type(missingColDocId)
cy.get('[data-testid="submit-button"]').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${missingColDocId}`)
// Step 2: Click Import CSV button
@@ -217,8 +217,8 @@ Bob Johnson,Development`
// Step 1: Login and create new document
cy.loginAsAdmin()
cy.visit('/admin')
cy.get('[data-testid="new-doc-input"]').type(largeCsvDocId)
cy.contains('button', 'Confirm').click()
cy.get('[data-testid="doc-url-input"]').type(largeCsvDocId)
cy.get('[data-testid="submit-button"]').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${largeCsvDocId}`)
// Step 2: Generate large CSV with 50 emails

View File

@@ -47,109 +47,155 @@
</div>
<!-- Signatures List -->
<div v-else class="space-y-4">
<div v-else class="space-y-3">
<div
v-for="signature in signatures"
:key="signature.id"
:class="[
'bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 sm:p-5 hover:shadow-md transition-shadow',
'bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 hover:shadow-md transition-shadow',
isDeleted ? 'opacity-60' : ''
]"
>
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<!-- Title Row -->
<div class="flex flex-wrap items-center gap-2 mb-3">
<h3 class="text-base sm:text-lg font-semibold text-slate-900 dark:text-white truncate">
{{ signature.docTitle || signature.docId }}
</h3>
<!-- Compact Mode: email + date + details -->
<template v-if="compact">
<div class="flex items-center justify-between gap-3 mb-2">
<div class="flex items-center gap-2 min-w-0">
<span class="text-sm font-medium text-slate-900 dark:text-white truncate">
{{ signature.userName || signature.userEmail }}
</span>
<!-- Status Badge -->
<span
v-if="!isDeleted"
class="inline-flex items-center gap-1 px-2.5 py-1 bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400 text-xs font-medium rounded-full"
class="inline-flex items-center gap-1 px-2 py-0.5 bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400 text-xs font-medium rounded-full flex-shrink-0"
>
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
{{ $t('signatureList.confirmed') }}
</span>
<span
v-else
class="inline-flex items-center gap-1 px-2.5 py-1 bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400 text-xs font-medium rounded-full"
>
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{{ $t('signatureList.documentDeleted') }}{{ signature.docDeletedAt ? ` ${formatDate(signature.docDeletedAt)}` : '' }}
</span>
</div>
<span class="text-xs text-slate-500 dark:text-slate-400 flex-shrink-0">
{{ formatDate(signature.signedAt) }}
</span>
</div>
<!-- Info Grid -->
<div class="space-y-2 text-sm text-slate-600 dark:text-slate-400">
<p v-if="signature.docTitle" class="flex items-start gap-2">
<span class="text-xs font-medium text-slate-500 dark:text-slate-500 uppercase tracking-wide min-w-[60px]">{{ $t('signatureList.fields.id') }}</span>
<span class="font-mono text-slate-700 dark:text-slate-300 break-all">{{ signature.docId }}</span>
<!-- Verification Details (compact) -->
<details v-if="showDetails" class="text-xs text-slate-500 dark:text-slate-400 group">
<summary class="cursor-pointer hover:text-slate-700 dark:hover:text-slate-300 font-medium flex items-center gap-1.5">
<svg class="h-3.5 w-3.5 transition-transform group-open:rotate-90" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
{{ $t('signatureList.verificationDetails') }}
</summary>
<div class="mt-2 accent-border bg-slate-50 dark:bg-slate-900/50 rounded-r-lg p-2.5 space-y-1 font-mono text-xs">
<p class="break-all">
<span class="font-semibold text-slate-600 dark:text-slate-400">{{ $t('signatureList.fields.hash') }}</span> {{ signature.payloadHash }}
</p>
<p v-if="signature.docUrl" class="flex items-start gap-2">
<span class="text-xs font-medium text-slate-500 dark:text-slate-500 uppercase tracking-wide min-w-[60px]">{{ $t('signatureList.fields.document') }}</span>
<a :href="signature.docUrl" target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:underline break-all">
{{ signature.docUrl }}
</a>
</p>
<p v-if="showUserInfo" class="flex items-start gap-2">
<span class="text-xs font-medium text-slate-500 dark:text-slate-500 uppercase tracking-wide min-w-[60px]">{{ $t('signatureList.fields.reader') }}</span>
<span class="text-slate-700 dark:text-slate-300">{{ signature.userName || signature.userEmail }}</span>
</p>
<p class="flex items-start gap-2">
<span class="text-xs font-medium text-slate-500 dark:text-slate-500 uppercase tracking-wide min-w-[60px]">{{ $t('signatureList.fields.date') }}</span>
<span class="text-slate-700 dark:text-slate-300">{{ formatDate(signature.signedAt) }}</span>
</p>
<p v-if="signature.serviceInfo" class="flex items-start gap-2">
<span class="text-xs font-medium text-slate-500 dark:text-slate-500 uppercase tracking-wide min-w-[60px]">{{ $t('signatureList.fields.source') }}</span>
<span class="inline-flex items-center gap-1.5 text-slate-700 dark:text-slate-300">
<span v-html="signature.serviceInfo.icon"></span>
<span>{{ signature.serviceInfo.name }}</span>
</span>
<p class="break-all">
<span class="font-semibold text-slate-600 dark:text-slate-400">{{ $t('signatureList.confirmation') }}</span>
{{ signature.signature.substring(0, 64) }}...
</p>
</div>
</details>
</template>
<!-- Verification Details -->
<div v-if="showDetails" class="mt-4 pt-4 border-t border-slate-200 dark:border-slate-700">
<details class="text-xs text-slate-500 dark:text-slate-400 group">
<summary class="cursor-pointer hover:text-slate-700 dark:hover:text-slate-300 font-medium flex items-center gap-1.5">
<svg class="h-4 w-4 transition-transform group-open:rotate-90" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
<!-- Full Mode: title + all info -->
<template v-else>
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<!-- Title Row -->
<div class="flex flex-wrap items-center gap-2 mb-3">
<h3 class="text-base sm:text-lg font-semibold text-slate-900 dark:text-white truncate">
{{ signature.docTitle || signature.docId }}
</h3>
<!-- Status Badge -->
<span
v-if="!isDeleted"
class="inline-flex items-center gap-1 px-2.5 py-1 bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400 text-xs font-medium rounded-full"
>
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
{{ $t('signatureList.verificationDetails') }}
</summary>
<div class="mt-3 accent-border bg-slate-50 dark:bg-slate-900/50 rounded-r-lg p-3 space-y-1.5 font-mono text-xs">
<p><span class="font-semibold text-slate-600 dark:text-slate-400">{{ $t('signatureList.fields.id') }}</span> {{ signature.id }}</p>
<p><span class="font-semibold text-slate-600 dark:text-slate-400">{{ $t('signatureList.fields.nonce') }}</span> {{ signature.nonce }}</p>
<p class="break-all">
<span class="font-semibold text-slate-600 dark:text-slate-400">{{ $t('signatureList.fields.hash') }}</span> {{ signature.payloadHash }}
</p>
<p class="break-all">
<span class="font-semibold text-slate-600 dark:text-slate-400">{{ $t('signatureList.confirmation') }}</span>
{{ signature.signature.substring(0, 64) }}...
</p>
<p v-if="signature.prevHash" class="break-all">
<span class="font-semibold text-slate-600 dark:text-slate-400">{{ $t('signatureList.previousHash') }}</span> {{ signature.prevHash }}
</p>
</div>
</details>
{{ $t('signatureList.confirmed') }}
</span>
<span
v-else
class="inline-flex items-center gap-1 px-2.5 py-1 bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400 text-xs font-medium rounded-full"
>
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{{ $t('signatureList.documentDeleted') }}{{ signature.docDeletedAt ? ` ${formatDate(signature.docDeletedAt)}` : '' }}
</span>
</div>
<!-- Info Grid -->
<div class="space-y-2 text-sm text-slate-600 dark:text-slate-400">
<p v-if="signature.docTitle" class="flex items-start gap-2">
<span class="text-xs font-medium text-slate-500 dark:text-slate-500 uppercase tracking-wide min-w-[60px]">{{ $t('signatureList.fields.id') }}</span>
<span class="font-mono text-slate-700 dark:text-slate-300 break-all">{{ signature.docId }}</span>
</p>
<p v-if="signature.docUrl" class="flex items-start gap-2">
<span class="text-xs font-medium text-slate-500 dark:text-slate-500 uppercase tracking-wide min-w-[60px]">{{ $t('signatureList.fields.document') }}</span>
<a :href="signature.docUrl" target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:underline break-all">
{{ signature.docUrl }}
</a>
</p>
<p v-if="showUserInfo" class="flex items-start gap-2">
<span class="text-xs font-medium text-slate-500 dark:text-slate-500 uppercase tracking-wide min-w-[60px]">{{ $t('signatureList.fields.reader') }}</span>
<span class="text-slate-700 dark:text-slate-300">{{ signature.userName || signature.userEmail }}</span>
</p>
<p class="flex items-start gap-2">
<span class="text-xs font-medium text-slate-500 dark:text-slate-500 uppercase tracking-wide min-w-[60px]">{{ $t('signatureList.fields.date') }}</span>
<span class="text-slate-700 dark:text-slate-300">{{ formatDate(signature.signedAt) }}</span>
</p>
<p v-if="signature.serviceInfo" class="flex items-start gap-2">
<span class="text-xs font-medium text-slate-500 dark:text-slate-500 uppercase tracking-wide min-w-[60px]">{{ $t('signatureList.fields.source') }}</span>
<span class="inline-flex items-center gap-1.5 text-slate-700 dark:text-slate-300">
<span v-html="signature.serviceInfo.icon"></span>
<span>{{ signature.serviceInfo.name }}</span>
</span>
</p>
</div>
<!-- Verification Details -->
<div v-if="showDetails" class="mt-4 pt-4 border-t border-slate-200 dark:border-slate-700">
<details class="text-xs text-slate-500 dark:text-slate-400 group">
<summary class="cursor-pointer hover:text-slate-700 dark:hover:text-slate-300 font-medium flex items-center gap-1.5">
<svg class="h-4 w-4 transition-transform group-open:rotate-90" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
{{ $t('signatureList.verificationDetails') }}
</summary>
<div class="mt-3 accent-border bg-slate-50 dark:bg-slate-900/50 rounded-r-lg p-3 space-y-1.5 font-mono text-xs">
<p><span class="font-semibold text-slate-600 dark:text-slate-400">{{ $t('signatureList.fields.id') }}</span> {{ signature.id }}</p>
<p><span class="font-semibold text-slate-600 dark:text-slate-400">{{ $t('signatureList.fields.nonce') }}</span> {{ signature.nonce }}</p>
<p class="break-all">
<span class="font-semibold text-slate-600 dark:text-slate-400">{{ $t('signatureList.fields.hash') }}</span> {{ signature.payloadHash }}
</p>
<p class="break-all">
<span class="font-semibold text-slate-600 dark:text-slate-400">{{ $t('signatureList.confirmation') }}</span>
{{ signature.signature.substring(0, 64) }}...
</p>
<p v-if="signature.prevHash" class="break-all">
<span class="font-semibold text-slate-600 dark:text-slate-400">{{ $t('signatureList.previousHash') }}</span> {{ signature.prevHash }}
</p>
</div>
</details>
</div>
</div>
<!-- Actions -->
<div v-if="showActions" class="flex-shrink-0">
<button
@click="$emit('view-details', signature)"
class="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 text-sm font-medium px-3 py-1.5 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
>
{{ $t('signatureList.viewDetails') }}
</button>
</div>
</div>
<!-- Actions -->
<div v-if="showActions" class="flex-shrink-0">
<button
@click="$emit('view-details', signature)"
class="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 text-sm font-medium px-3 py-1.5 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
>
{{ $t('signatureList.viewDetails') }}
</button>
</div>
</div>
</template>
</div>
</div>
</div>
@@ -166,6 +212,7 @@ interface Props {
showActions?: boolean
emptyMessage?: string
isDeleted?: boolean
compact?: boolean
}
withDefaults(defineProps<Props>(), {
@@ -174,6 +221,7 @@ withDefaults(defineProps<Props>(), {
showDetails: true,
showActions: false,
isDeleted: false,
compact: false,
})
defineEmits<{

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, FileText, Settings, Webhook } from 'lucide-vue-next'
import { Menu, X, ChevronDown, LogOut, FileText, Settings, Webhook, CheckSquare } from 'lucide-vue-next'
import ThemeToggle from './ThemeToggle.vue'
import LanguageSelect from './LanguageSelect.vue'
import AppLogo from '@/components/AppLogo.vue'
@@ -17,7 +17,6 @@ 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)
@@ -90,14 +89,6 @@ const closeMobileMenu = () => {
const closeUserMenu = () => {
userMenuOpen.value = false
}
const toggleAdminMenu = () => {
adminMenuOpen.value = !adminMenuOpen.value
}
const closeAdminMenu = () => {
adminMenuOpen.value = false
}
</script>
<template>
@@ -113,19 +104,6 @@ const closeAdminMenu = () => {
<!-- Desktop Navigation -->
<div class="hidden md:flex md:items-center md:space-x-1">
<!-- Home - always visible -->
<router-link
to="/"
:class="[
'px-3 py-2 text-sm font-medium rounded-lg transition-colors',
isActive('/')
? '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'
]"
>
{{ t('nav.home') }}
</router-link>
<!-- My confirmations - authenticated only -->
<router-link
v-if="isAuthenticated"
@@ -140,89 +118,19 @@ const closeAdminMenu = () => {
{{ t('nav.myConfirmations') }}
</router-link>
<!-- My documents - authenticated + can create -->
<!-- My documents - if can create -->
<router-link
v-if="isAuthenticated && canCreateDocuments"
to="/documents"
:class="[
v-if="canCreateDocuments"
to="/documents"
:class="[
'px-3 py-2 text-sm font-medium rounded-lg transition-colors',
isActive('/documents') || route.path.startsWith('/documents/')
isActive('/documents')
? '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'
]"
>
{{ 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 -->
@@ -270,7 +178,42 @@ const closeAdminMenu = () => {
<p class="text-xs text-slate-500 dark:text-slate-400 truncate">{{ user?.email }}</p>
</div>
<!-- Menu items -->
<!-- Admin section - if admin -->
<template v-if="isAdmin">
<p class="px-3 py-1 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
{{ t('nav.administration') }}
</p>
<router-link
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"
>
<FileText :size="16" />
<span>{{ t('nav.adminMenu.allDocuments') }}</span>
</router-link>
<router-link
to="/admin/settings"
@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"
>
<Settings :size="16" />
<span>{{ t('nav.adminMenu.settings') }}</span>
</router-link>
<router-link
to="/admin/webhooks"
@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"
>
<Webhook :size="16" />
<span>{{ t('nav.adminMenu.webhooks') }}</span>
</router-link>
<div class="border-t border-slate-100 dark:border-slate-700 my-2"></div>
</template>
<!-- Logout -->
<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"
@@ -318,33 +261,26 @@ const closeAdminMenu = () => {
>
<div v-if="mobileMenuOpen" class="md:hidden border-t border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900">
<div class="space-y-1 px-4 pb-4 pt-2">
<!-- Home - always visible -->
<router-link
to="/"
@click="closeMobileMenu"
:class="[
'block rounded-lg px-3 py-2.5 text-base font-medium transition-colors',
isActive('/')
? '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.home') }}
</router-link>
<!-- Navigation links (authenticated) -->
<template v-if="isAuthenticated">
<!-- User info -->
<div class="px-3 py-2 mb-2">
<p class="font-medium text-slate-900 dark:text-slate-100">{{ displayName }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">{{ user?.email }}</p>
</div>
<router-link
to="/signatures"
@click="closeMobileMenu"
:class="[
'block rounded-lg px-3 py-2.5 text-base font-medium transition-colors',
'flex items-center space-x-2 rounded-lg px-3 py-2.5 text-base font-medium transition-colors',
isActive('/signatures')
? '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.myConfirmations') }}
<CheckSquare :size="18" />
<span>{{ t('nav.myConfirmations') }}</span>
</router-link>
<router-link
@@ -352,13 +288,14 @@ const closeAdminMenu = () => {
to="/documents"
@click="closeMobileMenu"
:class="[
'block rounded-lg px-3 py-2.5 text-base font-medium transition-colors',
'flex items-center space-x-2 rounded-lg px-3 py-2.5 text-base font-medium transition-colors',
isActive('/documents') || route.path.startsWith('/documents/')
? '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.myDocuments') }}
<FileText :size="18" />
<span>{{ t('nav.myDocuments') }}</span>
</router-link>
<!-- Admin section -->
@@ -409,17 +346,14 @@ const closeAdminMenu = () => {
</div>
</template>
<!-- User section -->
<!-- Logout -->
<div class="border-t border-slate-200 dark:border-slate-700 pt-3 mt-3">
<div class="px-3 py-2 mb-2">
<p class="font-medium text-slate-900 dark:text-slate-100">{{ displayName }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">{{ user?.email }}</p>
</div>
<button
@click="logout"
class="w-full text-left rounded-lg px-3 py-2.5 text-base font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
class="flex w-full items-center space-x-2 rounded-lg px-3 py-2.5 text-base font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
{{ t('nav.logout') }}
<LogOut :size="18" />
<span>{{ t('nav.logout') }}</span>
</button>
</div>
</template>

View File

@@ -602,8 +602,8 @@ onMounted(async () => {
<SignatureList
:signatures="documentSignatures"
:loading="loadingSignatures"
:show-user-info="true"
:show-details="true"
:compact="true"
/>
</div>
</div>

View File

@@ -1109,8 +1109,8 @@ onMounted(() => {
<div class="p-6 space-y-4">
<p class="text-sm text-slate-600 dark:text-slate-400">{{ remindersMessage }}</p>
<div class="flex justify-end space-x-3 pt-4">
<button type="button" @click="cancelSendReminders" class="bg-white dark:bg-slate-700 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-600 transition-colors">{{ t('common.cancel') }}</button>
<button @click="sendRemindersAction" :disabled="sendingReminders" class="trust-gradient text-white font-medium rounded-lg px-4 py-2.5 text-sm hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2">
<button type="button" data-testid="cancel-button" @click="cancelSendReminders" class="bg-white dark:bg-slate-700 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-600 transition-colors">{{ t('common.cancel') }}</button>
<button data-testid="confirm-button" @click="sendRemindersAction" :disabled="sendingReminders" class="trust-gradient text-white font-medium rounded-lg px-4 py-2.5 text-sm hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2">
<Loader2 v-if="sendingReminders" :size="16" class="animate-spin" />
{{ t('common.confirm') }}
</button>