This commit is contained in:
fabio 2026-02-22 17:47:28 +01:00
parent 722dd85fc6
commit 036aadb09a
18 changed files with 746 additions and 39 deletions

View File

@ -0,0 +1,27 @@
Implementa AUTH completo (server-rendered) e templates in /web/templates/public.
Routes:
- GET/POST /signup
- GET/POST /login
- POST /logout
- GET /verify-email?token=...
- GET/POST /forgot-password
- GET/POST /reset-password?token=...
Comportamento:
- Signup crea user (role=user, verified=false), genera verify token (hash in DB), invia email (mailer).
- Login: blocca se email non verificata.
- Verify-email: valida token, set EmailVerified=true, elimina token.
- Forgot-password: risposta sempre generica; se user esiste+verified, genera reset token e invia email.
- Reset-password: valida token, aggiorna password, elimina token.
Crea templates:
- public/login.html
- public/signup.html
- public/forgot_password.html
- public/reset_password.html
- public/verify_notice.html
- public/home.html (opzionale)
Aggiungi partial per flash (public/_flash.html) e includilo nel layout.
Usa repo/service per accesso DB e logica (non tutto nel controller).

View File

View File

@ -51,7 +51,9 @@ func NewApp(cfg *config.Config) (*fiber.App, error) {
}
}
apphttp.RegisterRoutes(app, store, database, cfg)
if err := apphttp.RegisterRoutes(app, store, database, cfg); err != nil {
return nil, fmt.Errorf("register routes: %w", err)
}
return app, nil
}

View File

@ -0,0 +1,196 @@
package controllers
import (
"errors"
"strings"
httpmw "trustcontact/internal/http/middleware"
"trustcontact/internal/services"
"github.com/gofiber/fiber/v2"
)
type AuthController struct {
authService *services.AuthService
}
func NewAuthController(authService *services.AuthService) *AuthController {
return &AuthController{authService: authService}
}
func (ac *AuthController) ShowHome(c *fiber.Ctx) error {
return renderPublic(c, "home.html", map[string]any{
"Title": "Home",
"NavSection": "public",
})
}
func (ac *AuthController) ShowSignup(c *fiber.Ctx) error {
return renderPublic(c, "signup.html", map[string]any{
"Title": "Sign up",
"NavSection": "public",
})
}
func (ac *AuthController) Signup(c *fiber.Ctx) error {
email := strings.TrimSpace(c.FormValue("email"))
password := c.FormValue("password")
if err := ac.authService.Signup(c.UserContext(), email, password); err != nil {
if errors.Is(err, services.ErrEmailAlreadyExists) {
httpmw.SetTemplateData(c, "FlashError", "Email gia registrata")
} else {
httpmw.SetTemplateData(c, "FlashError", "Impossibile completare la registrazione")
}
return renderPublic(c, "signup.html", map[string]any{
"Title": "Sign up",
"NavSection": "public",
"Email": email,
})
}
if err := httpmw.SetFlashSuccess(c, "Registrazione completata. Controlla la tua email per verificare l'account."); err != nil {
return err
}
return c.Redirect("/verify-notice")
}
func (ac *AuthController) ShowLogin(c *fiber.Ctx) error {
return renderPublic(c, "login.html", map[string]any{
"Title": "Login",
"NavSection": "public",
})
}
func (ac *AuthController) Login(c *fiber.Ctx) error {
email := strings.TrimSpace(c.FormValue("email"))
password := c.FormValue("password")
user, err := ac.authService.Login(email, password)
if err != nil {
switch {
case errors.Is(err, services.ErrEmailNotVerified):
httpmw.SetTemplateData(c, "FlashError", "Email non verificata. Controlla la posta.")
case errors.Is(err, services.ErrInvalidCredentials):
httpmw.SetTemplateData(c, "FlashError", "Credenziali non valide")
default:
httpmw.SetTemplateData(c, "FlashError", "Errore durante il login")
}
return renderPublic(c, "login.html", map[string]any{
"Title": "Login",
"NavSection": "public",
"Email": email,
})
}
if err := httpmw.SetSessionUserID(c, user.ID); err != nil {
return err
}
if err := httpmw.SetFlashSuccess(c, "Login effettuato"); err != nil {
return err
}
return c.Redirect("/private")
}
func (ac *AuthController) Logout(c *fiber.Ctx) error {
if err := httpmw.ClearSessionUser(c); err != nil {
return err
}
if err := httpmw.SetFlashSuccess(c, "Logout effettuato"); err != nil {
return err
}
return c.Redirect("/login")
}
func (ac *AuthController) VerifyEmail(c *fiber.Ctx) error {
token := strings.TrimSpace(c.Query("token"))
if token == "" {
httpmw.SetTemplateData(c, "FlashError", "Token non valido")
return renderPublic(c, "verify_notice.html", map[string]any{
"Title": "Verifica email",
"NavSection": "public",
})
}
if err := ac.authService.VerifyEmail(token); err != nil {
httpmw.SetTemplateData(c, "FlashError", "Token non valido o scaduto")
return renderPublic(c, "verify_notice.html", map[string]any{
"Title": "Verifica email",
"NavSection": "public",
})
}
if err := httpmw.SetFlashSuccess(c, "Email verificata. Ora puoi accedere."); err != nil {
return err
}
return c.Redirect("/login")
}
func (ac *AuthController) ShowVerifyNotice(c *fiber.Ctx) error {
return renderPublic(c, "verify_notice.html", map[string]any{
"Title": "Verifica email",
"NavSection": "public",
})
}
func (ac *AuthController) ShowForgotPassword(c *fiber.Ctx) error {
return renderPublic(c, "forgot_password.html", map[string]any{
"Title": "Forgot password",
"NavSection": "public",
})
}
func (ac *AuthController) ForgotPassword(c *fiber.Ctx) error {
email := strings.TrimSpace(c.FormValue("email"))
if err := ac.authService.ForgotPassword(c.UserContext(), email); err != nil {
httpmw.SetTemplateData(c, "FlashError", "Impossibile elaborare la richiesta")
return renderPublic(c, "forgot_password.html", map[string]any{
"Title": "Forgot password",
"NavSection": "public",
"Email": email,
})
}
httpmw.SetTemplateData(c, "FlashSuccess", "Se l'account esiste, riceverai una email con le istruzioni.")
return renderPublic(c, "forgot_password.html", map[string]any{
"Title": "Forgot password",
"NavSection": "public",
})
}
func (ac *AuthController) ShowResetPassword(c *fiber.Ctx) error {
token := strings.TrimSpace(c.Query("token"))
return renderPublic(c, "reset_password.html", map[string]any{
"Title": "Reset password",
"NavSection": "public",
"Token": token,
})
}
func (ac *AuthController) ResetPassword(c *fiber.Ctx) error {
token := strings.TrimSpace(c.Query("token"))
password := c.FormValue("password")
if token == "" {
httpmw.SetTemplateData(c, "FlashError", "Token non valido")
return renderPublic(c, "reset_password.html", map[string]any{
"Title": "Reset password",
"NavSection": "public",
})
}
if err := ac.authService.ResetPassword(token, password); err != nil {
httpmw.SetTemplateData(c, "FlashError", "Token non valido o scaduto")
return renderPublic(c, "reset_password.html", map[string]any{
"Title": "Reset password",
"NavSection": "public",
"Token": token,
})
}
if err := httpmw.SetFlashSuccess(c, "Password aggiornata. Effettua il login."); err != nil {
return err
}
return c.Redirect("/login")
}

View File

@ -0,0 +1,53 @@
package controllers
import (
"bytes"
"html/template"
"path/filepath"
"github.com/gofiber/fiber/v2"
)
func renderPublic(c *fiber.Ctx, page string, data map[string]any) error {
viewData := map[string]any{}
for k, v := range localsTemplateData(c) {
viewData[k] = v
}
for k, v := range data {
viewData[k] = v
}
if _, ok := viewData["Title"]; !ok {
viewData["Title"] = "Trustcontact"
}
if _, ok := viewData["NavSection"]; !ok {
viewData["NavSection"] = "public"
}
files := []string{
"web/templates/layout.html",
"web/templates/public/_flash.html",
filepath.Join("web/templates/public", page),
}
tmpl, err := template.ParseFiles(files...)
if err != nil {
return err
}
var out bytes.Buffer
if err := tmpl.ExecuteTemplate(&out, "layout.html", viewData); err != nil {
return err
}
c.Type("html", "utf-8")
return c.Send(out.Bytes())
}
func localsTemplateData(c *fiber.Ctx) map[string]any {
data, ok := c.Locals("template_data").(map[string]any)
if !ok || data == nil {
return map[string]any{}
}
return data
}

View File

@ -1,46 +1,54 @@
package http
import (
"fmt"
"trustcontact/internal/config"
"trustcontact/internal/controllers"
httpmw "trustcontact/internal/http/middleware"
"trustcontact/internal/services"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/session"
"gorm.io/gorm"
)
func RegisterRoutes(app *fiber.App, store *session.Store, database *gorm.DB, _ *config.Config) {
func RegisterRoutes(app *fiber.App, store *session.Store, database *gorm.DB, cfg *config.Config) error {
app.Use(httpmw.SessionStoreMiddleware(store))
app.Use(httpmw.CurrentUserMiddleware(store, database))
app.Use(httpmw.ConsumeFlash())
authService, err := services.NewAuthService(database, cfg)
if err != nil {
return fmt.Errorf("init auth service: %w", err)
}
authController := controllers.NewAuthController(authService)
app.Get("/healthz", func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
app.Get("/", func(c *fiber.Ctx) error {
c.Locals("nav_section", "public")
httpmw.SetTemplateData(c, "NavSection", "public")
return c.SendString("public area")
})
app.Get("/login", func(c *fiber.Ctx) error {
c.Locals("nav_section", "public")
httpmw.SetTemplateData(c, "NavSection", "public")
return c.SendString("login page")
})
app.Get("/", authController.ShowHome)
app.Get("/signup", authController.ShowSignup)
app.Post("/signup", authController.Signup)
app.Get("/login", authController.ShowLogin)
app.Post("/login", authController.Login)
app.Post("/logout", authController.Logout)
app.Get("/verify-email", authController.VerifyEmail)
app.Get("/verify-notice", authController.ShowVerifyNotice)
app.Get("/forgot-password", authController.ShowForgotPassword)
app.Post("/forgot-password", authController.ForgotPassword)
app.Get("/reset-password", authController.ShowResetPassword)
app.Post("/reset-password", authController.ResetPassword)
private := app.Group("/private", httpmw.RequireAuth())
private.Get("/", func(c *fiber.Ctx) error {
c.Locals("nav_section", "private")
httpmw.SetTemplateData(c, "NavSection", "private")
return c.SendString("private area")
})
admin := app.Group("/admin", httpmw.RequireAuth(), httpmw.RequireAdmin())
admin.Get("/", func(c *fiber.Ctx) error {
c.Locals("nav_section", "admin")
httpmw.SetTemplateData(c, "NavSection", "admin")
return c.SendString("admin area")
})
return nil
}

View File

@ -0,0 +1,42 @@
package repo
import (
"errors"
"time"
"trustcontact/internal/models"
"gorm.io/gorm"
)
type EmailVerificationTokenRepo struct {
db *gorm.DB
}
func NewEmailVerificationTokenRepo(db *gorm.DB) *EmailVerificationTokenRepo {
return &EmailVerificationTokenRepo{db: db}
}
func (r *EmailVerificationTokenRepo) Create(token *models.EmailVerificationToken) error {
return r.db.Create(token).Error
}
func (r *EmailVerificationTokenRepo) FindValidByHash(tokenHash string, now time.Time) (*models.EmailVerificationToken, error) {
var token models.EmailVerificationToken
err := r.db.Where("token_hash = ? AND expires_at > ?", tokenHash, now).First(&token).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &token, nil
}
func (r *EmailVerificationTokenRepo) DeleteByID(id uint) error {
return r.db.Delete(&models.EmailVerificationToken{}, id).Error
}
func (r *EmailVerificationTokenRepo) DeleteByUserID(userID uint) error {
return r.db.Where("user_id = ?", userID).Delete(&models.EmailVerificationToken{}).Error
}

View File

@ -0,0 +1,42 @@
package repo
import (
"errors"
"time"
"trustcontact/internal/models"
"gorm.io/gorm"
)
type PasswordResetTokenRepo struct {
db *gorm.DB
}
func NewPasswordResetTokenRepo(db *gorm.DB) *PasswordResetTokenRepo {
return &PasswordResetTokenRepo{db: db}
}
func (r *PasswordResetTokenRepo) Create(token *models.PasswordResetToken) error {
return r.db.Create(token).Error
}
func (r *PasswordResetTokenRepo) FindValidByHash(tokenHash string, now time.Time) (*models.PasswordResetToken, error) {
var token models.PasswordResetToken
err := r.db.Where("token_hash = ? AND expires_at > ?", tokenHash, now).First(&token).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &token, nil
}
func (r *PasswordResetTokenRepo) DeleteByID(id uint) error {
return r.db.Delete(&models.PasswordResetToken{}, id).Error
}
func (r *PasswordResetTokenRepo) DeleteByUserID(userID uint) error {
return r.db.Where("user_id = ?", userID).Delete(&models.PasswordResetToken{}).Error
}

View File

@ -27,3 +27,31 @@ func (r *UserRepo) FindByID(id uint) (*models.User, error) {
return &user, nil
}
func (r *UserRepo) FindByEmail(email string) (*models.User, error) {
var user models.User
if err := r.db.Where("email = ?", email).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &user, nil
}
func (r *UserRepo) Create(user *models.User) error {
return r.db.Create(user).Error
}
func (r *UserRepo) SetEmailVerified(userID uint, verified bool) error {
return r.db.Model(&models.User{}).
Where("id = ?", userID).
Update("email_verified", verified).Error
}
func (r *UserRepo) UpdatePasswordHash(userID uint, passwordHash string) error {
return r.db.Model(&models.User{}).
Where("id = ?", userID).
Update("password_hash", passwordHash).Error
}

View File

@ -0,0 +1,247 @@
package services
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"time"
"trustcontact/internal/auth"
"trustcontact/internal/config"
"trustcontact/internal/mailer"
"trustcontact/internal/models"
"trustcontact/internal/repo"
"gorm.io/gorm"
)
var (
ErrEmailAlreadyExists = errors.New("email already exists")
ErrInvalidCredentials = errors.New("invalid credentials")
ErrEmailNotVerified = errors.New("email not verified")
ErrInvalidOrExpiredToken = errors.New("invalid or expired token")
)
type AuthService struct {
cfg *config.Config
users *repo.UserRepo
verifyTokens *repo.EmailVerificationTokenRepo
resetTokens *repo.PasswordResetTokenRepo
mailer mailer.Mailer
templateRender *mailer.TemplateRenderer
nowFn func() time.Time
}
func NewAuthService(database *gorm.DB, cfg *config.Config) (*AuthService, error) {
sender, err := mailer.NewMailer(cfg)
if err != nil {
return nil, err
}
return &AuthService{
cfg: cfg,
users: repo.NewUserRepo(database),
verifyTokens: repo.NewEmailVerificationTokenRepo(database),
resetTokens: repo.NewPasswordResetTokenRepo(database),
mailer: sender,
templateRender: mailer.NewTemplateRenderer(""),
nowFn: func() time.Time {
return time.Now().UTC()
},
}, nil
}
func (s *AuthService) Signup(ctx context.Context, email, password string) error {
email = normalizeEmail(email)
if email == "" || strings.TrimSpace(password) == "" {
return ErrInvalidCredentials
}
existing, err := s.users.FindByEmail(email)
if err != nil {
return err
}
if existing != nil {
return ErrEmailAlreadyExists
}
passwordHash, err := auth.HashPassword(password)
if err != nil {
return err
}
user := &models.User{
Email: email,
PasswordHash: passwordHash,
EmailVerified: false,
Role: models.RoleUser,
}
if err := s.users.Create(user); err != nil {
return err
}
return s.issueVerifyEmail(ctx, user)
}
func (s *AuthService) Login(email, password string) (*models.User, error) {
email = normalizeEmail(email)
user, err := s.users.FindByEmail(email)
if err != nil {
return nil, err
}
if user == nil {
return nil, ErrInvalidCredentials
}
ok, err := auth.ComparePassword(user.PasswordHash, password)
if err != nil {
return nil, err
}
if !ok {
return nil, ErrInvalidCredentials
}
if !user.EmailVerified {
return nil, ErrEmailNotVerified
}
return user, nil
}
func (s *AuthService) VerifyEmail(token string) error {
hash := auth.HashToken(token)
record, err := s.verifyTokens.FindValidByHash(hash, s.nowFn())
if err != nil {
return err
}
if record == nil {
return ErrInvalidOrExpiredToken
}
if err := s.users.SetEmailVerified(record.UserID, true); err != nil {
return err
}
return s.verifyTokens.DeleteByID(record.ID)
}
func (s *AuthService) ForgotPassword(ctx context.Context, email string) error {
email = normalizeEmail(email)
if email == "" {
return nil
}
user, err := s.users.FindByEmail(email)
if err != nil {
return err
}
if user == nil || !user.EmailVerified {
return nil
}
plainToken, err := auth.NewToken()
if err != nil {
return err
}
if err := s.resetTokens.DeleteByUserID(user.ID); err != nil {
return err
}
record := &models.PasswordResetToken{
UserID: user.ID,
TokenHash: auth.HashToken(plainToken),
ExpiresAt: auth.ResetTokenExpiresAt(s.nowFn()),
}
if err := s.resetTokens.Create(record); err != nil {
return err
}
return s.sendResetEmail(ctx, user, plainToken)
}
func (s *AuthService) ResetPassword(token, newPassword string) error {
if strings.TrimSpace(newPassword) == "" {
return ErrInvalidCredentials
}
hash := auth.HashToken(token)
record, err := s.resetTokens.FindValidByHash(hash, s.nowFn())
if err != nil {
return err
}
if record == nil {
return ErrInvalidOrExpiredToken
}
passwordHash, err := auth.HashPassword(newPassword)
if err != nil {
return err
}
if err := s.users.UpdatePasswordHash(record.UserID, passwordHash); err != nil {
return err
}
return s.resetTokens.DeleteByID(record.ID)
}
func (s *AuthService) issueVerifyEmail(ctx context.Context, user *models.User) error {
plainToken, err := auth.NewToken()
if err != nil {
return err
}
if err := s.verifyTokens.DeleteByUserID(user.ID); err != nil {
return err
}
record := &models.EmailVerificationToken{
UserID: user.ID,
TokenHash: auth.HashToken(plainToken),
ExpiresAt: auth.VerifyTokenExpiresAt(s.nowFn()),
}
if err := s.verifyTokens.Create(record); err != nil {
return err
}
verifyURL := strings.TrimRight(s.cfg.BaseURL, "/") + "/verify-email?token=" + url.QueryEscape(plainToken)
htmlBody, textBody, err := s.templateRender.RenderVerifyEmail(mailer.TemplateData{
AppName: s.cfg.AppName,
BaseURL: s.cfg.BaseURL,
VerifyURL: verifyURL,
UserEmail: user.Email,
})
if err != nil {
return fmt.Errorf("render verify email: %w", err)
}
if err := s.mailer.Send(ctx, user.Email, "Verify your email", htmlBody, textBody); err != nil {
return fmt.Errorf("send verify email: %w", err)
}
return nil
}
func (s *AuthService) sendResetEmail(ctx context.Context, user *models.User, plainToken string) error {
resetURL := strings.TrimRight(s.cfg.BaseURL, "/") + "/reset-password?token=" + url.QueryEscape(plainToken)
htmlBody, textBody, err := s.templateRender.RenderResetPassword(mailer.TemplateData{
AppName: s.cfg.AppName,
BaseURL: s.cfg.BaseURL,
ResetURL: resetURL,
UserEmail: user.Email,
})
if err != nil {
return fmt.Errorf("render reset email: %w", err)
}
if err := s.mailer.Send(ctx, user.Email, "Reset your password", htmlBody, textBody); err != nil {
return fmt.Errorf("send reset email: %w", err)
}
return nil
}
func normalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}

View File

@ -1,42 +1,40 @@
{{- $flashSuccess := index . "FlashSuccess" -}}
{{- $flashError := index . "FlashError" -}}
{{- $nav := index . "NavSection" -}}
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{index . "Title"}}</title>
<title>{{.Title}}</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; background: #f5f7fb; color: #1f2937; }
nav { background: #111827; color: #fff; padding: 12px 16px; display: flex; gap: 12px; }
nav a { color: #e5e7eb; text-decoration: none; }
nav a.active { color: #fff; font-weight: 600; }
.container { max-width: 960px; margin: 20px auto; padding: 0 16px; }
.flash { padding: 12px 14px; border-radius: 8px; margin-bottom: 12px; }
.flash.success { background: #dcfce7; color: #166534; }
.flash.error { background: #fee2e2; color: #991b1b; }
main { background: #fff; border-radius: 10px; padding: 20px; }
.container { max-width: 920px; margin: 20px auto; padding: 0 16px; }
.card { background: #fff; border-radius: 10px; padding: 20px; }
form { display: grid; gap: 10px; max-width: 420px; }
input { padding: 10px; border: 1px solid #d1d5db; border-radius: 8px; }
button { padding: 10px 14px; border: 0; border-radius: 8px; background: #111827; color: #fff; cursor: pointer; }
.muted { color: #6b7280; font-size: 0.95rem; }
.row { display: flex; gap: 10px; flex-wrap: wrap; }
</style>
</head>
<body>
<nav>
<a href="/" class="{{if eq $nav "public"}}active{{end}}">Public</a>
<a href="/private" class="{{if eq $nav "private"}}active{{end}}">Private</a>
<a href="/admin" class="{{if eq $nav "admin"}}active{{end}}">Admin</a>
<a href="/" class="{{if eq .NavSection "public"}}active{{end}}">Public</a>
<a href="/private" class="{{if eq .NavSection "private"}}active{{end}}">Private</a>
<a href="/admin" class="{{if eq .NavSection "admin"}}active{{end}}">Admin</a>
{{if .CurrentUser}}
<form action="/logout" method="post" style="margin-left:auto;">
<button type="submit">Logout</button>
</form>
{{end}}
</nav>
<div class="container">
{{if $flashSuccess}}
<div class="flash success">{{$flashSuccess}}</div>
{{end}}
{{if $flashError}}
<div class="flash error">{{$flashError}}</div>
{{end}}
<main>
{{index . "Content"}}
</main>
{{template "_flash.html" .}}
<div class="card">
{{template "content" .}}
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,6 @@
{{if .FlashSuccess}}
<div style="background:#dcfce7;color:#166534;padding:12px;border-radius:8px;margin:0 0 12px;">{{.FlashSuccess}}</div>
{{end}}
{{if .FlashError}}
<div style="background:#fee2e2;color:#991b1b;padding:12px;border-radius:8px;margin:0 0 12px;">{{.FlashError}}</div>
{{end}}

View File

@ -0,0 +1,9 @@
{{define "content"}}
<h1>Password dimenticata</h1>
<p class="muted">Inserisci la tua email. Se l'account esiste e risulta verificato, invieremo un link di reset.</p>
<form action="/forgot-password" method="post">
<label>Email</label>
<input type="email" name="email" value="{{.Email}}" required>
<button type="submit">Invia link reset</button>
</form>
{{end}}

View File

@ -0,0 +1,9 @@
{{define "content"}}
<h1>Trustcontact</h1>
<p class="muted">Boilerplate GoFiber + HTMX + Svelte CE + GORM.</p>
<div class="row">
<a href="/signup">Crea account</a>
<a href="/login">Accedi</a>
<a href="/forgot-password">Password dimenticata</a>
</div>
{{end}}

View File

@ -0,0 +1,11 @@
{{define "content"}}
<h1>Login</h1>
<form action="/login" method="post">
<label>Email</label>
<input type="email" name="email" value="{{.Email}}" required>
<label>Password</label>
<input type="password" name="password" required>
<button type="submit">Accedi</button>
</form>
<p class="muted">Non hai un account? <a href="/signup">Registrati</a></p>
{{end}}

View File

@ -0,0 +1,12 @@
{{define "content"}}
<h1>Reset password</h1>
{{if .Token}}
<form action="/reset-password?token={{.Token}}" method="post">
<label>Nuova password</label>
<input type="password" name="password" required>
<button type="submit">Aggiorna password</button>
</form>
{{else}}
<p class="muted">Token mancante o non valido.</p>
{{end}}
{{end}}

View File

@ -0,0 +1,11 @@
{{define "content"}}
<h1>Sign up</h1>
<form action="/signup" method="post">
<label>Email</label>
<input type="email" name="email" value="{{.Email}}" required>
<label>Password</label>
<input type="password" name="password" required>
<button type="submit">Crea account</button>
</form>
<p class="muted">Hai già un account? <a href="/login">Accedi</a></p>
{{end}}

View File

@ -0,0 +1,6 @@
{{define "content"}}
<h1>Verifica email</h1>
<p class="muted">Controlla la casella di posta e apri il link di verifica ricevuto.</p>
<p class="muted">Se il link è scaduto, ripeti la registrazione o contatta supporto.</p>
<p><a href="/login">Vai al login</a></p>
{{end}}