Compare commits

...

10 Commits

Author SHA1 Message Date
fabio
83e85bf899 adattato html, test htmx con componente svelte 2026-02-22 20:23:21 +01:00
fabio
0cd6ce05cd tailwind c ss 2026-02-22 18:31:19 +01:00
fabio
81245535b3 prompt 10 2026-02-22 18:01:37 +01:00
fabio
e069100c53 prompt 9 2026-02-22 17:58:31 +01:00
fabio
70e34465de prompt 8 2026-02-22 17:53:29 +01:00
fabio
c60ff109a4 prompt 7 2026-02-22 17:51:25 +01:00
fabio
036aadb09a prompt 6 2026-02-22 17:47:28 +01:00
fabio
722dd85fc6 prompt-5 2026-02-22 17:43:04 +01:00
fabio
ae48383dc8 prompt 4 2026-02-22 17:39:36 +01:00
fabio
be462b814c prompt 1,2,3 2026-02-22 17:36:16 +01:00
78 changed files with 10253 additions and 65 deletions

13
.gitignore vendored
View File

@@ -19,12 +19,19 @@ tmp/
# Environment
.env
.env.*
!.env.example
# Editors / OS
.DS_Store
.idea/
.vscode/
# Dev database/files
data/*
!data/.gitkeep
# JS deps
ui-kit/node_modules/
# Dev data and local state
/data/
*.sqlite
*.sqlite3
*.db
/data/emails/

25
Makefile Normal file
View File

@@ -0,0 +1,25 @@
.PHONY: dev ui-build ui-dev css-build css-dev test db-reset fmt
dev:
go run ./cmd/server
ui-build:
cd ui-kit && npm i && npm run build && npm run css:build
ui-dev:
cd ui-kit && npm i && npm run dev
css-build:
cd ui-kit && npm i && npm run css:build
css-dev:
cd ui-kit && npm i && npm run css:dev
test:
go test ./...
db-reset:
rm -f ./data/app.db ./data/app.sqlite3
fmt:
gofmt -w $$(find ./cmd ./internal -type f -name '*.go')

135
README.md
View File

@@ -1,69 +1,80 @@
# GoFiber MVC Boilerplate
Boilerplate riusabile per:
Boilerplate GoFiber MVC + HTMX + Svelte Custom Elements + GORM, con auth server-rendered, area private/admin e mail sink in sviluppo.
- GoFiber (MVC)
- HTMX
- Svelte Custom Elements (UI kit)
- GORM
- SQLite/Postgres
- Auth + ruolo `admin`
- Email sink
- CORS
- Template directory `public` / `private` / `admin`
## Quickstart SQLite
## Struttura iniziale
```text
.
├── cmd/
│ └── server/
├── internal/
│ ├── app/
│ ├── auth/
│ ├── config/
│ ├── controllers/
│ ├── db/
│ ├── http/
│ ├── mailer/
│ ├── middleware/
│ ├── models/
│ ├── repo/
│ └── services/
├── web/
│ ├── emails/
│ │ └── templates/
│ ├── static/
│ │ ├── css/
│ │ ├── ui/
│ │ └── vendor/
│ └── templates/
│ ├── admin/
│ ├── private/
│ └── public/
├── ui-kit/
└── data/ # solo sviluppo locale
```bash
cp .env.example .env
make css-build
make ui-build
make dev
```
## TODO Checklist
Default SQLite path: `./data/app.sqlite3`.
- [ ] Definire bootstrap server in `cmd/server` (entrypoint + lifecycle).
- [ ] Configurare loader config (`env`, `flags`) in `internal/config`.
- [ ] Impostare `internal/db` con supporto SQLite (dev) e Postgres (prod).
- [ ] Definire modelli base GORM in `internal/models` (User, Role, Session, ecc.).
- [ ] Implementare repository layer in `internal/repo`.
- [ ] Implementare service layer in `internal/services`.
- [ ] Implementare controller layer in `internal/controllers`.
- [ ] Configurare router HTTP in `internal/http` (gruppi public/private/admin).
- [ ] Aggiungere middleware comuni in `internal/middleware` (logging, recovery, auth, cors).
- [ ] Implementare auth in `internal/auth` (login/logout/session o token).
- [ ] Implementare RBAC con ruolo `admin`.
- [ ] Configurare mailer + email sink in `internal/mailer`.
- [ ] Definire template rendering per `web/templates/public`, `web/templates/private`, `web/templates/admin`.
- [ ] Preparare template email in `web/emails/templates`.
- [ ] Definire static assets pipeline e convenzioni in `web/static`.
- [ ] Impostare `ui-kit` con Svelte Custom Elements e output in `web/static/ui`.
- [ ] Definire integrazione HTMX lato template/partials.
- [ ] Aggiungere migrazioni DB iniziali e seed minimo.
- [ ] Aggiungere test base (unit + integrazione) per router/auth/repo.
- [ ] Aggiungere script Makefile/task runner per setup e run locale.
Comandi utili:
```bash
make test
make fmt
make db-reset
```
## Quickstart Postgres (Docker Compose)
```bash
docker compose up -d
cp .env.example .env
```
Configura `.env` così:
```env
DB_DRIVER=postgres
DB_PG_DSN=postgres://trustcontact:trustcontact@localhost:5432/trustcontact?sslmode=disable
```
`DB_POSTGRES_DSN` è comunque supportato.
## Tailwind + UI Kit
Tailwind (template server-rendered) compila in `web/static/css/app.css`.
UI kit (Svelte custom elements) compila in `web/static/ui`.
Comandi:
```bash
make css-build # build tailwind
make css-dev # watch tailwind
make ui-build # build ui-kit + css tailwind
make ui-dev # vite dev server ui-kit
```
Layout include:
- `/static/css/app.css?v={{.BuildHash}}`
- `/static/ui/ui.css?v={{.BuildHash}}`
- `/static/ui/ui.esm.js?v={{.BuildHash}}`
## Template Directories
- Public: `web/templates/public`
- Private: `web/templates/private`
- Admin: `web/templates/admin`
## Email in Develop
In `develop`, le email vengono salvate in `./data/emails`.
## Make Targets
- `make dev` -> `go run ./cmd/server`
- `make ui-build` -> install + build ui-kit + build css tailwind
- `make ui-dev` -> watch UI con Vite
- `make css-build` -> build Tailwind CSS
- `make css-dev` -> watch Tailwind CSS
- `make test` -> `go test ./...`
- `make db-reset` -> reset DB sqlite locale (`./data/app.db` / `./data/app.sqlite3`)
- `make fmt` -> `gofmt` su `cmd/` e `internal/`

24
cmd/server/main.go Normal file
View File

@@ -0,0 +1,24 @@
package main
import (
"log"
"trustcontact/internal/app"
"trustcontact/internal/config"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("config load failed: %v", err)
}
server, err := app.NewApp(cfg)
if err != nil {
log.Fatalf("app init failed: %v", err)
}
if err := server.Listen(":" + cfg.Port); err != nil {
log.Fatalf("server listen failed: %v", err)
}
}

View File

@@ -0,0 +1,20 @@
Aggiungi DX boilerplate.
- Makefile:
- make dev (go run ./cmd/server)
- make ui-build (cd ui-kit && npm i && npm run build)
- make ui-dev (watch)
- make test (go test ./...)
- make db-reset (solo sqlite: rimuovi ./data/app.db)
- make fmt (gofmt)
- docker-compose.yml:
- postgres service (porta 5432)
- env compatibile con DB_PG_DSN
- README.md:
- Quickstart sqlite
- Quickstart postgres (docker compose)
- dove stanno templates public/private/admin
- email in develop: ./data/emails
- build UI kit

View File

@@ -0,0 +1,19 @@
Implementa internal/db (db.go, migrate.go, seed.go) con GORM.
- db.go: Open(cfg) (*gorm.DB, error) con switch sqlite/postgres.
- sqlite usa cfg.SQLitePath, crea directory se serve.
- postgres usa cfg.PostgresDSN.
- logger più verboso in develop.
- migrate.go: Migrate(db) che fa AutoMigrate su tutti i modelli (User, EmailVerificationToken, PasswordResetToken).
- esegui solo se cfg.AutoMigrate=true (gestisci in app.go o in migrate.go).
- seed.go: Seed(db) idempotente se cfg.SeedEnabled=true:
- in develop crea:
- admin@example.com (role=admin, verified=true, password="password")
- user@example.com (role=user, verified=true, password="password")
- crea anche utenti demo aggiuntivi per tabella.
- usa upsert by email (GORM clauses OnConflict dove possibile).
- NON loggare password in chiaro.
Aggiorna/crea internal/models con i modelli necessari.

19
codex-prompt/prompt-3.txt Normal file
View File

@@ -0,0 +1,19 @@
Implementa internal/models e internal/auth.
- internal/models/user.go:
- User: ID, Email unique, PasswordHash, EmailVerified, Role (default user), timestamps.
- internal/models/auth_tokens.go:
- EmailVerificationToken: UserID, TokenHash unique, ExpiresAt, timestamps
- PasswordResetToken: UserID, TokenHash unique, ExpiresAt, timestamps
- internal/auth/passwords.go:
- HashPassword(plain) -> hash (bcrypt)
- ComparePassword(hash, plain) -> bool/error
- internal/auth/tokens.go:
- NewToken() -> plainToken (base64url random 32+ bytes)
- HashToken(plainToken) -> hex/bytes SHA-256 string
- ExpiresAt helpers (verify 24h, reset 1h)
Assicurati che nel DB venga salvato SOLO lhash del token.

24
codex-prompt/prompt-4.txt Normal file
View File

@@ -0,0 +1,24 @@
Implementa internal/mailer.
Requisiti:
- directory template email: /web/emails/templates
- verify_email.html + .txt
- reset_password.html + .txt
- internal/mailer/templates.go:
- carica e renderizza template (html+txt) con dati: AppName, BaseURL, VerifyURL/ResetURL, UserEmail.
- internal/mailer/mailer.go:
- interfaccia Mailer { Send(ctx, to, subject, htmlBody, textBody) error }
- factory NewMailer(cfg) che ritorna:
- sink mailer se cfg.Env==develop
- smtp mailer altrimenti
- internal/mailer/sink.go:
- salva in cfg.EmailSinkDir file con timestamp__type__to.eml (o .txt/.html)
- includi subject, to, bodies e link.
- internal/mailer/smtp.go:
- invio via SMTP usando cfg.SMTPHost/Port/User/Password/From/FromName.
Aggiorna README con “in develop le email sono salvate in ./data/emails”.

14
codex-prompt/prompt-5.txt Normal file
View File

@@ -0,0 +1,14 @@
Aggiungi session e middleware.
- Usa Fiber session middleware (cookie session). Configura key da cfg.SessionKey, cookie secure in prod, SameSite Lax, HttpOnly.
- Implementa internal/http/middleware:
- RequireAuth: se non loggato redirect /login
- RequireAdmin: se role != admin -> 403 (pagina admin/forbidden o testo)
- CurrentUser helper (legge user_id da sessione, carica user da DB con repo)
- Implementa flash messages (success/error) in sessione:
- SetFlashSuccess/SetFlashError
- ConsumeFlash middleware che aggiunge al template data
Aggiorna layout.html per mostrare flash e navbar diversa per public/private/admin.

27
codex-prompt/prompt-6.txt Normal file
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).

22
codex-prompt/prompt-7.txt Normal file
View File

@@ -0,0 +1,22 @@
Implementa modulo “users” sotto /web/templates/private/users.
Routes protette (RequireAuth):
- GET /users -> pagina con search + container tabella
- GET /users/table -> partial HTML tabella (htmx)
- GET /users/:id/modal -> partial HTML contenuto modal
Requisiti tabella:
- query params: q, sort (id|name|email whitelist), dir (asc|desc), page, pageSize
- server-driven paging/sort/search usando GORM (Count + Limit/Offset + Order)
- _table.html deve includere:
- header th cliccabili con hx-get (toggle dir)
- pager prev/next con hx-get
- bottone “Apri” che hx-get sul modal e hx-target="#userModal" hx-swap="innerHTML"
- apri modal via JS minimal: setAttribute('open','') dopo swap (o onclick)
Crea template:
- private/users/index.html
- private/users/_table.html
- private/users/_modal.html
Integra <ui-modal id="userModal"> nella index privata.

11
codex-prompt/prompt-8.txt Normal file
View File

@@ -0,0 +1,11 @@
Implementa area admin.
Routes protette (RequireAuth + RequireAdmin):
- GET /admin -> admin/dashboard.html
- GET /admin/users -> pagina elenco utenti (server-rendered semplice)
Templates:
- admin/dashboard.html
- admin/users.html
Navbar nel layout deve mostrare link Admin solo se role=admin.

33
codex-prompt/prompt-9.txt Normal file
View File

@@ -0,0 +1,33 @@
Crea /ui-kit come progetto Vite + Svelte per custom elements.
Requisiti:
- build deve scrivere direttamente in ../web/static/ui:
- ui.esm.js
- ui.css (tokens+base)
- src/index.ts registra:
- ui-modal
- ui-drop-down
- ui-data-table-shell (driver htmx per aggiornare un target)
Componenti:
1) UiModal.svelte:
- <svelte:options customElement="ui-modal" />
- attributi: title, open (boolean presence)
- close on ESC, backdrop click
- focus trap minimale
- emette evento "ui:close" (bubbles+composed)
- slot contenuto (HTMX swappa dentro al tag)
2) UiDropDown.svelte:
- usa <option> del light DOM
- espone value/name/placeholder/disabled
- integra con form MVC (hidden input name=...)
- emette change + ui:change
3) UiDataTableShell.svelte:
- attributi: endpoint, target, page-size
- input search -> usa htmx.ajax('GET', url, {target}) se disponibile
- non renderizza tabella, solo toolbar
Aggiorna layout per includere /static/ui/ui.css e /static/ui/ui.esm.js con ?v={{.BuildHash}}.
Aggiorna README con comandi npm.

23
docker-compose.yml Normal file
View File

@@ -0,0 +1,23 @@
version: "3.9"
services:
postgres:
image: postgres:16-alpine
container_name: trustcontact-postgres
restart: unless-stopped
ports:
- "5432:5432"
environment:
POSTGRES_USER: trustcontact
POSTGRES_PASSWORD: trustcontact
POSTGRES_DB: trustcontact
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
# App env compatibility:
# DB_DRIVER=postgres
# DB_PG_DSN=postgres://trustcontact:trustcontact@localhost:5432/trustcontact?sslmode=disable
# (also supported: DB_POSTGRES_DSN)

32
go.mod
View File

@@ -1,3 +1,35 @@
module trustcontact
go 1.25.4
require (
github.com/gofiber/fiber/v2 v2.52.11
github.com/joho/godotenv v1.5.1
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
)

69
go.sum Normal file
View File

@@ -0,0 +1,69 @@
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gofiber/fiber/v2 v2.52.11 h1:5f4yzKLcBcF8ha1GQTWB+mpblWz3Vz6nSAbTL31HkWs=
github.com/gofiber/fiber/v2 v2.52.11/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

59
internal/app/app.go Normal file
View File

@@ -0,0 +1,59 @@
package app
import (
"fmt"
"strings"
"trustcontact/internal/config"
"trustcontact/internal/db"
apphttp "trustcontact/internal/http"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/session"
)
func NewApp(cfg *config.Config) (*fiber.App, error) {
if cfg == nil {
return nil, fmt.Errorf("config is nil")
}
app := fiber.New()
app.Use(cors.New(cors.Config{
AllowOrigins: strings.Join(cfg.CORS.Origins, ","),
AllowHeaders: strings.Join(cfg.CORS.Headers, ","),
AllowMethods: strings.Join(cfg.CORS.Methods, ","),
AllowCredentials: cfg.CORS.Credentials,
}))
store := session.New(session.Config{
KeyLookup: "cookie:" + cfg.SessionKey,
CookieHTTPOnly: true,
CookieSecure: cfg.Env == config.EnvProd,
CookieSameSite: fiber.CookieSameSiteLaxMode,
})
database, err := db.Open(cfg)
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
if cfg.AutoMigrate {
if err := db.Migrate(database); err != nil {
return nil, fmt.Errorf("migrate db: %w", err)
}
}
if cfg.SeedEnabled && cfg.Env == config.EnvDevelop {
if err := db.Seed(database); err != nil {
return nil, fmt.Errorf("seed db: %w", err)
}
}
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,24 @@
package auth
import "golang.org/x/crypto/bcrypt"
func HashPassword(plain string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func ComparePassword(hash string, plain string) (bool, error) {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain))
if err != nil {
if err == bcrypt.ErrMismatchedHashAndPassword {
return false, nil
}
return false, err
}
return true, nil
}

33
internal/auth/tokens.go Normal file
View File

@@ -0,0 +1,33 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"time"
)
const tokenBytes = 32
func NewToken() (string, error) {
buf := make([]byte, tokenBytes)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
func HashToken(plainToken string) string {
sum := sha256.Sum256([]byte(plainToken))
return hex.EncodeToString(sum[:])
}
func VerifyTokenExpiresAt(now time.Time) time.Time {
return now.UTC().Add(24 * time.Hour)
}
func ResetTokenExpiresAt(now time.Time) time.Time {
return now.UTC().Add(1 * time.Hour)
}

214
internal/config/config.go Normal file
View File

@@ -0,0 +1,214 @@
package config
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"github.com/joho/godotenv"
)
const (
EnvDevelop = "develop"
EnvProd = "prod"
DBDriverSQLite = "sqlite"
DBDriverPostgres = "postgres"
)
type Config struct {
AppName string
Env string
Port string
BaseURL string
BuildHash string
DBDriver string
SQLitePath string
PostgresDSN string
CORS CORSConfig
SessionKey string
SMTP SMTPConfig
EmailSinkDir string
AutoMigrate bool
SeedEnabled bool
}
type CORSConfig struct {
Origins []string
Headers []string
Methods []string
Credentials bool
}
type SMTPConfig struct {
Host string
Port int
Username string
Password string
From string
FromName string
}
func Load() (*Config, error) {
if err := godotenv.Load(); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("load .env: %w", err)
}
cfg := &Config{
AppName: envOrDefault("APP_NAME", "trustcontact"),
Env: envOrDefault("APP_ENV", EnvDevelop),
Port: envOrDefault("APP_PORT", "3000"),
BaseURL: envOrDefault("APP_BASE_URL", "http://localhost:3000"),
BuildHash: envOrDefault("APP_BUILD_HASH", "dev"),
DBDriver: envOrDefault("DB_DRIVER", DBDriverSQLite),
SQLitePath: envOrDefault("DB_SQLITE_PATH", "data/app.sqlite3"),
PostgresDSN: envFirstNonEmpty("DB_POSTGRES_DSN", "DB_PG_DSN"),
CORS: CORSConfig{
Origins: envListOrDefault("CORS_ORIGINS", []string{"http://localhost:3000"}),
Headers: envListOrDefault("CORS_HEADERS", []string{"Origin", "Content-Type", "Accept", "Authorization", "HX-Request"}),
Methods: envListOrDefault("CORS_METHODS", []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}),
Credentials: envBoolOrDefault("CORS_CREDENTIALS", true),
},
SessionKey: envOrDefault("SESSION_KEY", "change-me-in-prod"),
EmailSinkDir: envOrDefault("EMAIL_SINK_DIR", "data/emails"),
AutoMigrate: envBoolOrDefault("AUTO_MIGRATE", true),
SeedEnabled: envBoolOrDefault("SEED_ENABLED", false),
SMTP: SMTPConfig{
Host: envOrDefault("SMTP_HOST", "localhost"),
Port: envIntOrDefault("SMTP_PORT", 1025),
Username: strings.TrimSpace(os.Getenv("SMTP_USERNAME")),
Password: strings.TrimSpace(os.Getenv("SMTP_PASSWORD")),
From: envOrDefault("SMTP_FROM", "noreply@example.test"),
FromName: envOrDefault("SMTP_FROM_NAME", "Trustcontact"),
},
}
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
}
func (c *Config) Validate() error {
if c.AppName == "" {
return errors.New("APP_NAME is required")
}
switch c.Env {
case EnvDevelop, EnvProd:
default:
return fmt.Errorf("APP_ENV must be one of [%s,%s]", EnvDevelop, EnvProd)
}
if strings.TrimSpace(c.Port) == "" {
return errors.New("APP_PORT is required")
}
switch c.DBDriver {
case DBDriverSQLite:
if strings.TrimSpace(c.SQLitePath) == "" {
return errors.New("DB_SQLITE_PATH is required when DB_DRIVER=sqlite")
}
case DBDriverPostgres:
if strings.TrimSpace(c.PostgresDSN) == "" {
return errors.New("DB_POSTGRES_DSN is required when DB_DRIVER=postgres")
}
default:
return fmt.Errorf("DB_DRIVER must be one of [%s,%s]", DBDriverSQLite, DBDriverPostgres)
}
if strings.TrimSpace(c.SessionKey) == "" {
return errors.New("SESSION_KEY is required")
}
if c.SMTP.Port <= 0 {
return errors.New("SMTP_PORT must be > 0")
}
if strings.TrimSpace(c.SMTP.From) == "" {
return errors.New("SMTP_FROM is required")
}
if strings.TrimSpace(c.EmailSinkDir) == "" {
return errors.New("EMAIL_SINK_DIR is required")
}
return nil
}
func envOrDefault(key, fallback string) string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
return value
}
func envBoolOrDefault(key string, fallback bool) bool {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return fallback
}
return parsed
}
func envIntOrDefault(key string, fallback int) int {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return parsed
}
func envListOrDefault(key string, fallback []string) []string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parts := strings.Split(value, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
clean := strings.TrimSpace(part)
if clean != "" {
out = append(out, clean)
}
}
if len(out) == 0 {
return fallback
}
return out
}
func envFirstNonEmpty(keys ...string) string {
for _, key := range keys {
value := strings.TrimSpace(os.Getenv(key))
if value != "" {
return value
}
}
return ""
}

View File

@@ -0,0 +1,34 @@
package controllers
import (
"html/template"
"github.com/gofiber/fiber/v2"
)
type AdminController struct{}
func NewAdminController() *AdminController {
return &AdminController{}
}
func (ac *AdminController) Dashboard(c *fiber.Ctx) error {
viewData := map[string]any{
"Title": "Admin Dashboard",
"NavSection": "admin",
}
for k, v := range localsTemplateData(c) {
viewData[k] = v
}
tmpl, err := template.ParseFiles(
"web/templates/layout.html",
"web/templates/public/_flash.html",
"web/templates/admin/dashboard.html",
)
if err != nil {
return err
}
return executeLayout(c, tmpl, viewData)
}

View File

@@ -0,0 +1,207 @@
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 {
if _, ok := httpmw.CurrentUserFromContext(c); ok {
return c.Redirect("/welcome")
}
return renderPublic(c, "home.html", map[string]any{
"Title": "Home",
"NavSection": "public",
})
}
func (ac *AuthController) ShowWelcome(c *fiber.Ctx) error {
return renderPrivate(c, "welcome.html", map[string]any{
"Title": "Welcome",
"NavSection": "private",
})
}
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("/welcome")
}
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,89 @@
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 renderPrivate(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"] = "private"
}
files := []string{
"web/templates/layout.html",
"web/templates/public/_flash.html",
filepath.Join("web/templates/private", 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

@@ -0,0 +1,125 @@
package controllers
import (
"fmt"
"html/template"
"path/filepath"
"strconv"
"strings"
httpmw "trustcontact/internal/http/middleware"
"trustcontact/internal/services"
"github.com/gofiber/fiber/v2"
)
type UsersController struct {
usersService *services.UsersService
}
func NewUsersController(usersService *services.UsersService) *UsersController {
return &UsersController{usersService: usersService}
}
func (uc *UsersController) Index(c *fiber.Ctx) error {
pageData, err := uc.queryPage(c)
if err != nil {
return err
}
viewData := map[string]any{
"Title": "Admin Users",
"NavSection": "admin",
"PageData": pageData,
}
for k, v := range localsTemplateData(c) {
viewData[k] = v
}
tmpl, err := template.ParseFiles(
"web/templates/layout.html",
"web/templates/public/_flash.html",
"web/templates/admin/users/index.html",
"web/templates/admin/users/_table.html",
)
if err != nil {
return err
}
return executeLayout(c, tmpl, viewData)
}
func (uc *UsersController) Table(c *fiber.Ctx) error {
pageData, err := uc.queryPage(c)
if err != nil {
return err
}
viewData := map[string]any{"PageData": pageData}
tmpl, err := template.ParseFiles("web/templates/admin/users/_table.html")
if err != nil {
return err
}
c.Type("html", "utf-8")
return tmpl.ExecuteTemplate(c.Response().BodyWriter(), "users_table", viewData)
}
func (uc *UsersController) Modal(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return c.Status(fiber.StatusBadRequest).SendString("invalid user id")
}
user, err := uc.usersService.GetByID(uint(id))
if err != nil {
return err
}
if user == nil {
return c.Status(fiber.StatusNotFound).SendString("user not found")
}
viewData := map[string]any{"User": user}
tmpl, err := template.ParseFiles("web/templates/admin/users/_modal.html")
if err != nil {
return err
}
c.Type("html", "utf-8")
return tmpl.ExecuteTemplate(c.Response().BodyWriter(), "users_modal", viewData)
}
func (uc *UsersController) queryPage(c *fiber.Ctx) (*services.UsersPage, error) {
page := parseIntOrDefault(c.Query("page"), 1)
pageSize := parseIntOrDefault(c.Query("pageSize"), 10)
q := strings.TrimSpace(c.Query("q"))
sort := c.Query("sort", "id")
dir := c.Query("dir", "asc")
pageData, err := uc.usersService.List(services.UsersQuery{
Q: q,
Sort: sort,
Dir: dir,
Page: page,
PageSize: pageSize,
})
if err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
return pageData, nil
}
func parseIntOrDefault(value string, fallback int) int {
parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func executeLayout(c *fiber.Ctx, tmpl *template.Template, viewData map[string]any) error {
httpmw.SetTemplateData(c, "NavSection", viewData["NavSection"])
c.Type("html", "utf-8")
return tmpl.ExecuteTemplate(c.Response().BodyWriter(), filepath.Base("web/templates/layout.html"), viewData)
}

57
internal/db/db.go Normal file
View File

@@ -0,0 +1,57 @@
package db
import (
"fmt"
"os"
"path/filepath"
"time"
"trustcontact/internal/config"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
func Open(cfg *config.Config) (*gorm.DB, error) {
if cfg == nil {
return nil, fmt.Errorf("config is nil")
}
gormCfg := &gorm.Config{Logger: newLogger(cfg.Env)}
switch cfg.DBDriver {
case config.DBDriverSQLite:
if err := ensureSQLiteDir(cfg.SQLitePath); err != nil {
return nil, fmt.Errorf("prepare sqlite dir: %w", err)
}
return gorm.Open(sqlite.Open(cfg.SQLitePath), gormCfg)
case config.DBDriverPostgres:
return gorm.Open(postgres.Open(cfg.PostgresDSN), gormCfg)
default:
return nil, fmt.Errorf("unsupported db driver: %s", cfg.DBDriver)
}
}
func newLogger(env string) gormlogger.Interface {
level := gormlogger.Warn
if env == config.EnvDevelop {
level = gormlogger.Info
}
return gormlogger.Default.LogMode(level)
}
func ensureSQLiteDir(sqlitePath string) error {
dir := filepath.Dir(sqlitePath)
if dir == "." || dir == "" {
return nil
}
return os.MkdirAll(dir, 0o755)
}
func nowUTC() time.Time {
return time.Now().UTC()
}

15
internal/db/migrate.go Normal file
View File

@@ -0,0 +1,15 @@
package db
import (
"trustcontact/internal/models"
"gorm.io/gorm"
)
func Migrate(database *gorm.DB) error {
return database.AutoMigrate(
&models.User{},
&models.EmailVerificationToken{},
&models.PasswordResetToken{},
)
}

82
internal/db/seed.go Normal file
View File

@@ -0,0 +1,82 @@
package db
import (
"fmt"
"trustcontact/internal/auth"
"trustcontact/internal/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func Seed(database *gorm.DB) error {
passwordHash, err := auth.HashPassword("password")
if err != nil {
return fmt.Errorf("hash seed password: %w", err)
}
seedUsers := []models.User{
{
Name: "Admin User",
Email: "admin@example.com",
Role: models.RoleAdmin,
EmailVerified: true,
PasswordHash: passwordHash,
},
{
Name: "Normal User",
Email: "user@example.com",
Role: models.RoleUser,
EmailVerified: true,
PasswordHash: passwordHash,
},
{
Name: "Demo One",
Email: "demo1@example.com",
Role: models.RoleUser,
EmailVerified: true,
PasswordHash: passwordHash,
},
{
Name: "Demo Two",
Email: "demo2@example.com",
Role: models.RoleUser,
EmailVerified: true,
PasswordHash: passwordHash,
},
{
Name: "Demo Three",
Email: "demo3@example.com",
Role: models.RoleUser,
EmailVerified: true,
PasswordHash: passwordHash,
},
}
for _, user := range seedUsers {
if err := upsertUser(database, user); err != nil {
return err
}
}
return nil
}
func upsertUser(database *gorm.DB, user models.User) error {
result := database.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}},
DoUpdates: clause.AssignmentColumns([]string{
"name",
"role",
"email_verified",
"password_hash",
"updated_at",
}),
}).Create(&user)
if result.Error != nil {
return fmt.Errorf("seed user %s: %w", user.Email, result.Error)
}
return nil
}

View File

@@ -0,0 +1,62 @@
package middleware
import (
"errors"
"trustcontact/internal/models"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/session"
)
func RequireAuth() fiber.Handler {
return func(c *fiber.Ctx) error {
if _, ok := CurrentUserFromContext(c); !ok {
return c.Redirect("/login")
}
return c.Next()
}
}
func RequireAdmin() fiber.Handler {
return func(c *fiber.Ctx) error {
user, ok := CurrentUserFromContext(c)
if !ok {
return c.Redirect("/login")
}
if user.Role != models.RoleAdmin {
return c.Status(fiber.StatusForbidden).SendString("forbidden")
}
return c.Next()
}
}
func SetSessionUserID(c *fiber.Ctx, userID uint) error {
store, ok := c.Locals(contextStoreKey).(*session.Store)
if !ok || store == nil {
return errors.New("session store not available")
}
sess, err := store.Get(c)
if err != nil {
return err
}
sess.Set(sessionUserIDKey, userID)
return sess.Save()
}
func ClearSessionUser(c *fiber.Ctx) error {
store, ok := c.Locals(contextStoreKey).(*session.Store)
if !ok || store == nil {
return errors.New("session store not available")
}
sess, err := store.Get(c)
if err != nil {
return err
}
sess.Delete(sessionUserIDKey)
return sess.Save()
}

View File

@@ -0,0 +1,132 @@
package middleware
import (
"fmt"
"strconv"
"trustcontact/internal/models"
"trustcontact/internal/repo"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/session"
"gorm.io/gorm"
)
const (
sessionUserIDKey = "user_id"
contextUserKey = "current_user"
contextStoreKey = "session_store"
contextTemplateKey = "template_data"
)
func SessionStoreMiddleware(store *session.Store) fiber.Handler {
return func(c *fiber.Ctx) error {
c.Locals(contextStoreKey, store)
return c.Next()
}
}
func CurrentUserMiddleware(store *session.Store, database *gorm.DB) fiber.Handler {
userRepo := repo.NewUserRepo(database)
return func(c *fiber.Ctx) error {
user, err := CurrentUser(c, store, userRepo)
if err != nil {
return err
}
c.Locals(contextUserKey, user)
setTemplateData(c, "CurrentUser", user)
return c.Next()
}
}
func CurrentUser(c *fiber.Ctx, store *session.Store, userRepo *repo.UserRepo) (*models.User, error) {
sess, err := store.Get(c)
if err != nil {
return nil, fmt.Errorf("get session: %w", err)
}
uidRaw := sess.Get(sessionUserIDKey)
uid, ok := normalizeUserID(uidRaw)
if !ok || uid == 0 {
return nil, nil
}
user, err := userRepo.FindByID(uid)
if err != nil {
return nil, fmt.Errorf("load current user: %w", err)
}
if user == nil {
sess.Delete(sessionUserIDKey)
if err := sess.Save(); err != nil {
return nil, fmt.Errorf("save session: %w", err)
}
return nil, nil
}
return user, nil
}
func CurrentUserFromContext(c *fiber.Ctx) (*models.User, bool) {
user, ok := c.Locals(contextUserKey).(*models.User)
if !ok || user == nil {
return nil, false
}
return user, true
}
func normalizeUserID(v any) (uint, bool) {
switch value := v.(type) {
case uint:
return value, true
case uint64:
return uint(value), true
case uint32:
return uint(value), true
case int:
if value <= 0 {
return 0, false
}
return uint(value), true
case int64:
if value <= 0 {
return 0, false
}
return uint(value), true
case int32:
if value <= 0 {
return 0, false
}
return uint(value), true
case string:
parsed, err := strconv.ParseUint(value, 10, 64)
if err != nil || parsed == 0 {
return 0, false
}
return uint(parsed), true
default:
return 0, false
}
}
func setTemplateData(c *fiber.Ctx, key string, value any) {
data := templateData(c)
data[key] = value
c.Locals(contextTemplateKey, data)
}
func SetTemplateData(c *fiber.Ctx, key string, value any) {
setTemplateData(c, key, value)
}
func templateData(c *fiber.Ctx) map[string]any {
existing, ok := c.Locals(contextTemplateKey).(map[string]any)
if ok && existing != nil {
return existing
}
fresh := make(map[string]any)
c.Locals(contextTemplateKey, fresh)
return fresh
}

View File

@@ -0,0 +1,70 @@
package middleware
import (
"errors"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/session"
)
const (
flashSuccessKey = "flash_success"
flashErrorKey = "flash_error"
)
func SetFlashSuccess(c *fiber.Ctx, message string) error {
return setFlash(c, flashSuccessKey, message)
}
func SetFlashError(c *fiber.Ctx, message string) error {
return setFlash(c, flashErrorKey, message)
}
func ConsumeFlash() fiber.Handler {
return func(c *fiber.Ctx) error {
store, ok := c.Locals(contextStoreKey).(*session.Store)
if !ok || store == nil {
return errors.New("session store not available")
}
sess, err := store.Get(c)
if err != nil {
return err
}
success, _ := sess.Get(flashSuccessKey).(string)
errMsg, _ := sess.Get(flashErrorKey).(string)
sess.Delete(flashSuccessKey)
sess.Delete(flashErrorKey)
if err := sess.Save(); err != nil {
return err
}
if success != "" {
setTemplateData(c, "FlashSuccess", success)
}
if errMsg != "" {
setTemplateData(c, "FlashError", errMsg)
}
c.Locals("flash_success", success)
c.Locals("flash_error", errMsg)
return c.Next()
}
}
func setFlash(c *fiber.Ctx, key, message string) error {
store, ok := c.Locals(contextStoreKey).(*session.Store)
if !ok || store == nil {
return errors.New("session store not available")
}
sess, err := store.Get(c)
if err != nil {
return err
}
sess.Set(key, message)
return sess.Save()
}

65
internal/http/router.go Normal file
View File

@@ -0,0 +1,65 @@
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, cfg *config.Config) error {
app.Static("/static", "web/static")
app.Use(httpmw.SessionStoreMiddleware(store))
app.Use(httpmw.CurrentUserMiddleware(store, database))
app.Use(httpmw.ConsumeFlash())
app.Use(func(c *fiber.Ctx) error {
httpmw.SetTemplateData(c, "BuildHash", cfg.BuildHash)
return c.Next()
})
authService, err := services.NewAuthService(database, cfg)
if err != nil {
return fmt.Errorf("init auth service: %w", err)
}
authController := controllers.NewAuthController(authService)
usersService := services.NewUsersService(database)
usersController := controllers.NewUsersController(usersService)
adminController := controllers.NewAdminController()
app.Get("/healthz", func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
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)
app.Get("/welcome", httpmw.RequireAuth(), authController.ShowWelcome)
private := app.Group("/private", httpmw.RequireAuth(), httpmw.RequireAdmin())
private.Get("/", func(c *fiber.Ctx) error {
return c.Redirect("/admin/users")
})
admin := app.Group("/admin", httpmw.RequireAuth(), httpmw.RequireAdmin())
admin.Get("/", adminController.Dashboard)
admin.Get("/users", usersController.Index)
admin.Get("/users/table", usersController.Table)
admin.Get("/users/:id/modal", usersController.Modal)
return nil
}

19
internal/mailer/mailer.go Normal file
View File

@@ -0,0 +1,19 @@
package mailer
import (
"context"
"trustcontact/internal/config"
)
type Mailer interface {
Send(ctx context.Context, to, subject, htmlBody, textBody string) error
}
func NewMailer(cfg *config.Config) (Mailer, error) {
if cfg.Env == config.EnvDevelop {
return NewSinkMailer(cfg.EmailSinkDir)
}
return NewSMTPMailer(cfg)
}

64
internal/mailer/sink.go Normal file
View File

@@ -0,0 +1,64 @@
package mailer
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
var sinkFileSafe = regexp.MustCompile(`[^a-zA-Z0-9@._-]+`)
type SinkMailer struct {
dir string
}
func NewSinkMailer(dir string) (*SinkMailer, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create sink dir: %w", err)
}
return &SinkMailer{dir: dir}, nil
}
func (m *SinkMailer) Send(ctx context.Context, to, subject, htmlBody, textBody string) error {
if ctx != nil {
if err := ctx.Err(); err != nil {
return err
}
}
ts := time.Now().UTC().Format("20060102T150405.000000000Z")
safeTo := sanitizeRecipient(to)
base := fmt.Sprintf("%s__email__%s", ts, safeTo)
emlPath := filepath.Join(m.dir, base+".eml")
textPath := filepath.Join(m.dir, base+".txt")
htmlPath := filepath.Join(m.dir, base+".html")
emlContent := fmt.Sprintf("Subject: %s\nTo: %s\nDate: %s\n\nTEXT:\n%s\n\nHTML:\n%s\n", subject, to, time.Now().UTC().Format(time.RFC3339), textBody, htmlBody)
if err := os.WriteFile(emlPath, []byte(emlContent), 0o644); err != nil {
return fmt.Errorf("write sink eml: %w", err)
}
if err := os.WriteFile(textPath, []byte(textBody), 0o644); err != nil {
return fmt.Errorf("write sink text: %w", err)
}
if err := os.WriteFile(htmlPath, []byte(htmlBody), 0o644); err != nil {
return fmt.Errorf("write sink html: %w", err)
}
return nil
}
func sanitizeRecipient(to string) string {
clean := strings.TrimSpace(to)
if clean == "" {
return "unknown"
}
return sinkFileSafe.ReplaceAllString(clean, "_")
}

102
internal/mailer/smtp.go Normal file
View File

@@ -0,0 +1,102 @@
package mailer
import (
"context"
"fmt"
"net"
"net/mail"
"net/smtp"
"strconv"
"strings"
"time"
"trustcontact/internal/config"
)
type SMTPMailer struct {
host string
port int
username string
password string
from string
fromName string
}
func NewSMTPMailer(cfg *config.Config) (*SMTPMailer, error) {
if strings.TrimSpace(cfg.SMTP.Host) == "" {
return nil, fmt.Errorf("smtp host is required")
}
if cfg.SMTP.Port <= 0 {
return nil, fmt.Errorf("smtp port must be > 0")
}
if strings.TrimSpace(cfg.SMTP.From) == "" {
return nil, fmt.Errorf("smtp from is required")
}
return &SMTPMailer{
host: cfg.SMTP.Host,
port: cfg.SMTP.Port,
username: cfg.SMTP.Username,
password: cfg.SMTP.Password,
from: cfg.SMTP.From,
fromName: cfg.SMTP.FromName,
}, nil
}
func (m *SMTPMailer) Send(ctx context.Context, to, subject, htmlBody, textBody string) error {
if ctx != nil {
if err := ctx.Err(); err != nil {
return err
}
}
addr := net.JoinHostPort(m.host, strconv.Itoa(m.port))
msg := m.buildMessage(to, subject, htmlBody, textBody)
var auth smtp.Auth
if m.username != "" {
auth = smtp.PlainAuth("", m.username, m.password, m.host)
}
if err := smtp.SendMail(addr, auth, m.from, []string{to}, []byte(msg)); err != nil {
return err
}
if ctx != nil {
if err := ctx.Err(); err != nil {
return err
}
}
return nil
}
func (m *SMTPMailer) buildMessage(to, subject, htmlBody, textBody string) string {
boundary := fmt.Sprintf("mixed-%d", time.Now().UTC().UnixNano())
fromAddr := (&mail.Address{Name: m.fromName, Address: m.from}).String()
toAddr := (&mail.Address{Address: to}).String()
headers := []string{
fmt.Sprintf("From: %s", fromAddr),
fmt.Sprintf("To: %s", toAddr),
fmt.Sprintf("Subject: %s", subject),
"MIME-Version: 1.0",
fmt.Sprintf("Content-Type: multipart/alternative; boundary=%q", boundary),
"",
}
parts := []string{
fmt.Sprintf("--%s", boundary),
"Content-Type: text/plain; charset=UTF-8",
"",
textBody,
fmt.Sprintf("--%s", boundary),
"Content-Type: text/html; charset=UTF-8",
"",
htmlBody,
fmt.Sprintf("--%s--", boundary),
"",
}
return strings.Join(append(headers, parts...), "\r\n")
}

View File

@@ -0,0 +1,94 @@
package mailer
import (
"bytes"
htmltemplate "html/template"
"os"
"path/filepath"
texttemplate "text/template"
)
const defaultEmailTemplateDir = "web/emails/templates"
type TemplateData struct {
AppName string
BaseURL string
VerifyURL string
ResetURL string
UserEmail string
}
type TemplateRenderer struct {
templatesDir string
}
func NewTemplateRenderer(templatesDir string) *TemplateRenderer {
if templatesDir == "" {
templatesDir = defaultEmailTemplateDir
}
return &TemplateRenderer{templatesDir: templatesDir}
}
func (r *TemplateRenderer) RenderVerifyEmail(data TemplateData) (htmlBody string, textBody string, err error) {
return r.render("verify_email", data)
}
func (r *TemplateRenderer) RenderResetPassword(data TemplateData) (htmlBody string, textBody string, err error) {
return r.render("reset_password", data)
}
func (r *TemplateRenderer) render(baseName string, data TemplateData) (string, string, error) {
htmlPath := filepath.Join(r.templatesDir, baseName+".html")
textPath := filepath.Join(r.templatesDir, baseName+".txt")
htmlBody, err := renderHTMLFile(htmlPath, data)
if err != nil {
return "", "", err
}
textBody, err := renderTextFile(textPath, data)
if err != nil {
return "", "", err
}
return htmlBody, textBody, nil
}
func renderHTMLFile(path string, data TemplateData) (string, error) {
content, err := os.ReadFile(path)
if err != nil {
return "", err
}
tmpl, err := htmltemplate.New(filepath.Base(path)).Parse(string(content))
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
func renderTextFile(path string, data TemplateData) (string, error) {
content, err := os.ReadFile(path)
if err != nil {
return "", err
}
tmpl, err := texttemplate.New(filepath.Base(path)).Parse(string(content))
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}

View File

@@ -0,0 +1,21 @@
package models
import "time"
type EmailVerificationToken struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"not null;index"`
TokenHash string `gorm:"size:64;uniqueIndex;not null"`
ExpiresAt time.Time `gorm:"not null;index"`
CreatedAt time.Time
UpdatedAt time.Time
}
type PasswordResetToken struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"not null;index"`
TokenHash string `gorm:"size:64;uniqueIndex;not null"`
ExpiresAt time.Time `gorm:"not null;index"`
CreatedAt time.Time
UpdatedAt time.Time
}

19
internal/models/user.go Normal file
View File

@@ -0,0 +1,19 @@
package models
import "time"
const (
RoleAdmin = "admin"
RoleUser = "user"
)
type User struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:120;index"`
Email string `gorm:"size:320;uniqueIndex;not null"`
PasswordHash string `gorm:"size:255;not null"`
EmailVerified bool `gorm:"not null;default:false"`
Role string `gorm:"size:32;index;not null;default:user"`
CreatedAt time.Time
UpdatedAt time.Time
}

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
}

129
internal/repo/user_repo.go Normal file
View File

@@ -0,0 +1,129 @@
package repo
import (
"errors"
"fmt"
"strings"
"trustcontact/internal/models"
"gorm.io/gorm"
)
type UserRepo struct {
db *gorm.DB
}
type UserListParams struct {
Query string
Sort string
Dir string
Page int
PageSize int
}
func NewUserRepo(db *gorm.DB) *UserRepo {
return &UserRepo{db: db}
}
func (r *UserRepo) FindByID(id uint) (*models.User, error) {
var user models.User
if err := r.db.First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
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
}
func (r *UserRepo) List(params UserListParams) ([]models.User, int64, error) {
query := r.db.Model(&models.User{})
search := strings.TrimSpace(params.Query)
if search != "" {
like := "%" + strings.ToLower(search) + "%"
query = query.Where("LOWER(name) LIKE ? OR LOWER(email) LIKE ?", like, like)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
orderBy := sanitizeSort(params.Sort)
orderDir := sanitizeDir(params.Dir)
orderClause := fmt.Sprintf("%s %s", orderBy, orderDir)
page := params.Page
if page < 1 {
page = 1
}
pageSize := params.PageSize
if pageSize <= 0 {
pageSize = 10
}
if pageSize > 100 {
pageSize = 100
}
offset := (page - 1) * pageSize
var users []models.User
if err := query.Order(orderClause).Limit(pageSize).Offset(offset).Find(&users).Error; err != nil {
return nil, 0, err
}
return users, total, nil
}
func sanitizeSort(sort string) string {
switch strings.ToLower(strings.TrimSpace(sort)) {
case "id":
return "id"
case "name":
return "name"
case "email":
return "email"
default:
return "id"
}
}
func sanitizeDir(dir string) string {
switch strings.ToLower(strings.TrimSpace(dir)) {
case "desc":
return "desc"
default:
return "asc"
}
}

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

@@ -0,0 +1,132 @@
package services
import (
"strings"
"trustcontact/internal/models"
"trustcontact/internal/repo"
"gorm.io/gorm"
)
type UsersService struct {
users *repo.UserRepo
}
type UsersQuery struct {
Q string
Sort string
Dir string
Page int
PageSize int
}
type UsersPage struct {
Users []models.User
Total int64
Page int
PageSize int
TotalPages int
HasPrev bool
HasNext bool
PrevPage int
NextPage int
Sort string
Dir string
Q string
}
func NewUsersService(database *gorm.DB) *UsersService {
return &UsersService{users: repo.NewUserRepo(database)}
}
func (s *UsersService) List(query UsersQuery) (*UsersPage, error) {
page := query.Page
if page < 1 {
page = 1
}
pageSize := query.PageSize
if pageSize <= 0 {
pageSize = 10
}
if pageSize > 100 {
pageSize = 100
}
sort := normalizeSort(query.Sort)
dir := normalizeDir(query.Dir)
users, total, err := s.users.List(repo.UserListParams{
Query: strings.TrimSpace(query.Q),
Sort: sort,
Dir: dir,
Page: page,
PageSize: pageSize,
})
if err != nil {
return nil, err
}
totalPages := 0
if total > 0 {
totalPages = int((total + int64(pageSize) - 1) / int64(pageSize))
}
if totalPages > 0 && page > totalPages {
page = totalPages
users, total, err = s.users.List(repo.UserListParams{
Query: strings.TrimSpace(query.Q),
Sort: sort,
Dir: dir,
Page: page,
PageSize: pageSize,
})
if err != nil {
return nil, err
}
}
hasPrev := page > 1
hasNext := totalPages > 0 && page < totalPages
return &UsersPage{
Users: users,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
HasPrev: hasPrev,
HasNext: hasNext,
PrevPage: max(1, page-1),
NextPage: page + 1,
Sort: sort,
Dir: dir,
Q: strings.TrimSpace(query.Q),
}, nil
}
func (s *UsersService) GetByID(id uint) (*models.User, error) {
return s.users.FindByID(id)
}
func normalizeSort(sort string) string {
switch strings.ToLower(strings.TrimSpace(sort)) {
case "id", "name", "email":
return strings.ToLower(strings.TrimSpace(sort))
default:
return "id"
}
}
func normalizeDir(dir string) string {
if strings.ToLower(strings.TrimSpace(dir)) == "desc" {
return "desc"
}
return "asc"
}
func max(a, b int) int {
if a > b {
return a
}
return b
}

13
temp.html Normal file
View File

@@ -0,0 +1,13 @@
<nav class="flex items-center justify-between px-6 md:px-16 lg:px-24 xl:px-32 py-4 border-b border-gray-300 bg-white relative transition-all">
<a href="https://prebuiltui.com">
<img class="h-9" src="https://raw.githubusercontent.com/prebuiltui/prebuiltui/main/assets/dummyLogo/dummyLogoColored.svg" alt="dummyLogoColored">
</a>
<div class="hidden sm:flex items-center gap-8">
<button class="cursor-pointer px-8 py-2 bg-indigo-500 hover:bg-indigo-600 transition text-white rounded-full">
Login
</button>
</div>
</nav>

15
ui-kit/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UI Kit Dev</title>
</head>
<body>
<h1>UI Kit Dev</h1>
<ui-data-table-shell endpoint="/users/table" target="#target" page-size="10"></ui-data-table-shell>
<div id="target"></div>
<script type="module" src="/src/index.ts"></script>
</body>
</html>

2373
ui-kit/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
ui-kit/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "trustcontact-ui-kit",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"css:build": "tailwindcss -c tailwind.config.cjs -i ./styles/tailwind.css -o ../web/static/css/app.css --minify",
"css:dev": "tailwindcss -c tailwind.config.cjs -i ./styles/tailwind.css -o ../web/static/css/app.css --watch"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"svelte": "^5.0.0",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.0",
"vite": "^5.4.0"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

19
ui-kit/src/base.css Normal file
View File

@@ -0,0 +1,19 @@
:root {
--ui-bg: #ffffff;
--ui-fg: #111827;
--ui-muted: #6b7280;
--ui-border: #d1d5db;
--ui-overlay: rgba(17, 24, 39, 0.56);
--ui-panel: #ffffff;
--ui-radius: 10px;
--ui-shadow: 0 10px 35px rgba(15, 23, 42, 0.2);
--ui-primary: #111827;
--ui-primary-contrast: #ffffff;
}
ui-modal,
ui-drop-down,
ui-data-table-shell {
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
color: var(--ui-fg);
}

View File

@@ -0,0 +1,74 @@
<svelte:options customElement="ui-data-table-shell" />
<script lang="ts">
export let endpoint = '';
export let target = '';
export let pageSize = 10;
let query = '';
function submit(page = 1) {
if (!endpoint || !target) return;
const params = new URLSearchParams();
if (query.trim()) params.set('q', query.trim());
params.set('page', String(page));
params.set('pageSize', String(pageSize));
const url = `${endpoint}${endpoint.includes('?') ? '&' : '?'}${params.toString()}`;
const htmxApi = (window as any).htmx;
if (htmxApi && typeof htmxApi.ajax === 'function') {
htmxApi.ajax('GET', url, { target });
return;
}
const targetEl = document.querySelector(target);
if (!targetEl) return;
fetch(url)
.then((res) => res.text())
.then((html) => {
targetEl.innerHTML = html;
})
.catch(() => {
// no-op fallback
});
}
</script>
<form class="toolbar" on:submit|preventDefault={() => submit(1)}>
<input
type="search"
placeholder="Search..."
bind:value={query}
on:keydown={(e) => e.key === 'Enter' && submit(1)}
/>
<button type="submit">Search</button>
</form>
<style>
.toolbar {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
}
input[type='search'] {
flex: 1;
min-width: 180px;
padding: 10px;
border: 1px solid var(--ui-border);
border-radius: 8px;
}
button {
border: 0;
border-radius: 8px;
background: var(--ui-primary);
color: var(--ui-primary-contrast);
padding: 10px 12px;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,153 @@
<svelte:options customElement="ui-drop-down" />
<script lang="ts">
import { onMount } from 'svelte';
export let value = '';
export let name = '';
export let placeholder = 'Select...';
export let disabled = false;
let rootEl: HTMLElement;
let host: HTMLElement;
let hiddenInput: HTMLInputElement;
let open = false;
type OptionItem = { value: string; label: string };
let options: OptionItem[] = [];
function loadOptions() {
options = Array.from(host.querySelectorAll('option')).map((opt) => ({
value: opt.value,
label: opt.textContent?.trim() || opt.value
}));
if (!value) {
const selected = host.querySelector('option[selected]') as HTMLOptionElement | null;
if (selected) value = selected.value;
}
}
function selectedLabel() {
if (!value) return placeholder;
return options.find((o) => o.value === value)?.label || placeholder;
}
function selectOption(nextValue: string) {
value = nextValue;
open = false;
syncHiddenInput();
host.dispatchEvent(new Event('change', { bubbles: true }));
host.dispatchEvent(
new CustomEvent('ui:change', {
detail: { value },
bubbles: true,
composed: true
})
);
}
function syncHiddenInput() {
if (!hiddenInput) return;
hiddenInput.name = name;
hiddenInput.value = value;
hiddenInput.disabled = disabled || !name;
}
onMount(() => {
host = (rootEl.getRootNode() as ShadowRoot).host as HTMLElement;
if (!host) return;
hiddenInput = host.querySelector('input[data-ui-dropdown-hidden="true"]') as HTMLInputElement;
if (!hiddenInput) {
hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.setAttribute('data-ui-dropdown-hidden', 'true');
host.appendChild(hiddenInput);
}
loadOptions();
syncHiddenInput();
const observer = new MutationObserver(() => {
loadOptions();
syncHiddenInput();
});
observer.observe(host, { childList: true, subtree: true, attributes: true });
return () => observer.disconnect();
});
</script>
<div class="dropdown" bind:this={rootEl}>
<button type="button" class="trigger" disabled={disabled} on:click={() => (open = !open)}>
<span>{selectedLabel()}</span>
<span aria-hidden="true"></span>
</button>
{#if open}
<div class="menu" role="listbox">
{#if options.length === 0}
<div class="empty">No options</div>
{:else}
{#each options as opt}
<button type="button" class="item" on:click={() => selectOption(opt.value)}>
{opt.label}
</button>
{/each}
{/if}
</div>
{/if}
</div>
<style>
.dropdown {
position: relative;
display: inline-block;
min-width: 180px;
}
.trigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-bg);
cursor: pointer;
}
.menu {
position: absolute;
z-index: 30;
left: 0;
right: 0;
margin-top: 6px;
border: 1px solid var(--ui-border);
background: var(--ui-bg);
border-radius: 8px;
box-shadow: var(--ui-shadow);
overflow: hidden;
}
.item {
width: 100%;
text-align: left;
border: 0;
background: transparent;
padding: 10px 12px;
cursor: pointer;
}
.item:hover {
background: #f3f4f6;
}
.empty {
padding: 10px 12px;
color: var(--ui-muted);
}
</style>

View File

@@ -0,0 +1,156 @@
<svelte:options customElement="ui-modal" />
<script lang="ts">
import { onMount } from 'svelte';
export let title = '';
let rootEl: HTMLElement;
let panelEl: HTMLElement;
let hostEl: HTMLElement;
function isOpen(): boolean {
return !!hostEl?.hasAttribute('open');
}
function closeModal() {
hostEl?.removeAttribute('open');
hostEl?.dispatchEvent(
new CustomEvent('ui:close', { bubbles: true, composed: true })
);
}
function onKeyDown(event: KeyboardEvent) {
if (!isOpen()) return;
if (event.key === 'Escape') {
event.preventDefault();
closeModal();
return;
}
if (event.key === 'Tab') {
trapFocus(event);
}
}
function trapFocus(event: KeyboardEvent) {
const focusables = panelEl?.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
);
if (!focusables || focusables.length === 0) {
event.preventDefault();
panelEl?.focus();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement as HTMLElement | null;
if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
return;
}
if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
}
function onBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
closeModal();
}
}
onMount(() => {
hostEl = (rootEl.getRootNode() as ShadowRoot).host as HTMLElement;
const observer = new MutationObserver(() => {
if (isOpen()) {
setTimeout(() => {
const autofocus = panelEl?.querySelector<HTMLElement>('[autofocus]');
(autofocus || panelEl)?.focus();
}, 0);
}
});
observer.observe(hostEl, { attributes: true, attributeFilter: ['open'] });
if (isOpen()) {
setTimeout(() => {
(panelEl as HTMLElement | undefined)?.focus();
}, 0);
}
return () => observer.disconnect();
});
</script>
<div class="overlay" on:click={onBackdropClick} on:keydown={onKeyDown} role="presentation" bind:this={rootEl}>
<div class="panel" role="dialog" aria-modal="true" tabindex="-1" bind:this={panelEl}>
<header class="header">
<h3>{title}</h3>
<button type="button" class="close" on:click={closeModal} aria-label="Close">×</button>
</header>
<section class="body">
<slot />
</section>
</div>
</div>
<style>
:host {
display: block;
}
.overlay {
position: fixed;
inset: 0;
background: var(--ui-overlay);
display: none;
place-items: center;
z-index: 1000;
}
:host([open]) .overlay {
display: grid;
}
.panel {
width: min(640px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
overflow: auto;
background: var(--ui-panel);
border-radius: var(--ui-radius);
box-shadow: var(--ui-shadow);
outline: none;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--ui-border);
}
h3 {
margin: 0;
font-size: 1rem;
}
.close {
border: 0;
background: transparent;
font-size: 1.4rem;
cursor: pointer;
}
.body {
padding: 12px 16px 16px;
}
</style>

6
ui-kit/src/index.ts Normal file
View File

@@ -0,0 +1,6 @@
import './base.css';
import './components/UiModal.svelte';
import './components/UiDropDown.svelte';
import './components/UiDataTableShell.svelte';
export {};

View File

@@ -0,0 +1,31 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply m-0 bg-slate-100 text-slate-800;
}
a {
@apply text-slate-800;
}
}
@layer components {
.row {
@apply flex flex-wrap gap-2;
}
.muted {
@apply text-sm text-slate-500;
}
.btn-primary {
@apply rounded-lg bg-slate-900 px-4 py-2 text-white hover:bg-slate-700;
}
.input-base {
@apply rounded-lg border border-slate-300 px-3 py-2;
}
}

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/**/*.{svelte,ts,js}',
'../web/templates/**/*.html'
],
theme: {
extend: {}
},
plugins: []
};

11
ui-kit/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src/**/*", "vite.config.ts"]
}

32
ui-kit/vite.config.ts Normal file
View File

@@ -0,0 +1,32 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [
svelte({
compilerOptions: {
customElement: true
}
})
],
build: {
outDir: '../web/static/ui',
emptyOutDir: true,
cssCodeSplit: false,
lib: {
entry: 'src/index.ts',
formats: ['es'],
fileName: () => 'ui.esm.js'
},
rollupOptions: {
output: {
assetFileNames: (assetInfo) => {
if (assetInfo.name === 'style.css') {
return 'ui.css';
}
return '[name][extname]';
}
}
}
}
});

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="it">
<body style="font-family: Arial, sans-serif; line-height: 1.5; color: #111;">
<p>Ciao {{.UserEmail}},</p>
<p>abbiamo ricevuto una richiesta di reset password per <strong>{{.AppName}}</strong>.</p>
<p>Usa questo link per impostare una nuova password:</p>
<p><a href="{{.ResetURL}}">Reset password</a></p>
<p>Se non hai richiesto questa operazione, ignora questa email.</p>
<hr>
<p>{{.AppName}}<br><a href="{{.BaseURL}}">{{.BaseURL}}</a></p>
</body>
</html>

View File

@@ -0,0 +1,10 @@
Ciao {{.UserEmail}},
abbiamo ricevuto una richiesta di reset password per {{.AppName}}.
Usa questo link per impostare una nuova password:
{{.ResetURL}}
Se non hai richiesto questa operazione, ignora questa email.
{{.AppName}}
{{.BaseURL}}

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="it">
<body style="font-family: Arial, sans-serif; line-height: 1.5; color: #111;">
<p>Ciao {{.UserEmail}},</p>
<p>benvenuto su <strong>{{.AppName}}</strong>.</p>
<p>Per verificare la tua email clicca qui:</p>
<p><a href="{{.VerifyURL}}">Verifica email</a></p>
<p>Se non hai richiesto questa operazione, puoi ignorare questo messaggio.</p>
<hr>
<p>{{.AppName}}<br><a href="{{.BaseURL}}">{{.BaseURL}}</a></p>
</body>
</html>

View File

@@ -0,0 +1,10 @@
Ciao {{.UserEmail}},
benvenuto su {{.AppName}}.
Per verificare la tua email visita questo link:
{{.VerifyURL}}
Se non hai richiesto questa operazione, puoi ignorare questo messaggio.
{{.AppName}}
{{.BaseURL}}

1116
web/static/css/app.css Normal file

File diff suppressed because it is too large Load Diff

View File

1
web/static/ui/ui.css Normal file
View File

@@ -0,0 +1 @@
:root{--ui-bg: #ffffff;--ui-fg: #111827;--ui-muted: #6b7280;--ui-border: #d1d5db;--ui-overlay: rgba(17, 24, 39, .56);--ui-panel: #ffffff;--ui-radius: 10px;--ui-shadow: 0 10px 35px rgba(15, 23, 42, .2);--ui-primary: #111827;--ui-primary-contrast: #ffffff}ui-modal,ui-drop-down,ui-data-table-shell{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:var(--ui-fg)}

3251
web/static/ui/ui.esm.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
{{define "content"}}
<div class="space-y-3">
<h1 class="text-2xl font-semibold">Admin Dashboard</h1>
<p class="muted">Area amministrazione.</p>
<div class="row">
<a href="/admin/users" class="rounded-lg border border-slate-300 px-4 py-2 hover:bg-slate-50">Gestione utenti</a>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,13 @@
{{define "users_modal"}}
<div style="padding:16px;">
<h3 style="margin-top:0;">Dettaglio utente #{{.User.ID}}</h3>
<p><strong>Name:</strong> {{if .User.Name}}{{.User.Name}}{{else}}-{{end}}</p>
<p><strong>Email:</strong> {{.User.Email}}</p>
<p><strong>Role:</strong> {{.User.Role}}</p>
<p><strong>Verified:</strong> {{if .User.EmailVerified}}yes{{else}}no{{end}}</p>
<p><strong>Created:</strong> {{.User.CreatedAt}}</p>
<div class="row">
<button type="button" onclick="document.getElementById('userModal').removeAttribute('open')">Chiudi</button>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,47 @@
{{define "users_table"}}
{{ $p := .PageData }}
<table style="width:100%;border-collapse:collapse;margin-top:16px;">
<thead>
<tr>
<th style="text-align:left;border-bottom:1px solid #e5e7eb;padding:8px;">
<a href="#" hx-get="/admin/users/table?q={{$p.Q}}&sort=id&dir={{if and (eq $p.Sort "id") (eq $p.Dir "asc")}}desc{{else}}asc{{end}}&page=1&pageSize={{$p.PageSize}}" hx-target="#usersTableContainer" hx-swap="innerHTML">ID</a>
</th>
<th style="text-align:left;border-bottom:1px solid #e5e7eb;padding:8px;">
<a href="#" hx-get="/admin/users/table?q={{$p.Q}}&sort=name&dir={{if and (eq $p.Sort "name") (eq $p.Dir "asc")}}desc{{else}}asc{{end}}&page=1&pageSize={{$p.PageSize}}" hx-target="#usersTableContainer" hx-swap="innerHTML">Name</a>
</th>
<th style="text-align:left;border-bottom:1px solid #e5e7eb;padding:8px;">
<a href="#" hx-get="/admin/users/table?q={{$p.Q}}&sort=email&dir={{if and (eq $p.Sort "email") (eq $p.Dir "asc")}}desc{{else}}asc{{end}}&page=1&pageSize={{$p.PageSize}}" hx-target="#usersTableContainer" hx-swap="innerHTML">Email</a>
</th>
<th style="text-align:left;border-bottom:1px solid #e5e7eb;padding:8px;">Role</th>
<th style="text-align:left;border-bottom:1px solid #e5e7eb;padding:8px;">Azioni</th>
</tr>
</thead>
<tbody>
{{range $u := $p.Users}}
<tr>
<td style="border-bottom:1px solid #f1f5f9;padding:8px;">{{$u.ID}}</td>
<td style="border-bottom:1px solid #f1f5f9;padding:8px;">{{if $u.Name}}{{$u.Name}}{{else}}-{{end}}</td>
<td style="border-bottom:1px solid #f1f5f9;padding:8px;">{{$u.Email}}</td>
<td style="border-bottom:1px solid #f1f5f9;padding:8px;">{{$u.Role}}</td>
<td style="border-bottom:1px solid #f1f5f9;padding:8px;">
<button
hx-get="/admin/users/{{$u.ID}}/modal"
hx-target="#userModalContent"
hx-swap="innerHTML"
>Apri</button>
</td>
</tr>
{{else}}
<tr><td colspan="5" style="padding:12px;">Nessun utente trovato.</td></tr>
{{end}}
</tbody>
</table>
<div class="row" style="margin-top:12px;align-items:center;justify-content:space-between;">
<div class="muted">Totale: {{$p.Total}} utenti. Pagina {{$p.Page}}{{if gt $p.TotalPages 0}} / {{$p.TotalPages}}{{end}}</div>
<div class="row">
<button {{if not $p.HasPrev}}disabled{{end}} hx-get="/admin/users/table?q={{$p.Q}}&sort={{$p.Sort}}&dir={{$p.Dir}}&page={{$p.PrevPage}}&pageSize={{$p.PageSize}}" hx-target="#usersTableContainer" hx-swap="innerHTML">Prev</button>
<button {{if not $p.HasNext}}disabled{{end}} hx-get="/admin/users/table?q={{$p.Q}}&sort={{$p.Sort}}&dir={{$p.Dir}}&page={{$p.NextPage}}&pageSize={{$p.PageSize}}" hx-target="#usersTableContainer" hx-swap="innerHTML">Next</button>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,36 @@
{{define "content"}}
<h1>Users</h1>
<p class="muted">Ricerca, ordinamento e paging server-side via HTMX.</p>
<form id="usersFilters" class="row" hx-get="/admin/users/table" hx-target="#usersTableContainer" hx-swap="innerHTML">
<input type="text" name="q" placeholder="Cerca nome o email" value="{{.PageData.Q}}">
<input type="number" name="pageSize" min="1" max="100" value="{{.PageData.PageSize}}" style="max-width:120px;">
<input type="hidden" name="sort" value="{{.PageData.Sort}}">
<input type="hidden" name="dir" value="{{.PageData.Dir}}">
<input type="hidden" name="page" value="1">
<button type="submit">Cerca</button>
</form>
<div id="usersTableContainer" hx-get="/admin/users/table?q={{.PageData.Q}}&sort={{.PageData.Sort}}&dir={{.PageData.Dir}}&page={{.PageData.Page}}&pageSize={{.PageData.PageSize}}" hx-trigger="load" hx-swap="innerHTML">
{{template "users_table" .}}
</div>
<ui-modal id="userModal" title="Dettaglio utente">
<div
id="userModalContent"
hx-on:htmx:after-swap="document.getElementById('userModal').setAttribute('open','')"
></div>
</ui-modal>
<script>
(function () {
var modal = document.getElementById('userModal');
var content = document.getElementById('userModalContent');
if (!modal || !content || modal.dataset.closeBound === '1') return;
modal.dataset.closeBound = '1';
modal.addEventListener('ui:close', function () {
content.innerHTML = '';
});
})();
</script>
{{end}}

42
web/templates/layout.html Normal file
View File

@@ -0,0 +1,42 @@
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<link rel="stylesheet" href="/static/css/app.css?v={{.BuildHash}}">
<link rel="stylesheet" href="/static/ui/ui.css?v={{.BuildHash}}">
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<script type="module" src="/static/ui/ui.esm.js?v={{.BuildHash}}"></script>
</head>
<body>
<nav class="relative flex items-center justify-between border-b border-gray-300 bg-white px-6 py-4 transition-all md:px-16 lg:px-24 xl:px-32">
<a href="/" class="text-lg font-semibold text-slate-800">Trustcontact</a>
<div class="hidden items-center gap-8 sm:flex">
{{if and .CurrentUser (eq .CurrentUser.Role "admin")}}
<a href="/admin" class="text-slate-700 hover:text-slate-900 {{if eq .NavSection "admin"}}font-semibold{{end}}">Admin</a>
{{end}}
{{if .CurrentUser}}
<form action="/logout" method="post">
<button type="submit" class="cursor-pointer rounded-full bg-indigo-500 px-8 py-2 text-white transition hover:bg-indigo-600">
Logout
</button>
</form>
{{else}}
<a href="/login" class="cursor-pointer rounded-full bg-indigo-500 px-8 py-2 text-white transition hover:bg-indigo-600">
Login
</a>
{{end}}
</div>
</nav>
<div class="mx-auto my-5 max-w-5xl px-4">
{{template "_flash.html" .}}
<div class="rounded-xl bg-white p-5 shadow-sm">
{{template "content" .}}
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,16 @@
{{define "content"}}
<div class="space-y-5">
<div>
<h1 class="text-2xl font-semibold">Welcome</h1>
{{if .CurrentUser}}
<p class="muted">Bentornato {{if .CurrentUser.Name}}{{.CurrentUser.Name}}{{else}}{{.CurrentUser.Email}}{{end}}.</p>
{{else}}
<p class="muted">Benvenuto.</p>
{{end}}
</div>
{{if and .CurrentUser (ne .CurrentUser.Role "admin")}}
<p class="muted">Non hai privilegi admin.</p>
{{end}}
</div>
{{end}}

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,25 @@
{{define "content"}}
<div class="mx-auto w-full max-w-96 rounded-xl border border-gray-200 px-6 py-8">
<div class="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-amber-100">
<svg class="h-8 w-8 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 11c0 1.657-1.343 3-3 3S6 12.657 6 11s1.343-3 3-3 3 1.343 3 3zm0 0V9a4 4 0 118 0v2m-8 0h8m-8 0H4m16 0v8a2 2 0 01-2 2H6a2 2 0 01-2-2v-8"></path>
</svg>
</div>
<h3 class="mb-3 text-center text-xl font-bold text-gray-800">Forgot Password</h3>
<p class="mb-6 text-center text-sm text-gray-500">Inserisci la tua email. Se l'account esiste e risulta verificato, invieremo un link di reset.</p>
<form action="/forgot-password" method="post">
<div class="mb-6">
<label class="mb-1 block text-sm font-medium text-gray-700">Email</label>
<input type="email" name="email" value="{{.Email}}" class="w-full rounded-lg border border-gray-300 px-3 py-2 transition outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-500" required />
</div>
<button type="submit" class="w-full rounded-lg bg-amber-500 px-4 py-2 font-medium text-white transition duration-300 hover:bg-amber-600">Invia link reset</button>
<div class="mt-4 text-center">
<a href="/login" class="text-sm text-slate-600 hover:text-slate-800">Torna al login</a>
</div>
</form>
</div>
{{end}}

View File

@@ -0,0 +1,10 @@
{{define "content"}}
<div class="space-y-3">
<h1 class="text-2xl font-semibold">Trustcontact</h1>
<p class="muted">Accedi o registrati per continuare.</p>
<div class="row">
<a class="rounded-lg border border-slate-300 px-4 py-2 hover:bg-slate-50" href="/login">Accedi</a>
<a class="rounded-lg border border-slate-300 px-4 py-2 hover:bg-slate-50" href="/signup">Registrati</a>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,32 @@
{{define "content"}}
<div class="mx-auto w-full max-w-96 rounded-xl border border-gray-200 px-6 py-8">
<div class="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-blue-100">
<svg class="h-8 w-8 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
<h3 class="mb-6 text-center text-xl font-bold text-gray-800">Quick Login</h3>
<form action="/login" method="post">
<div class="mb-4">
<label class="mb-1 block text-sm font-medium text-gray-700">Email or Patient ID</label>
<input type="text" name="email" value="{{.Email}}" class="w-full rounded-lg border border-gray-300 px-3 py-2 transition outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500" required />
</div>
<div class="mb-6">
<label class="mb-1 block text-sm font-medium text-gray-700">Password</label>
<input type="password" name="password" class="w-full rounded-lg border border-gray-300 px-3 py-2 transition outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500" required />
</div>
<button type="submit" class="w-full rounded-lg bg-blue-500 px-4 py-2 font-medium text-white transition duration-300 hover:bg-blue-600">Sign In</button>
<div class="mt-4 text-center">
<a href="/forgot-password" class="text-sm text-blue-500 hover:text-blue-600">Forgot Password?</a>
</div>
<div class="mt-3 text-center">
<a href="/signup" class="text-sm text-slate-600 hover:text-slate-800">Non hai un account? Registrati</a>
</div>
</form>
</div>
{{end}}

View File

@@ -0,0 +1,24 @@
{{define "content"}}
<div class="mx-auto w-full max-w-96 rounded-xl border border-gray-200 px-6 py-8">
<div class="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-violet-100">
<svg class="h-8 w-8 text-violet-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 11V7a4 4 0 118 0v4m-8 0h8m-8 0H5m14 0v8a2 2 0 01-2 2H7a2 2 0 01-2-2v-8"></path>
</svg>
</div>
<h3 class="mb-6 text-center text-xl font-bold text-gray-800">Reset Password</h3>
{{if .Token}}
<form action="/reset-password?token={{.Token}}" method="post">
<div class="mb-6">
<label class="mb-1 block text-sm font-medium text-gray-700">Nuova password</label>
<input type="password" name="password" class="w-full rounded-lg border border-gray-300 px-3 py-2 transition outline-none focus:border-violet-500 focus:ring-2 focus:ring-violet-500" required />
</div>
<button type="submit" class="w-full rounded-lg bg-violet-500 px-4 py-2 font-medium text-white transition duration-300 hover:bg-violet-600">Aggiorna password</button>
</form>
{{else}}
<p class="text-center text-sm text-gray-500">Token mancante o non valido.</p>
{{end}}
</div>
{{end}}

View File

@@ -0,0 +1,29 @@
{{define "content"}}
<div class="mx-auto w-full max-w-96 rounded-xl border border-gray-200 px-6 py-8">
<div class="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100">
<svg class="h-8 w-8 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3M5 7h8M5 11h4m1 10h8a2 2 0 002-2V5a2 2 0 00-2-2H6a2 2 0 00-2 2v14a2 2 0 002 2h4z"></path>
</svg>
</div>
<h3 class="mb-6 text-center text-xl font-bold text-gray-800">Create Account</h3>
<form action="/signup" method="post">
<div class="mb-4">
<label class="mb-1 block text-sm font-medium text-gray-700">Email</label>
<input type="email" name="email" value="{{.Email}}" class="w-full rounded-lg border border-gray-300 px-3 py-2 transition outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500" required />
</div>
<div class="mb-6">
<label class="mb-1 block text-sm font-medium text-gray-700">Password</label>
<input type="password" name="password" class="w-full rounded-lg border border-gray-300 px-3 py-2 transition outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500" required />
</div>
<button type="submit" class="w-full rounded-lg bg-emerald-500 px-4 py-2 font-medium text-white transition duration-300 hover:bg-emerald-600">Sign Up</button>
<div class="mt-4 text-center">
<a href="/login" class="text-sm text-slate-600 hover:text-slate-800">Hai già un account? Accedi</a>
</div>
</form>
</div>
{{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}}