feat(admin): improve email reminders UX and fix signer deletion

Backend changes:
- Add SMTPEnabled flag to distinguish SMTP service from MagicLink authentication
- Fix URL-encoded email decoding in DELETE /signers/{email} endpoint
- Add detailed error logging for expected signer removal operations
- Initialize ReminderAsync and MagicLink services unconditionally
- Update config tests to reflect new MagicLink requires explicit enabling

Frontend changes:
- Add ACKIFY_SMTP_ENABLED window variable for feature detection
- Hide delete button for expected signers who have already signed
- Show email reminders card only when SMTP enabled or history exists
- Display informative alert when SMTP disabled but reminder history present
- Add i18n translations for email service disabled message (5 languages)

These changes improve admin experience by preventing invalid operations
(deleting signers who signed, sending emails without SMTP) and providing
clear feedback about feature availability.
This commit is contained in:
Benjamin
2025-11-22 00:27:16 +01:00
parent 34146fb02d
commit da1f300d2d
14 changed files with 140 additions and 66 deletions

View File

@@ -32,6 +32,7 @@ type AppConfig struct {
SecureCookies bool
AdminEmails []string
OnlyAdminCanCreate bool
SMTPEnabled bool // True if SMTP is configured (for email reminders)
}
type DatabaseConfig struct {
@@ -205,15 +206,9 @@ func Load() (*Config, error) {
}
}
// Auto-detect MagicLink enabled: true if SMTP is configured
magicLinkConfigured := mailHost != ""
// Allow manual override via environment variable
if magicLinkEnabledStr := getEnv("ACKIFY_AUTH_MAGICLINK_ENABLED", ""); magicLinkEnabledStr != "" {
config.Auth.MagicLinkEnabled = getEnvBool("ACKIFY_AUTH_MAGICLINK_ENABLED", false)
} else {
config.Auth.MagicLinkEnabled = magicLinkConfigured
}
smtpConfigured := mailHost != ""
config.App.SMTPEnabled = smtpConfigured
config.Auth.MagicLinkEnabled = getEnvBool("ACKIFY_AUTH_MAGICLINK_ENABLED", false) && smtpConfigured
// Validation: At least one authentication method must be enabled
if !config.Auth.OAuthEnabled && !config.Auth.MagicLinkEnabled {

View File

@@ -1257,11 +1257,12 @@ func TestConfig_AuthValidation(t *testing.T) {
{
name: "MagicLink only (auto-detected)",
envVars: map[string]string{
"ACKIFY_BASE_URL": "http://localhost:8080",
"ACKIFY_ORGANISATION": "Test Org",
"ACKIFY_DB_DSN": "postgres://localhost/test",
"ACKIFY_OAUTH_COOKIE_SECRET": base64.StdEncoding.EncodeToString([]byte("test-secret-32-bytes-long!!!!!!")),
"ACKIFY_MAIL_HOST": "smtp.example.com",
"ACKIFY_BASE_URL": "http://localhost:8080",
"ACKIFY_ORGANISATION": "Test Org",
"ACKIFY_DB_DSN": "postgres://localhost/test",
"ACKIFY_OAUTH_COOKIE_SECRET": base64.StdEncoding.EncodeToString([]byte("test-secret-32-bytes-long!!!!!!")),
"ACKIFY_MAIL_HOST": "smtp.example.com",
"ACKIFY_AUTH_MAGICLINK_ENABLED": "true",
},
expectError: false,
checkAuth: func(t *testing.T, cfg *Config) {
@@ -1271,19 +1272,23 @@ func TestConfig_AuthValidation(t *testing.T) {
if !cfg.Auth.MagicLinkEnabled {
t.Error("MagicLink should be enabled")
}
if !cfg.App.SMTPEnabled {
t.Error("SMTP should be enabled")
}
},
},
{
name: "Both OAuth and MagicLink enabled",
envVars: map[string]string{
"ACKIFY_BASE_URL": "http://localhost:8080",
"ACKIFY_ORGANISATION": "Test Org",
"ACKIFY_DB_DSN": "postgres://localhost/test",
"ACKIFY_OAUTH_COOKIE_SECRET": base64.StdEncoding.EncodeToString([]byte("test-secret-32-bytes-long!!!!!!")),
"ACKIFY_OAUTH_CLIENT_ID": "test-client-id",
"ACKIFY_OAUTH_CLIENT_SECRET": "test-secret",
"ACKIFY_OAUTH_PROVIDER": "google",
"ACKIFY_MAIL_HOST": "smtp.example.com",
"ACKIFY_BASE_URL": "http://localhost:8080",
"ACKIFY_ORGANISATION": "Test Org",
"ACKIFY_DB_DSN": "postgres://localhost/test",
"ACKIFY_OAUTH_COOKIE_SECRET": base64.StdEncoding.EncodeToString([]byte("test-secret-32-bytes-long!!!!!!")),
"ACKIFY_OAUTH_CLIENT_ID": "test-client-id",
"ACKIFY_OAUTH_CLIENT_SECRET": "test-secret",
"ACKIFY_OAUTH_PROVIDER": "google",
"ACKIFY_MAIL_HOST": "smtp.example.com",
"ACKIFY_AUTH_MAGICLINK_ENABLED": "true",
},
expectError: false,
checkAuth: func(t *testing.T, cfg *Config) {
@@ -1293,6 +1298,9 @@ func TestConfig_AuthValidation(t *testing.T) {
if !cfg.Auth.MagicLinkEnabled {
t.Error("MagicLink should be enabled")
}
if !cfg.App.SMTPEnabled {
t.Error("SMTP should be enabled")
}
},
},
{
@@ -1326,14 +1334,15 @@ func TestConfig_AuthValidation(t *testing.T) {
{
name: "Manual override - disable OAuth even with credentials",
envVars: map[string]string{
"ACKIFY_BASE_URL": "http://localhost:8080",
"ACKIFY_ORGANISATION": "Test Org",
"ACKIFY_DB_DSN": "postgres://localhost/test",
"ACKIFY_OAUTH_COOKIE_SECRET": base64.StdEncoding.EncodeToString([]byte("test-secret-32-bytes-long!!!!!!")),
"ACKIFY_OAUTH_CLIENT_ID": "test-client-id",
"ACKIFY_OAUTH_CLIENT_SECRET": "test-secret",
"ACKIFY_MAIL_HOST": "smtp.example.com",
"ACKIFY_AUTH_OAUTH_ENABLED": "false",
"ACKIFY_BASE_URL": "http://localhost:8080",
"ACKIFY_ORGANISATION": "Test Org",
"ACKIFY_DB_DSN": "postgres://localhost/test",
"ACKIFY_OAUTH_COOKIE_SECRET": base64.StdEncoding.EncodeToString([]byte("test-secret-32-bytes-long!!!!!!")),
"ACKIFY_OAUTH_CLIENT_ID": "test-client-id",
"ACKIFY_OAUTH_CLIENT_SECRET": "test-secret",
"ACKIFY_MAIL_HOST": "smtp.example.com",
"ACKIFY_AUTH_OAUTH_ENABLED": "false",
"ACKIFY_AUTH_MAGICLINK_ENABLED": "true",
},
expectError: false,
checkAuth: func(t *testing.T, cfg *Config) {

View File

@@ -5,10 +5,12 @@ import (
"context"
"encoding/json"
"net/http"
"net/url"
"github.com/btouchard/ackify-ce/backend/internal/domain/models"
"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/go-chi/chi/v5"
)
@@ -255,7 +257,15 @@ func (h *Handler) HandleAddExpectedSigner(w http.ResponseWriter, r *http.Request
func (h *Handler) HandleRemoveExpectedSigner(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
docID := chi.URLParam(r, "docId")
email := chi.URLParam(r, "email")
emailEncoded := chi.URLParam(r, "email")
// Decode URL-encoded email (e.g., al%40bundy.com -> al@bundy.com)
email, err := url.QueryUnescape(emailEncoded)
if err != nil {
logger.Logger.Error("failed to decode email from URL", "error", err, "email_encoded", emailEncoded)
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Invalid email format", nil)
return
}
if docID == "" || email == "" {
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Document ID and email are required", nil)
@@ -263,8 +273,9 @@ func (h *Handler) HandleRemoveExpectedSigner(w http.ResponseWriter, r *http.Requ
}
// Remove expected signer
err := h.expectedSignerRepo.Remove(ctx, docID, email)
err = h.expectedSignerRepo.Remove(ctx, docID, email)
if err != nil {
logger.Logger.Error("failed to remove expected signer", "error", err, "doc_id", docID, "email", email)
shared.WriteError(w, http.StatusInternalServerError, shared.ErrCodeInternal, "Failed to remove expected signer", nil)
return
}
@@ -598,7 +609,8 @@ func (h *Handler) HandleGetDocumentStatus(w http.ResponseWriter, r *http.Request
// Get reminder stats if service available
if h.reminderService != nil {
if reminderStats, err := h.reminderService.GetReminderStats(ctx, docID); err == nil {
reminderStats, err := h.reminderService.GetReminderStats(ctx, docID)
if err == nil && reminderStats != nil {
var lastSentAt *string
if reminderStats.LastSentAt != nil {
formatted := reminderStats.LastSentAt.Format("2006-01-02T15:04:05Z07:00")
@@ -609,6 +621,8 @@ func (h *Handler) HandleGetDocumentStatus(w http.ResponseWriter, r *http.Request
PendingCount: reminderStats.PendingCount,
LastSentAt: lastSentAt,
}
} else if err != nil {
logger.Logger.Debug("Failed to get reminder stats", "doc_id", docID, "error", err.Error())
}
}

View File

@@ -114,38 +114,29 @@ func NewServer(ctx context.Context, cfg *config.Config, frontend embed.FS, versi
return nil, fmt.Errorf("failed to start webhook worker: %w", err)
}
// Initialize Magic Link service (only if enabled) - MUST be before ReminderService
var magicLinkService *services.MagicLinkService
if cfg.Auth.MagicLinkEnabled && emailSender != nil {
magicLinkService = services.NewMagicLinkService(services.MagicLinkServiceConfig{
Repository: magicLinkRepo,
EmailSender: emailSender,
BaseURL: cfg.App.BaseURL,
AppName: cfg.App.Organisation,
})
logger.Logger.Info("Magic Link authentication enabled")
} else if !cfg.Auth.MagicLinkEnabled {
logger.Logger.Info("Magic Link authentication disabled")
}
magicLinkService := services.NewMagicLinkService(services.MagicLinkServiceConfig{
Repository: magicLinkRepo,
EmailSender: emailSender,
BaseURL: cfg.App.BaseURL,
AppName: cfg.App.Organisation,
})
logger.Logger.Info("Magic Link authentication enabled")
// Initialize Magic Link cleanup worker
var magicLinkWorker *workers.MagicLinkCleanupWorker
if magicLinkService != nil {
if cfg.Auth.MagicLinkEnabled {
magicLinkWorker = workers.NewMagicLinkCleanupWorker(magicLinkService, 1*time.Hour)
go magicLinkWorker.Start(ctx)
}
// Initialize reminder service with async support (needs magicLinkService)
var reminderService *services.ReminderAsyncService
if emailQueueRepo != nil && magicLinkService != nil {
reminderService = services.NewReminderAsyncService(
expectedSignerRepo,
reminderRepo,
emailQueueRepo,
magicLinkService,
cfg.App.BaseURL,
)
}
reminderService := services.NewReminderAsyncService(
expectedSignerRepo,
reminderRepo,
emailQueueRepo,
magicLinkService,
cfg.App.BaseURL,
)
// Initialize OAuth session cleanup worker
var sessionWorker *auth.SessionWorker
@@ -191,7 +182,7 @@ func NewServer(ctx context.Context, cfg *config.Config, frontend embed.FS, versi
router.Get("/oembed", handlers.HandleOEmbed(cfg.App.BaseURL))
router.NotFound(EmbedFolder(frontend, "web/dist", cfg.App.BaseURL, version, cfg.Auth.OAuthEnabled, cfg.Auth.MagicLinkEnabled, cfg.App.OnlyAdminCanCreate, signatureRepo))
router.NotFound(EmbedFolder(frontend, "web/dist", cfg.App.BaseURL, version, cfg.Auth.OAuthEnabled, cfg.Auth.MagicLinkEnabled, cfg.App.SMTPEnabled, cfg.App.OnlyAdminCanCreate, signatureRepo))
httpServer := &http.Server{
Addr: cfg.Server.ListenAddr,

View File

@@ -22,9 +22,10 @@ import (
// For index.html, it replaces __ACKIFY_BASE_URL__ placeholder with the actual base URL,
// __ACKIFY_VERSION__ with the application version,
// __ACKIFY_OAUTH_ENABLED__ and __ACKIFY_MAGICLINK_ENABLED__ with auth method flags,
// __ACKIFY_SMTP_ENABLED__ with SMTP availability flag,
// __ACKIFY_ONLY_ADMIN_CAN_CREATE__ with document creation restriction flag,
// and __META_TAGS__ with dynamic meta tags based on query parameters
func EmbedFolder(fsEmbed embed.FS, targetPath string, baseURL string, version string, oauthEnabled bool, magicLinkEnabled bool, onlyAdminCanCreate bool, signatureRepo *database.SignatureRepository) http.HandlerFunc {
func EmbedFolder(fsEmbed embed.FS, targetPath string, baseURL string, version string, oauthEnabled bool, magicLinkEnabled bool, smtpEnabled bool, onlyAdminCanCreate bool, signatureRepo *database.SignatureRepository) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fsys, err := fs.Sub(fsEmbed, targetPath)
if err != nil {
@@ -63,7 +64,7 @@ func EmbedFolder(fsEmbed embed.FS, targetPath string, baseURL string, version st
defer file.Close()
if shouldServeIndex || strings.HasSuffix(cleanPath, "index.html") {
serveIndexTemplate(w, r, file, baseURL, version, oauthEnabled, magicLinkEnabled, onlyAdminCanCreate, signatureRepo)
serveIndexTemplate(w, r, file, baseURL, version, oauthEnabled, magicLinkEnabled, smtpEnabled, onlyAdminCanCreate, signatureRepo)
return
}
@@ -72,7 +73,7 @@ func EmbedFolder(fsEmbed embed.FS, targetPath string, baseURL string, version st
}
}
func serveIndexTemplate(w http.ResponseWriter, r *http.Request, file fs.File, baseURL string, version string, oauthEnabled bool, magicLinkEnabled bool, onlyAdminCanCreate bool, signatureRepo *database.SignatureRepository) {
func serveIndexTemplate(w http.ResponseWriter, r *http.Request, file fs.File, baseURL string, version string, oauthEnabled bool, magicLinkEnabled bool, smtpEnabled bool, onlyAdminCanCreate bool, signatureRepo *database.SignatureRepository) {
content, err := io.ReadAll(file)
if err != nil {
logger.Logger.Error("Failed to read index.html", "error", err.Error())
@@ -92,6 +93,10 @@ func serveIndexTemplate(w http.ResponseWriter, r *http.Request, file fs.File, ba
if magicLinkEnabled {
magicLinkEnabledStr = "true"
}
smtpEnabledStr := "false"
if smtpEnabled {
smtpEnabledStr = "true"
}
onlyAdminCanCreateStr := "false"
if onlyAdminCanCreate {
onlyAdminCanCreateStr = "true"
@@ -99,6 +104,7 @@ func serveIndexTemplate(w http.ResponseWriter, r *http.Request, file fs.File, ba
processedContent = strings.ReplaceAll(processedContent, "__ACKIFY_OAUTH_ENABLED__", oauthEnabledStr)
processedContent = strings.ReplaceAll(processedContent, "__ACKIFY_MAGICLINK_ENABLED__", magicLinkEnabledStr)
processedContent = strings.ReplaceAll(processedContent, "__ACKIFY_SMTP_ENABLED__", smtpEnabledStr)
processedContent = strings.ReplaceAll(processedContent, "__ACKIFY_ONLY_ADMIN_CAN_CREATE__", onlyAdminCanCreateStr)
metaTags := generateMetaTags(r, baseURL, signatureRepo)

View File

@@ -56,6 +56,28 @@ ACKIFY_OAUTH_LOGOUT_URL=https://your-provider.com/logout
ACKIFY_OAUTH_SCOPES=openid,email,profile
```
### Authentication Methods
**Important**: At least ONE authentication method must be enabled (OAuth or MagicLink).
```bash
# Force enable/disable OAuth (default: auto-detected from credentials)
ACKIFY_AUTH_OAUTH_ENABLED=true
# Enable MagicLink passwordless authentication (default: false)
# Requires ACKIFY_MAIL_HOST to be configured
ACKIFY_AUTH_MAGICLINK_ENABLED=true
```
**Auto-detection**:
- **OAuth** is automatically enabled if `ACKIFY_OAUTH_CLIENT_ID` and `ACKIFY_OAUTH_CLIENT_SECRET` are set
- **MagicLink** requires explicit activation with `ACKIFY_AUTH_MAGICLINK_ENABLED=true` + SMTP configuration
- **SMTP/Email service** is automatically enabled when `ACKIFY_MAIL_HOST` is configured (independent of MagicLink)
**Note**: SMTP and MagicLink are two distinct features:
- **SMTP** = Email reminder service for expected signers (auto-detected)
- **MagicLink** = Passwordless email authentication (requires explicit activation + SMTP)
### Administration
```bash

View File

@@ -56,6 +56,28 @@ ACKIFY_OAUTH_LOGOUT_URL=https://your-provider.com/logout
ACKIFY_OAUTH_SCOPES=openid,email,profile
```
### Méthodes d'Authentification
**Important** : Au moins UNE méthode d'authentification doit être activée (OAuth ou MagicLink).
```bash
# Forcer l'activation/désactivation d'OAuth (défaut: auto-détecté depuis les credentials)
ACKIFY_AUTH_OAUTH_ENABLED=true
# Activer l'authentification MagicLink sans mot de passe (défaut: false)
# Nécessite que ACKIFY_MAIL_HOST soit configuré
ACKIFY_AUTH_MAGICLINK_ENABLED=true
```
**Auto-détection** :
- **OAuth** est automatiquement activé si `ACKIFY_OAUTH_CLIENT_ID` et `ACKIFY_OAUTH_CLIENT_SECRET` sont définis
- **MagicLink** nécessite une activation explicite avec `ACKIFY_AUTH_MAGICLINK_ENABLED=true` + configuration SMTP
- **Service SMTP/Email** est automatiquement activé quand `ACKIFY_MAIL_HOST` est configuré (indépendant de MagicLink)
**Note** : SMTP et MagicLink sont deux fonctionnalités distinctes :
- **SMTP** = Service d'envoi de rappels email aux signataires attendus (auto-détecté)
- **MagicLink** = Authentification sans mot de passe par email (nécessite activation explicite + SMTP)
### Administration
```bash

View File

@@ -11,6 +11,7 @@
window.ACKIFY_VERSION = '__ACKIFY_VERSION__';
window.ACKIFY_OAUTH_ENABLED = '__ACKIFY_OAUTH_ENABLED__' === 'true';
window.ACKIFY_MAGICLINK_ENABLED = '__ACKIFY_MAGICLINK_ENABLED__' === 'true';
window.ACKIFY_SMTP_ENABLED = '__ACKIFY_SMTP_ENABLED__' === 'true';
window.ACKIFY_ONLY_ADMIN_CAN_CREATE = '__ACKIFY_ONLY_ADMIN_CAN_CREATE__' === 'true';
</script>
</head>

View File

@@ -370,6 +370,7 @@
"sendToAll": "An alle wartenden Leser senden ({count})",
"sendToSelected": "Nur an Ausgewählte senden ({count})",
"allContacted": "✓ Alle erwarteten Leser wurden kontaktiert oder haben bestätigt",
"emailServiceDisabled": "⚠️ Der E-Mail-Dienst ist derzeit deaktiviert. Der Verlauf der Erinnerungen bleibt sichtbar, aber das Senden neuer Erinnerungen ist nicht verfügbar.",
"unexpectedSignatures": "⚠ Zusätzliche Lesebestätigungen",
"unexpectedDescription": "Benutzer, die bestätigt haben, aber nicht in der Liste der erwarteten Leser stehen",
"createdBy": "Erstellt von {by} am {date}",

View File

@@ -370,6 +370,7 @@
"sendToAll": "Send to all pending readers ({count})",
"sendToSelected": "Send to selected only ({count})",
"allContacted": "✓ All expected readers have been contacted or confirmed",
"emailServiceDisabled": "⚠️ The email service is currently disabled. Reminder history remains visible, but sending new reminders is not available.",
"unexpectedSignatures": "⚠ Additional reading confirmations",
"unexpectedDescription": "Users who confirmed but are not on the expected readers list",
"createdBy": "Created by {by} on {date}",

View File

@@ -370,6 +370,7 @@
"sendToAll": "Enviar a todos los lectores en espera ({count})",
"sendToSelected": "Enviar solo a los seleccionados ({count})",
"allContacted": "✓ Todos los lectores esperados han sido contactados o han confirmado",
"emailServiceDisabled": "⚠️ El servicio de correo electrónico está actualmente desactivado. El historial de recordatorios permanece visible, pero el envío de nuevos recordatorios no está disponible.",
"unexpectedSignatures": "⚠ Confirmaciones de lectura complementarias",
"unexpectedDescription": "Usuarios que han confirmado pero no están presentes en la lista de lectores esperados",
"createdBy": "Creado por {by} el {date}",

View File

@@ -367,6 +367,7 @@
"sendToAll": "Envoyer à tous les lecteurs en attente ({count})",
"sendToSelected": "Envoyer uniquement aux sélectionnés ({count})",
"allContacted": "✓ Tous les lecteurs attendus ont été contactés ou ont confirmé",
"emailServiceDisabled": "⚠️ Le service d'envoi d'emails est actuellement désactivé. L'historique des rappels reste visible, mais l'envoi de nouveaux rappels n'est pas disponible.",
"unexpectedSignatures": "⚠ Confirmations de lecture complémentaires",
"unexpectedDescription": "Utilisateurs ayant confirmé mais non présents dans la liste des lecteurs attendus",
"createdBy": "Créé par {by} le {date}",

View File

@@ -370,6 +370,7 @@
"sendToAll": "Invia a tutti i lettori in attesa ({count})",
"sendToSelected": "Invia solo ai selezionati ({count})",
"allContacted": "✓ Tutti i lettori previsti sono stati contattati o hanno confermato",
"emailServiceDisabled": "⚠️ Il servizio di posta elettronica è attualmente disabilitato. La cronologia dei promemoria rimane visibile, ma l'invio di nuovi promemoria non è disponibile.",
"unexpectedSignatures": "⚠ Conferme di lettura aggiuntive",
"unexpectedDescription": "Utenti che hanno confermato ma non presenti nell'elenco dei lettori previsti",
"createdBy": "Creato da {by} il {date}",

View File

@@ -110,6 +110,7 @@ const shareLink = computed(() => {
const stats = computed(() => documentStatus.value?.stats)
const reminderStats = computed(() => documentStatus.value?.reminderStats)
const smtpEnabled = computed(() => (window as any).ACKIFY_SMTP_ENABLED || false)
const expectedSigners = computed(() => documentStatus.value?.expectedSigners || [])
const unexpectedSignatures = computed(() => documentStatus.value?.unexpectedSignatures || [])
const documentMetadata = computed(() => documentStatus.value?.document)
@@ -567,9 +568,10 @@ onMounted(() => {
{{ signer.signedAt ? formatDate(signer.signedAt) : '-' }}
</TableCell>
<TableCell>
<Button @click="confirmRemoveSigner(signer.email)" variant="ghost" size="sm">
<Button v-if="!signer.hasSigned" @click="confirmRemoveSigner(signer.email)" variant="ghost" size="sm">
<Trash2 :size="14" class="text-destructive" />
</Button>
<span v-else class="text-xs text-muted-foreground">-</span>
</TableCell>
</TableRow>
</TableBody>
@@ -614,7 +616,7 @@ onMounted(() => {
</Card>
<!-- Email Reminders -->
<Card v-if="reminderStats && stats && stats.expectedCount > 0" class="clay-card">
<Card v-if="reminderStats && stats && stats.expectedCount > 0 && (smtpEnabled || reminderStats.totalSent > 0)" class="clay-card">
<CardHeader>
<CardTitle>{{ t('admin.documentDetail.reminders') }}</CardTitle>
<CardDescription>{{ t('admin.documentDetail.remindersDescription') }}</CardDescription>
@@ -636,8 +638,15 @@ onMounted(() => {
</div>
</div>
<!-- Send Form -->
<div v-if="reminderStats.pendingCount > 0" class="space-y-4">
<!-- Alert if SMTP disabled but reminders exist -->
<Alert v-if="!smtpEnabled" class="border-orange-500 bg-orange-50 dark:bg-orange-900/20">
<AlertDescription class="text-orange-800 dark:text-orange-200">
{{ t('admin.documentDetail.emailServiceDisabled') }}
</AlertDescription>
</Alert>
<!-- Send Form - Only shown if SMTP is enabled -->
<div v-if="smtpEnabled" class="space-y-4">
<div class="space-y-2">
<label class="flex items-center space-x-2">
<input type="radio" v-model="sendMode" value="all" class="rounded-full" />
@@ -653,7 +662,7 @@ onMounted(() => {
{{ sendingReminders ? t('admin.documentDetail.sending') : t('admin.documentDetail.sendReminders') }}
</Button>
</div>
<div v-else class="text-center py-4 text-muted-foreground">
<div v-else-if="smtpEnabled && reminderStats.pendingCount === 0" class="text-center py-4 text-muted-foreground">
{{ t('admin.documentDetail.allContacted') }}
</div>
</CardContent>