mirror of
https://github.com/btouchard/ackify.git
synced 2026-02-09 23:38:30 -06:00
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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{})}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -602,8 +602,8 @@ onMounted(async () => {
|
||||
<SignatureList
|
||||
:signatures="documentSignatures"
|
||||
:loading="loadingSignatures"
|
||||
:show-user-info="true"
|
||||
:show-details="true"
|
||||
:compact="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user