Compare commits

..

23 Commits

Author SHA1 Message Date
fabio
2dd819444e wc 2026-03-02 19:44:24 +01:00
fabio
25f4701b54 aggiunto il link home 2026-03-01 20:50:02 +01:00
fabio
66a3cc7cdb aggiunto e testato quasar apps 2026-03-01 20:42:27 +01:00
fabio
cdcacadb5f quasar admin setup 2026-03-01 17:51:00 +01:00
fabio
6d5d58581e aggiunto le due sezioni quasar private e admin 2026-03-01 17:44:45 +01:00
fabio
675264f26a eliminato ui-kit 2026-03-01 17:20:29 +01:00
fabio
a5dda58555 eliminato flowbit-ui 2026-03-01 17:19:23 +01:00
fabio
b852f656d4 stato intermedio 2026-03-01 14:36:26 +01:00
fabio
e0ef48f6fd dark mode 2026-02-23 17:31:06 +01:00
fabio
fce8b9d4bb corretto uso di partials 2026-02-23 17:20:17 +01:00
fabio
2552b8ad8f genarato alcuni partials e corretto drop down 2026-02-23 17:09:35 +01:00
fabio
3fc01cc4f7 aggiornato per uso di taiwind 2026-02-23 13:46:44 +01:00
fabio
275d3df3f1 some docs 2026-02-22 20:36:47 +01:00
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
155 changed files with 18950 additions and 389 deletions

16
.gitignore vendored
View File

@@ -19,12 +19,22 @@ 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/
# Root JS deps
node_modules/

27
Makefile Normal file
View File

@@ -0,0 +1,27 @@
.PHONY: tw-build tw-watch htmx-copy flags-copy assets server test db-reset fmt
tw-build:
npm run tw:build
tw-watch:
npm run tw:watch
htmx-copy:
mkdir -p web/static/vendor && cp node_modules/htmx.org/dist/htmx.min.js web/static/vendor/htmx.min.js
flags-copy:
mkdir -p web/static/vendor/flags && cp assets/flags/*.svg web/static/vendor/flags/
assets: htmx-copy flags-copy tw-build
server:
go run ./cmd/server
test:
go test ./...
db-reset:
rm -f ./data/app.db ./data/app.sqlite3
fmt:
gofmt -w $$(find ./cmd ./internal -type f -name '*.go')

159
README.md
View File

@@ -1,69 +1,106 @@
# GoFiber MVC Boilerplate
Boilerplate riusabile per:
Boilerplate GoFiber MVC + HTMX + Flowbite + 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`
## Setup Assets + Server
## Struttura iniziale
Terminale 1:
```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
npm i
make assets
make tw-watch
```
## TODO Checklist
Terminale 2:
- [ ] 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.
```bash
make server
```
Admin SPA (Quasar):
- il backend serve `quasar/admin_section/dist/spa` sotto `/admin` (protetto da auth + ruolo admin)
- build frontend admin: `cd quasar/admin_section && npm i && npm run build`
Private SPA (Quasar):
- il backend serve `quasar/private_section/dist/spa` sotto `/private` (protetto da auth)
- build frontend private: `cd quasar/private_section && npm i && npm run build`
`make assets` esegue:
- copia di `node_modules/flowbite/dist/flowbite.min.js` in `web/static/vendor/flowbite.js`
- build Tailwind in `web/static/css/app.css`
Dark mode globale:
- toggle nel footer su tutte le pagine
- preferenza persistente in `localStorage` con chiave `theme` (`dark`/`light`)
- fallback automatico a `prefers-color-scheme` quando non c'e una preferenza salvata
## Quickstart SQLite
```bash
cp .env.example .env
npm i
make assets
make server
```
Default SQLite path: `./data/app.sqlite3`.
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.
## Template Directories
- Public: `web/templates/public`
- Private: `quasar/private_section/dist/spa` (SPA servita da Go sotto `/private`)
- Admin: `quasar/admin_section/dist/spa` (SPA servita da Go sotto `/admin`)
## Email in Develop
In `develop`, le email vengono salvate in `./data/emails`.
## Make Targets
- `make tw-build` -> build Tailwind CSS
- `make tw-watch` -> watch Tailwind CSS
- `make flowbite-copy` -> copia `node_modules/flowbite/dist/flowbite.min.js` in `web/static/vendor/flowbite.js`
- `make assets` -> `flowbite-copy` + `tw-build`
- `make server` -> `go run ./cmd/server`
- `make test` -> `go test ./...`
- `make db-reset` -> reset DB sqlite locale (`./data/app.db` / `./data/app.sqlite3`)
- `make fmt` -> `gofmt` su `cmd/` e `internal/`
# Third-Party Notices
This project uses third-party software distributed under the MIT License.
## Flowbite
- Package: `flowbite`
- License: MIT
- Upstream: https://github.com/themesberg/flowbite
- Full text: `licenses/FLOWBITE-MIT.txt`
## Tailwind CSS
- Packages: `tailwindcss`, `@tailwindcss/cli`
- License: MIT
- Upstream: https://github.com/tailwindlabs/tailwindcss

5
assets/flags/ch.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Swiss flag">
<rect width="32" height="32" fill="#d52b1e"/>
<rect x="13" y="6" width="6" height="20" fill="#ffffff"/>
<rect x="6" y="13" width="20" height="6" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 271 B

5
assets/flags/de.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" role="img" aria-label="German flag">
<rect width="48" height="10.67" x="0" y="0" fill="#000000"/>
<rect width="48" height="10.67" x="0" y="10.67" fill="#dd0000"/>
<rect width="48" height="10.66" x="0" y="21.34" fill="#ffce00"/>
</svg>

After

Width:  |  Height:  |  Size: 301 B

9
assets/flags/en.svg Normal file
View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" role="img" aria-label="English flag">
<rect width="48" height="32" fill="#012169"/>
<path d="M0 0 48 32M48 0 0 32" stroke="#ffffff" stroke-width="6"/>
<path d="M0 0 48 32M48 0 0 32" stroke="#c8102e" stroke-width="3"/>
<rect x="20" y="0" width="8" height="32" fill="#ffffff"/>
<rect x="0" y="12" width="48" height="8" fill="#ffffff"/>
<rect x="22" y="0" width="4" height="32" fill="#c8102e"/>
<rect x="0" y="14" width="48" height="4" fill="#c8102e"/>
</svg>

After

Width:  |  Height:  |  Size: 531 B

53
assets/flags/en_us.svg Normal file
View File

@@ -0,0 +1,53 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" role="img" aria-label="United States flag">
<rect width="48" height="32" fill="#b22234"/>
<g fill="#ffffff">
<rect y="2.46" width="48" height="2.46"/>
<rect y="7.38" width="48" height="2.46"/>
<rect y="12.30" width="48" height="2.46"/>
<rect y="17.22" width="48" height="2.46"/>
<rect y="22.14" width="48" height="2.46"/>
<rect y="27.06" width="48" height="2.46"/>
</g>
<rect width="20" height="17.23" fill="#3c3b6e"/>
<g fill="#ffffff">
<circle cx="2.2" cy="2.2" r="0.7"/>
<circle cx="5.4" cy="2.2" r="0.7"/>
<circle cx="8.6" cy="2.2" r="0.7"/>
<circle cx="11.8" cy="2.2" r="0.7"/>
<circle cx="15.0" cy="2.2" r="0.7"/>
<circle cx="18.2" cy="2.2" r="0.7"/>
<circle cx="3.8" cy="4.4" r="0.7"/>
<circle cx="7.0" cy="4.4" r="0.7"/>
<circle cx="10.2" cy="4.4" r="0.7"/>
<circle cx="13.4" cy="4.4" r="0.7"/>
<circle cx="16.6" cy="4.4" r="0.7"/>
<circle cx="2.2" cy="6.6" r="0.7"/>
<circle cx="5.4" cy="6.6" r="0.7"/>
<circle cx="8.6" cy="6.6" r="0.7"/>
<circle cx="11.8" cy="6.6" r="0.7"/>
<circle cx="15.0" cy="6.6" r="0.7"/>
<circle cx="18.2" cy="6.6" r="0.7"/>
<circle cx="3.8" cy="8.8" r="0.7"/>
<circle cx="7.0" cy="8.8" r="0.7"/>
<circle cx="10.2" cy="8.8" r="0.7"/>
<circle cx="13.4" cy="8.8" r="0.7"/>
<circle cx="16.6" cy="8.8" r="0.7"/>
<circle cx="2.2" cy="11" r="0.7"/>
<circle cx="5.4" cy="11" r="0.7"/>
<circle cx="8.6" cy="11" r="0.7"/>
<circle cx="11.8" cy="11" r="0.7"/>
<circle cx="15.0" cy="11" r="0.7"/>
<circle cx="18.2" cy="11" r="0.7"/>
<circle cx="3.8" cy="13.2" r="0.7"/>
<circle cx="7.0" cy="13.2" r="0.7"/>
<circle cx="10.2" cy="13.2" r="0.7"/>
<circle cx="13.4" cy="13.2" r="0.7"/>
<circle cx="16.6" cy="13.2" r="0.7"/>
<circle cx="2.2" cy="15.4" r="0.7"/>
<circle cx="5.4" cy="15.4" r="0.7"/>
<circle cx="8.6" cy="15.4" r="0.7"/>
<circle cx="11.8" cy="15.4" r="0.7"/>
<circle cx="15.0" cy="15.4" r="0.7"/>
<circle cx="18.2" cy="15.4" r="0.7"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

5
assets/flags/fr.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" role="img" aria-label="French flag">
<rect width="16" height="32" x="0" y="0" fill="#0055a4"/>
<rect width="16" height="32" x="16" y="0" fill="#ffffff"/>
<rect width="16" height="32" x="32" y="0" fill="#ef4135"/>
</svg>

After

Width:  |  Height:  |  Size: 286 B

5
assets/flags/it.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" role="img" aria-label="Italian flag">
<rect width="16" height="32" x="0" y="0" fill="#009246"/>
<rect width="16" height="32" x="16" y="0" fill="#ffffff"/>
<rect width="16" height="32" x="32" y="0" fill="#ce2b37"/>
</svg>

After

Width:  |  Height:  |  Size: 287 B

14
assets/tailwind/input.css Normal file
View File

@@ -0,0 +1,14 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@layer utilities {
.flag-lang {
width: 32px;
height: 22px;
}
.flag-lang-ch {
width: 22px;
height: 22px;
}
}

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

@@ -1,13 +0,0 @@
Sei Codex in VS Code. Lavora direttamente nel workspace.
Obiettivo: creare un boilerplate riusabile “GoFiber MVC + HTMX + Svelte Custom Elements UI kit + GORM + SQLite/Postgres + Auth + Email sink + CORS + template directory public/private/admin + role admin”.
1) Scansiona il workspace e dimmi cosa esiste già.
2) Crea/aggiorna la struttura cartelle secondo questa convenzione:
/cmd/server
/internal/{app,config,http,middleware,db,models,repo,services,controllers,auth,mailer}
/web/{templates/{public,private,admin},emails/templates,static/{vendor,ui,css}}
/ui-kit
/data (solo dev)
3) Crea una TODO checklist in README.md con i passi rimanenti.
Non implementare ancora logica: solo struttura + README e .gitignore.

View File

@@ -1,24 +0,0 @@
Implementa internal/config e internal/app.
- Aggiungi internal/config/config.go:
- carica .env se presente (godotenv)
- espone Config con: AppName, Env (develop|prod), Port, BaseURL, BuildHash
DBDriver (sqlite|postgres), SQLitePath, PostgresDSN
CORS settings (origins/headers/methods/credentials)
SessionKey
SMTP settings + EmailSinkDir
Flags: AutoMigrate, SeedEnabled
- valida i campi essenziali (es. DB DSN se postgres)
- Aggiungi internal/app/app.go:
- crea fiber.App
- registra CORS middleware
- registra session store
- init DB (internal/db) + migrate/seed (in base ai flag)
- registra router (internal/http/router.go)
- espone NewApp(cfg) (*fiber.App, error)
- Aggiorna cmd/server/main.go per usare internal/app.
Crea/aggiorna .env.example e .gitignore (escludi .env, /data, db sqlite, email sink).
Scrivi codice compilabile.

288
codex.md
View File

@@ -1,288 +0,0 @@
# Progetto: GoFiber MVC + HTMX + Svelte UI Kit + GORM + AUTH + Role System + Template Separation
# OBIETTIVO
Implementare un progetto GoFiber MVC completo con:
- HTML server-rendered (html/template)
- HTMX per partial HTML
- Design System Svelte (Custom Elements)
- GORM + SQLite/Postgres selezionabile via .env
- Migrazioni + seed
- CORS
- AUTH completo (signup, login, logout, verify email, lost password, reset)
- Email transactional (SMTP + file sink in develop)
- Separazione template per:
- public (pagine accessibili senza login)
- private (solo utenti autenticati)
- admin (solo utenti role=admin)
Architettura server-first. Nessuna SPA.
---
# TEMPLATE DIRECTORY STRUCTURE (OBBLIGATORIA)
Strutturare /web/templates così:
/web/templates/
layout.html
/public/
home.html
login.html
signup.html
forgot_password.html
reset_password.html
verify_notice.html
/private/
dashboard.html
users/
index.html
_table.html
_modal.html
/admin/
dashboard.html
users.html
Il layout deve essere unico e includere:
- ui.css
- htmx.min.js
- ui.esm.js
---
# RUOLI UTENTE
Aggiungere campo Role nel model User:
- role string
- "user" default
- "admin"
Vincoli:
- Solo admin può accedere a /admin/*
- /private/* richiede autenticazione
- /public/* accessibile a tutti
---
# ROUTING CON GRUPPI
Configurare in main.go:
Public routes:
- GET /
- GET /login
- POST /login
- GET /signup
- POST /signup
- GET /forgot-password
- POST /forgot-password
- GET /reset-password
- POST /reset-password
- GET /verify-email
Private group (RequireAuth middleware):
- GET /dashboard
- GET /users
- GET /users/table
- GET /users/:id/modal
- POST /logout
Admin group (RequireAuth + RequireAdmin middleware):
- GET /admin
- GET /admin/users
---
# MIDDLEWARE
Implementare:
## RequireAuth
- verifica sessione
- se non autenticato → redirect /login
## RequireAdmin
- verifica user.Role == "admin"
- se non admin → 403 o redirect /dashboard
---
# DATABASE MODEL UPDATE
Aggiornare model User:
- ID uint
- Email string unique
- PasswordHash string
- EmailVerified bool
- Role string (default "user")
- CreatedAt
- UpdatedAt
Migrazioni devono includere nuovo campo Role.
Seed:
- In develop creare:
- admin@example.com (role=admin, email verified)
- user@example.com (role=user, email verified)
Password default esempio: "password"
---
# AUTH REQUIREMENTS (RIEPILOGO)
Signup:
- crea utente con role=user
- EmailVerified=false
- genera token verifica
- invia email o salva in sink
Login:
- verifica password
- verifica EmailVerified
- salva sessione con:
- user_id
- user_role
Logout:
- distrugge sessione
Verify email:
- valida token hash
- set EmailVerified=true
Forgot password:
- genera reset token
- invia/salva email
Reset password:
- aggiorna PasswordHash
- invalida token
---
# EMAIL TEMPLATE DIRECTORY
Creare:
/web/emails/templates/
verify_email.html
verify_email.txt
reset_password.html
reset_password.txt
In develop:
- salvare email in:
EMAIL_SINK_DIR
- nome file:
timestamp__type__to.eml
In prod:
- inviare SMTP
---
# DIRECTORY PROTEZIONE LOGICA
Controllers devono renderizzare template in base al path:
- c.Render("public/login", data)
- c.Render("private/users/index", data)
- c.Render("admin/dashboard", data)
Mai mischiare.
---
# LAYOUT
layout.html deve:
- rilevare se utente autenticato
- mostrare navbar differente:
- public: login/signup
- user: dashboard + logout
- admin: dashboard + admin + logout
Passare CurrentUser al template se autenticato.
---
# HTMX IN PRIVATE
/users:
- search con hx-get
- table partial in private/users/_table.html
- modal in private/users/_modal.html
---
# CORS
Configurato da .env
---
# BUILD HASH
Usare BUILD_HASH in query string per css/js.
---
# CRITERI DI ACCETTAZIONE
1) Template directory separata correttamente
2) Accesso diretto a /private/* senza login → redirect /login
3) Accesso a /admin/* senza role=admin → 403
4) Signup crea utente role=user
5) Seed crea admin role=admin
6) Navbar cambia in base a stato login
7) Email sink funziona in develop
8) DB driver selezionabile via .env
9) Migrazioni e seed eseguiti all'avvio
10) Nessuna logica di autorizzazione nel frontend
---
# STRUTTURA FINALE PROGETTO (SEMPLIFICATA)
/cmd/server
/internal
/config
/db
/models
/repo
/services
/auth
/mailer
/controllers
/web
/templates
/public
/private
/admin
/emails/templates
/static
/ui-kit
---
# NOTE PER CODEX
- Creare tutti i file mancanti
- Scrivere codice completo, compilabile
- Commentare le parti sicurezza (token hashing)
- Usare bcrypt
- Usare SHA256 per token hash
- Non salvare mai token in chiaro nel DB
- Creare helper per CurrentUser
- Applicare middleware correttamente
Non semplificare larchitettura.
Mantenere separazione MVC pulita.

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)

51
docs/pitch.md Normal file
View File

@@ -0,0 +1,51 @@
## Technical Pitch
GoFiber Secure MVC Starter is a production-grade, server-first web application foundation.
It combines:
- Clean MVC layering
- HTMX progressive enhancement
- Web Components for reusable UI
- Secure authentication flows
- Role-based access control
- Full audit logging
- Environment-driven configuration
- Database abstraction via GORM
- Secure email workflows
It eliminates frontend complexity while maintaining modern UX.
---
## Business Pitch
This starter provides:
- Faster time-to-market
- Reduced security risk
- Maintainable architecture
- Easy onboarding for backend developers
- Lower frontend maintenance overhead
- Clear separation of public/private/admin areas
Ideal for:
- SaaS products
- Admin dashboards
- Internal enterprise tools
- Secure web platforms
---
# Stato del Progetto
You now have:
✔ Secure auth system
✔ Production-ready structure
✔ Audit traceability
✔ Modular extensibility
✔ Clear separation of concerns

77
docs/security.md Normal file
View File

@@ -0,0 +1,77 @@
# Security Policy
## Overview
This project implements a layered security model with:
- Secure password hashing (bcrypt)
- Hashed verification and reset tokens (SHA-256)
- Role-based authorization
- Audit logging
- Environment-based email handling
- Configurable CORS
---
## Authentication
Passwords are hashed using bcrypt before storage.
Email verification is required before login.
Reset and verification tokens:
- Random 32+ bytes
- SHA-256 hashed before database storage
- Expiration enforced
- One-time use
---
## Authorization
Access control enforced via middleware:
- RequireAuth
- RequireAdmin
No authorization logic is implemented in templates.
---
## Session Security
- HttpOnly cookies
- SameSite=Lax
- Secure flag enabled in production
- Session key configured via environment variable
---
## Email Security
Develop mode:
- Emails are written to filesystem
- No external transmission
Production mode:
- SMTP authenticated delivery
---
## Audit Logging
The system logs:
- Signup
- Login
- Email verification
- Password reset
- Admin actions
Logs contain:
- UserID
- Action
- Entity
- EntityID
- IP
- UserAgent
- Timestamp

View File

@@ -0,0 +1,37 @@
┌─────────────────────────────┐
│ Browser │
│ HTML + HTMX + UI Kit │
└──────────────┬──────────────┘
HTTPS (TLS)
┌──────────────▼──────────────┐
│ GoFiber App │
├─────────────────────────────┤
│ CORS Middleware │
│ Session Middleware │
│ CSRF (optional) │
│ Rate Limiter (Auth) │
└──────────────┬──────────────┘
┌───────────────────▼───────────────────┐
│ Auth & Authorization │
│ - RequireAuth │
│ - RequireAdmin │
│ - Role validation │
└───────────────────┬───────────────────┘
┌───────────────────▼───────────────────┐
│ Business Layer (Services) │
└───────────────────┬───────────────────┘
┌───────────────────▼───────────────────┐
│ Repository Layer (GORM) │
└───────────────────┬───────────────────┘
┌───────────────────▼───────────────────┐
│ Database │
│ - Users (bcrypt passwords) │
│ - Token hashes (SHA-256) │
│ - AuditLog │
└───────────────────────────────────────┘

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,27 @@
package controllers
import (
"path/filepath"
"github.com/gofiber/fiber/v2"
)
type AdminController struct {
spaDir string
}
func NewAdminController(spaDir string) *AdminController {
return &AdminController{spaDir: spaDir}
}
func (ac *AdminController) Dashboard(c *fiber.Ctx) error {
return c.SendFile(filepath.Join(ac.spaDir, "index.html"))
}
func (ac *AdminController) Fallback(c *fiber.Ctx) error {
return c.SendFile(filepath.Join(ac.spaDir, "index.html"))
}
func (ac *AdminController) Favicon(c *fiber.Ctx) error {
return c.SendFile(filepath.Join(ac.spaDir, "favicon.ico"))
}

View File

@@ -0,0 +1,263 @@
package controllers
import (
"errors"
"strings"
httpmw "trustcontact/internal/http/middleware"
"trustcontact/internal/services"
"github.com/gofiber/fiber/v2"
)
type AuthController struct {
authService *services.AuthService
}
func NewAuthController(authService *services.AuthService) *AuthController {
return &AuthController{authService: authService}
}
func (ac *AuthController) ShowHome(c *fiber.Ctx) error {
return renderPublic(c, "home.html", map[string]any{
"Title": "Home",
"NavSection": "home",
})
}
func (ac *AuthController) ShowSignup(c *fiber.Ctx) error {
return renderPublic(c, "signup.html", map[string]any{
"Title": "Sign up",
"NavSection": "public",
})
}
func (ac *AuthController) Signup(c *fiber.Ctx) error {
email := strings.TrimSpace(c.FormValue("email"))
password := c.FormValue("password")
if err := ac.authService.Signup(c.UserContext(), email, password); err != nil {
if errors.Is(err, services.ErrEmailAlreadyExists) {
httpmw.SetTemplateData(c, "FlashError", "Email gia registrata")
} else {
httpmw.SetTemplateData(c, "FlashError", "Impossibile completare la registrazione")
}
return renderPublic(c, "signup.html", map[string]any{
"Title": "Sign up",
"NavSection": "public",
"Email": email,
})
}
if err := httpmw.SetFlashSuccess(c, "Registrazione completata. Controlla la tua email per verificare l'account."); err != nil {
return err
}
return c.Redirect("/verify-notice")
}
func (ac *AuthController) ShowLogin(c *fiber.Ctx) error {
return renderPublic(c, "login.html", map[string]any{
"Title": "Login",
"NavSection": "public",
})
}
func (ac *AuthController) Login(c *fiber.Ctx) error {
email := strings.TrimSpace(c.FormValue("email"))
password := c.FormValue("password")
user, err := ac.authService.Login(email, password)
if err != nil {
switch {
case errors.Is(err, services.ErrEmailNotVerified):
httpmw.SetTemplateData(c, "FlashError", "Email non verificata. Controlla la posta.")
case errors.Is(err, services.ErrInvalidCredentials):
httpmw.SetTemplateData(c, "FlashError", "Credenziali non valide")
default:
httpmw.SetTemplateData(c, "FlashError", "Errore durante il login")
}
return renderPublic(c, "login.html", map[string]any{
"Title": "Login",
"NavSection": "public",
"Email": email,
})
}
if err := httpmw.SetSessionUserID(c, user.ID); err != nil {
return err
}
if err := httpmw.SetFlashSuccess(c, "Login effettuato"); err != nil {
return err
}
return c.Redirect("/private")
}
func (ac *AuthController) Logout(c *fiber.Ctx) error {
if err := httpmw.ClearSessionUser(c); err != nil {
return err
}
if err := httpmw.SetFlashSuccess(c, "Logout effettuato"); err != nil {
return err
}
return c.Redirect("/login")
}
func (ac *AuthController) VerifyEmail(c *fiber.Ctx) error {
token := strings.TrimSpace(c.Query("token"))
if token == "" {
httpmw.SetTemplateData(c, "FlashError", "Token non valido")
return renderPublic(c, "verify_notice.html", map[string]any{
"Title": "Verifica email",
"NavSection": "public",
})
}
if err := ac.authService.VerifyEmail(token); err != nil {
httpmw.SetTemplateData(c, "FlashError", "Token non valido o scaduto")
return renderPublic(c, "verify_notice.html", map[string]any{
"Title": "Verifica email",
"NavSection": "public",
})
}
if err := httpmw.SetFlashSuccess(c, "Email verificata. Ora puoi accedere."); err != nil {
return err
}
return c.Redirect("/login")
}
func (ac *AuthController) ShowVerifyNotice(c *fiber.Ctx) error {
return renderPublic(c, "verify_notice.html", map[string]any{
"Title": "Verifica email",
"NavSection": "public",
})
}
func (ac *AuthController) ShowForbidden(c *fiber.Ctx) error {
return renderPublic(c, "forbidden.html", map[string]any{
"Title": "Forbidden",
"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")
}
func (ac *AuthController) UpdateLanguage(c *fiber.Ctx) error {
currentUser, ok := httpmw.CurrentUserFromContext(c)
if !ok {
return c.SendStatus(fiber.StatusUnauthorized)
}
type langRequest struct {
Lang string `json:"lang" form:"lang"`
}
var req langRequest
if err := c.BodyParser(&req); err != nil {
req.Lang = c.FormValue("lang")
}
lang := services.NormalizeLanguage(req.Lang)
if !services.IsSupportedLanguage(lang) {
return c.Status(fiber.StatusBadRequest).SendString("invalid language")
}
if err := ac.authService.UpdateUserLanguage(currentUser.ID, lang); err != nil {
if errors.Is(err, services.ErrInvalidLanguage) {
return c.Status(fiber.StatusBadRequest).SendString("invalid language")
}
return c.Status(fiber.StatusInternalServerError).SendString("cannot update language")
}
return c.SendStatus(fiber.StatusNoContent)
}
func (ac *AuthController) UpdateTheme(c *fiber.Ctx) error {
currentUser, ok := httpmw.CurrentUserFromContext(c)
if !ok {
return c.SendStatus(fiber.StatusUnauthorized)
}
type themeRequest struct {
Theme string `json:"theme" form:"theme"`
}
var req themeRequest
if err := c.BodyParser(&req); err != nil {
req.Theme = c.FormValue("theme")
}
theme := services.NormalizeTheme(req.Theme)
if theme != "dark" && theme != "light" {
return c.Status(fiber.StatusBadRequest).SendString("invalid theme")
}
if err := ac.authService.UpdateUserTheme(currentUser.ID, theme); err != nil {
if errors.Is(err, services.ErrInvalidTheme) {
return c.Status(fiber.StatusBadRequest).SendString("invalid theme")
}
return c.Status(fiber.StatusInternalServerError).SendString("cannot update theme")
}
return c.SendStatus(fiber.StatusNoContent)
}

View File

@@ -0,0 +1,27 @@
package controllers
import (
"path/filepath"
"github.com/gofiber/fiber/v2"
)
type PrivateController struct {
spaDir string
}
func NewPrivateController(spaDir string) *PrivateController {
return &PrivateController{spaDir: spaDir}
}
func (ac *PrivateController) Dashboard(c *fiber.Ctx) error {
return c.SendFile(filepath.Join(ac.spaDir, "index.html"))
}
func (ac *PrivateController) Fallback(c *fiber.Ctx) error {
return c.SendFile(filepath.Join(ac.spaDir, "index.html"))
}
func (ac *PrivateController) Favicon(c *fiber.Ctx) error {
return c.SendFile(filepath.Join(ac.spaDir, "favicon.ico"))
}

View File

@@ -0,0 +1,93 @@
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/_navbar.html",
"web/templates/partials/language_dropdown.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, _ 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/_navbar.html",
"web/templates/partials/language_dropdown.html",
"web/templates/public/_flash.html",
"web/templates/private.html",
}
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
}

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()
}

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

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

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

@@ -0,0 +1,124 @@
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,
Properties: models.UserProperties{
Lang: "en",
Dark: true,
},
},
{
Name: "Normal User",
Email: "user@example.com",
Role: models.RoleUser,
EmailVerified: true,
PasswordHash: passwordHash,
Properties: models.UserProperties{
Lang: "it",
Dark: false,
},
},
{
Name: "Demo One",
Email: "demo1@example.com",
Role: models.RoleUser,
EmailVerified: true,
PasswordHash: passwordHash,
Properties: models.UserProperties{
Lang: "en",
Dark: true,
},
},
{
Name: "Demo Two",
Email: "demo2@example.com",
Role: models.RoleUser,
EmailVerified: true,
PasswordHash: passwordHash,
Properties: models.UserProperties{
Lang: "en",
Dark: true,
},
},
{
Name: "Demo Three",
Email: "demo3@example.com",
Role: models.RoleUser,
EmailVerified: true,
PasswordHash: passwordHash,
Properties: models.UserProperties{
Lang: "en",
Dark: true,
},
},
}
for _, user := range seedUsers {
userID, err := upsertUser(database, user)
if err != nil {
return err
}
user.Properties.UserId = userID
if err := upsertUserProperties(database, user.Properties, user.Email); err != nil {
return err
}
}
return nil
}
func upsertUser(database *gorm.DB, user models.User) (string, error) {
result := database.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}},
DoUpdates: clause.AssignmentColumns([]string{
"name",
"role",
"email_verified",
"password_hash",
"updated_at",
}),
}).Omit("Properties").Create(&user)
if result.Error != nil {
return "", fmt.Errorf("seed user %s: %w", user.Email, result.Error)
}
var persisted models.User
if err := database.Select("id").Where("email = ?", user.Email).First(&persisted).Error; err != nil {
return "", fmt.Errorf("load seeded user %s: %w", user.Email, err)
}
return persisted.ID, nil
}
func upsertUserProperties(database *gorm.DB, props models.UserProperties, userEmail string) error {
result := database.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}},
DoNothing: true,
}).Create(&props)
if result.Error != nil {
return fmt.Errorf("seed user properties %s: %w", userEmail, 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).Redirect("/forbidden")
}
return c.Next()
}
}
func SetSessionUserID(c *fiber.Ctx, userID 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(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,126 @@
package middleware
import (
"fmt"
"strings"
"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)
if user != nil {
setTemplateData(c, "UserLang", strings.TrimSpace(user.Properties.Lang))
if user.Properties.UserId != "" {
if user.Properties.Dark {
setTemplateData(c, "UserTheme", "dark")
} else {
setTemplateData(c, "UserTheme", "light")
}
} else {
setTemplateData(c, "UserTheme", "")
}
} else {
setTemplateData(c, "UserLang", "")
setTemplateData(c, "UserTheme", "")
}
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 == "" {
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) (string, bool) {
switch value := v.(type) {
case string:
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "", false
}
return trimmed, true
default:
return "", 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()
}

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

@@ -0,0 +1,82 @@
package http
import (
"fmt"
"path/filepath"
"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)
privateSPADir := filepath.FromSlash("quasar/private_section/dist/spa")
privateController := controllers.NewPrivateController(privateSPADir)
adminSPADir := filepath.FromSlash("quasar/admin_section/dist/spa")
adminController := controllers.NewAdminController(adminSPADir)
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("/forbidden", authController.ShowForbidden)
app.Post("/preferences/lang", httpmw.RequireAuth(), authController.UpdateLanguage)
app.Post("/preferences/theme", httpmw.RequireAuth(), authController.UpdateTheme)
// Quasar admin SPA assets are emitted with absolute paths (/assets, /icons, /favicon.ico).
// Protect them with the same auth/admin middleware used by /admin.
app.Use("/assets", httpmw.RequireAuth(), httpmw.RequireAdmin())
app.Use("/icons", httpmw.RequireAuth(), httpmw.RequireAdmin())
app.Get("/favicon.ico", httpmw.RequireAuth(), httpmw.RequireAdmin(), privateController.Favicon)
app.Static("/assets", filepath.Join(privateSPADir, "assets"))
app.Static("/icons", filepath.Join(privateSPADir, "icons"))
private := app.Group("/private", httpmw.RequireAuth(), httpmw.RequireAdmin())
private.Get("/", privateController.Dashboard)
private.Get("/*", privateController.Fallback)
// Quasar admin SPA assets are emitted with absolute paths (/assets, /icons, /favicon.ico).
// Protect them with the same auth/admin middleware used by /admin.
app.Use("/assets", httpmw.RequireAuth(), httpmw.RequireAdmin())
app.Use("/icons", httpmw.RequireAuth(), httpmw.RequireAdmin())
app.Get("/favicon.ico", httpmw.RequireAuth(), httpmw.RequireAdmin(), adminController.Favicon)
app.Static("/assets", filepath.Join(adminSPADir, "assets"))
app.Static("/icons", filepath.Join(adminSPADir, "icons"))
admin := app.Group("/admin", httpmw.RequireAuth(), httpmw.RequireAdmin())
admin.Get("/", adminController.Dashboard)
admin.Get("/*", adminController.Fallback)
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,38 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type EmailVerificationToken struct {
ID string `gorm:"primaryKey"`
UserID string `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
}
func (token *EmailVerificationToken) BeforeCreate(tx *gorm.DB) (err error) {
// UUID version 4
token.ID = uuid.NewString()
return
}
type PasswordResetToken struct {
ID string `gorm:"primaryKey"`
UserID string `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
}
func (token *PasswordResetToken) BeforeCreate(tx *gorm.DB) (err error) {
// UUID version 4
token.ID = uuid.NewString()
return
}

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

@@ -0,0 +1,37 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
RoleAdmin = "admin"
RoleUser = "user"
)
type User struct {
ID string `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"`
Properties UserProperties `gorm:"foreignKey:UserId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (user *User) BeforeCreate(tx *gorm.DB) (err error) {
// UUID version 4
user.ID = uuid.NewString()
return
}
type UserProperties struct {
UserId string `json:"user_id" gorm:"uniqueIndex"`
Lang string `json:"lang"`
Dark bool `json:"dark"`
}

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 string) error {
return r.db.Delete(&models.EmailVerificationToken{}, id).Error
}
func (r *EmailVerificationTokenRepo) DeleteByUserID(userID string) 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 string) error {
return r.db.Delete(&models.PasswordResetToken{}, id).Error
}
func (r *PasswordResetTokenRepo) DeleteByUserID(userID string) error {
return r.db.Where("user_id = ?", userID).Delete(&models.PasswordResetToken{}).Error
}

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

@@ -0,0 +1,169 @@
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 string) (*models.User, error) {
var user models.User
if err := r.db.Preload("Properties").Where("id = ?", id).First(&user).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.Preload("Properties").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 string, verified bool) error {
return r.db.Model(&models.User{}).
Where("id = ?", userID).
Update("email_verified", verified).Error
}
func (r *UserRepo) UpdatePasswordHash(userID string, passwordHash string) error {
return r.db.Model(&models.User{}).
Where("id = ?", userID).
Update("password_hash", passwordHash).Error
}
func (r *UserRepo) UpsertLanguagePreference(userID string, lang string) error {
var existing models.UserProperties
err := r.db.Where("user_id = ?", userID).First(&existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if errors.Is(err, gorm.ErrRecordNotFound) {
props := models.UserProperties{
UserId: userID,
Lang: lang,
}
return r.db.Create(&props).Error
}
return r.db.Model(&models.UserProperties{}).
Where("user_id = ?", userID).
Update("lang", lang).Error
}
func (r *UserRepo) UpsertDarkPreference(userID string, dark bool) error {
var existing models.UserProperties
err := r.db.Where("user_id = ?", userID).First(&existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if errors.Is(err, gorm.ErrRecordNotFound) {
props := models.UserProperties{
UserId: userID,
Dark: dark,
}
return r.db.Create(&props).Error
}
return r.db.Model(&models.UserProperties{}).
Where("user_id = ?", userID).
Update("dark", dark).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 > 500 {
pageSize = 500
}
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,290 @@
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")
ErrInvalidLanguage = errors.New("invalid language")
ErrInvalidTheme = errors.New("invalid theme")
)
var supportedLanguages = map[string]struct{}{
"it": {},
"en": {},
"en_us": {},
"de": {},
"fr": {},
"de_ch": {},
"fr_ch": {},
}
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) UpdateUserLanguage(userID string, lang string) error {
normalized := NormalizeLanguage(lang)
if !IsSupportedLanguage(normalized) {
return ErrInvalidLanguage
}
return s.users.UpsertLanguagePreference(userID, normalized)
}
func (s *AuthService) UpdateUserTheme(userID string, theme string) error {
normalized := NormalizeTheme(theme)
if normalized != "dark" && normalized != "light" {
return ErrInvalidTheme
}
return s.users.UpsertDarkPreference(userID, normalized == "dark")
}
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))
}
func NormalizeLanguage(lang string) string {
normalized := strings.ToLower(strings.TrimSpace(lang))
return strings.ReplaceAll(normalized, "-", "_")
}
func IsSupportedLanguage(lang string) bool {
_, ok := supportedLanguages[NormalizeLanguage(lang)]
return ok
}
func NormalizeTheme(theme string) string {
return strings.ToLower(strings.TrimSpace(theme))
}

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 > 500 {
pageSize = 500
}
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 string) (*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
}

21
licenses/FLOWBITE-MIT.txt Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Themesberg
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

1063
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

16
package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "trustcontact-flowbite",
"private": true,
"version": "1.0.0",
"scripts": {
"tw:build": "npx @tailwindcss/cli -i ./assets/tailwind/input.css -o ./web/static/css/app.css --minify",
"tw:watch": "npx @tailwindcss/cli -i ./assets/tailwind/input.css -o ./web/static/css/app.css --watch"
},
"devDependencies": {
"@tailwindcss/cli": "^4.1.13",
"tailwindcss": "^4.1.13"
},
"dependencies": {
"htmx.org": "^2.0.6"
}
}

View File

@@ -0,0 +1,7 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
charset = utf-8
indent_size = 2
indent_style = space
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

33
quasar/admin_section/.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
.DS_Store
.thumbs.db
node_modules
# Quasar core related directories
.quasar
/dist
/quasar.config.*.temporary.compiled*
# Cordova related directories and files
/src-cordova/node_modules
/src-cordova/platforms
/src-cordova/plugins
/src-cordova/www
# Capacitor related directories and files
/src-capacitor/www
/src-capacitor/node_modules
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
# local .env files
.env.local*

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"singleQuote": true,
"printWidth": 100
}

View File

@@ -0,0 +1,43 @@
# Quasar App (admin-section)
A Quasar Project
## Install the dependencies
```bash
yarn
# or
npm install
```
### Start the app in development mode (hot-code reloading, error reporting, etc.)
```bash
quasar dev
```
### Lint the files
```bash
yarn lint
# or
npm run lint
```
### Format the files
```bash
yarn format
# or
npm run format
```
### Build the app for production
```bash
quasar build
```
### Customize the configuration
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).

View File

@@ -0,0 +1,83 @@
import js from '@eslint/js';
import globals from 'globals';
import pluginVue from 'eslint-plugin-vue';
import pluginQuasar from '@quasar/app-vite/eslint';
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript';
import prettierSkipFormatting from '@vue/eslint-config-prettier/skip-formatting';
export default defineConfigWithVueTs(
{
/**
* Ignore the following files.
* Please note that pluginQuasar.configs.recommended() already ignores
* the "node_modules" folder for you (and all other Quasar project
* relevant folders and files).
*
* ESLint requires "ignores" key to be the only one in this object
*/
// ignores: []
},
pluginQuasar.configs.recommended(),
js.configs.recommended,
/**
* https://eslint.vuejs.org
*
* pluginVue.configs.base
* -> Settings and rules to enable correct ESLint parsing.
* pluginVue.configs[ 'flat/essential']
* -> base, plus rules to prevent errors or unintended behavior.
* pluginVue.configs["flat/strongly-recommended"]
* -> Above, plus rules to considerably improve code readability and/or dev experience.
* pluginVue.configs["flat/recommended"]
* -> Above, plus rules to enforce subjective community defaults to ensure consistency.
*/
pluginVue.configs['flat/essential'],
{
files: ['**/*.ts', '**/*.vue'],
rules: {
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
},
},
// https://github.com/vuejs/eslint-config-typescript
vueTsConfigs.recommendedTypeChecked,
{
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
...globals.node, // SSR, Electron, config files
process: 'readonly', // process.env.*
ga: 'readonly', // Google Analytics
cordova: 'readonly',
Capacitor: 'readonly',
chrome: 'readonly', // BEX related
browser: 'readonly', // BEX related
},
},
// add your custom rules here
rules: {
'prefer-promise-reject-errors': 'off',
// allow debugger during development only
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
},
},
{
files: ['src-pwa/custom-service-worker.ts'],
languageOptions: {
globals: {
...globals.serviceworker,
},
},
},
prettierSkipFormatting,
);

View File

@@ -0,0 +1,24 @@
<!doctype html>
<html>
<head>
<title><%= productName %></title>
<meta charset="utf-8" />
<meta name="description" content="<%= productDescription %>" />
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<meta
name="viewport"
content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>"
/>
<link rel="icon" type="image/png" sizes="128x128" href="icons/favicon-128x128.png" />
<link rel="icon" type="image/png" sizes="96x96" href="icons/favicon-96x96.png" />
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png" />
<link rel="icon" type="image/ico" href="favicon.ico" />
</head>
<body>
<!-- quasar:entry-point -->
</body>
</html>

View File

@@ -0,0 +1,48 @@
{
"name": "admin-section",
"version": "0.0.1",
"description": "A Quasar Project",
"productName": "Quasar App",
"author": "fabio <prada.fabio@gmail.com>",
"type": "module",
"private": true,
"scripts": {
"lint": "eslint -c ./eslint.config.js \"./src*/**/*.{ts,js,cjs,mjs,vue}\"",
"format": "prettier --write \"**/*.{js,ts,vue,scss,html,md,json}\" --ignore-path .gitignore",
"test": "echo \"No test specified\" && exit 0",
"dev": "quasar dev",
"build": "quasar build",
"postinstall": "quasar prepare"
},
"dependencies": {
"vue-i18n": "^11.0.0",
"pinia": "^3.0.1",
"@quasar/extras": "^1.16.4",
"quasar": "^2.16.0",
"vue": "^3.5.22",
"vue-router": "^5.0.0"
},
"devDependencies": {
"@eslint/js": "^9.14.0",
"eslint": "^9.14.0",
"eslint-plugin-vue": "^10.4.0",
"globals": "^16.4.0",
"vue-tsc": "^3.0.7",
"@vue/eslint-config-typescript": "^14.4.0",
"vite-plugin-checker": "^0.11.0",
"vue-eslint-parser": "^10.2.0",
"@vue/eslint-config-prettier": "^10.1.0",
"prettier": "^3.3.3",
"@types/node": "^20.5.9",
"@intlify/unplugin-vue-i18n": "^4.0.0",
"@quasar/app-vite": "^2.1.0",
"autoprefixer": "^10.4.2",
"typescript": "^5.9.2"
},
"engines": {
"node": "^28 || ^26 || ^24 || ^22 || ^20",
"npm": ">= 6.13.4",
"yarn": ">= 1.21.1",
"pnpm": ">= 10.0.0"
}
}

5844
quasar/admin_section/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
# https://pnpm.io/settings
shamefullyHoist: true

View File

@@ -0,0 +1,29 @@
// https://github.com/michael-ciniawsky/postcss-load-config
import autoprefixer from 'autoprefixer';
// import rtlcss from 'postcss-rtlcss'
export default {
plugins: [
// https://github.com/postcss/autoprefixer
autoprefixer({
overrideBrowserslist: [
'last 4 Chrome versions',
'last 4 Firefox versions',
'last 4 Edge versions',
'last 4 Safari versions',
'last 4 Android versions',
'last 4 ChromeAndroid versions',
'last 4 FirefoxAndroid versions',
'last 4 iOS versions',
],
}),
// https://github.com/elchininet/postcss-rtlcss
// If you want to support RTL css, then
// 1. yarn/pnpm/bun/npm install postcss-rtlcss
// 2. optionally set quasar.config.js > framework > lang to an RTL language
// 3. uncomment the following line (and its import statement above):
// rtlcss()
],
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,242 @@
// Configuration for your app
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file
import { defineConfig } from '#q-app/wrappers';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import dotenv from 'dotenv';
dotenv.config({ path: resolve(__dirname, '../../.env') });
export default defineConfig((ctx) => {
return {
// https://v2.quasar.dev/quasar-cli-vite/prefetch-feature
// preFetch: true,
// app boot file (/src/boot)
// --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli-vite/boot-files
boot: ['i18n'],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css
css: ['app.scss'],
// https://github.com/quasarframework/quasar/tree/dev/extras
extras: [
// 'ionicons-v4',
// 'mdi-v7',
// 'fontawesome-v6',
// 'eva-icons',
// 'themify',
// 'line-awesome',
// 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
'roboto-font', // optional, you are not bound to it
'material-icons', // optional, you are not bound to it
],
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#build
build: {
env: {
SITE_URL: process.env.SITE_URL || '',
},
target: {
browser: ['es2022', 'firefox115', 'chrome115', 'safari14'],
node: 'node20',
},
typescript: {
strict: true,
vueShim: true,
// extendTsConfig (tsConfig) {}
},
vueRouterMode: 'hash', // available values: 'hash', 'history'
// vueRouterBase,
// vueDevtools,
// vueOptionsAPI: false,
// rebuildCache: true, // rebuilds Vite/linter/etc cache on startup
// publicPath: '/',
// analyze: true,
// env: {},
// rawDefine: {}
// ignorePublicFolder: true,
// minify: false,
// polyfillModulePreload: true,
// distDir
// extendViteConf (viteConf) {},
// viteVuePluginOptions: {},
vitePlugins: [
[
'@intlify/unplugin-vue-i18n/vite',
{
// if you want to use Vue I18n Legacy API, you need to set `compositionOnly: false`
// compositionOnly: false,
// if you want to use named tokens in your Vue I18n messages, such as 'Hello {name}',
// you need to set `runtimeOnly: false`
// runtimeOnly: false,
ssr: ctx.modeName === 'ssr',
// you need to set i18n resource including paths !
include: [fileURLToPath(new URL('./src/i18n', import.meta.url))],
},
],
[
'vite-plugin-checker',
{
vueTsc: true,
eslint: {
lintCommand: 'eslint -c ./eslint.config.js "./src*/**/*.{ts,js,mjs,cjs,vue}"',
useFlatConfig: true,
},
},
{ server: false },
],
],
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#devserver
devServer: {
// https: true,
open: true, // opens browser window automatically
},
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework
framework: {
config: {},
// iconSet: 'material-icons', // Quasar icon set
// lang: 'en-US', // Quasar language pack
// For special cases outside of where the auto-import strategy can have an impact
// (like functional components as one of the examples),
// you can manually specify Quasar components/directives to be available everywhere:
//
// components: [],
// directives: [],
// Quasar plugins
plugins: [],
},
// animations: 'all', // --- includes all animations
// https://v2.quasar.dev/options/animations
animations: [],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#sourcefiles
// sourceFiles: {
// rootComponent: 'src/App.vue',
// router: 'src/router/index',
// store: 'src/store/index',
// pwaRegisterServiceWorker: 'src-pwa/register-service-worker',
// pwaServiceWorker: 'src-pwa/custom-service-worker',
// pwaManifestFile: 'src-pwa/manifest.json',
// electronMain: 'src-electron/electron-main',
// electronPreload: 'src-electron/electron-preload'
// bexManifestFile: 'src-bex/manifest.json
// },
// https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr
ssr: {
prodPort: 3000, // The default port that the production server should use
// (gets superseded if process.env.PORT is specified at runtime)
middlewares: [
'render', // keep this as last one
],
// extendPackageJson (json) {},
// extendSSRWebserverConf (esbuildConf) {},
// manualStoreSerialization: true,
// manualStoreSsrContextInjection: true,
// manualStoreHydration: true,
// manualPostHydrationTrigger: true,
pwa: false,
// pwaOfflineHtmlFilename: 'offline.html', // do NOT use index.html as name!
// pwaExtendGenerateSWOptions (cfg) {},
// pwaExtendInjectManifestOptions (cfg) {}
},
// https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa
pwa: {
workboxMode: 'GenerateSW', // 'GenerateSW' or 'InjectManifest'
// swFilename: 'sw.js',
// manifestFilename: 'manifest.json',
// extendManifestJson (json) {},
// useCredentialsForManifestTag: true,
// injectPwaMetaTags: false,
// extendPWACustomSWConf (esbuildConf) {},
// extendGenerateSWOptions (cfg) {},
// extendInjectManifestOptions (cfg) {}
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-cordova-apps/configuring-cordova
cordova: {
// noIosLegacyBuildFlag: true, // uncomment only if you know what you are doing
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-capacitor-apps/configuring-capacitor
capacitor: {
hideSplashscreen: true,
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/configuring-electron
electron: {
// extendElectronMainConf (esbuildConf) {},
// extendElectronPreloadConf (esbuildConf) {},
// extendPackageJson (json) {},
// Electron preload scripts (if any) from /src-electron, WITHOUT file extension
preloadScripts: ['electron-preload'],
// specify the debugging port to use for the Electron app when running in development mode
inspectPort: 5858,
bundler: 'packager', // 'packager' or 'builder'
packager: {
// https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
// OS X / Mac App Store
// appBundleId: '',
// appCategoryType: '',
// osxSign: '',
// protocol: 'myapp://path',
// Windows only
// win32metadata: { ... }
},
builder: {
// https://www.electron.build/configuration
appId: 'admin-section',
},
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-browser-extensions/configuring-bex
bex: {
// extendBexScriptsConf (esbuildConf) {},
// extendBexManifestJson (json) {},
/**
* The list of extra scripts (js/ts) not in your bex manifest that you want to
* compile and use in your browser extension. Maybe dynamic use them?
*
* Each entry in the list should be a relative filename to /src-bex/
*
* @example [ 'my-script.ts', 'sub-folder/my-other-script.js' ]
*/
extraScripts: [],
},
};
});

View File

@@ -0,0 +1,7 @@
<template>
<router-view />
</template>
<script setup lang="ts">
//
</script>

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 356 360">
<path
d="M43.4 303.4c0 3.8-2.3 6.3-7.1 6.3h-15v-22h14.4c4.3 0 6.2 2.2 6.2 5.2 0 2.6-1.5 4.4-3.4 5 2.8.4 4.9 2.5 4.9 5.5zm-8-13H24.1v6.9H35c2.1 0 4-1.3 4-3.8 0-2.2-1.3-3.1-3.7-3.1zm5.1 12.6c0-2.3-1.8-3.7-4-3.7H24.2v7.7h11.7c3.4 0 4.6-1.8 4.6-4zm36.3 4v2.7H56v-22h20.6v2.7H58.9v6.8h14.6v2.3H58.9v7.5h17.9zm23-5.8v8.5H97v-8.5l-11-13.4h3.4l8.9 11 8.8-11h3.4l-10.8 13.4zm19.1-1.8V298c0-7.9 5.2-10.7 12.7-10.7 7.5 0 13 2.8 13 10.7v1.4c0 7.9-5.5 10.8-13 10.8s-12.7-3-12.7-10.8zm22.7 0V298c0-5.7-3.9-8-10-8-6 0-9.8 2.3-9.8 8v1.4c0 5.8 3.8 8.1 9.8 8.1 6 0 10-2.3 10-8.1zm37.2-11.6v21.9h-2.9l-15.8-17.9v17.9h-2.8v-22h3l15.6 18v-18h2.9zm37.9 10.2v1.3c0 7.8-5.2 10.4-12.4 10.4H193v-22h11.2c7.2 0 12.4 2.8 12.4 10.3zm-3 0c0-5.3-3.3-7.6-9.4-7.6h-8.4V307h8.4c6 0 9.5-2 9.5-7.7V298zm50.8-7.6h-9.7v19.3h-3v-19.3h-9.7v-2.6h22.4v2.6zm34.4-2.6v21.9h-3v-10.1h-16.8v10h-2.8v-21.8h2.8v9.2H296v-9.2h2.9zm34.9 19.2v2.7h-20.7v-22h20.6v2.7H316v6.8h14.5v2.3H316v7.5h17.8zM24 340.2v7.3h13.9v2.4h-14v9.6H21v-22h20v2.7H24zm41.5 11.4h-9.8v7.9H53v-22h13.3c5.1 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6H66c3.1 0 5.3-1.5 5.3-4.7 0-3.3-2.2-4.1-5.3-4.1H55.7v8.8zm47.9 6.2H89l-2 4.3h-3.2l10.7-22.2H98l10.7 22.2h-3.2l-2-4.3zm-1-2.3l-6.3-13-6 13h12.2zm46.3-15.3v21.9H146v-17.2L135.7 358h-2.1l-10.2-15.6v17h-2.8v-21.8h3l11 16.9 11.3-17h3zm35 19.3v2.6h-20.7v-22h20.6v2.7H166v6.8h14.5v2.3H166v7.6h17.8zm47-19.3l-8.3 22h-3l-7.1-18.6-7 18.6h-3l-8.2-22h3.3L204 356l6.8-18.5h3.4L221 356l6.6-18.5h3.3zm10 11.6v-1.4c0-7.8 5.2-10.7 12.7-10.7 7.6 0 13 2.9 13 10.7v1.4c0 7.9-5.4 10.8-13 10.8-7.5 0-12.7-3-12.7-10.8zm22.8 0v-1.4c0-5.7-4-8-10-8s-9.9 2.3-9.9 8v1.4c0 5.8 3.8 8.2 9.8 8.2 6.1 0 10-2.4 10-8.2zm28.3 2.4h-9.8v7.9h-2.8v-22h13.2c5.2 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6h10.2c3 0 5.2-1.5 5.2-4.7 0-3.3-2.1-4.1-5.2-4.1h-10.2v8.8zm40.3-1.5l-6.8 5.6v6.4h-2.9v-22h2.9v12.3l15.2-12.2h3.7l-9.9 8.1 10.3 13.8h-3.6l-8.9-12z" />
<path fill="#050A14"
d="M188.4 71.7a10.4 10.4 0 01-20.8 0 10.4 10.4 0 1120.8 0zM224.2 45c-2.2-3.9-5-7.5-8.2-10.7l-12 7c-3.7-3.2-8-5.7-12.6-7.3a49.4 49.4 0 00-9.7 13.9 59 59 0 0140.1 14l7.6-4.4a57 57 0 00-5.2-12.5zM178 125.1c4.5 0 9-.6 13.4-1.7v-14a40 40 0 0012.5-7.2 47.7 47.7 0 00-7.1-15.3 59 59 0 01-32.2 27.7v8.7c4.4 1.2 8.9 1.8 13.4 1.8zM131.8 45c-2.3 4-4 8.1-5.2 12.5l12 7a40 40 0 000 14.4c5.7 1.5 11.3 2 16.9 1.5a59 59 0 01-8-41.7l-7.5-4.3c-3.2 3.2-6 6.7-8.2 10.6z" />
<path fill="#00B4FF"
d="M224.2 98.4c2.3-3.9 4-8 5.2-12.4l-12-7a40 40 0 000-14.5c-5.7-1.5-11.3-2-16.9-1.5a59 59 0 018 41.7l7.5 4.4c3.2-3.2 6-6.8 8.2-10.7zm-92.4 0c2.2 4 5 7.5 8.2 10.7l12-7a40 40 0 0012.6 7.3c4-4.1 7.3-8.8 9.7-13.8a59 59 0 01-40-14l-7.7 4.4c1.2 4.3 3 8.5 5.2 12.4zm46.2-80c-4.5 0-9 .5-13.4 1.7V34a40 40 0 00-12.5 7.2c1.5 5.7 4 10.8 7.1 15.4a59 59 0 0132.2-27.7V20a53.3 53.3 0 00-13.4-1.8z" />
<path fill="#00B4FF"
d="M178 9.2a62.6 62.6 0 11-.1 125.2A62.6 62.6 0 01178 9.2m0-9.2a71.7 71.7 0 100 143.5A71.7 71.7 0 00178 0z" />
<path fill="#050A14"
d="M96.6 212v4.3c-9.2-.8-15.4-5.8-15.4-17.8V180h4.6v18.4c0 8.6 4 12.6 10.8 13.5zm16-31.9v18.4c0 8.9-4.3 12.8-10.9 13.5v4.4c9.2-.7 15.5-5.6 15.5-18v-18.3h-4.7zM62.2 199v-2.2c0-12.7-8.8-17.4-21-17.4-12.1 0-20.7 4.7-20.7 17.4v2.2c0 12.8 8.6 17.6 20.7 17.6 1.5 0 3-.1 4.4-.3l11.8 6.2 2-3.3-8.2-4-6.4-3.1a32 32 0 01-3.6.2c-9.8 0-16-3.9-16-13.3v-2.2c0-9.3 6.2-13.1 16-13.1 9.9 0 16.3 3.8 16.3 13.1v2.2c0 5.3-2.1 8.7-5.6 10.8l4.8 2.4c3.4-2.8 5.5-7 5.5-13.2zM168 215.6h5.1L156 179.7h-4.8l17 36zM143 205l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.8-3.7H143zm133.7 10.7h5.2l-17.3-35.9h-4.8l17 36zm-25-10.7l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.7-3.7h-14.8zm73.8-2.5c6-1.2 9-5.4 9-11.4 0-8-4.5-10.9-12.9-10.9h-21.4v35.5h4.6v-31.3h16.5c5 0 8.5 1.4 8.5 6.7 0 5.2-3.5 7.7-8.5 7.7h-11.4v4.1h10.7l9.3 12.8h5.5l-9.9-13.2zm-117.4 9.9c-9.7 0-14.7-2.5-18.6-6.3l-2.2 3.8c5.1 5 11 6.7 21 6.7 1.6 0 3.1-.1 4.6-.3l-1.9-4h-3zm18.4-7c0-6.4-4.7-8.6-13.8-9.4l-10.1-1c-6.7-.7-9.3-2.2-9.3-5.6 0-2.5 1.4-4 4.6-5l-1.8-3.8c-4.7 1.4-7.5 4.2-7.5 8.9 0 5.2 3.4 8.7 13 9.6l11.3 1.2c6.4.6 8.9 2 8.9 5.4 0 2.7-2.1 4.7-6 5.8l1.8 3.9c5.3-1.6 8.9-4.7 8.9-10zm-20.3-21.9c7.9 0 13.3 1.8 18.1 5.7l1.8-3.9a30 30 0 00-19.6-5.9c-2 0-4 .1-5.7.3l1.9 4 3.5-.2z" />
<path fill="#00B4FF"
d="M.5 251.9c29.6-.5 59.2-.8 88.8-1l88.7-.3 88.7.3 44.4.4 44.4.6-44.4.6-44.4.4-88.7.3-88.7-.3a7981 7981 0 01-88.8-1z" />
<path fill="none" d="M-565.2 324H-252v15.8h-313.2z" />
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,33 @@
import { defineBoot } from '#q-app/wrappers';
import { createI18n } from 'vue-i18n';
import messages from 'src/i18n';
export type MessageLanguages = keyof typeof messages;
// Type-define 'en-US' as the master schema for the resource
export type MessageSchema = (typeof messages)['en-US'];
// See https://vue-i18n.intlify.dev/guide/advanced/typescript.html#global-resource-schema-type-definition
/* eslint-disable @typescript-eslint/no-empty-object-type */
declare module 'vue-i18n' {
// define the locale messages schema
export interface DefineLocaleMessage extends MessageSchema {}
// define the datetime format schema
export interface DefineDateTimeFormat {}
// define the number format schema
export interface DefineNumberFormat {}
}
/* eslint-enable @typescript-eslint/no-empty-object-type */
export default defineBoot(({ app }) => {
const i18n = createI18n<{ message: MessageSchema }, MessageLanguages>({
locale: 'en-US',
legacy: false,
messages,
});
// Set i18n instance on app
app.use(i18n);
});

View File

@@ -0,0 +1 @@
// app global css in SCSS form

View File

@@ -0,0 +1,25 @@
// Quasar SCSS (& Sass) Variables
// --------------------------------------------------
// To customize the look and feel of this app, you can override
// the Sass/SCSS variables found in Quasar's source Sass/SCSS files.
// Check documentation for full list of Quasar variables
// Your own variables (that are declared here) and Quasar's own
// ones will be available out of the box in your .vue/.scss/.sass files
// It's highly recommended to change the default colors
// to match your app's branding.
// Tip: Use the "Theme Builder" on Quasar's documentation website.
$primary: #1976d2;
$secondary: #26a69a;
$accent: #9c27b0;
$dark: #1d1d1d;
$dark-page: #121212;
$positive: #21ba45;
$negative: #c10015;
$info: #31ccec;
$warning: #f2c037;

8
quasar/admin_section/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV: string;
SITE_URL: string | undefined;
VUE_ROUTER_MODE: 'hash' | 'history' | 'abstract' | undefined;
VUE_ROUTER_BASE: string | undefined;
}
}

View File

@@ -0,0 +1,7 @@
// This is just an example,
// so you can safely delete all default props below
export default {
failed: 'Action failed',
success: 'Action was successful',
};

View File

@@ -0,0 +1,5 @@
import enUS from './en-US';
export default {
'en-US': enUS,
};

View File

@@ -0,0 +1,42 @@
<template>
<q-layout view="lHh Lpr lFf">
<q-header elevated>
<q-toolbar>
<q-btn flat dense round icon="menu" aria-label="Menu" @click="toggleLeftDrawer" />
<q-toolbar-title> Quasar App </q-toolbar-title>
<div>Quasar v{{ $q.version }}</div>
</q-toolbar>
</q-header>
<q-drawer v-model="leftDrawerOpen" show-if-above bordered>
<q-list>
<q-item-label header> List Items </q-item-label>
<q-item
clickable
tag="a"
:href="privateLink"
>
<q-item-section>Private</q-item-section>
</q-item>
</q-list>
</q-drawer>
<q-page-container>
<router-view />
</q-page-container>
</q-layout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const leftDrawerOpen = ref(false);
const siteUrl = (process.env.SITE_URL || '').replace(/\/+$/, '');
const privateLink = `${siteUrl}/private`;
function toggleLeftDrawer() {
leftDrawerOpen.value = !leftDrawerOpen.value;
}
</script>

View File

@@ -0,0 +1,23 @@
<template>
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center">
<div>
<div style="font-size: 30vh">404</div>
<div class="text-h2" style="opacity: 0.4">Oops. Nothing here...</div>
<q-btn
class="q-mt-xl"
color="white"
text-color="blue"
unelevated
to="/"
label="Go Home"
no-caps
/>
</div>
</div>
</template>
<script setup lang="ts">
//
</script>

View File

@@ -0,0 +1,9 @@
<template>
<q-page class="row items-center justify-evenly">
admin page
</q-page>
</template>
<script setup lang="ts">
</script>

View File

@@ -0,0 +1,37 @@
import { defineRouter } from '#q-app/wrappers';
import {
createMemoryHistory,
createRouter,
createWebHashHistory,
createWebHistory,
} from 'vue-router';
import routes from './routes';
/*
* If not building with SSR mode, you can
* directly export the Router instantiation;
*
* The function below can be async too; either use
* async/await or return a Promise which resolves
* with the Router instance.
*/
export default defineRouter(function (/* { store, ssrContext } */) {
const createHistory = process.env.SERVER
? createMemoryHistory
: process.env.VUE_ROUTER_MODE === 'history'
? createWebHistory
: createWebHashHistory;
const Router = createRouter({
scrollBehavior: () => ({ left: 0, top: 0 }),
routes,
// Leave this as is and make changes in quasar.conf.js instead!
// quasar.conf.js -> build -> vueRouterMode
// quasar.conf.js -> build -> publicPath
history: createHistory(process.env.VUE_ROUTER_BASE),
});
return Router;
});

View File

@@ -0,0 +1,18 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('layouts/MainLayout.vue'),
children: [{ path: '', component: () => import('pages/IndexPage.vue') }],
},
// Always leave this as last one,
// but you can also remove it
{
path: '/:catchAll(.*)*',
component: () => import('pages/ErrorNotFound.vue'),
},
];
export default routes;

View File

@@ -0,0 +1,21 @@
import { defineStore, acceptHMRUpdate } from 'pinia';
export const useCounterStore = defineStore('counter', {
state: () => ({
counter: 0,
}),
getters: {
doubleCount: (state) => state.counter * 2,
},
actions: {
increment() {
this.counter++;
},
},
});
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useCounterStore, import.meta.hot));
}

View File

@@ -0,0 +1,32 @@
import { defineStore } from '#q-app/wrappers';
import { createPinia } from 'pinia';
/*
* When adding new properties to stores, you should also
* extend the `PiniaCustomProperties` interface.
* @see https://pinia.vuejs.org/core-concepts/plugins.html#typing-new-store-properties
*/
declare module 'pinia' {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface PiniaCustomProperties {
// add your custom properties here, if any
}
}
/*
* If not building with SSR mode, you can
* directly export the Store instantiation;
*
* The function below can be async too; either use
* async/await or return a Promise which resolves
* with the Store instance.
*/
export default defineStore((/* { ssrContext } */) => {
const pinia = createPinia();
// You can add Pinia plugins here
// pinia.use(SomePiniaPlugin)
return pinia;
});

View File

@@ -0,0 +1,7 @@
{
"extends": "./.quasar/tsconfig.json",
"compilerOptions": {
"skipLibCheck": true
},
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,7 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
charset = utf-8
indent_size = 2
indent_style = space
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

33
quasar/private_section/.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
.DS_Store
.thumbs.db
node_modules
# Quasar core related directories
.quasar
/dist
/quasar.config.*.temporary.compiled*
# Cordova related directories and files
/src-cordova/node_modules
/src-cordova/platforms
/src-cordova/plugins
/src-cordova/www
# Capacitor related directories and files
/src-capacitor/www
/src-capacitor/node_modules
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
# local .env files
.env.local*

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"singleQuote": true,
"printWidth": 100
}

View File

@@ -0,0 +1,43 @@
# Quasar App (private_section)
A Quasar Project
## Install the dependencies
```bash
yarn
# or
npm install
```
### Start the app in development mode (hot-code reloading, error reporting, etc.)
```bash
quasar dev
```
### Lint the files
```bash
yarn lint
# or
npm run lint
```
### Format the files
```bash
yarn format
# or
npm run format
```
### Build the app for production
```bash
quasar build
```
### Customize the configuration
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).

View File

@@ -0,0 +1,83 @@
import js from '@eslint/js';
import globals from 'globals';
import pluginVue from 'eslint-plugin-vue';
import pluginQuasar from '@quasar/app-vite/eslint';
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript';
import prettierSkipFormatting from '@vue/eslint-config-prettier/skip-formatting';
export default defineConfigWithVueTs(
{
/**
* Ignore the following files.
* Please note that pluginQuasar.configs.recommended() already ignores
* the "node_modules" folder for you (and all other Quasar project
* relevant folders and files).
*
* ESLint requires "ignores" key to be the only one in this object
*/
// ignores: []
},
pluginQuasar.configs.recommended(),
js.configs.recommended,
/**
* https://eslint.vuejs.org
*
* pluginVue.configs.base
* -> Settings and rules to enable correct ESLint parsing.
* pluginVue.configs[ 'flat/essential']
* -> base, plus rules to prevent errors or unintended behavior.
* pluginVue.configs["flat/strongly-recommended"]
* -> Above, plus rules to considerably improve code readability and/or dev experience.
* pluginVue.configs["flat/recommended"]
* -> Above, plus rules to enforce subjective community defaults to ensure consistency.
*/
pluginVue.configs['flat/essential'],
{
files: ['**/*.ts', '**/*.vue'],
rules: {
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
},
},
// https://github.com/vuejs/eslint-config-typescript
vueTsConfigs.recommendedTypeChecked,
{
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
...globals.node, // SSR, Electron, config files
process: 'readonly', // process.env.*
ga: 'readonly', // Google Analytics
cordova: 'readonly',
Capacitor: 'readonly',
chrome: 'readonly', // BEX related
browser: 'readonly', // BEX related
},
},
// add your custom rules here
rules: {
'prefer-promise-reject-errors': 'off',
// allow debugger during development only
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
},
},
{
files: ['src-pwa/custom-service-worker.ts'],
languageOptions: {
globals: {
...globals.serviceworker,
},
},
},
prettierSkipFormatting,
);

View File

@@ -0,0 +1,24 @@
<!doctype html>
<html>
<head>
<title><%= productName %></title>
<meta charset="utf-8" />
<meta name="description" content="<%= productDescription %>" />
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<meta
name="viewport"
content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>"
/>
<link rel="icon" type="image/png" sizes="128x128" href="icons/favicon-128x128.png" />
<link rel="icon" type="image/png" sizes="96x96" href="icons/favicon-96x96.png" />
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png" />
<link rel="icon" type="image/ico" href="favicon.ico" />
</head>
<body>
<!-- quasar:entry-point -->
</body>
</html>

View File

@@ -0,0 +1,48 @@
{
"name": "private_section",
"version": "0.0.1",
"description": "Quasar Project for the private section of the application",
"productName": "Quasar App",
"author": "fabio <prada.fabio@gmail.com>",
"type": "module",
"private": true,
"scripts": {
"lint": "eslint -c ./eslint.config.js \"./src*/**/*.{ts,js,cjs,mjs,vue}\"",
"format": "prettier --write \"**/*.{js,ts,vue,scss,html,md,json}\" --ignore-path .gitignore",
"test": "echo \"No test specified\" && exit 0",
"dev": "quasar dev",
"build": "quasar build",
"postinstall": "quasar prepare"
},
"dependencies": {
"@quasar/extras": "^1.17.0",
"pinia": "^3.0.4",
"quasar": "^2.18.6",
"vue": "^3.5.29",
"vue-i18n": "^11.2.8",
"vue-router": "^5.0.3"
},
"devDependencies": {
"@eslint/js": "^9.39.3",
"@intlify/unplugin-vue-i18n": "^4.0.0",
"@quasar/app-vite": "^2.4.1",
"@types/node": "^20.19.35",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.7.0",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.3",
"eslint-plugin-vue": "^10.8.0",
"globals": "^16.5.0",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"vite-plugin-checker": "^0.11.0",
"vue-eslint-parser": "^10.4.0",
"vue-tsc": "^3.2.5"
},
"engines": {
"node": "^28 || ^26 || ^24 || ^22 || ^20",
"npm": ">= 6.13.4",
"yarn": ">= 1.21.1",
"pnpm": ">= 10.0.0"
}
}

5844
quasar/private_section/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
# https://pnpm.io/settings
shamefullyHoist: true

View File

@@ -0,0 +1,29 @@
// https://github.com/michael-ciniawsky/postcss-load-config
import autoprefixer from 'autoprefixer';
// import rtlcss from 'postcss-rtlcss'
export default {
plugins: [
// https://github.com/postcss/autoprefixer
autoprefixer({
overrideBrowserslist: [
'last 4 Chrome versions',
'last 4 Firefox versions',
'last 4 Edge versions',
'last 4 Safari versions',
'last 4 Android versions',
'last 4 ChromeAndroid versions',
'last 4 FirefoxAndroid versions',
'last 4 iOS versions',
],
}),
// https://github.com/elchininet/postcss-rtlcss
// If you want to support RTL css, then
// 1. yarn/pnpm/bun/npm install postcss-rtlcss
// 2. optionally set quasar.config.js > framework > lang to an RTL language
// 3. uncomment the following line (and its import statement above):
// rtlcss()
],
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,242 @@
// Configuration for your app
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file
import { defineConfig } from '#q-app/wrappers';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import dotenv from 'dotenv';
dotenv.config({ path: resolve(__dirname, '../../.env') });
export default defineConfig((ctx) => {
return {
// https://v2.quasar.dev/quasar-cli-vite/prefetch-feature
// preFetch: true,
// app boot file (/src/boot)
// --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli-vite/boot-files
boot: ['i18n'],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css
css: ['app.scss'],
// https://github.com/quasarframework/quasar/tree/dev/extras
extras: [
// 'ionicons-v4',
// 'mdi-v7',
// 'fontawesome-v6',
// 'eva-icons',
// 'themify',
// 'line-awesome',
// 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
'roboto-font', // optional, you are not bound to it
'material-icons', // optional, you are not bound to it
],
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#build
build: {
env: {
SITE_URL: process.env.SITE_URL || '',
},
target: {
browser: ['es2022', 'firefox115', 'chrome115', 'safari14'],
node: 'node20',
},
typescript: {
strict: true,
vueShim: true,
// extendTsConfig (tsConfig) {}
},
vueRouterMode: 'hash', // available values: 'hash', 'history'
// vueRouterBase,
// vueDevtools,
// vueOptionsAPI: false,
// rebuildCache: true, // rebuilds Vite/linter/etc cache on startup
// publicPath: '/',
// analyze: true,
// env: {},
// rawDefine: {}
// ignorePublicFolder: true,
// minify: false,
// polyfillModulePreload: true,
// distDir
// extendViteConf (viteConf) {},
// viteVuePluginOptions: {},
vitePlugins: [
[
'@intlify/unplugin-vue-i18n/vite',
{
// if you want to use Vue I18n Legacy API, you need to set `compositionOnly: false`
// compositionOnly: false,
// if you want to use named tokens in your Vue I18n messages, such as 'Hello {name}',
// you need to set `runtimeOnly: false`
// runtimeOnly: false,
ssr: ctx.modeName === 'ssr',
// you need to set i18n resource including paths !
include: [fileURLToPath(new URL('./src/i18n', import.meta.url))],
},
],
[
'vite-plugin-checker',
{
vueTsc: true,
eslint: {
lintCommand: 'eslint -c ./eslint.config.js "./src*/**/*.{ts,js,mjs,cjs,vue}"',
useFlatConfig: true,
},
},
{ server: false },
],
],
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#devserver
devServer: {
// https: true,
open: true, // opens browser window automatically
},
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework
framework: {
config: {},
// iconSet: 'material-icons', // Quasar icon set
// lang: 'en-US', // Quasar language pack
// For special cases outside of where the auto-import strategy can have an impact
// (like functional components as one of the examples),
// you can manually specify Quasar components/directives to be available everywhere:
//
// components: [],
// directives: [],
// Quasar plugins
plugins: [],
},
// animations: 'all', // --- includes all animations
// https://v2.quasar.dev/options/animations
animations: [],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#sourcefiles
// sourceFiles: {
// rootComponent: 'src/App.vue',
// router: 'src/router/index',
// store: 'src/store/index',
// pwaRegisterServiceWorker: 'src-pwa/register-service-worker',
// pwaServiceWorker: 'src-pwa/custom-service-worker',
// pwaManifestFile: 'src-pwa/manifest.json',
// electronMain: 'src-electron/electron-main',
// electronPreload: 'src-electron/electron-preload'
// bexManifestFile: 'src-bex/manifest.json
// },
// https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr
ssr: {
prodPort: 3000, // The default port that the production server should use
// (gets superseded if process.env.PORT is specified at runtime)
middlewares: [
'render', // keep this as last one
],
// extendPackageJson (json) {},
// extendSSRWebserverConf (esbuildConf) {},
// manualStoreSerialization: true,
// manualStoreSsrContextInjection: true,
// manualStoreHydration: true,
// manualPostHydrationTrigger: true,
pwa: false,
// pwaOfflineHtmlFilename: 'offline.html', // do NOT use index.html as name!
// pwaExtendGenerateSWOptions (cfg) {},
// pwaExtendInjectManifestOptions (cfg) {}
},
// https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa
pwa: {
workboxMode: 'GenerateSW', // 'GenerateSW' or 'InjectManifest'
// swFilename: 'sw.js',
// manifestFilename: 'manifest.json',
// extendManifestJson (json) {},
// useCredentialsForManifestTag: true,
// injectPwaMetaTags: false,
// extendPWACustomSWConf (esbuildConf) {},
// extendGenerateSWOptions (cfg) {},
// extendInjectManifestOptions (cfg) {}
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-cordova-apps/configuring-cordova
cordova: {
// noIosLegacyBuildFlag: true, // uncomment only if you know what you are doing
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-capacitor-apps/configuring-capacitor
capacitor: {
hideSplashscreen: true,
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/configuring-electron
electron: {
// extendElectronMainConf (esbuildConf) {},
// extendElectronPreloadConf (esbuildConf) {},
// extendPackageJson (json) {},
// Electron preload scripts (if any) from /src-electron, WITHOUT file extension
preloadScripts: ['electron-preload'],
// specify the debugging port to use for the Electron app when running in development mode
inspectPort: 5858,
bundler: 'packager', // 'packager' or 'builder'
packager: {
// https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
// OS X / Mac App Store
// appBundleId: '',
// appCategoryType: '',
// osxSign: '',
// protocol: 'myapp://path',
// Windows only
// win32metadata: { ... }
},
builder: {
// https://www.electron.build/configuration
appId: 'private_section',
},
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-browser-extensions/configuring-bex
bex: {
// extendBexScriptsConf (esbuildConf) {},
// extendBexManifestJson (json) {},
/**
* The list of extra scripts (js/ts) not in your bex manifest that you want to
* compile and use in your browser extension. Maybe dynamic use them?
*
* Each entry in the list should be a relative filename to /src-bex/
*
* @example [ 'my-script.ts', 'sub-folder/my-other-script.js' ]
*/
extraScripts: [],
},
};
});

View File

@@ -0,0 +1,7 @@
<template>
<router-view />
</template>
<script setup lang="ts">
//
</script>

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 356 360">
<path
d="M43.4 303.4c0 3.8-2.3 6.3-7.1 6.3h-15v-22h14.4c4.3 0 6.2 2.2 6.2 5.2 0 2.6-1.5 4.4-3.4 5 2.8.4 4.9 2.5 4.9 5.5zm-8-13H24.1v6.9H35c2.1 0 4-1.3 4-3.8 0-2.2-1.3-3.1-3.7-3.1zm5.1 12.6c0-2.3-1.8-3.7-4-3.7H24.2v7.7h11.7c3.4 0 4.6-1.8 4.6-4zm36.3 4v2.7H56v-22h20.6v2.7H58.9v6.8h14.6v2.3H58.9v7.5h17.9zm23-5.8v8.5H97v-8.5l-11-13.4h3.4l8.9 11 8.8-11h3.4l-10.8 13.4zm19.1-1.8V298c0-7.9 5.2-10.7 12.7-10.7 7.5 0 13 2.8 13 10.7v1.4c0 7.9-5.5 10.8-13 10.8s-12.7-3-12.7-10.8zm22.7 0V298c0-5.7-3.9-8-10-8-6 0-9.8 2.3-9.8 8v1.4c0 5.8 3.8 8.1 9.8 8.1 6 0 10-2.3 10-8.1zm37.2-11.6v21.9h-2.9l-15.8-17.9v17.9h-2.8v-22h3l15.6 18v-18h2.9zm37.9 10.2v1.3c0 7.8-5.2 10.4-12.4 10.4H193v-22h11.2c7.2 0 12.4 2.8 12.4 10.3zm-3 0c0-5.3-3.3-7.6-9.4-7.6h-8.4V307h8.4c6 0 9.5-2 9.5-7.7V298zm50.8-7.6h-9.7v19.3h-3v-19.3h-9.7v-2.6h22.4v2.6zm34.4-2.6v21.9h-3v-10.1h-16.8v10h-2.8v-21.8h2.8v9.2H296v-9.2h2.9zm34.9 19.2v2.7h-20.7v-22h20.6v2.7H316v6.8h14.5v2.3H316v7.5h17.8zM24 340.2v7.3h13.9v2.4h-14v9.6H21v-22h20v2.7H24zm41.5 11.4h-9.8v7.9H53v-22h13.3c5.1 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6H66c3.1 0 5.3-1.5 5.3-4.7 0-3.3-2.2-4.1-5.3-4.1H55.7v8.8zm47.9 6.2H89l-2 4.3h-3.2l10.7-22.2H98l10.7 22.2h-3.2l-2-4.3zm-1-2.3l-6.3-13-6 13h12.2zm46.3-15.3v21.9H146v-17.2L135.7 358h-2.1l-10.2-15.6v17h-2.8v-21.8h3l11 16.9 11.3-17h3zm35 19.3v2.6h-20.7v-22h20.6v2.7H166v6.8h14.5v2.3H166v7.6h17.8zm47-19.3l-8.3 22h-3l-7.1-18.6-7 18.6h-3l-8.2-22h3.3L204 356l6.8-18.5h3.4L221 356l6.6-18.5h3.3zm10 11.6v-1.4c0-7.8 5.2-10.7 12.7-10.7 7.6 0 13 2.9 13 10.7v1.4c0 7.9-5.4 10.8-13 10.8-7.5 0-12.7-3-12.7-10.8zm22.8 0v-1.4c0-5.7-4-8-10-8s-9.9 2.3-9.9 8v1.4c0 5.8 3.8 8.2 9.8 8.2 6.1 0 10-2.4 10-8.2zm28.3 2.4h-9.8v7.9h-2.8v-22h13.2c5.2 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6h10.2c3 0 5.2-1.5 5.2-4.7 0-3.3-2.1-4.1-5.2-4.1h-10.2v8.8zm40.3-1.5l-6.8 5.6v6.4h-2.9v-22h2.9v12.3l15.2-12.2h3.7l-9.9 8.1 10.3 13.8h-3.6l-8.9-12z" />
<path fill="#050A14"
d="M188.4 71.7a10.4 10.4 0 01-20.8 0 10.4 10.4 0 1120.8 0zM224.2 45c-2.2-3.9-5-7.5-8.2-10.7l-12 7c-3.7-3.2-8-5.7-12.6-7.3a49.4 49.4 0 00-9.7 13.9 59 59 0 0140.1 14l7.6-4.4a57 57 0 00-5.2-12.5zM178 125.1c4.5 0 9-.6 13.4-1.7v-14a40 40 0 0012.5-7.2 47.7 47.7 0 00-7.1-15.3 59 59 0 01-32.2 27.7v8.7c4.4 1.2 8.9 1.8 13.4 1.8zM131.8 45c-2.3 4-4 8.1-5.2 12.5l12 7a40 40 0 000 14.4c5.7 1.5 11.3 2 16.9 1.5a59 59 0 01-8-41.7l-7.5-4.3c-3.2 3.2-6 6.7-8.2 10.6z" />
<path fill="#00B4FF"
d="M224.2 98.4c2.3-3.9 4-8 5.2-12.4l-12-7a40 40 0 000-14.5c-5.7-1.5-11.3-2-16.9-1.5a59 59 0 018 41.7l7.5 4.4c3.2-3.2 6-6.8 8.2-10.7zm-92.4 0c2.2 4 5 7.5 8.2 10.7l12-7a40 40 0 0012.6 7.3c4-4.1 7.3-8.8 9.7-13.8a59 59 0 01-40-14l-7.7 4.4c1.2 4.3 3 8.5 5.2 12.4zm46.2-80c-4.5 0-9 .5-13.4 1.7V34a40 40 0 00-12.5 7.2c1.5 5.7 4 10.8 7.1 15.4a59 59 0 0132.2-27.7V20a53.3 53.3 0 00-13.4-1.8z" />
<path fill="#00B4FF"
d="M178 9.2a62.6 62.6 0 11-.1 125.2A62.6 62.6 0 01178 9.2m0-9.2a71.7 71.7 0 100 143.5A71.7 71.7 0 00178 0z" />
<path fill="#050A14"
d="M96.6 212v4.3c-9.2-.8-15.4-5.8-15.4-17.8V180h4.6v18.4c0 8.6 4 12.6 10.8 13.5zm16-31.9v18.4c0 8.9-4.3 12.8-10.9 13.5v4.4c9.2-.7 15.5-5.6 15.5-18v-18.3h-4.7zM62.2 199v-2.2c0-12.7-8.8-17.4-21-17.4-12.1 0-20.7 4.7-20.7 17.4v2.2c0 12.8 8.6 17.6 20.7 17.6 1.5 0 3-.1 4.4-.3l11.8 6.2 2-3.3-8.2-4-6.4-3.1a32 32 0 01-3.6.2c-9.8 0-16-3.9-16-13.3v-2.2c0-9.3 6.2-13.1 16-13.1 9.9 0 16.3 3.8 16.3 13.1v2.2c0 5.3-2.1 8.7-5.6 10.8l4.8 2.4c3.4-2.8 5.5-7 5.5-13.2zM168 215.6h5.1L156 179.7h-4.8l17 36zM143 205l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.8-3.7H143zm133.7 10.7h5.2l-17.3-35.9h-4.8l17 36zm-25-10.7l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.7-3.7h-14.8zm73.8-2.5c6-1.2 9-5.4 9-11.4 0-8-4.5-10.9-12.9-10.9h-21.4v35.5h4.6v-31.3h16.5c5 0 8.5 1.4 8.5 6.7 0 5.2-3.5 7.7-8.5 7.7h-11.4v4.1h10.7l9.3 12.8h5.5l-9.9-13.2zm-117.4 9.9c-9.7 0-14.7-2.5-18.6-6.3l-2.2 3.8c5.1 5 11 6.7 21 6.7 1.6 0 3.1-.1 4.6-.3l-1.9-4h-3zm18.4-7c0-6.4-4.7-8.6-13.8-9.4l-10.1-1c-6.7-.7-9.3-2.2-9.3-5.6 0-2.5 1.4-4 4.6-5l-1.8-3.8c-4.7 1.4-7.5 4.2-7.5 8.9 0 5.2 3.4 8.7 13 9.6l11.3 1.2c6.4.6 8.9 2 8.9 5.4 0 2.7-2.1 4.7-6 5.8l1.8 3.9c5.3-1.6 8.9-4.7 8.9-10zm-20.3-21.9c7.9 0 13.3 1.8 18.1 5.7l1.8-3.9a30 30 0 00-19.6-5.9c-2 0-4 .1-5.7.3l1.9 4 3.5-.2z" />
<path fill="#00B4FF"
d="M.5 251.9c29.6-.5 59.2-.8 88.8-1l88.7-.3 88.7.3 44.4.4 44.4.6-44.4.6-44.4.4-88.7.3-88.7-.3a7981 7981 0 01-88.8-1z" />
<path fill="none" d="M-565.2 324H-252v15.8h-313.2z" />
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

Some files were not shown because too many files have changed in this diff Show More