From 9c53a8bf2b3a747bba89fc0afb95f158e2259b49 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Wed, 1 Oct 2025 00:13:40 +0200 Subject: [PATCH] feat: implement complete i18n support with French and English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive internationalization support: - Browser language detection via Accept-Language header - Cookie-based language preference persistence (1 year) - Language switcher with flag emojis (🇫🇷 🇬🇧) - 71 translation keys covering all UI elements - Context-based translation injection via middleware Replace Tailwind CDN with production build: - Tailwind CLI v3.4.16 for CSS compilation - Minified CSS output (5.9KB from several MB) - Docker build integration - Custom color palette configuration Update all templates with i18n support: - Main pages: home, sign, signatures, error - Admin dashboard and document details - Embed iframe widget (English only for international use) - Language switcher preserves current page URL Technical implementation: - golang.org/x/text for language matching - Middleware pattern for consistent i18n injection - Fallback chain: Cookie → Accept-Language → English - Separate translation files (locales/fr.json, locales/en.json) --- .gitignore | 8 + Dockerfile | 15 +- assets/input.css | 3 + build-css.sh | 29 ++++ go.mod | 1 + go.sum | 2 + internal/infrastructure/i18n/i18n.go | 156 ++++++++++++++++++ internal/infrastructure/i18n/middleware.go | 55 ++++++ internal/presentation/admin/handlers_admin.go | 9 + .../presentation/admin/middleware_admin.go | 26 ++- internal/presentation/handlers/lang.go | 57 +++++++ internal/presentation/handlers/signature.go | 16 +- locales/en.json | 84 ++++++++++ locales/fr.json | 84 ++++++++++ pkg/web/server.go | 89 +++++++++- tailwind.config.js | 41 +++++ templates/admin_dashboard.html.tpl | 20 +-- templates/admin_doc_details.html.tpl | 25 ++- templates/base.html.tpl | 51 +++--- templates/embed.html.tpl | 88 +++++----- templates/error.html.tpl | 6 +- templates/index.html.tpl | 44 ++--- templates/sign.html.tpl | 32 ++-- templates/signatures.html.tpl | 24 +-- 24 files changed, 807 insertions(+), 158 deletions(-) create mode 100644 assets/input.css create mode 100755 build-css.sh create mode 100644 internal/infrastructure/i18n/i18n.go create mode 100644 internal/infrastructure/i18n/middleware.go create mode 100644 internal/presentation/handlers/lang.go create mode 100644 locales/en.json create mode 100644 locales/fr.json create mode 100644 tailwind.config.js diff --git a/.gitignore b/.gitignore index ce1e021..9690269 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,11 @@ AGENTS.md docker-compose.local.yml docker-compose.prod.yml client_secret*.json + +/static +/community +/migrate + +# Tailwind CSS +/bin/tailwindcss +/static/output.css \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 693fc7e..d7f3c89 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # ---- Build ---- FROM golang:alpine AS builder -RUN apk update && apk add --no-cache ca-certificates git && rm -rf /var/cache/apk/* +RUN apk update && apk add --no-cache ca-certificates git curl && rm -rf /var/cache/apk/* RUN adduser -D -g '' ackuser WORKDIR /app @@ -10,6 +10,14 @@ ENV GOTOOLCHAIN=auto RUN go mod download && go mod verify COPY . . +# Download Tailwind CSS CLI (use v3 for compatibility) +RUN curl -sL https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.16/tailwindcss-linux-x64 -o /tmp/tailwindcss && \ + chmod +x /tmp/tailwindcss + +# Build CSS +RUN mkdir -p ./static && \ + /tmp/tailwindcss -i ./assets/input.css -o ./static/output.css --minify + ARG VERSION="dev" ARG COMMIT="unknown" ARG BUILD_DATE="unknown" @@ -42,10 +50,13 @@ WORKDIR /app COPY --from=builder /app/ackify /app/ackify COPY --from=builder /app/migrate /app/migrate COPY --from=builder /app/migrations /app/migrations - +COPY --from=builder /app/locales /app/locales COPY --from=builder /app/templates /app/templates +COPY --from=builder /app/static /app/static ENV ACKIFY_TEMPLATES_DIR=/app/templates +ENV ACKIFY_LOCALES_DIR=/app/locales +ENV ACKIFY_STATIC_DIR=/app/static EXPOSE 8080 diff --git a/assets/input.css b/assets/input.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/assets/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/build-css.sh b/build-css.sh new file mode 100755 index 0000000..fbd4ddf --- /dev/null +++ b/build-css.sh @@ -0,0 +1,29 @@ +#!/bin/bash +set -e + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${YELLOW}Building Tailwind CSS...${NC}" + +# Check if tailwindcss binary exists, if not download it +if [ ! -f "bin/tailwindcss" ]; then + echo "Downloading Tailwind CSS CLI v3.4.16..." + mkdir -p bin + curl -sL https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.16/tailwindcss-linux-x64 -o bin/tailwindcss + chmod +x bin/tailwindcss +fi + +# Build CSS +mkdir -p ./static + +if [ "$1" = "--watch" ]; then + echo -e "${YELLOW}Watching for changes...${NC}" + ./bin/tailwindcss -i ./assets/input.css -o ./static/output.css --watch +else + # Production build with minification + ./bin/tailwindcss -i ./assets/input.css -o ./static/output.css --minify + echo -e "${GREEN}✓ CSS built successfully at static/output.css${NC}" +fi diff --git a/go.mod b/go.mod index c1595b9..f966d85 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect + golang.org/x/text v0.29.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 7b5299d..2c2e8ce 100644 --- a/go.sum +++ b/go.sum @@ -85,6 +85,8 @@ golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/infrastructure/i18n/i18n.go b/internal/infrastructure/i18n/i18n.go new file mode 100644 index 0000000..05a56b4 --- /dev/null +++ b/internal/infrastructure/i18n/i18n.go @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +package i18n + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "golang.org/x/text/language" +) + +const ( + LangCookieName = "lang" + DefaultLang = "en" +) + +var ( + SupportedLangs = []language.Tag{ + language.English, + language.French, + } + matcher = language.NewMatcher(SupportedLangs) +) + +type I18n struct { + translations map[string]map[string]string // lang -> key -> value +} + +func NewI18n(localesDir string) (*I18n, error) { + i18n := &I18n{ + translations: make(map[string]map[string]string), + } + + // Load English translations + if err := i18n.loadTranslations(filepath.Join(localesDir, "en.json"), "en"); err != nil { + return nil, fmt.Errorf("failed to load English translations: %w", err) + } + + // Load French translations + if err := i18n.loadTranslations(filepath.Join(localesDir, "fr.json"), "fr"); err != nil { + return nil, fmt.Errorf("failed to load French translations: %w", err) + } + + return i18n, nil +} + +func (i *I18n) loadTranslations(filePath, lang string) error { + data, err := os.ReadFile(filePath) + if err != nil { + return err + } + + translations := make(map[string]string) + if err := json.Unmarshal(data, &translations); err != nil { + return err + } + + i.translations[lang] = translations + return nil +} + +// T translates a key for a given language +func (i *I18n) T(lang, key string) string { + if translations, ok := i.translations[lang]; ok { + if value, ok := translations[key]; ok { + return value + } + } + + // Fallback to English + if lang != "en" { + if translations, ok := i.translations["en"]; ok { + if value, ok := translations[key]; ok { + return value + } + } + } + + // Return key if translation not found + return key +} + +// GetLangFromRequest extracts language from cookie or Accept-Language header +func GetLangFromRequest(r *http.Request) string { + // First, check cookie + if cookie, err := r.Cookie(LangCookieName); err == nil && cookie.Value != "" { + lang := normalizeLang(cookie.Value) + if isSupported(lang) { + return lang + } + } + + // Then, check Accept-Language header + acceptLang := r.Header.Get("Accept-Language") + if acceptLang != "" { + tags, _, _ := language.ParseAcceptLanguage(acceptLang) + if len(tags) > 0 { + _, index, _ := matcher.Match(tags...) + if index < len(SupportedLangs) { + return normalizeLang(SupportedLangs[index].String()) + } + } + } + + // Default to English + return DefaultLang +} + +// SetLangCookie sets the language preference cookie +func SetLangCookie(w http.ResponseWriter, lang string, secureCookies bool) { + lang = normalizeLang(lang) + if !isSupported(lang) { + lang = DefaultLang + } + + cookie := &http.Cookie{ + Name: LangCookieName, + Value: lang, + Path: "/", + MaxAge: 365 * 24 * 60 * 60, // 1 year + HttpOnly: true, + Secure: secureCookies, + SameSite: http.SameSiteLaxMode, + } + + http.SetCookie(w, cookie) +} + +// normalizeLang normalizes language codes (en-US -> en, fr-FR -> fr) +func normalizeLang(lang string) string { + lang = strings.ToLower(lang) + if strings.HasPrefix(lang, "en") { + return "en" + } + if strings.HasPrefix(lang, "fr") { + return "fr" + } + return lang +} + +// isSupported checks if a language is supported +func isSupported(lang string) bool { + lang = normalizeLang(lang) + return lang == "en" || lang == "fr" +} + +// GetTranslations returns all translations for a given language +func (i *I18n) GetTranslations(lang string) map[string]string { + if translations, ok := i.translations[lang]; ok { + return translations + } + return i.translations[DefaultLang] +} diff --git a/internal/infrastructure/i18n/middleware.go b/internal/infrastructure/i18n/middleware.go new file mode 100644 index 0000000..215ba4a --- /dev/null +++ b/internal/infrastructure/i18n/middleware.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +package i18n + +import ( + "context" + "net/http" +) + +type contextKey string + +const ( + langContextKey = contextKey("lang") + i18nContextKey = contextKey("i18n") + transContextKey = contextKey("translations") +) + +// Middleware injects language and i18n service into request context +func Middleware(i18n *I18n) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + lang := GetLangFromRequest(r) + + // Inject language and i18n service into context + ctx := context.WithValue(r.Context(), langContextKey, lang) + ctx = context.WithValue(ctx, i18nContextKey, i18n) + ctx = context.WithValue(ctx, transContextKey, i18n.GetTranslations(lang)) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// GetLang extracts language from context +func GetLang(ctx context.Context) string { + if lang, ok := ctx.Value(langContextKey).(string); ok { + return lang + } + return DefaultLang +} + +// GetI18n extracts i18n service from context +func GetI18n(ctx context.Context) *I18n { + if i18n, ok := ctx.Value(i18nContextKey).(*I18n); ok { + return i18n + } + return nil +} + +// GetTranslations extracts translations map from context +func GetTranslations(ctx context.Context) map[string]string { + if trans, ok := ctx.Value(transContextKey).(map[string]string); ok { + return trans + } + return make(map[string]string) +} diff --git a/internal/presentation/admin/handlers_admin.go b/internal/presentation/admin/handlers_admin.go index 45e82a9..583dd4f 100644 --- a/internal/presentation/admin/handlers_admin.go +++ b/internal/presentation/admin/handlers_admin.go @@ -10,6 +10,7 @@ import ( "github.com/btouchard/ackify-ce/internal/domain/models" "github.com/btouchard/ackify-ce/internal/infrastructure/database" + "github.com/btouchard/ackify-ce/internal/infrastructure/i18n" ) type Handlers struct { @@ -55,6 +56,8 @@ func (h *Handlers) HandleDashboard(w http.ResponseWriter, r *http.Request) { Documents []database.DocumentAgg DocID *string IsAdmin bool + Lang string + T map[string]string }{ TemplateName: "admin_dashboard", User: user, @@ -62,6 +65,8 @@ func (h *Handlers) HandleDashboard(w http.ResponseWriter, r *http.Request) { Documents: documents, DocID: nil, IsAdmin: true, // L'utilisateur est forcément admin pour accéder à cette page + Lang: i18n.GetLang(ctx), + T: i18n.GetTranslations(ctx), } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -114,6 +119,8 @@ func (h *Handlers) HandleDocumentDetails(w http.ResponseWriter, r *http.Request) Signatures []*models.Signature ChainIntegrity *database.ChainIntegrityResult IsAdmin bool + Lang string + T map[string]string }{ TemplateName: "admin_doc_details", User: user, @@ -122,6 +129,8 @@ func (h *Handlers) HandleDocumentDetails(w http.ResponseWriter, r *http.Request) Signatures: signatures, ChainIntegrity: chainIntegrity, IsAdmin: true, // L'utilisateur est forcément admin pour accéder à cette page + Lang: i18n.GetLang(ctx), + T: i18n.GetTranslations(ctx), } w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/internal/presentation/admin/middleware_admin.go b/internal/presentation/admin/middleware_admin.go index daf66d1..f6e47a5 100644 --- a/internal/presentation/admin/middleware_admin.go +++ b/internal/presentation/admin/middleware_admin.go @@ -8,6 +8,7 @@ import ( "time" "github.com/btouchard/ackify-ce/internal/domain/models" + "github.com/btouchard/ackify-ce/internal/infrastructure/i18n" "github.com/btouchard/ackify-ce/pkg/logger" ) @@ -42,7 +43,7 @@ func (m *Middleware) RequireAdmin(next http.HandlerFunc) http.HandlerFunc { } if !m.isAdminUser(user) { - m.renderForbidden(w, user) + m.renderForbidden(w, r, user) return } @@ -75,9 +76,22 @@ func (m *Middleware) isAdminUser(user *models.User) bool { return false } -func (m *Middleware) renderForbidden(w http.ResponseWriter, user *models.User) { +func (m *Middleware) renderForbidden(w http.ResponseWriter, r *http.Request, user *models.User) { w.Header().Set("Content-Type", "text/html; charset=utf-8") + ctx := r.Context() + lang := i18n.GetLang(ctx) + translations := i18n.GetTranslations(ctx) + + // Get translated error messages + errorTitle := "Access Denied" + errorMessage := "You do not have permission to access the admin panel." + + if lang == "fr" { + errorTitle = "Accès refusé" + errorMessage = "Vous n'avez pas la permission d'accéder au panneau d'administration." + } + data := struct { TemplateName string User *models.User @@ -87,15 +101,19 @@ func (m *Middleware) renderForbidden(w http.ResponseWriter, user *models.User) { ErrorTitle string ErrorMessage string DocID *string + Lang string + T map[string]string }{ TemplateName: "error", User: user, BaseURL: m.baseURL, Year: time.Now().Year(), IsAdmin: false, - ErrorTitle: "Access Denied", - ErrorMessage: "You do not have permission to access the admin panel.", + ErrorTitle: errorTitle, + ErrorMessage: errorMessage, DocID: nil, + Lang: lang, + T: translations, } if err := m.templates.ExecuteTemplate(w, "base", data); err != nil { diff --git a/internal/presentation/handlers/lang.go b/internal/presentation/handlers/lang.go new file mode 100644 index 0000000..bec204f --- /dev/null +++ b/internal/presentation/handlers/lang.go @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +package handlers + +import ( + "net/http" + "net/url" + "strings" + + "github.com/btouchard/ackify-ce/internal/infrastructure/i18n" + "github.com/go-chi/chi/v5" + "github.com/btouchard/ackify-ce/pkg/logger" +) + +type LangHandlers struct { + secureCookies bool +} + +func NewLangHandlers(secureCookies bool) *LangHandlers { + return &LangHandlers{ + secureCookies: secureCookies, + } +} + +// HandleLangSwitch changes the user's language preference +func (h *LangHandlers) HandleLangSwitch(w http.ResponseWriter, r *http.Request) { + lang := chi.URLParam(r, "code") + + // Set language cookie + i18n.SetLangCookie(w, lang, h.secureCookies) + + // Get redirect URL from query parameter first, then referer + redirectTo := r.URL.Query().Get("redirect") + + if redirectTo == "" { + // Try to get referer + referer := r.Header.Get("Referer") + if referer != "" { + // Parse referer to get just the path + if refererURL, err := url.Parse(referer); err == nil { + // Only use path + query, ignore host to prevent open redirect + redirectTo = refererURL.Path + if refererURL.RawQuery != "" { + redirectTo += "?" + refererURL.RawQuery + } + } + } + } + + // Default to home if no valid redirect + if redirectTo == "" || redirectTo == "/lang/fr" || redirectTo == "/lang/en" || strings.HasPrefix(redirectTo, "/lang/") { + redirectTo = "/" + } + + logger.Logger.Debug("Language switch", "lang", lang, "redirect", redirectTo) + + http.Redirect(w, r, redirectTo, http.StatusFound) +} diff --git a/internal/presentation/handlers/signature.go b/internal/presentation/handlers/signature.go index 10388dc..c9064f6 100644 --- a/internal/presentation/handlers/signature.go +++ b/internal/presentation/handlers/signature.go @@ -11,6 +11,7 @@ import ( "time" "github.com/btouchard/ackify-ce/internal/domain/models" + "github.com/btouchard/ackify-ce/internal/infrastructure/i18n" "github.com/btouchard/ackify-ce/internal/presentation/admin" "github.com/btouchard/ackify-ce/pkg/services" ) @@ -56,6 +57,8 @@ type PageData struct { BaseURL string Signatures []*models.Signature IsAdmin bool + Lang string + T map[string]string ServiceInfo *struct { Name string Icon string @@ -267,7 +270,7 @@ func (h *SignatureHandlers) HandleUserSignatures(w http.ResponseWriter, r *http. h.render(w, r, "signatures", PageData{User: user, BaseURL: h.baseURL, Signatures: signatures}) } -func (h *SignatureHandlers) render(w http.ResponseWriter, _ *http.Request, templateName string, data PageData) { +func (h *SignatureHandlers) render(w http.ResponseWriter, r *http.Request, templateName string, data PageData) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if data.Year == 0 { @@ -280,6 +283,15 @@ func (h *SignatureHandlers) render(w http.ResponseWriter, _ *http.Request, templ data.IsAdmin = admin.IsAdminUser(data.User, h.adminEmails) } + // Get language and translations from context + ctx := r.Context() + if data.Lang == "" { + data.Lang = i18n.GetLang(ctx) + } + if data.T == nil { + data.T = i18n.GetTranslations(ctx) + } + templateData := map[string]interface{}{ "User": data.User, "Year": data.Year, @@ -291,6 +303,8 @@ func (h *SignatureHandlers) render(w http.ResponseWriter, _ *http.Request, templ "Signatures": data.Signatures, "ServiceInfo": data.ServiceInfo, "IsAdmin": data.IsAdmin, + "Lang": data.Lang, + "T": data.T, } if err := h.template.ExecuteTemplate(w, "base", templateData); err != nil { diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..010a14c --- /dev/null +++ b/locales/en.json @@ -0,0 +1,84 @@ +{ + "site.title": "Ackify - Proof of Read", + "site.brand": "Ackify - Proof of Read", + "header.logout": "Sign Out", + + "home.title": "Ackify", + "home.subtitle": "Professional solution to validate document reading", + "home.doc_label": "Document identifier", + "home.doc_placeholder": "doc_123abc...", + "home.doc_help": "Add a certified proof of reading to your documents", + "home.submit": "Continue to signature", + "home.my_signatures": "My signatures", + "home.administration": "Administration", + + "home.feature_secure_title": "Secure", + "home.feature_secure_desc": "Ed25519 cryptography and OAuth2 authentication for maximum security", + "home.feature_efficient_title": "Efficient", + "home.feature_efficient_desc": "Validate your readings in 30 seconds, guaranteed traceability", + "home.feature_compliant_title": "Compliant", + "home.feature_compliant_desc": "Complete audit trail for your regulatory compliance needs", + + "sign.document_title": "Document {{.DocID}}", + "sign.already_signed_title": "Document signed", + "sign.already_signed_desc": "You have confirmed reading this document", + "sign.signed_at": "Signed on {{.SignedAt}}", + "sign.signed_verified": "Cryptographic signature recorded and verifiable", + + "sign.not_signed_title": "Document to sign", + "sign.not_signed_desc": "You must confirm that you have read and understood this document", + "sign.warning_title": "Before signing", + "sign.warning_desc": "Make sure you have read and understood the entire document. The signature is irreversible.", + "sign.submit": "I certify that I have read and understood this document", + + "sign.actions_title": "Additional actions", + "sign.view_signatures": "View my signatures", + "sign.back_home": "Back to home", + + "signatures.title": "My signatures", + "signatures.subtitle": "List of all documents you have signed", + "signatures.total": "{{.Count}} signature{{if gt .Count 1}}s{{end}} total", + "signatures.sorted": "Sorted by descending date", + "signatures.document": "Document {{.DocID}}", + "signatures.signed_at": "Signed on {{.Date}}", + "signatures.action_view": "View", + "signatures.action_status": "Status", + + "signatures.empty_title": "No signatures", + "signatures.empty_desc": "You have not signed any documents yet.", + "signatures.empty_action": "Sign a document", + + "admin.title": "Administration", + "admin.subtitle": "Document and signature management", + "admin.connected": "Admin connected", + "admin.doc_id": "Document ID", + "admin.signatures_count": "Number of signatures", + "admin.actions": "Actions", + "admin.view_details": "View details", + "admin.no_docs_title": "No documents", + "admin.no_docs_desc": "No documents have been signed yet.", + "admin.signature_count": "{{.Count}} signature{{if ne .Count 1}}s{{end}}", + + "admin_doc.title": "Document {{.DocID}}", + "admin_doc.details_subtitle": "Signature details", + "admin_doc.total_signatures": "Total signatures", + "admin_doc.table_user": "User", + "admin_doc.table_signed_at": "Signature date", + "admin_doc.table_service": "Service", + "admin_doc.table_user_id": "User ID", + "admin_doc.no_signatures_title": "No signatures", + "admin_doc.no_signatures_desc": "This document has not been signed yet.", + "admin_doc.chain_integrity_valid": "Blockchain integrity:", + "admin_doc.chain_integrity_count": "{{.ValidSigs}}/{{.TotalSigs}} valid signatures", + "admin_doc.chain_integrity_invalid": "Integrity issue detected:", + "admin_doc.chain_integrity_errors": "{{.InvalidSigs}} invalid signature(s)", + "admin_doc.chain_errors_title": "Detected errors:", + + "error.title": "Error", + "error.connected_as": "Connected as:", + "error.back_home": "Back to Home", + "error.sign_out": "Sign Out", + + "footer.developed_by": "Developed by", + "footer.year": "@2025" +} diff --git a/locales/fr.json b/locales/fr.json new file mode 100644 index 0000000..9e4326a --- /dev/null +++ b/locales/fr.json @@ -0,0 +1,84 @@ +{ + "site.title": "Ackify - Proof of Read", + "site.brand": "Ackify - Proof of Read", + "header.logout": "Déconnexion", + + "home.title": "Ackify", + "home.subtitle": "La solution professionnelle pour valider la lecture de vos documents", + "home.doc_label": "Identifiant du document", + "home.doc_placeholder": "doc_123abc...", + "home.doc_help": "Apposez à vos documents une preuve de lecture certifiée", + "home.submit": "Continuer vers la signature", + "home.my_signatures": "Mes signatures", + "home.administration": "Administration", + + "home.feature_secure_title": "Sécurisé", + "home.feature_secure_desc": "Cryptographie Ed25519 et authentification OAuth2 pour une sécurité maximale", + "home.feature_efficient_title": "Efficace", + "home.feature_efficient_desc": "Validez vos lectures en 30 secondes, traçabilité garantie", + "home.feature_compliant_title": "Conforme", + "home.feature_compliant_desc": "Audit trail complet pour vos besoins de conformité réglementaire", + + "sign.document_title": "Document {{.DocID}}", + "sign.already_signed_title": "Document signé", + "sign.already_signed_desc": "Vous avez confirmé la lecture de ce document", + "sign.signed_at": "Signé le {{.SignedAt}}", + "sign.signed_verified": "Signature cryptographique enregistrée et vérifiable", + + "sign.not_signed_title": "Document à signer", + "sign.not_signed_desc": "Vous devez confirmer avoir lu et compris ce document", + "sign.warning_title": "Avant de signer", + "sign.warning_desc": "Assurez-vous d'avoir lu et compris l'intégralité du document. La signature est irréversible.", + "sign.submit": "Je certifie avoir lu et compris ce document", + + "sign.actions_title": "Actions supplémentaires", + "sign.view_signatures": "Voir mes signatures", + "sign.back_home": "Retour à l'accueil", + + "signatures.title": "Mes signatures", + "signatures.subtitle": "Liste de tous les documents que vous avez signés", + "signatures.total": "{{.Count}} signature{{if gt .Count 1}}s{{end}} au total", + "signatures.sorted": "Trié par date décroissante", + "signatures.document": "Document {{.DocID}}", + "signatures.signed_at": "Signé le {{.Date}}", + "signatures.action_view": "Voir", + "signatures.action_status": "Statut", + + "signatures.empty_title": "Aucune signature", + "signatures.empty_desc": "Vous n'avez encore signé aucun document.", + "signatures.empty_action": "Signer un document", + + "admin.title": "Administration", + "admin.subtitle": "Gestion des documents et signatures", + "admin.connected": "Admin connecté", + "admin.doc_id": "Document ID", + "admin.signatures_count": "Nombre de signatures", + "admin.actions": "Actions", + "admin.view_details": "Voir détails", + "admin.no_docs_title": "Aucun document", + "admin.no_docs_desc": "Aucun document n'a encore été signé.", + "admin.signature_count": "{{.Count}} signature{{if ne .Count 1}}s{{end}}", + + "admin_doc.title": "Document {{.DocID}}", + "admin_doc.details_subtitle": "Détails des signatures", + "admin_doc.total_signatures": "Total signatures", + "admin_doc.table_user": "Utilisateur", + "admin_doc.table_signed_at": "Date de signature", + "admin_doc.table_service": "Service", + "admin_doc.table_user_id": "ID Utilisateur", + "admin_doc.no_signatures_title": "Aucune signature", + "admin_doc.no_signatures_desc": "Ce document n'a pas encore été signé.", + "admin_doc.chain_integrity_valid": "Chaîne de blocs intègre :", + "admin_doc.chain_integrity_count": "{{.ValidSigs}}/{{.TotalSigs}} signatures valides", + "admin_doc.chain_integrity_invalid": "Problème d'intégrité détecté :", + "admin_doc.chain_integrity_errors": "{{.InvalidSigs}} signature(s) invalide(s)", + "admin_doc.chain_errors_title": "Erreurs détectées :", + + "error.title": "Erreur", + "error.connected_as": "Connecté en tant que:", + "error.back_home": "Retour à l'accueil", + "error.sign_out": "Déconnexion", + + "footer.developed_by": "Développé par", + "footer.year": "@2025" +} diff --git a/pkg/web/server.go b/pkg/web/server.go index 62358cb..47b4d13 100644 --- a/pkg/web/server.go +++ b/pkg/web/server.go @@ -16,6 +16,7 @@ import ( "github.com/btouchard/ackify-ce/internal/infrastructure/auth" "github.com/btouchard/ackify-ce/internal/infrastructure/config" "github.com/btouchard/ackify-ce/internal/infrastructure/database" + "github.com/btouchard/ackify-ce/internal/infrastructure/i18n" "github.com/btouchard/ackify-ce/internal/presentation/handlers" "github.com/btouchard/ackify-ce/pkg/crypto" ) @@ -31,7 +32,7 @@ type Server struct { } func NewServer(ctx context.Context, cfg *config.Config) (*Server, error) { - db, tmpl, signer, err := initInfrastructure(ctx, cfg) + db, tmpl, signer, i18nService, err := initInfrastructure(ctx, cfg) if err != nil { return nil, fmt.Errorf("failed to initialize infrastructure: %w", err) } @@ -58,8 +59,9 @@ func NewServer(ctx context.Context, cfg *config.Config) (*Server, error) { badgeHandler := handlers.NewBadgeHandler(signatureService) oembedHandler := handlers.NewOEmbedHandler(signatureService, tmpl, cfg.App.BaseURL, cfg.App.Organisation) healthHandler := handlers.NewHealthHandler() + langHandlers := handlers.NewLangHandlers(cfg.App.SecureCookies) - router := setupRouter(authHandlers, authMiddleware, signatureHandlers, badgeHandler, oembedHandler, healthHandler) + router := setupRouter(authHandlers, authMiddleware, signatureHandlers, badgeHandler, oembedHandler, healthHandler, langHandlers, i18nService) httpServer := &http.Server{ Addr: cfg.Server.ListenAddr, @@ -119,25 +121,31 @@ func (s *Server) GetAuthService() *auth.OauthService { return s.authService } -func initInfrastructure(ctx context.Context, cfg *config.Config) (*sql.DB, *template.Template, *crypto.Ed25519Signer, error) { +func initInfrastructure(ctx context.Context, cfg *config.Config) (*sql.DB, *template.Template, *crypto.Ed25519Signer, *i18n.I18n, error) { db, err := database.InitDB(ctx, database.Config{ DSN: cfg.Database.DSN, }) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to initialize database: %w", err) + return nil, nil, nil, nil, fmt.Errorf("failed to initialize database: %w", err) } tmpl, err := initTemplates() if err != nil { - return nil, nil, nil, fmt.Errorf("failed to initialize templates: %w", err) + return nil, nil, nil, nil, fmt.Errorf("failed to initialize templates: %w", err) } signer, err := crypto.NewEd25519Signer() if err != nil { - return nil, nil, nil, fmt.Errorf("failed to initialize signer: %w", err) + return nil, nil, nil, nil, fmt.Errorf("failed to initialize signer: %w", err) } - return db, tmpl, signer, nil + localesDir := getLocalesDir() + i18nService, err := i18n.NewI18n(localesDir) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to initialize i18n: %w", err) + } + + return db, tmpl, signer, i18nService, nil } func setupRouter( @@ -147,9 +155,19 @@ func setupRouter( badgeHandler *handlers.BadgeHandler, oembedHandler *handlers.OEmbedHandler, healthHandler *handlers.HealthHandler, + langHandlers *handlers.LangHandlers, + i18nService *i18n.I18n, ) *chi.Mux { router := chi.NewRouter() + // Apply i18n middleware to all routes + router.Use(i18n.Middleware(i18nService)) + + // Serve static files (CSS) + staticDir := getStaticDir() + fileServer := http.FileServer(http.Dir(staticDir)) + router.Get("/static/*", http.StripPrefix("/static/", fileServer).ServeHTTP) + router.Get("/", signatureHandlers.HandleIndex) router.Get("/login", authHandlers.HandleLogin) router.Get("/logout", authHandlers.HandleLogout) @@ -162,6 +180,9 @@ func setupRouter( // Alias to match documentation and install script router.Get("/healthz", healthHandler.HandleHealth) + // Language switcher + router.Get("/lang/{code}", langHandlers.HandleLangSwitch) + router.Get("/sign", authMiddleware.RequireAuth(signatureHandlers.HandleSignGET)) router.Post("/sign", authMiddleware.RequireAuth(signatureHandlers.HandleSignPOST)) router.Get("/signatures", authMiddleware.RequireAuth(signatureHandlers.HandleUserSignatures)) @@ -216,3 +237,57 @@ func getTemplatesDir() string { 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", // When running from project root + "./locales", // Alternative relative path + } + + for _, path := range possiblePaths { + if _, err := os.Stat(path); err == nil { + return path + } + } + + return "locales" +} + +func getStaticDir() string { + if envDir := os.Getenv("ACKIFY_STATIC_DIR"); envDir != "" { + return envDir + } + + if execPath, err := os.Executable(); err == nil { + execDir := filepath.Dir(execPath) + defaultDir := filepath.Join(execDir, "static") + if _, err := os.Stat(defaultDir); err == nil { + return defaultDir + } + } + + possiblePaths := []string{ + "static", // When running from project root + "./static", // Alternative relative path + } + + for _, path := range possiblePaths { + if _, err := os.Stat(path); err == nil { + return path + } + } + + return "static" +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..45be87d --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,41 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./templates/**/*.{html,tpl}", + "./internal/presentation/**/*.go", + ], + theme: { + extend: { + colors: { + primary: { + 50: '#eff6ff', + 100: '#dbeafe', + 500: '#3b82f6', + 600: '#2563eb', + 700: '#1d4ed8', + 900: '#1e3a8a' + }, + success: { + 50: '#f0fdf4', + 100: '#dcfce7', + 500: '#22c55e', + 600: '#16a34a', + 700: '#15803d' + }, + warning: { + 50: '#fffbeb', + 100: '#fef3c7', + 500: '#f59e0b', + 600: '#d97706' + }, + danger: { + 50: '#fef2f2', + 100: '#fecaca', + 500: '#ef4444', + 600: '#dc2626' + } + } + } + }, + plugins: [], +} diff --git a/templates/admin_dashboard.html.tpl b/templates/admin_dashboard.html.tpl index e6863da..7010ed8 100644 --- a/templates/admin_dashboard.html.tpl +++ b/templates/admin_dashboard.html.tpl @@ -3,12 +3,12 @@
-

Administration

-

Gestion des documents et signatures

+

{{index .T "admin.title"}}

+

{{index .T "admin.subtitle"}}

- Admin connecté + {{index .T "admin.connected"}}
@@ -18,13 +18,13 @@ - Document ID + {{index .T "admin.doc_id"}} - Nombre de signatures + {{index .T "admin.signatures_count"}} - Actions + {{index .T "admin.actions"}} @@ -37,13 +37,13 @@
- {{.Count}} signature{{if ne .Count 1}}s{{end}} + {{.Count}} {{if eq $.Lang "fr"}}signature{{if ne .Count 1}}s{{end}}{{else}}signature{{if ne .Count 1}}s{{end}}{{end}}
- Voir détails + {{index $.T "admin.view_details"}} @@ -58,8 +58,8 @@
-

Aucun document

-

Aucun document n'a encore été signé.

+

{{index .T "admin.no_docs_title"}}

+

{{index .T "admin.no_docs_desc"}}

{{end}} diff --git a/templates/admin_doc_details.html.tpl b/templates/admin_doc_details.html.tpl index 0e28a30..c8268ce 100644 --- a/templates/admin_doc_details.html.tpl +++ b/templates/admin_doc_details.html.tpl @@ -9,12 +9,12 @@ -

Document {{.DocID}}

+

{{index .T "admin_doc.title"}}

-

Détails des signatures

+

{{index .T "admin_doc.details_subtitle"}}

-
Total signatures
+
{{index .T "admin_doc.total_signatures"}}
{{len .Signatures}}
@@ -25,16 +25,16 @@ - Utilisateur + {{index .T "admin_doc.table_user"}} - Date de signature + {{index .T "admin_doc.table_signed_at"}} - Service + {{index .T "admin_doc.table_service"}} - ID Utilisateur + {{index .T "admin_doc.table_user_id"}} @@ -86,14 +86,13 @@ -

Aucune signature

-

Ce document n'a pas encore été signé.

+

{{index .T "admin_doc.no_signatures_title"}}

+

{{index .T "admin_doc.no_signatures_desc"}}

{{end}} {{if .Signatures}} - {{if .ChainIntegrity}} {{if .ChainIntegrity.IsValid}}
@@ -105,7 +104,7 @@

- Chaîne de blocs intègre : {{.ChainIntegrity.ValidSigs}}/{{.ChainIntegrity.TotalSigs}} signatures valides + {{index .T "admin_doc.chain_integrity_valid"}} {{index .T "admin_doc.chain_integrity_count"}}

@@ -120,11 +119,11 @@

- Problème d'intégrité détecté : {{.ChainIntegrity.InvalidSigs}} signature(s) invalide(s) + {{index .T "admin_doc.chain_integrity_invalid"}} {{index .T "admin_doc.chain_integrity_errors"}}

{{if .ChainIntegrity.Errors}}
-

Erreurs détectées :

+

{{index .T "admin_doc.chain_errors_title"}}

    {{range .ChainIntegrity.Errors}}
  • {{.}}
  • diff --git a/templates/base.html.tpl b/templates/base.html.tpl index 2e603ce..f29224a 100644 --- a/templates/base.html.tpl +++ b/templates/base.html.tpl @@ -1,27 +1,13 @@ {{define "base"}} - + -Ackify - Proof of Read +{{index .T "site.title"}} {{if and (ne .TemplateName "admin_dashboard") (ne .TemplateName "admin_doc_details")}}{{if .DocID}} {{end}}{{end}} - - +
    @@ -35,11 +21,26 @@
    -

    Ackify - Proof of Read

    +

    {{index .T "site.brand"}}

- {{if .User}} -
+
+ + + + {{if .User}}
@@ -50,9 +51,9 @@ {{if .User.Name}}{{.User.Name}}{{else}}{{.User.Email}}{{end}}
- Déconnexion -
- {{end}} + {{index .T "header.logout"}} + {{end}} +
@@ -79,10 +80,10 @@

- Développé par + {{index .T "footer.developed_by"}} Benjamin Touchard - @2025 + {{index .T "footer.year"}}

diff --git a/templates/embed.html.tpl b/templates/embed.html.tpl index 99d1899..c064502 100644 --- a/templates/embed.html.tpl +++ b/templates/embed.html.tpl @@ -1,9 +1,9 @@ {{define "embed"}} - + - Signataires - Document {{.DocID}} + Signatories - Document {{.DocID}}