Build go-backend auth app with Podman dev flow

This commit is contained in:
Arthur Belleville 2026-05-08 12:08:53 +02:00
parent 98952ace6e
commit 0a38442d88
44 changed files with 3563 additions and 876 deletions

3
.gitignore vendored
View file

@ -45,3 +45,6 @@ dist
# Supabase
supabase/.temp
supabase/.branches
# Podman
.podman-compose

View file

@ -0,0 +1,209 @@
# Go Backend Login Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the current Vite-oriented `go_backend` app with a standalone Go web app that serves a templ-rendered XTablo login page and uses HTMX for inline login feedback on `http://localhost:3000`.
**Architecture:** The server remains a small `chi` application. `GET /` renders a full page from `templ`, `POST /login` returns a small server-rendered fragment for HTMX swaps, and `GET /static/*` serves local CSS. The page includes the HTMX runtime from the specified CDN URL and no custom app JavaScript.
**Tech Stack:** Go, chi, templ, net/http, HTMX CDN, server-served CSS
---
## Chunk 1: Test Scaffold And Module Setup
### Task 1: Add dependencies and basic file layout
**Files:**
- Modify: `go_backend/go.mod`
- Modify: `go_backend/go.sum`
- Create: `go_backend/internal/web/handlers/login.go`
- Create: `go_backend/internal/web/views/layout.templ`
- Create: `go_backend/internal/web/views/login.templ`
- Create: `go_backend/static/styles.css`
- [ ] **Step 1: Add the failing tests first before any production behavior changes**
- [ ] **Step 2: Add `templ` module dependency after the tests are in place**
- [ ] **Step 3: Create focused directories for handlers, views, and static assets**
- [ ] **Step 4: Keep Vite-only files untouched until replacement code is ready**
## Chunk 2: HTTP-Level TDD
### Task 2: Cover the root page
**Files:**
- Create: `go_backend/router_test.go`
- Test: `go_backend/router_test.go`
- [ ] **Step 1: Write a failing test for `GET /`**
```go
func TestRootRendersLoginPage(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
router := newRouter()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"Se connecter a Xtablo",
`hx-post="/login"`,
"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected body to contain %q", want)
}
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./...`
Expected: the new root-page assertions fail against the current Vite/Spa output.
- [ ] **Step 3: Implement the minimum root-page rendering path**
- [ ] **Step 4: Run test to verify it passes**
Run: `go test ./...`
Expected: `TestRootRendersLoginPage` passes.
### Task 3: Cover HTMX login fragment behavior
**Files:**
- Modify: `go_backend/router_test.go`
- Test: `go_backend/router_test.go`
- [ ] **Step 1: Write a failing test for missing login fields**
```go
func TestLoginReturnsValidationError(t *testing.T) {
form := url.Values{}
form.Set("email", "")
form.Set("password", "")
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
router := newRouter()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected status 422, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Veuillez renseigner votre email et votre mot de passe") {
t.Fatalf("expected validation error fragment, got %q", rec.Body.String())
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./...`
Expected: missing route or wrong response.
- [ ] **Step 3: Write a failing test for demo credential success**
```go
func TestLoginReturnsSuccessMessage(t *testing.T) {
form := url.Values{}
form.Set("email", "demo@xtablo.com")
form.Set("password", "xtablo-demo")
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
router := newRouter()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Connexion reussie") {
t.Fatalf("expected success fragment, got %q", rec.Body.String())
}
}
```
- [ ] **Step 4: Run test to verify it fails**
Run: `go test ./...`
Expected: no success behavior exists yet.
- [ ] **Step 5: Implement the minimal `POST /login` validation and fragment rendering**
- [ ] **Step 6: Run test to verify both login fragment tests pass**
Run: `go test ./...`
Expected: both login tests pass.
## Chunk 3: Replace Vite-Oriented Production Code
### Task 4: Simplify routing and startup
**Files:**
- Modify: `go_backend/main.go`
- Modify: `go_backend/router.go`
- Possibly remove references from: `go_backend/internal/spahandler/handler.go`
- Possibly remove references from: `go_backend/internal/frontend/file.go`
- [ ] **Step 1: Introduce a `newRouter()` function shared by tests and main**
- [ ] **Step 2: Bind the server to `localhost:3000`**
- [ ] **Step 3: Replace dev/prod SPA branching with direct routes for `/`, `/login`, and `/static/*`**
- [ ] **Step 4: Remove unused Vite-specific wiring once tests stay green**
### Task 5: Render the login page with templ
**Files:**
- Modify: `go_backend/internal/web/views/layout.templ`
- Modify: `go_backend/internal/web/views/login.templ`
- Modify: `go_backend/internal/web/handlers/login.go`
- Modify: `go_backend/static/styles.css`
- [ ] **Step 1: Create a base page layout with head metadata, CSS link, and HTMX CDN script**
- [ ] **Step 2: Create the login page component**
- [ ] **Step 3: Create a reusable status fragment component for success and error states**
- [ ] **Step 4: Match the requested visual structure with local CSS, not utility classes**
- [ ] **Step 5: Keep copy in French and links as placeholders where needed**
## Chunk 4: Verification
### Task 6: Generate code, format, test, and build
**Files:**
- Generated: `go_backend/internal/web/views/*_templ.go`
- [ ] **Step 1: Generate templ output**
Run: `templ generate`
Expected: updated generated Go files for the templ views.
- [ ] **Step 2: Format the Go code**
Run: `gofmt -w main.go router.go internal/web/handlers/login.go internal/web/views/*_templ.go router_test.go`
Expected: no output, files rewritten in place.
- [ ] **Step 3: Run the full Go test suite**
Run: `go test ./...`
Expected: all tests pass.
- [ ] **Step 4: Run a build verification**
Run: `go build ./...`
Expected: build succeeds with exit code 0.
- [ ] **Step 5: Manual smoke check**
Run: `go run .`
Expected: server listens on `http://localhost:3000`.
---
Plan complete and saved to `docs/superpowers/plans/2026-05-07-go-backend-login.md`. Ready to execute.

View file

@ -0,0 +1,142 @@
# Go Backend Login Design
**Date:** 2026-05-07
**Goal**
Build a new standalone web app in `go_backend` that serves a server-rendered XTablo login screen using Go, `templ`, and HTMX, with no Vite pipeline and no custom application JavaScript.
**Scope**
- Replace the current SPA/Vite-oriented serving path in `go_backend`.
- Render the main login screen at `/`.
- Submit the login form with HTMX to `/login`.
- Return a server-rendered HTML fragment into the login card for feedback.
- Serve local CSS and any local static assets directly from Go.
**Out of Scope**
- Real authentication integration
- Session management
- OAuth / Google sign-in implementation
- Theme switching behavior
- Multi-page navigation beyond placeholder links
**Architecture**
The app will be a small Go HTTP service using `chi` for routing and `templ` for HTML generation. The server will expose a page route for the full login screen, a form submission route for partial updates, and a static file route for CSS and local assets.
The development server should listen on `localhost:3000`.
HTMX will be loaded from the user-specified CDN URL:
`https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js`
No Vite integration, bundling, or custom frontend JavaScript will remain in the new app.
**Route Design**
- `GET /`
- Returns the full login page.
- `POST /login`
- Accepts form fields `email` and `password`.
- Returns a small server-rendered fragment for an inline status/message region.
- `GET /static/*`
- Serves CSS and optional local static assets.
**Server Structure**
Proposed structure:
- `go_backend/main.go`
- Server startup and configuration.
- `go_backend/router.go`
- Route registration.
- `go_backend/internal/web/handlers/`
- HTTP handlers for page rendering and login submission.
- `go_backend/internal/web/views/`
- `templ` page and fragment components.
- `go_backend/static/`
- CSS and optional local assets.
The code should keep routing, request handling, and view rendering separated so future auth wiring does not force template logic into the router layer.
**UI Design**
The login page should visually track the provided reference:
- full-screen soft gradient background
- centered frosted/glass login card
- top row with back link and theme-toggle placeholder button
- centered XTablo brand area
- French login title and surrounding copy
- email/password fields
- forgot-password link
- primary submit button
- “Ou continuer avec” separator
- Google continuation button
- signup prompt at the bottom
The background should include decorative floating logo-like ornaments implemented with CSS positioning and animation. If the repository does not already contain usable logo assets, the page should still render cleanly with text or simple shape placeholders rather than blocking implementation on image sourcing.
**Behavior**
The app remains primarily server-rendered.
The login form will use HTMX attributes:
- `hx-post="/login"`
- `hx-target` for an inline feedback region
- `hx-swap="innerHTML"`
On submit:
- missing fields should return an error fragment
- invalid placeholder credentials should return an error fragment
- a known demo credential path may return a success fragment
This keeps the page interactive without adding custom client-side scripts.
**Validation Strategy**
For the initial implementation:
- both fields required
- simple server-side empty-value validation
- optional demo credential branch for a success state
This is sufficient for a scaffold and keeps the design aligned with the user request without inventing an auth system.
**Testing Strategy**
Use TDD at the HTTP handler level.
Minimum tests:
- `GET /` returns `200` and includes core login page content
- `POST /login` with missing fields returns an error fragment
- `POST /login` with demo credentials returns a success fragment
Additional checks:
- the Go module builds after adding `templ`
- generated templ code is committed or present in the workspace
**Risks and Mitigations**
- `templ` introduces code generation.
- Mitigation: generate templates as part of the implementation flow and verify build output explicitly.
- Replacing the current Vite setup may leave dead code.
- Mitigation: remove or stop referencing Vite-specific handlers and dependencies during the migration.
- Visual parity with the sample may drift without its exact assets.
- Mitigation: match layout, hierarchy, spacing, color direction, and component treatment first; degrade gracefully on branding assets.
**Acceptance Criteria**
- Visiting `/` in `go_backend` shows a standalone login page rendered by Go.
- The server runs at `http://localhost:3000`.
- The page uses `templ` output rather than Vite or SPA mounting.
- HTMX is loaded from the specified CDN URL.
- No custom app JavaScript is added.
- Submitting the form updates an inline message region via HTMX.
- The app builds and targeted tests pass.

27
go-backend/.air.toml Normal file
View file

@ -0,0 +1,27 @@
#:schema https://json.schemastore.org/any.json
root = "."
tmp_dir = "tmp"
[build]
cmd = "go run github.com/a-h/templ/cmd/templ@latest generate && go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate && go build -o ./tmp/main ."
entrypoint = ["./tmp/main"]
include_ext = ["go", "templ", "sql", "css", "html", "png", "svg", "webmanifest", "json"]
exclude_dir = ["tmp", "vendor", ".git", "internal/db/sqlc"]
exclude_regex = ["_templ\\.go$"]
delay = 200
stop_on_error = true
send_interrupt = true
kill_delay = "500ms"
[color]
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"
[log]
time = true
[misc]
clean_on_exit = true

1
go-backend/.env.example Normal file
View file

@ -0,0 +1 @@
DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable

43
go-backend/README.md Normal file
View file

@ -0,0 +1,43 @@
# go-backend
## Local Postgres
Start Postgres with Podman:
```bash
just db-up
```
Reset the local database volume and reinitialize the schema:
```bash
just db-reset
```
The database is exposed at `localhost:5432` and initialized from `internal/db/schema.sql`.
The local workflow uses `podman compose`.
If your global Docker config contains corporate credential helpers, the `just` recipes isolate compose with a local `DOCKER_CONFIG` to avoid unrelated registry auth hooks on public image pulls.
Fresh database volumes are also seeded from `internal/db/seed.sql` with `demo@xtablo.com / xtablo-demo`.
Connection string:
```bash
export DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable
```
Run the app with hot reload:
```bash
just dev
```
Other useful commands:
```bash
just generate
just test
just build
just check
just db-logs
just db-down
```

23
go-backend/compose.yaml Normal file
View file

@ -0,0 +1,23 @@
services:
postgres:
image: postgres:16-alpine
container_name: xtablo-go-backend-postgres
restart: unless-stopped
environment:
POSTGRES_DB: xtablo
POSTGRES_USER: xtablo
POSTGRES_PASSWORD: xtablo
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./internal/db/schema.sql:/docker-entrypoint-initdb.d/001-auth-schema.sql:ro
- ./internal/db/seed.sql:/docker-entrypoint-initdb.d/002-seed.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U xtablo -d xtablo"]
interval: 5s
timeout: 5s
retries: 10
volumes:
postgres_data:

64
go-backend/go.mod Normal file
View file

@ -0,0 +1,64 @@
module xtablo-backend
go 1.26.0
require github.com/go-chi/chi/v5 v5.2.0
require (
github.com/a-h/templ v0.3.1001
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.2
github.com/sqlc-dev/sqlc v1.31.1
)
require (
cel.dev/expr v0.25.1 // indirect
filippo.io/edwards25519 v1.1.1 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/coreos/go-semver v0.3.1 // indirect
github.com/cubicdaiya/gonp v1.0.4 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/google/cel-go v0.28.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/ncruces/go-sqlite3 v0.32.0 // indirect
github.com/ncruces/julianday v1.0.0 // indirect
github.com/pganalyze/pg_query_go/v6 v6.2.2 // indirect
github.com/pingcap/errors v0.11.5-0.20250523034308-74f78ae071ee // indirect
github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect
github.com/pingcap/log v1.1.0 // indirect
github.com/pingcap/tidb/pkg/parser v0.0.0-20260418072757-ce92298d1124 // indirect
github.com/riza-io/grpc-go v0.2.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/sqlc-dev/doubleclick v1.0.0 // indirect
github.com/tetratelabs/wazero v1.11.0 // indirect
github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect
github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/rs/zerolog v1.33.0
golang.org/x/crypto v0.48.0
golang.org/x/sys v0.43.0 // indirect
)

192
go-backend/go.sum Normal file
View file

@ -0,0 +1,192 @@
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws=
github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I=
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/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc=
github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
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.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/ncruces/go-sqlite3 v0.32.0 h1:hNBUXp88LrfQCsuyXLqWTbTUG35sUuktDsqhhgHvU20=
github.com/ncruces/go-sqlite3 v0.32.0/go.mod h1:MIWTK60ONDl0oVY073zYvJP21C3Dly6P9bxVpgkLwdQ=
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
github.com/pganalyze/pg_query_go/v6 v6.2.2 h1:O0L6zMC226R82RF3X5n0Ki6HjytDsoAzuzp4ATVAHNo=
github.com/pganalyze/pg_query_go/v6 v6.2.2/go.mod h1:Cn6+j4870kJz3iYNsb0VsNG04vpSWgEvBwc590J4qD0=
github.com/pingcap/errors v0.11.0/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pingcap/errors v0.11.5-0.20250523034308-74f78ae071ee h1:/IDPbpzkzA97t1/Z1+C3KlxbevjMeaI6BQYxvivu4u8=
github.com/pingcap/errors v0.11.5-0.20250523034308-74f78ae071ee/go.mod h1:X2r9ueLEUZgtx2cIogM0v4Zj5uvvzhuuiu7Pn8HzMPg=
github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 h1:tdMsjOqUR7YXHoBitzdebTvOjs/swniBTOLy5XiMtuE=
github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86/go.mod h1:exzhVYca3WRtd6gclGNErRWb1qEgff3LYta0LvRmON4=
github.com/pingcap/log v1.1.0 h1:ELiPxACz7vdo1qAvvaWJg1NrYFoY6gqAh/+Uo6aXdD8=
github.com/pingcap/log v1.1.0/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4=
github.com/pingcap/tidb/pkg/parser v0.0.0-20260418072757-ce92298d1124 h1:zYmP5fBH+i2yhhU6f5uOol6zxHtR2/sD47BsJLfy0oU=
github.com/pingcap/tidb/pkg/parser v0.0.0-20260418072757-ce92298d1124/go.mod h1:zDLDsfNBU5+L6T4J9/OgWAHc/WZvMUjbpgHqQ/t3yKo=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ=
github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/sqlc-dev/doubleclick v1.0.0 h1:2/OApfQ2eLgcfa/Fqs8WSMA6atH0G8j9hHbQIgMfAXI=
github.com/sqlc-dev/doubleclick v1.0.0/go.mod h1:ODHRroSrk/rr5neRHlWMSRijqOak8YmNaO3VAZCNl5Y=
github.com/sqlc-dev/sqlc v1.31.1 h1:+V+BjBJfFNPX/RFfL8eiZD9jk9lVJUEGGllWvnYNqbc=
github.com/sqlc-dev/sqlc v1.31.1/go.mod h1:6ZPww/Jd3G6MzJeW6NrqizjL+52vYNaaXP9yMeJ/Nao=
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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo=
github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM=
github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4=
github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM=
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/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=

View file

@ -0,0 +1,29 @@
-- name: CreateAuthUser :one
INSERT INTO auth.users (
id,
email,
encrypted_password,
raw_user_meta_data,
created_at,
updated_at
) VALUES (
$1,
$2,
$3,
jsonb_build_object('display_name', sqlc.arg(display_name)),
now(),
now()
)
RETURNING id;
-- name: GetAuthUserByEmail :one
SELECT id, email, encrypted_password, created_at, updated_at
FROM auth.users
WHERE email = $1
LIMIT 1;
-- name: GetPublicUserByID :one
SELECT id, email, created_at, updated_at, display_name
FROM public.users
WHERE id = $1
LIMIT 1;

View file

@ -0,0 +1,102 @@
package db
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog/log"
sqlcdb "xtablo-backend/internal/db/sqlc"
"xtablo-backend/internal/web/handlers"
)
type PostgresAuthRepository struct {
pool *pgxpool.Pool
queries *sqlcdb.Queries
}
func NewPostgresAuthRepository(ctx context.Context, databaseURL string) (*PostgresAuthRepository, error) {
if databaseURL == "" {
return nil, errors.New("DATABASE_URL is required")
}
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
return nil, fmt.Errorf("connect postgres: %w", err)
}
return &PostgresAuthRepository{
pool: pool,
queries: sqlcdb.New(pool),
}, nil
}
func (r *PostgresAuthRepository) Close() {
r.pool.Close()
}
func (r *PostgresAuthRepository) CreateAuthUser(ctx context.Context, input handlers.CreateAuthUserInput) (uuid.UUID, error) {
id := uuid.New()
createdID, err := r.queries.CreateAuthUser(ctx, sqlcdb.CreateAuthUserParams{
ID: id,
Email: input.Email,
EncryptedPassword: input.EncryptedPassword,
DisplayName: input.DisplayName,
})
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return uuid.Nil, handlers.ErrUserAlreadyExists
}
return uuid.Nil, err
}
log.Info().
Str("component", "auth_store").
Str("action", "create_user").
Str("email", input.Email).
Msg("auth store mutated")
return createdID, nil
}
func (r *PostgresAuthRepository) GetAuthUserByEmail(ctx context.Context, email string) (handlers.AuthUser, error) {
row, err := r.queries.GetAuthUserByEmail(ctx, email)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return handlers.AuthUser{}, handlers.ErrUserNotFound
}
return handlers.AuthUser{}, err
}
return handlers.AuthUser{
ID: row.ID,
Email: row.Email,
EncryptedPassword: row.EncryptedPassword,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
}, nil
}
func (r *PostgresAuthRepository) GetPublicUserByID(ctx context.Context, id uuid.UUID) (handlers.PublicUser, error) {
row, err := r.queries.GetPublicUserByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return handlers.PublicUser{}, handlers.ErrUserNotFound
}
return handlers.PublicUser{}, err
}
return handlers.PublicUser{
ID: row.ID,
Email: row.Email,
DisplayName: row.DisplayName,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
}, nil
}

View file

@ -0,0 +1,42 @@
CREATE SCHEMA IF NOT EXISTS auth;
CREATE TABLE IF NOT EXISTS auth.users (
id uuid PRIMARY KEY,
email text NOT NULL UNIQUE,
encrypted_password text NOT NULL,
raw_user_meta_data jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.users (
id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
display_name text NOT NULL
);
CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
INSERT INTO public.users (id, email, created_at, updated_at, display_name)
VALUES (
NEW.id,
NEW.email,
NEW.created_at,
NEW.updated_at,
COALESCE(NEW.raw_user_meta_data ->> 'display_name', split_part(NEW.email, '@', 1))
);
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW
EXECUTE FUNCTION public.handle_new_user();

View file

@ -0,0 +1,16 @@
INSERT INTO auth.users (
id,
email,
encrypted_password,
raw_user_meta_data,
created_at,
updated_at
) VALUES (
'11111111-1111-1111-1111-111111111111',
'demo@xtablo.com',
'$2a$10$/xeyC8tiOZTcw2BBOSrv.uWu.EbRMYwF7MpFcDHSS40fOoTR.QrLS',
jsonb_build_object('display_name', 'demo'),
now(),
now()
)
ON CONFLICT (email) DO NOTHING;

View file

@ -0,0 +1,32 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package sqlc
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
return &Queries{
db: tx,
}
}

View file

@ -0,0 +1,27 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package sqlc
import (
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type AuthUser struct {
ID uuid.UUID `db:"id"`
Email string `db:"email"`
EncryptedPassword string `db:"encrypted_password"`
RawUserMetaData []byte `db:"raw_user_meta_data"`
CreatedAt pgtype.Timestamptz `db:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at"`
}
type User struct {
ID uuid.UUID `db:"id"`
Email string `db:"email"`
CreatedAt pgtype.Timestamptz `db:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at"`
DisplayName string `db:"display_name"`
}

View file

@ -0,0 +1,19 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package sqlc
import (
"context"
"github.com/google/uuid"
)
type Querier interface {
CreateAuthUser(ctx context.Context, arg CreateAuthUserParams) (uuid.UUID, error)
GetAuthUserByEmail(ctx context.Context, email string) (GetAuthUserByEmailRow, error)
GetPublicUserByID(ctx context.Context, id uuid.UUID) (User, error)
}
var _ Querier = (*Queries)(nil)

View file

@ -0,0 +1,99 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: queries.sql
package sqlc
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const createAuthUser = `-- name: CreateAuthUser :one
INSERT INTO auth.users (
id,
email,
encrypted_password,
raw_user_meta_data,
created_at,
updated_at
) VALUES (
$1,
$2,
$3,
jsonb_build_object('display_name', $4),
now(),
now()
)
RETURNING id
`
type CreateAuthUserParams struct {
ID uuid.UUID `db:"id"`
Email string `db:"email"`
EncryptedPassword string `db:"encrypted_password"`
DisplayName interface{} `db:"display_name"`
}
func (q *Queries) CreateAuthUser(ctx context.Context, arg CreateAuthUserParams) (uuid.UUID, error) {
row := q.db.QueryRow(ctx, createAuthUser,
arg.ID,
arg.Email,
arg.EncryptedPassword,
arg.DisplayName,
)
var id uuid.UUID
err := row.Scan(&id)
return id, err
}
const getAuthUserByEmail = `-- name: GetAuthUserByEmail :one
SELECT id, email, encrypted_password, created_at, updated_at
FROM auth.users
WHERE email = $1
LIMIT 1
`
type GetAuthUserByEmailRow struct {
ID uuid.UUID `db:"id"`
Email string `db:"email"`
EncryptedPassword string `db:"encrypted_password"`
CreatedAt pgtype.Timestamptz `db:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at"`
}
func (q *Queries) GetAuthUserByEmail(ctx context.Context, email string) (GetAuthUserByEmailRow, error) {
row := q.db.QueryRow(ctx, getAuthUserByEmail, email)
var i GetAuthUserByEmailRow
err := row.Scan(
&i.ID,
&i.Email,
&i.EncryptedPassword,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getPublicUserByID = `-- name: GetPublicUserByID :one
SELECT id, email, created_at, updated_at, display_name
FROM public.users
WHERE id = $1
LIMIT 1
`
func (q *Queries) GetPublicUserByID(ctx context.Context, id uuid.UUID) (User, error) {
row := q.db.QueryRow(ctx, getPublicUserByID, id)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.CreatedAt,
&i.UpdatedAt,
&i.DisplayName,
)
return i, err
}

View file

@ -0,0 +1,396 @@
package handlers
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt"
"xtablo-backend/internal/web/views"
)
const sessionCookieName = "xtablo_session"
var ErrUserNotFound = errors.New("user not found")
var ErrUserAlreadyExists = errors.New("user already exists")
type AuthRepository interface {
CreateAuthUser(ctx context.Context, input CreateAuthUserInput) (uuid.UUID, error)
GetAuthUserByEmail(ctx context.Context, email string) (AuthUser, error)
GetPublicUserByID(ctx context.Context, id uuid.UUID) (PublicUser, error)
}
type CreateAuthUserInput struct {
Email string
EncryptedPassword string
DisplayName string
}
type AuthUser struct {
ID uuid.UUID
Email string
EncryptedPassword string
CreatedAt time.Time
UpdatedAt time.Time
}
type PublicUser struct {
ID uuid.UUID
Email string
DisplayName string
CreatedAt time.Time
UpdatedAt time.Time
}
type AuthHandler struct {
repo AuthRepository
sessions *sessionStore
}
type sessionStore struct {
mu sync.RWMutex
sessions map[string]uuid.UUID
}
type InMemoryAuthRepository struct {
mu sync.RWMutex
authUsers map[string]AuthUser
publicUsers map[uuid.UUID]PublicUser
}
func NewAuthHandler(repo AuthRepository) *AuthHandler {
return &AuthHandler{
repo: repo,
sessions: &sessionStore{sessions: map[string]uuid.UUID{}},
}
}
func NewInMemoryAuthRepository() *InMemoryAuthRepository {
repo := &InMemoryAuthRepository{
authUsers: map[string]AuthUser{},
publicUsers: map[uuid.UUID]PublicUser{},
}
demoHash, err := hashPassword("xtablo-demo")
if err != nil {
panic(err)
}
if _, err := repo.CreateAuthUser(context.Background(), CreateAuthUserInput{
Email: "demo@xtablo.com",
EncryptedPassword: demoHash,
DisplayName: "demo",
}); err != nil {
panic(err)
}
return repo
}
func (h *AuthHandler) GetHome() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.currentUserID(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
user, err := h.repo.GetPublicUserByID(r.Context(), userID)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := views.HomePage(user.DisplayName, user.Email).Render(r.Context(), w); err != nil {
http.Error(w, "failed to render home page", http.StatusInternalServerError)
}
}
}
func (h *AuthHandler) PostLogout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var email string
if userID, ok := h.currentUserID(r); ok {
if user, err := h.repo.GetPublicUserByID(r.Context(), userID); err == nil {
email = user.Email
}
}
if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
h.sessions.delete(cookie.Value, email)
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
}
func (h *AuthHandler) GetLoginPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if _, ok := h.currentUserID(r); ok {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := views.AuthPage(views.LoginScreen()).Render(r.Context(), w); err != nil {
http.Error(w, "failed to render login page", http.StatusInternalServerError)
}
}
}
func (h *AuthHandler) GetSignupPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if _, ok := h.currentUserID(r); ok {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := views.AuthPage(views.SignupScreen()).Render(r.Context(), w); err != nil {
http.Error(w, "failed to render signup page", http.StatusInternalServerError)
}
}
}
func (h *AuthHandler) PostLogin() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form payload", http.StatusBadRequest)
return
}
email := normalizeEmail(r.FormValue("email"))
password := strings.TrimSpace(r.FormValue("password"))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
switch {
case email == "" || password == "":
w.WriteHeader(http.StatusUnprocessableEntity)
_ = views.AuthStatus("error", "Veuillez renseigner votre email et votre mot de passe.").Render(r.Context(), w)
return
}
authUser, err := h.repo.GetAuthUserByEmail(r.Context(), email)
if err != nil || bcrypt.CompareHashAndPassword([]byte(authUser.EncryptedPassword), []byte(password)) != nil {
w.WriteHeader(http.StatusUnauthorized)
_ = views.AuthStatus("error", "Identifiants invalides. Essayez demo@xtablo.com / xtablo-demo.").Render(r.Context(), w)
return
}
h.setSession(w, authUser.ID, authUser.Email)
w.Header().Set("HX-Redirect", "/")
_ = views.AuthStatus("success", "Connexion réussie.").Render(r.Context(), w)
}
}
func (h *AuthHandler) PostSignup() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form payload", http.StatusBadRequest)
return
}
email := normalizeEmail(r.FormValue("email"))
password := strings.TrimSpace(r.FormValue("password"))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
switch {
case email == "" || password == "":
w.WriteHeader(http.StatusUnprocessableEntity)
_ = views.AuthStatus("error", "Veuillez renseigner votre email et choisir un mot de passe.").Render(r.Context(), w)
return
}
if _, err := h.repo.GetAuthUserByEmail(r.Context(), email); err == nil {
w.WriteHeader(http.StatusConflict)
_ = views.AuthStatus("error", "Un compte existe déjà avec cet email.").Render(r.Context(), w)
return
} else if !errors.Is(err, ErrUserNotFound) {
http.Error(w, "failed to check existing user", http.StatusInternalServerError)
return
}
passwordHash, err := hashPassword(password)
if err != nil {
http.Error(w, "failed to hash password", http.StatusInternalServerError)
return
}
displayName := displayNameFromEmail(email)
userID, err := h.repo.CreateAuthUser(r.Context(), CreateAuthUserInput{
Email: email,
EncryptedPassword: passwordHash,
DisplayName: displayName,
})
if err != nil {
if errors.Is(err, ErrUserAlreadyExists) {
w.WriteHeader(http.StatusConflict)
_ = views.AuthStatus("error", "Un compte existe déjà avec cet email.").Render(r.Context(), w)
return
}
http.Error(w, "failed to create user", http.StatusInternalServerError)
return
}
h.setSession(w, userID, email)
w.Header().Set("HX-Redirect", "/")
_ = views.AuthStatus("success", "Compte créé.").Render(r.Context(), w)
}
}
func (h *AuthHandler) currentUserID(r *http.Request) (uuid.UUID, bool) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" {
return uuid.Nil, false
}
return h.sessions.get(cookie.Value)
}
func (h *AuthHandler) setSession(w http.ResponseWriter, userID uuid.UUID, email string) {
sessionID := randomToken(32)
h.sessions.set(sessionID, userID, email)
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: sessionID,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
func (s *sessionStore) get(sessionID string) (uuid.UUID, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
userID, ok := s.sessions[sessionID]
return userID, ok
}
func (s *sessionStore) set(sessionID string, userID uuid.UUID, email string) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[sessionID] = userID
logStoreMutation("create_session", email, sessionID, 0, len(s.sessions))
}
func (s *sessionStore) delete(sessionID string, email string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, sessionID)
logStoreMutation("delete_session", email, sessionID, 0, len(s.sessions))
}
func (r *InMemoryAuthRepository) CreateAuthUser(_ context.Context, input CreateAuthUserInput) (uuid.UUID, error) {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.authUsers[input.Email]; exists {
return uuid.Nil, ErrUserAlreadyExists
}
id := uuid.New()
now := time.Now().UTC()
authUser := AuthUser{
ID: id,
Email: input.Email,
EncryptedPassword: input.EncryptedPassword,
CreatedAt: now,
UpdatedAt: now,
}
publicUser := PublicUser{
ID: id,
Email: input.Email,
DisplayName: input.DisplayName,
CreatedAt: now,
UpdatedAt: now,
}
r.authUsers[input.Email] = authUser
r.publicUsers[id] = publicUser
logStoreMutation("create_user", input.Email, "", len(r.authUsers), 0)
return id, nil
}
func (r *InMemoryAuthRepository) GetAuthUserByEmail(_ context.Context, email string) (AuthUser, error) {
r.mu.RLock()
defer r.mu.RUnlock()
user, ok := r.authUsers[email]
if !ok {
return AuthUser{}, ErrUserNotFound
}
return user, nil
}
func (r *InMemoryAuthRepository) GetPublicUserByID(_ context.Context, id uuid.UUID) (PublicUser, error) {
r.mu.RLock()
defer r.mu.RUnlock()
user, ok := r.publicUsers[id]
if !ok {
return PublicUser{}, ErrUserNotFound
}
return user, nil
}
func normalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
func displayNameFromEmail(email string) string {
email = normalizeEmail(email)
if email == "" {
return ""
}
return strings.Split(email, "@")[0]
}
func hashPassword(password string) (string, error) {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(passwordHash), nil
}
func randomToken(size int) string {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
panic(err)
}
return hex.EncodeToString(buf)
}
func logStoreMutation(action string, email string, sessionID string, usersCount int, sessionsCount int) {
event := log.Info().
Str("component", "auth_store").
Str("action", action).
Int("users_count", usersCount).
Int("sessions_count", sessionsCount)
if email != "" {
event = event.Str("email", email)
}
if sessionID != "" {
event = event.Str("session_id", sessionID)
}
event.Msg("auth store mutated")
}

View file

@ -0,0 +1,120 @@
package handlers
import (
"bytes"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt"
)
func TestSignupLogsAuthStoreMutations(t *testing.T) {
var buf bytes.Buffer
restore := log.Logger
log.Logger = zerolog.New(&buf)
defer func() {
log.Logger = restore
}()
handler := newTestAuthHandler(t)
form := url.Values{}
form.Set("email", "new@xtablo.com")
form.Set("password", "xtablo-secret")
req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handler.PostSignup().ServeHTTP(rec, req)
output := buf.String()
for _, want := range []string{
`"action":"create_user"`,
`"email":"new@xtablo.com"`,
`"action":"create_session"`,
`"session_id":"`,
} {
if !strings.Contains(output, want) {
t.Fatalf("expected log output to contain %q, got %q", want, output)
}
}
}
func TestSignupHashesPasswordBeforeStoringUser(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
form := url.Values{}
form.Set("email", "new@xtablo.com")
form.Set("password", "xtablo-secret")
req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handler.PostSignup().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
storedUser, err := repo.GetAuthUserByEmail(req.Context(), "new@xtablo.com")
if err != nil {
t.Fatalf("expected stored user, got error %v", err)
}
if storedUser.EncryptedPassword == "xtablo-secret" {
t.Fatalf("expected stored password hash, got plaintext")
}
if bcrypt.CompareHashAndPassword([]byte(storedUser.EncryptedPassword), []byte("xtablo-secret")) != nil {
t.Fatalf("expected stored password to match bcrypt hash")
}
}
func TestLogoutLogsSessionDeletion(t *testing.T) {
var buf bytes.Buffer
restore := log.Logger
log.Logger = zerolog.New(&buf)
defer func() {
log.Logger = restore
}()
handler := newTestAuthHandler(t)
loginForm := url.Values{}
loginForm.Set("email", "demo@xtablo.com")
loginForm.Set("password", "xtablo-demo")
loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(loginForm.Encode()))
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
loginRec := httptest.NewRecorder()
handler.PostLogin().ServeHTTP(loginRec, loginReq)
sessionCookie := loginRec.Result().Cookies()[0]
logoutReq := httptest.NewRequest(http.MethodPost, "/logout", nil)
logoutReq.AddCookie(sessionCookie)
logoutRec := httptest.NewRecorder()
handler.PostLogout().ServeHTTP(logoutRec, logoutReq)
output := buf.String()
for _, want := range []string{
`"action":"delete_session"`,
`"email":"demo@xtablo.com"`,
`"session_id":"`,
} {
if !strings.Contains(output, want) {
t.Fatalf("expected log output to contain %q, got %q", want, output)
}
}
}
func newTestAuthHandler(t *testing.T) *AuthHandler {
t.Helper()
return NewAuthHandler(NewInMemoryAuthRepository())
}

View file

@ -0,0 +1,219 @@
package views
templ AuthPage(content templ.Component) {
<!doctype html>
<html lang="en" class="light">
<head>
<meta charset="UTF-8"/>
<link rel="icon" type="image/png" sizes="32x32" href="/pwa-icons/favicon-32x32.png"/>
<link rel="icon" type="image/png" sizes="16x16" href="/pwa-icons/favicon-16x16.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/pwa-icons/apple-touch-icon-180x180.png"/>
<link rel="manifest" href="/manifest.webmanifest"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
<meta name="theme-color" content="#1e1b2e"/>
<meta name="apple-mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-status-bar-style" content="default"/>
<title>XTablo</title>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js"></script>
<link rel="stylesheet" href="/static/styles.css"/>
</head>
<body>
<div id="root">
<section aria-label="Notifications alt+T" tabindex="-1" aria-live="polite" aria-relevant="additions text" aria-atomic="false"></section>
<div class="app-shell">
<div class="login-screen">
@AnimatedBackground()
<div class="card-wrap">
<div class="card-glow"></div>
<div data-testid="auth-card-shell" class="auth-card-shell">
<div class="auth-card-topbar">
<div>
<a href="https://www.xtablo.com" class="back-home-link">
<svg class="back-home-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
Retour à l'accueil
</a>
</div>
<button class="theme-toggle-button" aria-label="change theme (system)" type="button">
<svg xmlns="http://www.w3.org/2000/svg" class="theme-toggle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="20" height="14" x="2" y="3" rx="2"></rect>
<line x1="8" x2="16" y1="21" y2="21"></line>
<line x1="12" x2="12" y1="17" y2="21"></line>
</svg>
</button>
</div>
<div class="brand-header">
<img alt="Xtablo" class="brand-logo light-only" src="/logo_dark.png"/>
<img alt="Xtablo" class="brand-logo dark-only" src="/logo_white.png"/>
</div>
@content
</div>
</div>
</div>
</div>
</div>
</body>
</html>
}
templ LoginScreen() {
<div class="title-group">
<h1>Se connecter à Xtablo</h1>
</div>
<div class="new-experience-link-wrap">
<a class="new-experience-link" href="/login-v2">Découvrez la nouvelle expérience de connexion</a>
</div>
<div class="auth-body">
<form class="login-form" hx-post="/login" hx-target="#login-status" hx-swap="innerHTML">
<div id="login-status" class="status-slot"></div>
<div class="field-stack">
<label for="email">Email *</label>
<input id="email" name="email" type="email" placeholder="Votre email" required/>
</div>
<div class="field-stack">
<label for="password">Mot de passe *</label>
<input id="password" name="password" type="password" placeholder="Votre mot de passe" required/>
</div>
<div class="forgot-password-row">
<a class="forgot-password-link" href="/reset-password">Mot de passe oublié ?</a>
</div>
<button class="submit-button" type="submit">Se connecter</button>
</form>
@AuthDivider()
@GoogleButton()
<p class="signup-copy">
Pas encore de compte ?
<a class="signup-link" href="/signup">S'inscrire</a>
</p>
</div>
}
templ SignupScreen() {
<div class="title-group">
<h1>S'inscrire à Xtablo</h1>
</div>
<div class="new-experience-link-wrap">
<a class="new-experience-link" href="/login">Vous avez déjà un compte ?</a>
</div>
<div class="auth-body">
<form class="login-form" hx-post="/signup" hx-target="#signup-status" hx-swap="innerHTML">
<div id="signup-status" class="status-slot"></div>
<div class="field-stack">
<label for="signup-email">Email *</label>
<input id="signup-email" name="email" type="email" placeholder="Votre email" required/>
</div>
<div class="field-stack">
<label for="signup-password">Mot de passe *</label>
<input id="signup-password" name="password" type="password" placeholder="Choisissez un mot de passe" required/>
</div>
<button class="submit-button" type="submit">Créer mon compte</button>
</form>
@AuthDivider()
@GoogleButton()
<p class="signup-copy">
Vous avez déjà un compte ?
<a class="signup-link" href="/login">Se connecter</a>
</p>
</div>
}
templ HomePage(displayName string, email string) {
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>XTablo</title>
<link rel="stylesheet" href="/static/styles.css"/>
</head>
<body>
<main class="home-shell">
<section class="home-card">
<img alt="Xtablo" class="home-logo" src="/logo_dark.png"/>
<h1>Bienvenue</h1>
<p>{ displayName }</p>
<p>Session active pour { email }</p>
<form action="/logout" method="post" class="logout-form">
<button type="submit" class="submit-button logout-button">Se déconnecter</button>
</form>
</section>
</main>
</body>
</html>
}
templ AuthStatus(kind string, message string) {
if kind == "success" {
<div class="status-banner status-success" role="status">{ message }</div>
} else if kind == "error" {
<div class="status-banner status-error" role="alert">{ message }</div>
}
}
templ AuthDivider() {
<div class="divider-row">
<div class="divider-line"></div>
<span class="divider-pill">Ou continuer avec</span>
<div class="divider-line"></div>
</div>
}
templ GoogleButton() {
<button class="gsi-material-button" type="button">
<div class="gsi-material-button-state"></div>
<div class="gsi-material-button-content-wrapper">
<div class="gsi-material-button-icon">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" xmlns:xlink="http://www.w3.org/1999/xlink" style="display: block;">
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"></path>
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"></path>
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"></path>
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"></path>
<path fill="none" d="M0 0h48v48H0z"></path>
</svg>
</div>
<span class="gsi-material-button-contents">Continuer avec Google</span>
<span class="visually-hidden">Sign in with Google</span>
</div>
</button>
}
templ AnimatedBackground() {
<div class="background-layer" aria-hidden="true">
<div class="background-logo bg-01 animate-move-right-slow opacity-04"><img alt="Xtablo" class="logo-asset size-16 animate-spin-slow light-only" src="/logo_dark.png"/><img alt="Xtablo" class="logo-asset size-16 animate-spin-slow dark-only" src="/logo_white.png"/></div>
<div class="background-logo bg-02 animate-move-right-medium opacity-03"><img alt="Xtablo" class="logo-asset size-12 animate-bounce-gentle light-only" src="/logo_dark.png"/><img alt="Xtablo" class="logo-asset size-12 animate-bounce-gentle dark-only" src="/logo_white.png"/></div>
<div class="background-logo bg-03 animate-move-right-fast opacity-05"><img alt="Xtablo" class="logo-asset size-20 animate-pulse-gentle light-only" src="/logo_dark.png"/><img alt="Xtablo" class="logo-asset size-20 animate-pulse-gentle dark-only" src="/logo_white.png"/></div>
<div class="background-logo bg-04 animate-move-right-slow opacity-02"><img alt="Xtablo" class="logo-asset size-14 animate-wiggle light-only" src="/logo_dark.png"/><img alt="Xtablo" class="logo-asset size-14 animate-wiggle dark-only" src="/logo_white.png"/></div>
<div class="background-logo bg-05 animate-move-right-medium opacity-03"><img alt="Xtablo" class="logo-asset size-18 animate-float-gentle" src="/logo_dark.png"/></div>
<div class="background-logo bg-06 animate-move-diagonal-1 opacity-03"><img alt="Xtablo" class="logo-asset size-10 animate-spin-reverse" src="/logo_dark.png"/></div>
<div class="background-logo bg-07 animate-move-diagonal-2 opacity-04"><img alt="Xtablo" class="logo-asset size-16 animate-scale-gentle" src="/logo_dark.png"/></div>
<div class="background-logo bg-08 animate-move-diagonal-3 opacity-02"><img alt="Xtablo" class="logo-asset size-12 animate-rotate-gentle" src="/logo_dark.png"/></div>
<div class="background-logo bg-09 animate-move-down-slow opacity-03"><img alt="Xtablo" class="logo-asset size-14 animate-bounce-soft" src="/logo_dark.png"/></div>
<div class="background-logo bg-10 animate-move-down-medium opacity-04"><img alt="Xtablo" class="logo-asset size-16 animate-sway" src="/logo_dark.png"/></div>
<div class="background-logo bg-11 animate-orbit-1 opacity-02"><img alt="Xtablo" class="logo-asset size-08" src="/logo_dark.png"/></div>
<div class="background-logo bg-12 animate-orbit-2 opacity-03"><img alt="Xtablo" class="logo-asset size-10" src="/logo_dark.png"/></div>
<div class="background-logo bg-13 animate-orbit-3 opacity-02"><img alt="Xtablo" class="logo-asset size-06" src="/logo_dark.png"/></div>
<div class="background-logo bg-14 animate-orbit-4 opacity-03"><img alt="Xtablo" class="logo-asset size-12 animate-spin-fast" src="/logo_dark.png"/></div>
<div class="background-logo bg-15 animate-orbit-5 opacity-02"><img alt="Xtablo" class="logo-asset size-07 animate-pulse-fast" src="/logo_dark.png"/></div>
<div class="background-logo bg-16 animate-zigzag-1 opacity-04"><img alt="Xtablo" class="logo-asset size-14 animate-wobble" src="/logo_dark.png"/></div>
<div class="background-logo bg-17 animate-zigzag-2 opacity-03"><img alt="Xtablo" class="logo-asset size-11 animate-shake" src="/logo_dark.png"/></div>
<div class="background-logo bg-18 animate-zigzag-3 opacity-05"><img alt="Xtablo" class="logo-asset size-16 animate-bounce-crazy" src="/logo_dark.png"/></div>
<div class="background-logo bg-19 animate-spiral-1 opacity-03"><img alt="Xtablo" class="logo-asset size-09 animate-spin-wobble" src="/logo_dark.png"/></div>
<div class="background-logo bg-20 animate-spiral-2 opacity-04"><img alt="Xtablo" class="logo-asset size-13 animate-flip" src="/logo_dark.png"/></div>
<div class="background-logo bg-21 animate-float-random-1 opacity-02"><img alt="Xtablo" class="logo-asset size-08 animate-twirl" src="/logo_dark.png"/></div>
<div class="background-logo bg-22 animate-float-random-2 opacity-03"><img alt="Xtablo" class="logo-asset size-10 animate-dance" src="/logo_dark.png"/></div>
<div class="background-logo bg-23 animate-float-random-3 opacity-04"><img alt="Xtablo" class="logo-asset size-12 animate-jiggle" src="/logo_dark.png"/></div>
<div class="background-logo bg-24 animate-float-random-4 opacity-02"><img alt="Xtablo" class="logo-asset size-09 animate-vibrate" src="/logo_dark.png"/></div>
<div class="background-logo bg-25 animate-wave-1 opacity-03"><img alt="Xtablo" class="logo-asset size-11 animate-swing" src="/logo_dark.png"/></div>
<div class="background-logo bg-26 animate-wave-2 opacity-04"><img alt="Xtablo" class="logo-asset size-13 animate-pendulum" src="/logo_dark.png"/></div>
<div class="background-logo bg-27 animate-wave-3 opacity-02"><img alt="Xtablo" class="logo-asset size-10 animate-elastic" src="/logo_dark.png"/></div>
<div class="background-logo bg-28 animate-wave-4 opacity-05"><img alt="Xtablo" class="logo-asset size-15 animate-rubber" src="/logo_dark.png"/></div>
<div class="background-logo bg-29 animate-corner-shoot-1 opacity-03"><img alt="Xtablo" class="logo-asset size-12 animate-rocket" src="/logo_dark.png"/></div>
<div class="background-logo bg-30 animate-corner-shoot-2 opacity-04"><img alt="Xtablo" class="logo-asset size-14 animate-comet" src="/logo_dark.png"/></div>
<div class="background-logo bg-31 animate-corner-shoot-3 opacity-02"><img alt="Xtablo" class="logo-asset size-10 animate-meteor" src="/logo_dark.png"/></div>
<div class="background-logo bg-32 animate-corner-shoot-4 opacity-05"><img alt="Xtablo" class="logo-asset size-16 animate-blast" src="/logo_dark.png"/></div>
<div class="background-logo bg-33 animate-bounce-ball-1 opacity-04"><img alt="Xtablo" class="logo-asset size-08 animate-spin-bounce" src="/logo_dark.png"/></div>
<div class="background-logo bg-34 animate-bounce-ball-2 opacity-03"><img alt="Xtablo" class="logo-asset size-11 animate-flip-bounce" src="/logo_dark.png"/></div>
<div class="background-logo bg-35 animate-bounce-ball-3 opacity-05"><img alt="Xtablo" class="logo-asset size-13 animate-scale-bounce" src="/logo_dark.png"/></div>
</div>
}

File diff suppressed because one or more lines are too long

61
go-backend/justfile Normal file
View file

@ -0,0 +1,61 @@
set shell := ["bash", "-cu"]
database_url := "postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable"
compose_config_dir := ".podman-compose"
default:
@just --list
compose-config:
mkdir -p {{compose_config_dir}}
printf '%s\n' '{"auths":{}}' > {{compose_config_dir}}/config.json
machine-up:
@if command -v podman >/dev/null 2>&1; then \
if ! podman machine inspect podman-machine-default 2>/dev/null | grep -q '"State": "running"'; then \
podman machine start podman-machine-default; \
fi; \
else \
echo "podman is required" >&2; \
exit 1; \
fi
db-up: machine-up compose-config
DOCKER_CONFIG="$PWD/{{compose_config_dir}}" podman compose up -d postgres
db-down:
@if command -v podman >/dev/null 2>&1; then \
DOCKER_CONFIG="$PWD/{{compose_config_dir}}" podman compose down; \
else \
echo "podman is required" >&2; \
exit 1; \
fi
db-reset: compose-config
just machine-up
DOCKER_CONFIG="$PWD/{{compose_config_dir}}" podman compose down -v
DOCKER_CONFIG="$PWD/{{compose_config_dir}}" podman compose up -d postgres
db-logs: machine-up compose-config
DOCKER_CONFIG="$PWD/{{compose_config_dir}}" podman compose logs -f postgres
generate:
go run github.com/a-h/templ/cmd/templ@latest generate
go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate
fmt:
gofmt -w .
test:
go test ./...
build:
go build ./...
check: generate test build
dev: db-up
DATABASE_URL='{{database_url}}' air -c .air.toml
run: db-up
DATABASE_URL='{{database_url}}' go run .

56
go-backend/router.go Normal file
View file

@ -0,0 +1,56 @@
package main
import (
"context"
"io/fs"
"net/http"
"os"
"xtablo-backend/internal/db"
"xtablo-backend/internal/web/handlers"
chi "github.com/go-chi/chi/v5"
)
func newRouter() http.Handler {
databaseURL := os.Getenv("DATABASE_URL")
repo, err := db.NewPostgresAuthRepository(context.Background(), databaseURL)
if err != nil {
panic(err)
}
return newRouterWithHandler(handlers.NewAuthHandler(repo))
}
func newTestRouter() http.Handler {
return newRouterWithHandler(handlers.NewAuthHandler(handlers.NewInMemoryAuthRepository()))
}
func newRouterWithHandler(authHandler *handlers.AuthHandler) http.Handler {
mux := chi.NewRouter()
staticFS := os.DirFS("static")
// Views
mux.Get("/", authHandler.GetHome())
mux.Get("/login", authHandler.GetLoginPage())
mux.Get("/signup", authHandler.GetSignupPage())
mux.Post("/login", authHandler.PostLogin())
mux.Post("/signup", authHandler.PostSignup())
mux.Post("/logout", authHandler.PostLogout())
mux.Handle("/static/*", http.StripPrefix("/static/", http.FileServerFS(os.DirFS("static"))))
mux.Handle("/pwa-icons/*", http.StripPrefix("/pwa-icons/", http.FileServerFS(os.DirFS("static/pwa-icons"))))
mux.HandleFunc("/logo_dark.png", serveStaticFile(staticFS, "logo_dark.png", "image/png"))
mux.HandleFunc("/logo_white.png", serveStaticFile(staticFS, "logo_white.png", "image/png"))
mux.HandleFunc("/manifest.webmanifest", serveStaticFile(staticFS, "manifest.webmanifest", "application/manifest+json"))
return mux
}
func serveStaticFile(fileSystem fs.FS, path string, contentType string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if contentType != "" {
w.Header().Set("Content-Type", contentType)
}
http.ServeFileFS(w, r, fileSystem, path)
}
}

254
go-backend/router_test.go Normal file
View file

@ -0,0 +1,254 @@
package main
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestRootRedirectsToLoginWhenUnauthenticated(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
router := newTestRouter()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("expected status 303, got %d", rec.Code)
}
if location := rec.Header().Get("Location"); location != "/login" {
t.Fatalf("expected redirect to /login, got %q", location)
}
}
func TestLoginPageRenders(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/login", nil)
rec := httptest.NewRecorder()
router := newTestRouter()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"Se connecter à Xtablo",
`hx-post="/login"`,
"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js",
`href="/pwa-icons/favicon-32x32.png"`,
`href="/pwa-icons/favicon-16x16.png"`,
`href="/pwa-icons/apple-touch-icon-180x180.png"`,
`href="/manifest.webmanifest"`,
`src="/logo_dark.png"`,
`src="/logo_white.png"`,
`data-testid="auth-card-shell"`,
"Découvrez la nouvelle expérience de connexion",
"Mot de passe oublié ?",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected body to contain %q", want)
}
}
}
func TestSignupPageRenders(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/signup", nil)
rec := httptest.NewRecorder()
router := newTestRouter()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"S'inscrire à Xtablo",
`hx-post="/signup"`,
"Vous avez déjà un compte ?",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected body to contain %q", want)
}
}
}
func TestBrandingAssetsAreServed(t *testing.T) {
testCases := []string{
"/logo_dark.png",
"/logo_white.png",
"/pwa-icons/favicon-32x32.png",
"/pwa-icons/favicon-16x16.png",
"/pwa-icons/apple-touch-icon-180x180.png",
"/manifest.webmanifest",
}
router := newTestRouter()
for _, path := range testCases {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected %s to return 200, got %d", path, rec.Code)
}
}
}
func TestLoginReturnsValidationError(t *testing.T) {
form := url.Values{}
form.Set("email", "")
form.Set("password", "")
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
router := newTestRouter()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected status 422, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Veuillez renseigner votre email et votre mot de passe") {
t.Fatalf("expected validation error fragment, got %q", rec.Body.String())
}
}
func TestLoginCreatesSessionAndRedirects(t *testing.T) {
form := url.Values{}
form.Set("email", "demo@xtablo.com")
form.Set("password", "xtablo-demo")
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
router := newTestRouter()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
if redirect := rec.Header().Get("HX-Redirect"); redirect != "/" {
t.Fatalf("expected HX-Redirect to /, got %q", redirect)
}
sessionCookie := findCookie(rec.Result().Cookies(), "xtablo_session")
if sessionCookie == nil {
t.Fatalf("expected session cookie to be set")
}
homeReq := httptest.NewRequest(http.MethodGet, "/", nil)
homeReq.AddCookie(sessionCookie)
homeRec := httptest.NewRecorder()
router.ServeHTTP(homeRec, homeReq)
if homeRec.Code != http.StatusOK {
t.Fatalf("expected authenticated root status 200, got %d", homeRec.Code)
}
if !strings.Contains(homeRec.Body.String(), "Bienvenue") {
t.Fatalf("expected authenticated home page, got %q", homeRec.Body.String())
}
if !strings.Contains(homeRec.Body.String(), `action="/logout"`) {
t.Fatalf("expected authenticated home page to include logout form, got %q", homeRec.Body.String())
}
}
func TestSignupCreatesUserSessionAndRedirects(t *testing.T) {
form := url.Values{}
form.Set("email", "new@xtablo.com")
form.Set("password", "xtablo-secret")
req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
router := newTestRouter()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
if redirect := rec.Header().Get("HX-Redirect"); redirect != "/" {
t.Fatalf("expected HX-Redirect to /, got %q", redirect)
}
sessionCookie := findCookie(rec.Result().Cookies(), "xtablo_session")
if sessionCookie == nil {
t.Fatalf("expected session cookie to be set")
}
loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
loginRec := httptest.NewRecorder()
router.ServeHTTP(loginRec, loginReq)
if loginRec.Header().Get("HX-Redirect") != "/" {
t.Fatalf("expected signed up user to be able to log in")
}
}
func TestLogoutClearsSessionAndRedirectsToLogin(t *testing.T) {
form := url.Values{}
form.Set("email", "demo@xtablo.com")
form.Set("password", "xtablo-demo")
loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
loginRec := httptest.NewRecorder()
router := newTestRouter()
router.ServeHTTP(loginRec, loginReq)
sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session")
if sessionCookie == nil {
t.Fatalf("expected session cookie to be set")
}
logoutReq := httptest.NewRequest(http.MethodPost, "/logout", nil)
logoutReq.AddCookie(sessionCookie)
logoutRec := httptest.NewRecorder()
router.ServeHTTP(logoutRec, logoutReq)
if logoutRec.Code != http.StatusSeeOther {
t.Fatalf("expected logout status 303, got %d", logoutRec.Code)
}
if location := logoutRec.Header().Get("Location"); location != "/login" {
t.Fatalf("expected logout redirect to /login, got %q", location)
}
clearedCookie := findCookie(logoutRec.Result().Cookies(), "xtablo_session")
if clearedCookie == nil {
t.Fatalf("expected cleared session cookie")
}
if clearedCookie.MaxAge >= 0 && clearedCookie.Value != "" {
t.Fatalf("expected cleared session cookie to be expired, got %+v", clearedCookie)
}
homeReq := httptest.NewRequest(http.MethodGet, "/", nil)
homeReq.AddCookie(sessionCookie)
homeRec := httptest.NewRecorder()
router.ServeHTTP(homeRec, homeReq)
if homeRec.Code != http.StatusSeeOther {
t.Fatalf("expected logged-out root access to redirect, got %d", homeRec.Code)
}
if location := homeRec.Header().Get("Location"); location != "/login" {
t.Fatalf("expected logged-out root redirect to /login, got %q", location)
}
}
func findCookie(cookies []*http.Cookie, name string) *http.Cookie {
for _, cookie := range cookies {
if cookie.Name == name {
return cookie
}
}
return nil
}

28
go-backend/sqlc.yaml Normal file
View file

@ -0,0 +1,28 @@
version: "2"
sql:
- engine: "postgresql"
schema: "internal/db/schema.sql"
queries: "internal/db/queries.sql"
gen:
go:
package: "sqlc"
out: "internal/db/sqlc"
sql_package: "pgx/v5"
emit_interface: true
emit_db_tags: true
overrides:
- db_type: "uuid"
go_type:
import: "github.com/google/uuid"
type: "UUID"
- db_type: "pg_catalog.uuid"
nullable: true
go_type:
import: "github.com/google/uuid"
type: "UUID"
pointer: true
- db_type: "pg_catalog.timestamptz"
go_type:
import: "time"
type: "Time"

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -0,0 +1,19 @@
{
"name": "XTablo",
"short_name": "XTablo",
"display": "standalone",
"background_color": "#f8fafc",
"theme_color": "#1e1b2e",
"icons": [
{
"src": "/pwa-icons/favicon-32x32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "/pwa-icons/apple-touch-icon-180x180.png",
"sizes": "180x180",
"type": "image/png"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,993 @@
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(0 0% 9%);
--muted-foreground: hsl(0 0% 43.5%);
--border: hsl(0 0% 90.9%);
--input: hsl(0 0% 90.9%);
--card: rgba(255, 255, 255, 0.8);
--accent: hsl(0 0% 96.1%);
--primary: #1e1b2e;
--primary-foreground: #ffffff;
--secondary: #804eec;
--ring: rgba(30, 27, 46, 0.35);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
background: var(--background);
color: var(--foreground);
font-family:
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
}
a {
color: inherit;
text-decoration: none;
}
button,
input {
font: inherit;
}
.light-only {
display: block;
}
.dark-only {
display: none;
}
.visually-hidden {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
.app-shell,
.login-screen {
min-height: 100vh;
}
.app-shell {
background: var(--background);
}
.login-screen {
align-items: center;
background:
linear-gradient(135deg, rgba(128, 78, 236, 0.08), transparent 30%),
linear-gradient(160deg, rgba(30, 27, 46, 0.05), transparent 42%),
linear-gradient(to bottom right, rgba(30, 27, 46, 0.08), var(--background), rgba(128, 78, 236, 0.04));
display: flex;
justify-content: center;
overflow: hidden;
padding: 2rem 1rem;
position: relative;
}
.background-layer {
inset: 0;
overflow: hidden;
pointer-events: none;
position: absolute;
}
.background-logo {
position: absolute;
}
.logo-asset {
display: block;
height: auto;
object-fit: contain;
width: 100%;
}
.size-06 { width: 1.5rem; height: 1.5rem; }
.size-07 { width: 1.75rem; height: 1.75rem; }
.size-08 { width: 2rem; height: 2rem; }
.size-09 { width: 2.25rem; height: 2.25rem; }
.size-10 { width: 2.5rem; height: 2.5rem; }
.size-11 { width: 2.75rem; height: 2.75rem; }
.size-12 { width: 3rem; height: 3rem; }
.size-13 { width: 3.25rem; height: 3.25rem; }
.size-14 { width: 3.5rem; height: 3.5rem; }
.size-15 { width: 3.75rem; height: 3.75rem; }
.size-16 { width: 4rem; height: 4rem; }
.size-18 { width: 4.5rem; height: 4.5rem; }
.size-20 { width: 5rem; height: 5rem; }
.opacity-02 { opacity: 0.2; }
.opacity-03 { opacity: 0.3; }
.opacity-04 { opacity: 0.4; }
.opacity-05 { opacity: 0.5; }
.bg-01 { top: 25%; left: 0; }
.bg-02 { top: 33%; left: 0; }
.bg-03 { top: 50%; left: 0; }
.bg-04 { top: 66%; left: 0; }
.bg-05 { top: 75%; left: 0; }
.bg-06 { top: 0; left: 25%; }
.bg-07 { top: 0; left: 50%; }
.bg-08 { top: 0; left: 75%; }
.bg-09 { top: 0; left: 16.66%; }
.bg-10 { top: 0; left: 83.33%; }
.bg-11,
.bg-12,
.bg-13,
.bg-14,
.bg-15 {
left: 50%;
top: 50%;
}
.bg-16 { top: 25%; left: 0; }
.bg-17 { top: 50%; left: 0; }
.bg-18 { top: 75%; left: 0; }
.bg-19 { top: 0; left: 25%; }
.bg-20 { top: 0; left: 75%; }
.bg-21 { top: 16.66%; left: 33.33%; }
.bg-22 { top: 33.33%; left: 66.66%; }
.bg-23 { top: 66.66%; left: 25%; }
.bg-24 { top: 83.33%; left: 75%; }
.bg-25 { top: 12.5%; left: 0; }
.bg-26 { top: 37.5%; left: 0; }
.bg-27 { top: 62.5%; left: 0; }
.bg-28 { top: 87.5%; left: 0; }
.bg-29 { top: 0; left: 0; }
.bg-30 { top: 0; right: 0; }
.bg-31 { bottom: 0; left: 0; }
.bg-32 { bottom: 0; right: 0; }
.bg-33 { top: 20%; left: 20%; }
.bg-34 { top: 40%; left: 80%; }
.bg-35 { top: 80%; left: 40%; }
.card-wrap {
max-width: 32rem;
position: relative;
transition: transform 0.2s ease-out;
width: 100%;
z-index: 1;
}
.card-glow {
background: linear-gradient(to bottom right, rgba(30, 27, 46, 0.1), rgba(30, 27, 46, 0.05), rgba(128, 78, 236, 0.1));
border-radius: 1rem;
filter: blur(24px);
inset: 0;
position: absolute;
z-index: -1;
}
.auth-card-shell {
backdrop-filter: blur(12px);
background: var(--card);
border: 1px solid var(--border);
border-radius: 1rem;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.1);
padding: 1.25rem;
position: relative;
}
.auth-card-topbar {
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.back-home-link {
align-items: center;
color: var(--muted-foreground);
display: inline-flex;
font-size: 0.875rem;
transition: color 0.2s ease;
}
.back-home-link:hover,
.theme-toggle-button:hover,
.signup-link:hover {
color: var(--foreground);
}
.back-home-icon {
height: 1rem;
margin-right: 0.5rem;
width: 1rem;
}
.theme-toggle-button {
align-items: center;
background: transparent;
border: 0;
border-radius: 0.5rem;
color: var(--muted-foreground);
cursor: pointer;
display: inline-flex;
height: 2.25rem;
justify-content: center;
padding: 0.5rem;
transition:
background-color 0.2s ease,
color 0.2s ease;
width: 2.25rem;
}
.theme-toggle-button:hover {
background: var(--accent);
}
.theme-toggle-icon {
height: 1.25rem;
width: 1.25rem;
}
.brand-header {
display: flex;
justify-content: center;
margin-bottom: 1.5rem;
}
.brand-logo {
display: block;
height: 4rem;
object-fit: contain;
width: 4rem;
}
.title-group {
margin-bottom: 1.5rem;
text-align: center;
}
.title-group h1 {
font-size: clamp(1.5rem, 4vw, 1.875rem);
font-weight: 700;
margin: 0;
}
.new-experience-link-wrap {
margin-bottom: 1.5rem;
text-align: center;
}
.new-experience-link {
color: #804eec;
display: inline-flex;
font-size: 0.875rem;
font-weight: 500;
transition: color 0.2s ease;
}
.new-experience-link:hover,
.forgot-password-link:hover {
color: #6f3fd4;
}
.auth-body {
align-items: center;
display: flex;
flex-direction: column;
gap: 1rem;
}
.login-form {
display: flex;
flex-direction: column;
gap: 1rem;
margin: 0 auto;
max-width: 28rem;
width: 100%;
}
.field-stack {
display: grid;
gap: 0.5rem;
}
.field-stack label {
font-size: 0.875rem;
font-weight: 500;
}
.field-stack input {
background: var(--background);
border: 1px solid var(--input);
border-radius: 0.375rem;
color: var(--foreground);
height: 2.5rem;
padding: 0.5rem 0.75rem;
width: 100%;
}
.field-stack input:focus {
box-shadow: 0 0 0 1px var(--ring);
outline: none;
}
.field-stack input::placeholder {
color: var(--muted-foreground);
}
.forgot-password-row {
display: flex;
justify-content: flex-end;
}
.forgot-password-link {
color: #7c3aed;
font-size: 0.875rem;
transition: color 0.2s ease;
}
.submit-button {
align-items: center;
background: var(--primary);
border: 0;
border-radius: 0.375rem;
color: var(--primary-foreground);
cursor: pointer;
display: inline-flex;
font-size: 0.875rem;
font-weight: 500;
height: 2.25rem;
justify-content: center;
padding: 0.5rem 1rem;
transition: opacity 0.2s ease;
width: 100%;
}
.submit-button:hover {
opacity: 0.9;
}
.divider-row {
align-items: center;
display: flex;
gap: 0.25rem;
margin: 0.5rem 0 0;
position: relative;
width: 100%;
}
.divider-line {
border-top: 1px solid var(--border);
flex: 1;
}
.divider-pill {
background: var(--background);
border-radius: 999px;
color: var(--muted-foreground);
font-size: 0.875rem;
font-weight: 500;
padding: 0.25rem 1rem;
position: relative;
z-index: 1;
}
.signup-copy {
color: var(--muted-foreground);
font-size: 0.875rem;
margin: 0;
text-align: center;
}
.signup-link {
border-radius: 0.375rem;
color: var(--foreground);
display: inline-block;
font-weight: 500;
margin-left: 0.2rem;
padding: 0.25rem 0.5rem;
transition:
color 0.2s ease,
background-color 0.2s ease;
}
.signup-link:hover {
background: var(--accent);
}
.status-slot {
min-height: 0.25rem;
}
.status-banner {
border: 1px solid;
border-radius: 0.5rem;
font-size: 0.875rem;
padding: 0.75rem 0.875rem;
}
.status-success {
background: hsl(143 85% 96%);
border-color: hsl(145 92% 87%);
color: hsl(140 100% 27%);
}
.status-error {
background: hsl(359 100% 97%);
border-color: hsl(359 100% 94%);
color: hsl(360 100% 45%);
}
.gsi-material-button {
-moz-user-select: none;
-ms-user-select: none;
-webkit-appearance: none;
-webkit-user-select: none;
background-color: #fff;
background-image: none;
border: 1px solid #747775;
border-radius: 20px;
box-sizing: border-box;
color: #1f1f1f;
cursor: pointer;
font-family: "Roboto", Arial, sans-serif;
font-size: 14px;
height: 40px;
letter-spacing: 0.25px;
max-width: 400px;
min-width: min-content;
outline: none;
overflow: hidden;
padding: 0 12px;
position: relative;
text-align: center;
transition:
background-color 0.218s,
border-color 0.218s,
box-shadow 0.218s;
vertical-align: middle;
white-space: nowrap;
width: 100%;
}
.gsi-material-button .gsi-material-button-icon {
height: 20px;
margin-right: 12px;
min-width: 20px;
width: 20px;
}
.gsi-material-button .gsi-material-button-content-wrapper {
align-items: center;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
height: 100%;
justify-content: space-between;
position: relative;
width: 100%;
}
.gsi-material-button .gsi-material-button-contents {
flex-grow: 1;
font-family: "Roboto", Arial, sans-serif;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: top;
}
.gsi-material-button .gsi-material-button-state {
bottom: 0;
left: 0;
opacity: 0;
position: absolute;
right: 0;
top: 0;
transition: opacity 0.218s;
}
.gsi-material-button:not(:disabled):active .gsi-material-button-state,
.gsi-material-button:not(:disabled):focus .gsi-material-button-state {
background-color: #303030;
opacity: 0.12;
}
.gsi-material-button:not(:disabled):hover {
box-shadow:
0 1px 2px 0 rgba(60, 64, 67, 0.3),
0 1px 3px 1px rgba(60, 64, 67, 0.15);
}
.gsi-material-button:not(:disabled):hover .gsi-material-button-state {
background-color: #303030;
opacity: 0.08;
}
.home-shell {
align-items: center;
background:
linear-gradient(135deg, rgba(128, 78, 236, 0.08), transparent 30%),
linear-gradient(160deg, rgba(30, 27, 46, 0.05), transparent 42%),
linear-gradient(to bottom right, rgba(30, 27, 46, 0.08), var(--background), rgba(128, 78, 236, 0.04));
display: flex;
justify-content: center;
min-height: 100vh;
padding: 1.5rem;
}
.home-card {
backdrop-filter: blur(12px);
background: var(--card);
border: 1px solid var(--border);
border-radius: 1rem;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.1);
max-width: 28rem;
padding: 2rem;
text-align: center;
width: 100%;
}
.home-card h1 {
margin: 0 0 0.75rem;
}
.home-card p {
color: var(--muted-foreground);
margin: 0;
}
.home-logo {
display: block;
height: 4rem;
margin: 0 auto 1rem;
object-fit: contain;
width: 4rem;
}
.logout-form {
margin-top: 1.5rem;
}
.logout-button {
max-width: 14rem;
}
@keyframes move-right-slow {
from { transform: translateX(-6rem); }
to { transform: translateX(calc(100vw + 6rem)); }
}
@keyframes move-right-medium {
from { transform: translateX(-5rem); }
to { transform: translateX(calc(100vw + 5rem)); }
}
@keyframes move-right-fast {
from { transform: translateX(-7rem); }
to { transform: translateX(calc(100vw + 7rem)); }
}
@keyframes move-down-slow {
from { transform: translateY(-6rem); }
to { transform: translateY(calc(100vh + 6rem)); }
}
@keyframes move-down-medium {
from { transform: translateY(-5rem); }
to { transform: translateY(calc(100vh + 5rem)); }
}
@keyframes move-diagonal-1 {
from { transform: translate(-4rem, -4rem); }
to { transform: translate(52vw, 70vh); }
}
@keyframes move-diagonal-2 {
from { transform: translate(0, -4rem); }
to { transform: translate(-18vw, 76vh); }
}
@keyframes move-diagonal-3 {
from { transform: translate(0, -4rem); }
to { transform: translate(12vw, 72vh); }
}
@keyframes orbit-1 {
from { transform: translate(-50%, -50%) rotate(0deg) translateX(11rem); }
to { transform: translate(-50%, -50%) rotate(360deg) translateX(11rem); }
}
@keyframes orbit-2 {
from { transform: translate(-50%, -50%) rotate(0deg) translateX(7rem); }
to { transform: translate(-50%, -50%) rotate(-360deg) translateX(7rem); }
}
@keyframes orbit-3 {
from { transform: translate(-50%, -50%) rotate(0deg) translateX(5rem); }
to { transform: translate(-50%, -50%) rotate(360deg) translateX(5rem); }
}
@keyframes orbit-4 {
from { transform: translate(-50%, -50%) rotate(0deg) translateX(15rem); }
to { transform: translate(-50%, -50%) rotate(360deg) translateX(15rem); }
}
@keyframes orbit-5 {
from { transform: translate(-50%, -50%) rotate(0deg) translateX(8rem); }
to { transform: translate(-50%, -50%) rotate(-360deg) translateX(8rem); }
}
@keyframes zigzag-1 {
0% { transform: translateX(-6rem) translateY(0); }
25% { transform: translateX(25vw) translateY(-3rem); }
50% { transform: translateX(50vw) translateY(3rem); }
75% { transform: translateX(75vw) translateY(-2rem); }
100% { transform: translateX(calc(100vw + 6rem)) translateY(1rem); }
}
@keyframes zigzag-2 {
0% { transform: translateX(-5rem) translateY(0); }
25% { transform: translateX(25vw) translateY(2rem); }
50% { transform: translateX(50vw) translateY(-3rem); }
75% { transform: translateX(75vw) translateY(1.5rem); }
100% { transform: translateX(calc(100vw + 5rem)) translateY(0); }
}
@keyframes zigzag-3 {
0% { transform: translateX(-7rem) translateY(0); }
20% { transform: translateX(20vw) translateY(-4rem); }
40% { transform: translateX(40vw) translateY(4rem); }
60% { transform: translateX(60vw) translateY(-2rem); }
80% { transform: translateX(80vw) translateY(3rem); }
100% { transform: translateX(calc(100vw + 7rem)) translateY(0); }
}
@keyframes spiral-1 {
0% { transform: translate(0, 0) rotate(0deg) scale(0.6); }
100% { transform: translate(90vw, 90vh) rotate(360deg) scale(1.3); }
}
@keyframes spiral-2 {
0% { transform: translate(0, 0) rotate(0deg) scale(1.4); }
100% { transform: translate(-70vw, 90vh) rotate(-360deg) scale(0.7); }
}
@keyframes float-random-1 {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(1.4rem, -1rem); }
}
@keyframes float-random-2 {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-1.2rem, 1.1rem); }
}
@keyframes float-random-3 {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(0.9rem, -1.4rem); }
}
@keyframes float-random-4 {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-1rem, 0.8rem); }
}
@keyframes wave-1 {
from { transform: translateX(-4rem); }
to { transform: translateX(calc(100vw + 4rem)); }
}
@keyframes wave-2 {
from { transform: translateX(-5rem); }
to { transform: translateX(calc(100vw + 5rem)); }
}
@keyframes wave-3 {
from { transform: translateX(-4rem); }
to { transform: translateX(calc(100vw + 4rem)); }
}
@keyframes wave-4 {
from { transform: translateX(-6rem); }
to { transform: translateX(calc(100vw + 6rem)); }
}
@keyframes corner-shoot-1 {
from { transform: translate(0, 0); }
to { transform: translate(110vw, 110vh); }
}
@keyframes corner-shoot-2 {
from { transform: translate(0, 0); }
to { transform: translate(-110vw, 110vh); }
}
@keyframes corner-shoot-3 {
from { transform: translate(0, 0); }
to { transform: translate(110vw, -110vh); }
}
@keyframes corner-shoot-4 {
from { transform: translate(0, 0); }
to { transform: translate(-110vw, -110vh); }
}
@keyframes bounce-ball-1 {
0%, 100% { transform: translateY(0) translateX(0); }
50% { transform: translateY(-5rem) translateX(2rem); }
}
@keyframes bounce-ball-2 {
0%, 100% { transform: translateY(0) translateX(0); }
50% { transform: translateY(4rem) translateX(-2rem); }
}
@keyframes bounce-ball-3 {
0%, 100% { transform: translateY(0) translateX(0); }
50% { transform: translateY(-4rem) translateX(1.5rem); }
}
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes spin-reverse {
from { transform: rotate(0deg); }
to { transform: rotate(-360deg); }
}
@keyframes bounce-gentle {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-0.6rem); }
}
@keyframes pulse-gentle {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.08); opacity: 0.85; }
}
@keyframes wiggle {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(-4deg); }
75% { transform: rotate(4deg); }
}
@keyframes float-gentle {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-0.8rem); }
}
@keyframes scale-gentle {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.12); }
}
@keyframes rotate-gentle {
0%, 100% { transform: rotate(0deg); }
50% { transform: rotate(12deg); }
}
@keyframes bounce-soft {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-0.9rem); }
}
@keyframes sway {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(0.8rem); }
}
@keyframes spin-fast {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pulse-fast {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.2); }
}
@keyframes wobble {
0%, 100% { transform: translateX(0); }
15% { transform: translateX(-0.35rem) rotate(-5deg); }
30% { transform: translateX(0.25rem) rotate(4deg); }
45% { transform: translateX(-0.2rem) rotate(-2deg); }
60% { transform: translateX(0.12rem) rotate(1deg); }
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-0.12rem); }
75% { transform: translateX(0.12rem); }
}
@keyframes bounce-crazy {
0%, 100% { transform: translateY(0) scale(1); }
25% { transform: translateY(-0.5rem) scale(1.05); }
75% { transform: translateY(0.2rem) scale(0.98); }
}
@keyframes spin-wobble {
0% { transform: rotate(0deg) scale(1); }
50% { transform: rotate(180deg) scale(1.12); }
100% { transform: rotate(360deg) scale(1); }
}
@keyframes flip {
0%, 100% { transform: rotateY(0deg); }
50% { transform: rotateY(180deg); }
}
@keyframes twirl {
0%, 100% { transform: rotate(0deg) scale(1); }
50% { transform: rotate(180deg) scale(1.15); }
}
@keyframes dance {
0%, 100% { transform: translateY(0) rotate(0deg); }
25% { transform: translateY(-0.3rem) rotate(-6deg); }
75% { transform: translateY(0.3rem) rotate(6deg); }
}
@keyframes jiggle {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-0.18rem); }
50% { transform: translateX(0.18rem); }
75% { transform: translateX(-0.1rem); }
}
@keyframes vibrate {
0%, 100% { transform: translate(0); }
20% { transform: translate(-1px, 1px); }
40% { transform: translate(1px, -1px); }
60% { transform: translate(-1px, -1px); }
80% { transform: translate(1px, 1px); }
}
@keyframes swing {
0%, 100% { transform: rotate(0deg); }
50% { transform: rotate(10deg); }
}
@keyframes pendulum {
0%, 100% { transform: rotate(-8deg); }
50% { transform: rotate(8deg); }
}
@keyframes elastic {
0%, 100% { transform: scale(1); }
30% { transform: scale(1.15, 0.9); }
60% { transform: scale(0.95, 1.08); }
}
@keyframes rubber {
0%, 100% { transform: scale(1); }
35% { transform: scale(1.2, 0.9); }
65% { transform: scale(0.9, 1.15); }
}
@keyframes rocket {
0%, 100% { transform: translateY(0) rotate(-8deg); }
50% { transform: translateY(-0.8rem) rotate(-12deg); }
}
@keyframes comet {
0%, 100% { transform: translateX(0) rotate(12deg); }
50% { transform: translateX(0.8rem) rotate(18deg); }
}
@keyframes meteor {
0%, 100% { transform: translateY(0) rotate(8deg); }
50% { transform: translateY(-0.7rem) rotate(14deg); }
}
@keyframes blast {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.2) rotate(10deg); }
}
@keyframes spin-bounce {
0%, 100% { transform: rotate(0deg) translateY(0); }
50% { transform: rotate(180deg) translateY(-0.5rem); }
}
@keyframes flip-bounce {
0%, 100% { transform: rotateY(0deg) translateY(0); }
50% { transform: rotateY(180deg) translateY(-0.5rem); }
}
@keyframes scale-bounce {
0%, 100% { transform: scale(1) translateY(0); }
50% { transform: scale(1.14) translateY(-0.55rem); }
}
.animate-move-right-slow { animation: move-right-slow 25s linear infinite; }
.animate-move-right-medium { animation: move-right-medium 20s linear infinite; }
.animate-move-right-fast { animation: move-right-fast 15s linear infinite; }
.animate-move-down-slow { animation: move-down-slow 30s linear infinite; }
.animate-move-down-medium { animation: move-down-medium 25s linear infinite; }
.animate-move-diagonal-1 { animation: move-diagonal-1 35s linear infinite; }
.animate-move-diagonal-2 { animation: move-diagonal-2 28s linear infinite; }
.animate-move-diagonal-3 { animation: move-diagonal-3 32s linear infinite; }
.animate-orbit-1 { animation: orbit-1 20s linear infinite; }
.animate-orbit-2 { animation: orbit-2 25s linear infinite reverse; }
.animate-orbit-3 { animation: orbit-3 15s linear infinite; }
.animate-orbit-4 { animation: orbit-4 22s linear infinite; }
.animate-orbit-5 { animation: orbit-5 18s linear infinite; }
.animate-zigzag-1 { animation: zigzag-1 18s linear infinite; }
.animate-zigzag-2 { animation: zigzag-2 22s linear infinite; }
.animate-zigzag-3 { animation: zigzag-3 16s linear infinite; }
.animate-spiral-1 { animation: spiral-1 30s linear infinite; }
.animate-spiral-2 { animation: spiral-2 25s linear infinite; }
.animate-float-random-1 { animation: float-random-1 8s ease-in-out infinite; }
.animate-float-random-2 { animation: float-random-2 10s ease-in-out infinite; }
.animate-float-random-3 { animation: float-random-3 12s ease-in-out infinite; }
.animate-float-random-4 { animation: float-random-4 9s ease-in-out infinite; }
.animate-wave-1 { animation: wave-1 20s linear infinite; }
.animate-wave-2 { animation: wave-2 24s linear infinite; }
.animate-wave-3 { animation: wave-3 18s linear infinite; }
.animate-wave-4 { animation: wave-4 26s linear infinite; }
.animate-corner-shoot-1 { animation: corner-shoot-1 15s linear infinite; }
.animate-corner-shoot-2 { animation: corner-shoot-2 18s linear infinite; }
.animate-corner-shoot-3 { animation: corner-shoot-3 20s linear infinite; }
.animate-corner-shoot-4 { animation: corner-shoot-4 16s linear infinite; }
.animate-bounce-ball-1 { animation: bounce-ball-1 12s ease-in-out infinite; }
.animate-bounce-ball-2 { animation: bounce-ball-2 14s ease-in-out infinite; }
.animate-bounce-ball-3 { animation: bounce-ball-3 10s ease-in-out infinite; }
.animate-spin-slow { animation: spin-slow 8s linear infinite; }
.animate-spin-reverse { animation: spin-reverse 6s linear infinite; }
.animate-bounce-gentle { animation: bounce-gentle 3s ease-in-out infinite; }
.animate-bounce-soft { animation: bounce-soft 4s ease-in-out infinite; }
.animate-pulse-gentle { animation: pulse-gentle 4s ease-in-out infinite; }
.animate-wiggle { animation: wiggle 2s ease-in-out infinite; }
.animate-float-gentle { animation: float-gentle 5s ease-in-out infinite; }
.animate-scale-gentle { animation: scale-gentle 6s ease-in-out infinite; }
.animate-rotate-gentle { animation: rotate-gentle 8s ease-in-out infinite; }
.animate-sway { animation: sway 3s ease-in-out infinite; }
.animate-spin-fast { animation: spin-fast 2s linear infinite; }
.animate-pulse-fast { animation: pulse-fast 1.5s ease-in-out infinite; }
.animate-wobble { animation: wobble 2s ease-in-out infinite; }
.animate-shake { animation: shake 0.5s ease-in-out infinite; }
.animate-bounce-crazy { animation: bounce-crazy 1s ease-in-out infinite; }
.animate-spin-wobble { animation: spin-wobble 4s ease-in-out infinite; }
.animate-flip { animation: flip 3s ease-in-out infinite; }
.animate-twirl { animation: twirl 5s ease-in-out infinite; }
.animate-dance { animation: dance 3s ease-in-out infinite; }
.animate-jiggle { animation: jiggle 1s ease-in-out infinite; }
.animate-vibrate { animation: vibrate 0.3s ease-in-out infinite; }
.animate-swing { animation: swing 2.8s ease-in-out infinite; }
.animate-pendulum { animation: pendulum 2.4s ease-in-out infinite; }
.animate-elastic { animation: elastic 2.2s ease-in-out infinite; }
.animate-rubber { animation: rubber 2.5s ease-in-out infinite; }
.animate-rocket { animation: rocket 1.8s ease-in-out infinite; }
.animate-comet { animation: comet 2s ease-in-out infinite; }
.animate-meteor { animation: meteor 1.7s ease-in-out infinite; }
.animate-blast { animation: blast 2.2s ease-in-out infinite; }
.animate-spin-bounce { animation: spin-bounce 2.4s ease-in-out infinite; }
.animate-flip-bounce { animation: flip-bounce 2.6s ease-in-out infinite; }
.animate-scale-bounce { animation: scale-bounce 2.1s ease-in-out infinite; }
@media (max-width: 640px) {
.login-screen {
padding: 2rem 1rem;
}
.auth-card-shell {
padding: 1.25rem;
}
}

5
go-backend/tools.go Normal file
View file

@ -0,0 +1,5 @@
//go:build tools
package tools
import _ "github.com/sqlc-dev/sqlc/cmd/sqlc"

View file

@ -1,52 +0,0 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/main"
cmd = "go build -o tmp/main"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "ui"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
silent = false
time = false
[misc]
clean_on_exit = true
[proxy]
app_port = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = false
keep_scroll = true

View file

@ -1,30 +0,0 @@
FROM golang:alpine
# Set necessary environmet variables needed for our image
ENV GO111MODULE=on \
CGO_ENABLED=0 \
GOOS=linux \
GOARCH=amd64
# Move to working directory /build
WORKDIR /build
# Copy the code from /app to the build folder into the container
COPY . .
# Configure the build (go.mod and go.sum are already copied with prior step)
RUN go mod download
# Build the application
RUN go build -o main .
WORKDIR /app
# Copy binary from build to main folder
RUN cp /build/main .
# Export necessary port
EXPOSE 8080
# Command to run when starting the container
CMD ["/app/main"]

View file

@ -1,14 +0,0 @@
module xtablo-backend
go 1.23.4
require github.com/go-chi/chi/v5 v5.2.0
require github.com/a-h/templ v0.3.1001
require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/rs/zerolog v1.33.0
golang.org/x/sys v0.34.0 // indirect
)

View file

@ -1,24 +0,0 @@
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=

View file

@ -1,48 +0,0 @@
package handlers
import (
"net/http"
"strings"
"xtablo-backend/internal/web/views"
)
type LoginHandler struct{}
func NewLoginHandler() *LoginHandler {
return &LoginHandler{}
}
func (h *LoginHandler) GetPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := views.LoginPage().Render(r.Context(), w); err != nil {
http.Error(w, "failed to render login page", http.StatusInternalServerError)
}
}
}
func (h *LoginHandler) PostLogin() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form payload", http.StatusBadRequest)
return
}
email := strings.TrimSpace(r.FormValue("email"))
password := strings.TrimSpace(r.FormValue("password"))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
switch {
case email == "" || password == "":
w.WriteHeader(http.StatusUnprocessableEntity)
_ = views.LoginStatus("error", "Veuillez renseigner votre email et votre mot de passe.").Render(r.Context(), w)
case email == "demo@xtablo.com" && password == "xtablo-demo":
_ = views.LoginStatus("success", "Connexion reussie. Bienvenue sur XTablo.").Render(r.Context(), w)
default:
w.WriteHeader(http.StatusUnauthorized)
_ = views.LoginStatus("error", "Identifiants invalides. Essayez demo@xtablo.com / xtablo-demo.").Render(r.Context(), w)
}
}
}

View file

@ -1,95 +0,0 @@
package views
templ LoginPage() {
<!doctype html>
<html lang="fr" class="light">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="theme-color" content="#1e1b2e"/>
<title>XTablo</title>
<link rel="stylesheet" href="/static/styles.css"/>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js"></script>
</head>
<body>
<div class="page-shell">
<div class="page-background">
<div class="orb orb-a"><span>X</span></div>
<div class="orb orb-b"><span>X</span></div>
<div class="orb orb-c"><span>X</span></div>
<div class="orb orb-d"><span>X</span></div>
<div class="orb orb-e"><span>X</span></div>
<div class="orb orb-f"><span>X</span></div>
<div class="orb orb-g"><span>X</span></div>
<div class="orb orb-h"><span>X</span></div>
</div>
<main class="auth-stage">
<section class="auth-card">
<div class="auth-glow"></div>
<div class="card-body">
<div class="card-topbar">
<a class="back-link" href="https://www.xtablo.com">Retour a l'accueil</a>
<button type="button" class="theme-button" aria-label="theme placeholder">
<span class="theme-button-monitor"></span>
</button>
</div>
<div class="brand-lockup">
<div class="brand-mark">XT</div>
</div>
<div class="headline-block">
<h1>Se connecter a Xtablo</h1>
</div>
<div class="spotlight-link-wrap">
<a class="spotlight-link" href="/login-v2">Decouvrez la nouvelle experience de connexion</a>
</div>
<form
class="login-form"
hx-post="/login"
hx-target="#login-status"
hx-swap="innerHTML"
>
<div id="login-status" class="status-slot"></div>
<div class="field-group">
<label for="email">Email *</label>
<input id="email" name="email" type="email" placeholder="Votre email" required/>
</div>
<div class="field-group">
<label for="password">Mot de passe *</label>
<input id="password" name="password" type="password" placeholder="Votre mot de passe" required/>
</div>
<div class="aux-row">
<a href="/reset-password">Mot de passe oublie ?</a>
</div>
<button class="primary-button" type="submit">Se connecter</button>
</form>
<div class="divider">
<span>Ou continuer avec</span>
</div>
<button type="button" class="google-button">
<span class="google-mark" aria-hidden="true">
<span class="google-mark-blue"></span>
<span class="google-mark-red"></span>
<span class="google-mark-yellow"></span>
<span class="google-mark-green"></span>
</span>
<span>Continuer avec Google</span>
</button>
<p class="signup-copy">
Pas encore de compte ?
<a href="/signup">S'inscrire</a>
</p>
</div>
</section>
</main>
</div>
</body>
</html>
}
templ LoginStatus(kind string, message string) {
if kind == "success" {
<div class="status-banner status-success" role="status">{ message }</div>
} else if kind == "error" {
<div class="status-banner status-error" role="alert">{ message }</div>
}
}

View file

@ -1,102 +0,0 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package views
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func LoginPage() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"fr\" class=\"light\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><meta name=\"theme-color\" content=\"#1e1b2e\"><title>XTablo</title><link rel=\"stylesheet\" href=\"/static/styles.css\"><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js\"></script></head><body><div class=\"page-shell\"><div class=\"page-background\"><div class=\"orb orb-a\"><span>X</span></div><div class=\"orb orb-b\"><span>X</span></div><div class=\"orb orb-c\"><span>X</span></div><div class=\"orb orb-d\"><span>X</span></div><div class=\"orb orb-e\"><span>X</span></div><div class=\"orb orb-f\"><span>X</span></div><div class=\"orb orb-g\"><span>X</span></div><div class=\"orb orb-h\"><span>X</span></div></div><main class=\"auth-stage\"><section class=\"auth-card\"><div class=\"auth-glow\"></div><div class=\"card-body\"><div class=\"card-topbar\"><a class=\"back-link\" href=\"https://www.xtablo.com\">Retour a l'accueil</a> <button type=\"button\" class=\"theme-button\" aria-label=\"theme placeholder\"><span class=\"theme-button-monitor\"></span></button></div><div class=\"brand-lockup\"><div class=\"brand-mark\">XT</div></div><div class=\"headline-block\"><h1>Se connecter a Xtablo</h1></div><div class=\"spotlight-link-wrap\"><a class=\"spotlight-link\" href=\"/login-v2\">Decouvrez la nouvelle experience de connexion</a></div><form class=\"login-form\" hx-post=\"/login\" hx-target=\"#login-status\" hx-swap=\"innerHTML\"><div id=\"login-status\" class=\"status-slot\"></div><div class=\"field-group\"><label for=\"email\">Email *</label> <input id=\"email\" name=\"email\" type=\"email\" placeholder=\"Votre email\" required></div><div class=\"field-group\"><label for=\"password\">Mot de passe *</label> <input id=\"password\" name=\"password\" type=\"password\" placeholder=\"Votre mot de passe\" required></div><div class=\"aux-row\"><a href=\"/reset-password\">Mot de passe oublie ?</a></div><button class=\"primary-button\" type=\"submit\">Se connecter</button></form><div class=\"divider\"><span>Ou continuer avec</span></div><button type=\"button\" class=\"google-button\"><span class=\"google-mark\" aria-hidden=\"true\"><span class=\"google-mark-blue\"></span> <span class=\"google-mark-red\"></span> <span class=\"google-mark-yellow\"></span> <span class=\"google-mark-green\"></span></span> <span>Continuer avec Google</span></button><p class=\"signup-copy\">Pas encore de compte ? <a href=\"/signup\">S'inscrire</a></p></div></section></main></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func LoginStatus(kind string, message string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
if templ_7745c5c3_Var2 == nil {
templ_7745c5c3_Var2 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if kind == "success" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"status-banner status-success\" role=\"status\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(message)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/login.templ`, Line: 91, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if kind == "error" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"status-banner status-error\" role=\"alert\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(message)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/login.templ`, Line: 93, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -1,20 +0,0 @@
package main
import (
"net/http"
"os"
chi "github.com/go-chi/chi/v5"
"xtablo-backend/internal/web/handlers"
)
func newRouter() http.Handler {
mux := chi.NewRouter()
loginHandler := handlers.NewLoginHandler()
mux.Get("/", loginHandler.GetPage())
mux.Post("/login", loginHandler.PostLogin())
mux.Handle("/static/*", http.StripPrefix("/static/", http.FileServerFS(os.DirFS("static"))))
return mux
}

View file

@ -1,74 +0,0 @@
package main
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestRootRendersLoginPage(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
router := newRouter()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"Se connecter a Xtablo",
`hx-post="/login"`,
"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected body to contain %q", want)
}
}
}
func TestLoginReturnsValidationError(t *testing.T) {
form := url.Values{}
form.Set("email", "")
form.Set("password", "")
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
router := newRouter()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected status 422, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Veuillez renseigner votre email et votre mot de passe") {
t.Fatalf("expected validation error fragment, got %q", rec.Body.String())
}
}
func TestLoginReturnsSuccessMessage(t *testing.T) {
form := url.Values{}
form.Set("email", "demo@xtablo.com")
form.Set("password", "xtablo-demo")
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
router := newRouter()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Connexion reussie") {
t.Fatalf("expected success fragment, got %q", rec.Body.String())
}
}

View file

@ -1,417 +0,0 @@
:root {
--background: #f5f1ea;
--surface: rgba(255, 251, 246, 0.78);
--surface-border: rgba(84, 61, 31, 0.12);
--text: #1f1a17;
--muted: #73675d;
--primary: #1f6f64;
--primary-strong: #18584f;
--accent: #cf6b2d;
--accent-soft: rgba(207, 107, 45, 0.16);
--success-bg: rgba(31, 111, 100, 0.1);
--success-border: rgba(31, 111, 100, 0.25);
--error-bg: rgba(181, 69, 69, 0.1);
--error-border: rgba(181, 69, 69, 0.24);
--shadow: 0 24px 80px rgba(43, 24, 4, 0.12);
--font-body: "Avenir Next", "Segoe UI", sans-serif;
--font-display: "Iowan Old Style", "Georgia", serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
background:
radial-gradient(circle at top left, rgba(31, 111, 100, 0.16), transparent 30%),
radial-gradient(circle at top right, rgba(207, 107, 45, 0.16), transparent 26%),
linear-gradient(135deg, #f3efe7 0%, #f8f4ed 48%, #efe9dd 100%);
color: var(--text);
font-family: var(--font-body);
}
a {
color: inherit;
text-decoration: none;
}
button,
input {
font: inherit;
}
.page-shell {
min-height: 100vh;
position: relative;
overflow: hidden;
}
.page-background {
inset: 0;
pointer-events: none;
position: absolute;
}
.orb {
align-items: center;
animation: drift 18s linear infinite;
background: linear-gradient(135deg, rgba(31, 111, 100, 0.22), rgba(207, 107, 45, 0.1));
border: 1px solid rgba(255, 255, 255, 0.45);
border-radius: 999px;
color: rgba(31, 111, 100, 0.6);
display: flex;
font-family: var(--font-display);
font-size: 1.15rem;
font-weight: 700;
height: 3.4rem;
justify-content: center;
position: absolute;
width: 3.4rem;
}
.orb span {
transform: rotate(-12deg);
}
.orb-a { left: 6%; top: 18%; animation-duration: 14s; }
.orb-b { left: 12%; top: 70%; animation-duration: 19s; height: 4rem; width: 4rem; }
.orb-c { left: 24%; top: 8%; animation-duration: 16s; }
.orb-d { right: 14%; top: 20%; animation-duration: 21s; height: 4.4rem; width: 4.4rem; }
.orb-e { right: 8%; top: 66%; animation-duration: 17s; }
.orb-f { right: 26%; top: 10%; animation-duration: 23s; }
.orb-g { left: 36%; bottom: 10%; animation-duration: 20s; }
.orb-h { right: 35%; bottom: 8%; animation-duration: 15s; }
.auth-stage {
align-items: center;
display: flex;
justify-content: center;
min-height: 100vh;
padding: 2rem 1rem;
position: relative;
}
.auth-card {
max-width: 34rem;
position: relative;
width: 100%;
}
.auth-glow {
background: linear-gradient(135deg, rgba(31, 111, 100, 0.18), rgba(207, 107, 45, 0.1));
border-radius: 2rem;
filter: blur(24px);
inset: 1rem;
position: absolute;
z-index: 0;
}
.card-body {
backdrop-filter: blur(16px);
background: var(--surface);
border: 1px solid var(--surface-border);
border-radius: 1.75rem;
box-shadow: var(--shadow);
padding: 1.5rem;
position: relative;
z-index: 1;
}
.card-topbar {
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.back-link {
color: var(--muted);
font-size: 0.95rem;
transition: color 160ms ease;
}
.back-link:hover,
.aux-row a:hover,
.signup-copy a:hover {
color: var(--text);
}
.back-link::before {
content: "<";
margin-right: 0.55rem;
}
.theme-button {
align-items: center;
background: transparent;
border: 0;
border-radius: 999px;
color: var(--muted);
cursor: pointer;
display: inline-flex;
height: 2.5rem;
justify-content: center;
padding: 0;
transition: background-color 160ms ease, color 160ms ease;
width: 2.5rem;
}
.theme-button:hover {
background: rgba(31, 26, 23, 0.05);
color: var(--text);
}
.theme-button-monitor {
border: 2px solid currentColor;
border-radius: 0.35rem;
display: inline-block;
height: 1rem;
position: relative;
width: 1.3rem;
}
.theme-button-monitor::after {
border-top: 2px solid currentColor;
content: "";
left: 50%;
position: absolute;
top: calc(100% + 0.2rem);
transform: translateX(-50%);
width: 0.9rem;
}
.brand-lockup {
display: flex;
justify-content: center;
margin-bottom: 1.25rem;
}
.brand-mark {
align-items: center;
background: linear-gradient(135deg, rgba(31, 111, 100, 0.18), rgba(207, 107, 45, 0.2));
border: 1px solid rgba(31, 111, 100, 0.16);
border-radius: 1.25rem;
color: var(--primary-strong);
display: flex;
font-family: var(--font-display);
font-size: 1.3rem;
font-weight: 700;
height: 4.5rem;
justify-content: center;
letter-spacing: 0.12rem;
width: 4.5rem;
}
.headline-block {
margin-bottom: 1rem;
text-align: center;
}
.headline-block h1 {
font-family: var(--font-display);
font-size: clamp(2rem, 4vw, 2.7rem);
line-height: 1.05;
margin: 0;
}
.spotlight-link-wrap {
margin-bottom: 1.5rem;
text-align: center;
}
.spotlight-link {
color: var(--accent);
font-size: 0.95rem;
font-weight: 600;
}
.login-form {
display: grid;
gap: 1rem;
}
.status-slot {
min-height: 0.25rem;
}
.status-banner {
border: 1px solid;
border-radius: 1rem;
font-size: 0.94rem;
line-height: 1.45;
padding: 0.9rem 1rem;
}
.status-success {
background: var(--success-bg);
border-color: var(--success-border);
color: var(--primary-strong);
}
.status-error {
background: var(--error-bg);
border-color: var(--error-border);
color: #8f3737;
}
.field-group {
display: grid;
gap: 0.45rem;
}
.field-group label {
font-size: 0.95rem;
font-weight: 600;
}
.field-group input {
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(31, 26, 23, 0.12);
border-radius: 0.9rem;
color: var(--text);
min-height: 3rem;
padding: 0.8rem 0.95rem;
transition: border-color 160ms ease, box-shadow 160ms ease, background-color 160ms ease;
}
.field-group input:focus {
background: rgba(255, 255, 255, 0.92);
border-color: rgba(31, 111, 100, 0.45);
box-shadow: 0 0 0 4px rgba(31, 111, 100, 0.1);
outline: none;
}
.field-group input::placeholder {
color: #988d82;
}
.aux-row {
display: flex;
justify-content: flex-end;
}
.aux-row a {
color: var(--primary);
font-size: 0.92rem;
}
.primary-button,
.google-button {
align-items: center;
border: 0;
border-radius: 999px;
cursor: pointer;
display: inline-flex;
font-weight: 700;
justify-content: center;
min-height: 3rem;
transition: transform 160ms ease, box-shadow 160ms ease, background-color 160ms ease;
}
.primary-button {
background: linear-gradient(135deg, var(--primary), var(--primary-strong));
box-shadow: 0 14px 30px rgba(31, 111, 100, 0.28);
color: #fffdf9;
width: 100%;
}
.primary-button:hover,
.google-button:hover {
transform: translateY(-1px);
}
.divider {
align-items: center;
color: var(--muted);
display: flex;
gap: 0.85rem;
margin: 1.35rem 0;
}
.divider::before,
.divider::after {
background: rgba(31, 26, 23, 0.12);
content: "";
flex: 1;
height: 1px;
}
.divider span {
background: rgba(255, 251, 246, 0.72);
border-radius: 999px;
padding: 0.4rem 0.95rem;
}
.google-button {
background: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(31, 26, 23, 0.12);
color: var(--text);
gap: 0.8rem;
width: 100%;
}
.google-mark {
display: grid;
gap: 0.08rem;
grid-template-columns: repeat(2, 0.55rem);
}
.google-mark span {
border-radius: 0.12rem;
display: inline-block;
height: 0.55rem;
width: 0.55rem;
}
.google-mark-blue { background: #4285f4; }
.google-mark-red { background: #ea4335; }
.google-mark-yellow { background: #fbbc05; }
.google-mark-green { background: #34a853; }
.signup-copy {
color: var(--muted);
margin: 1.3rem 0 0;
text-align: center;
}
.signup-copy a {
color: var(--text);
font-weight: 700;
}
@keyframes drift {
0%,
100% {
transform: translate3d(0, 0, 0) rotate(0deg);
}
25% {
transform: translate3d(10px, -14px, 0) rotate(8deg);
}
50% {
transform: translate3d(-8px, 10px, 0) rotate(-6deg);
}
75% {
transform: translate3d(12px, 8px, 0) rotate(5deg);
}
}
@media (max-width: 640px) {
.card-body {
border-radius: 1.45rem;
padding: 1.2rem;
}
.headline-block h1 {
font-size: 2rem;
}
.divider span {
padding-inline: 0.7rem;
}
}