Files
ackify/backend/pkg/web/static.go
Benjamin da1f300d2d 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.
2025-11-22 00:43:35 +01:00

183 lines
6.7 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-or-later
package web
import (
"bytes"
"context"
"embed"
"fmt"
"html"
"io"
"io/fs"
"net/http"
"path"
"strings"
"github.com/btouchard/ackify-ce/backend/internal/infrastructure/database"
"github.com/btouchard/ackify-ce/backend/pkg/logger"
)
// EmbedFolder returns an http.HandlerFunc that serves an embedded filesystem
// with SPA fallback support (serves index.html for non-existent routes)
// 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, 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 {
logger.Logger.Error("Failed to load embedded files",
"target_path", targetPath,
"error", err.Error())
http.Error(w, "Failed to load embedded files", http.StatusInternalServerError)
return
}
urlPath := r.URL.Path
cleanPath := path.Clean(urlPath)
shouldServeIndex := false
if cleanPath == "/" {
cleanPath = "index.html"
shouldServeIndex = true
} else {
cleanPath = cleanPath[1:]
}
file, err := fsys.Open(cleanPath)
if err != nil {
logger.Logger.Debug("SPA fallback: file not found, serving index.html",
"requested_path", urlPath,
"clean_path", cleanPath)
cleanPath = "index.html"
shouldServeIndex = true
file, err = fsys.Open(cleanPath)
if err != nil {
http.Error(w, "index.html not found", http.StatusInternalServerError)
return
}
}
defer file.Close()
if shouldServeIndex || strings.HasSuffix(cleanPath, "index.html") {
serveIndexTemplate(w, r, file, baseURL, version, oauthEnabled, magicLinkEnabled, smtpEnabled, onlyAdminCanCreate, signatureRepo)
return
}
fileServer := http.FileServer(http.FS(fsys))
fileServer.ServeHTTP(w, r)
}
}
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())
http.Error(w, "Failed to read index.html", http.StatusInternalServerError)
return
}
processedContent := strings.ReplaceAll(string(content), "__ACKIFY_BASE_URL__", baseURL)
processedContent = strings.ReplaceAll(processedContent, "__ACKIFY_VERSION__", version)
// Convert boolean to string for JavaScript
oauthEnabledStr := "false"
if oauthEnabled {
oauthEnabledStr = "true"
}
magicLinkEnabledStr := "false"
if magicLinkEnabled {
magicLinkEnabledStr = "true"
}
smtpEnabledStr := "false"
if smtpEnabled {
smtpEnabledStr = "true"
}
onlyAdminCanCreateStr := "false"
if onlyAdminCanCreate {
onlyAdminCanCreateStr = "true"
}
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)
processedContent = strings.ReplaceAll(processedContent, "__META_TAGS__", metaTags)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
if _, err := io.Copy(w, bytes.NewBufferString(processedContent)); err != nil {
logger.Logger.Error("Failed to write response", "error", err.Error())
}
}
func generateMetaTags(r *http.Request, baseURL string, signatureRepo *database.SignatureRepository) string {
docID := r.URL.Query().Get("doc")
if docID == "" {
return ""
}
ctx := context.Background()
signatures, err := signatureRepo.GetByDoc(ctx, docID)
if err != nil {
logger.Logger.Warn("Failed to fetch signatures for meta tags", "doc_id", docID, "error", err.Error())
return generateBasicMetaTags(docID, baseURL, 0)
}
signatureCount := len(signatures)
return generateBasicMetaTags(docID, baseURL, signatureCount)
}
func generateBasicMetaTags(docID string, baseURL string, signatureCount int) string {
escapedDocID := html.EscapeString(docID)
currentURL := fmt.Sprintf("%s/?doc=%s", baseURL, docID)
escapedURL := html.EscapeString(currentURL)
var title, description string
if signatureCount == 0 {
title = fmt.Sprintf("Document: %s - Aucune confirmation", escapedDocID)
description = fmt.Sprintf("Confirmations de lecture pour le document %s", escapedDocID)
} else if signatureCount == 1 {
title = fmt.Sprintf("Document: %s - 1 confirmation", escapedDocID)
description = fmt.Sprintf("1 personne a confirmé avoir lu le document %s", escapedDocID)
} else {
title = fmt.Sprintf("Document: %s - %d confirmations", escapedDocID, signatureCount)
description = fmt.Sprintf("%d personnes ont confirmé avoir lu le document %s", signatureCount, escapedDocID)
}
var metaTags strings.Builder
// Open Graph tags
metaTags.WriteString(fmt.Sprintf(`<meta property="og:title" content="%s" />`, html.EscapeString(title)))
metaTags.WriteString("\n ")
metaTags.WriteString(fmt.Sprintf(`<meta property="og:description" content="%s" />`, html.EscapeString(description)))
metaTags.WriteString("\n ")
metaTags.WriteString(fmt.Sprintf(`<meta property="og:url" content="%s" />`, escapedURL))
metaTags.WriteString("\n ")
metaTags.WriteString(`<meta property="og:type" content="website" />`)
metaTags.WriteString("\n ")
// Twitter Card tags
metaTags.WriteString(`<meta name="twitter:card" content="summary" />`)
metaTags.WriteString("\n ")
metaTags.WriteString(fmt.Sprintf(`<meta name="twitter:title" content="%s" />`, html.EscapeString(title)))
metaTags.WriteString("\n ")
metaTags.WriteString(fmt.Sprintf(`<meta name="twitter:description" content="%s" />`, html.EscapeString(description)))
metaTags.WriteString("\n ")
// oEmbed discovery tag
oembedURL := fmt.Sprintf("%s/oembed?url=%s", baseURL, escapedURL)
metaTags.WriteString(fmt.Sprintf(`<link rel="alternate" type="application/json+oembed" href="%s" title="%s" />`,
html.EscapeString(oembedURL),
html.EscapeString(title)))
return metaTags.String()
}