diff --git a/.gitignore b/.gitignore index 3cfb0b0..22325c5 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ dist # Supabase supabase/.temp supabase/.branches + +# Podman +.podman-compose diff --git a/docs/superpowers/plans/2026-05-07-go-backend-login.md b/docs/superpowers/plans/2026-05-07-go-backend-login.md new file mode 100644 index 0000000..e748f2d --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-go-backend-login.md @@ -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. diff --git a/docs/superpowers/specs/2026-05-07-go-backend-login-design.md b/docs/superpowers/specs/2026-05-07-go-backend-login-design.md new file mode 100644 index 0000000..00b1442 --- /dev/null +++ b/docs/superpowers/specs/2026-05-07-go-backend-login-design.md @@ -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. diff --git a/go-backend/.air.toml b/go-backend/.air.toml new file mode 100644 index 0000000..7d3d4e4 --- /dev/null +++ b/go-backend/.air.toml @@ -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 diff --git a/go-backend/.env.example b/go-backend/.env.example new file mode 100644 index 0000000..619c25f --- /dev/null +++ b/go-backend/.env.example @@ -0,0 +1 @@ +DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable diff --git a/go-backend/README.md b/go-backend/README.md new file mode 100644 index 0000000..ae0c93d --- /dev/null +++ b/go-backend/README.md @@ -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 +``` diff --git a/go-backend/compose.yaml b/go-backend/compose.yaml new file mode 100644 index 0000000..95ebffc --- /dev/null +++ b/go-backend/compose.yaml @@ -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: diff --git a/go-backend/go.mod b/go-backend/go.mod new file mode 100644 index 0000000..20f94e9 --- /dev/null +++ b/go-backend/go.mod @@ -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 +) diff --git a/go-backend/go.sum b/go-backend/go.sum new file mode 100644 index 0000000..46529df --- /dev/null +++ b/go-backend/go.sum @@ -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= diff --git a/go-backend/internal/db/queries.sql b/go-backend/internal/db/queries.sql new file mode 100644 index 0000000..a338690 --- /dev/null +++ b/go-backend/internal/db/queries.sql @@ -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; diff --git a/go-backend/internal/db/repository.go b/go-backend/internal/db/repository.go new file mode 100644 index 0000000..f288964 --- /dev/null +++ b/go-backend/internal/db/repository.go @@ -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 +} diff --git a/go-backend/internal/db/schema.sql b/go-backend/internal/db/schema.sql new file mode 100644 index 0000000..2be7b1a --- /dev/null +++ b/go-backend/internal/db/schema.sql @@ -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(); diff --git a/go-backend/internal/db/seed.sql b/go-backend/internal/db/seed.sql new file mode 100644 index 0000000..0c59bd6 --- /dev/null +++ b/go-backend/internal/db/seed.sql @@ -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; diff --git a/go-backend/internal/db/sqlc/db.go b/go-backend/internal/db/sqlc/db.go new file mode 100644 index 0000000..a28f6fc --- /dev/null +++ b/go-backend/internal/db/sqlc/db.go @@ -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, + } +} diff --git a/go-backend/internal/db/sqlc/models.go b/go-backend/internal/db/sqlc/models.go new file mode 100644 index 0000000..ee1ee7d --- /dev/null +++ b/go-backend/internal/db/sqlc/models.go @@ -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"` +} diff --git a/go-backend/internal/db/sqlc/querier.go b/go-backend/internal/db/sqlc/querier.go new file mode 100644 index 0000000..2a2b436 --- /dev/null +++ b/go-backend/internal/db/sqlc/querier.go @@ -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) diff --git a/go-backend/internal/db/sqlc/queries.sql.go b/go-backend/internal/db/sqlc/queries.sql.go new file mode 100644 index 0000000..c24db1b --- /dev/null +++ b/go-backend/internal/db/sqlc/queries.sql.go @@ -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 +} diff --git a/go-backend/internal/web/handlers/auth.go b/go-backend/internal/web/handlers/auth.go new file mode 100644 index 0000000..6dfa6e0 --- /dev/null +++ b/go-backend/internal/web/handlers/auth.go @@ -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") +} diff --git a/go-backend/internal/web/handlers/auth_test.go b/go-backend/internal/web/handlers/auth_test.go new file mode 100644 index 0000000..ad27bbd --- /dev/null +++ b/go-backend/internal/web/handlers/auth_test.go @@ -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()) +} diff --git a/go-backend/internal/web/views/login.templ b/go-backend/internal/web/views/login.templ new file mode 100644 index 0000000..a879cd0 --- /dev/null +++ b/go-backend/internal/web/views/login.templ @@ -0,0 +1,219 @@ +package views + +templ AuthPage(content templ.Component) { + + + + + + + + + + + + + XTablo + + + + +
+
+
+ +
+
+ + +} + +templ LoginScreen() { +
+

Se connecter à Xtablo

+
+ +
+
+
+
+ + +
+
+ + +
+ + +
+ @AuthDivider() + @GoogleButton() +

+ Pas encore de compte ? + +

+
+} + +templ SignupScreen() { +
+

S'inscrire à Xtablo

+
+ +
+
+
+
+ + +
+
+ + +
+ +
+ @AuthDivider() + @GoogleButton() +

+ Vous avez déjà un compte ? + +

+
+} + +templ HomePage(displayName string, email string) { + + + + + + XTablo + + + +
+
+ +

Bienvenue

+

{ displayName }

+

Session active pour { email }

+
+ +
+
+
+ + +} + +templ AuthStatus(kind string, message string) { + if kind == "success" { +
{ message }
+ } else if kind == "error" { + + } +} + +templ AuthDivider() { +
+
+ Ou continuer avec +
+
+} + +templ GoogleButton() { + +} + +templ AnimatedBackground() { + +} diff --git a/go-backend/internal/web/views/login_templ.go b/go-backend/internal/web/views/login_templ.go new file mode 100644 index 0000000..f508771 --- /dev/null +++ b/go-backend/internal/web/views/login_templ.go @@ -0,0 +1,342 @@ +// 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 AuthPage(content templ.Component) 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, "XTablo
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = AnimatedBackground().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = content.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func LoginScreen() 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) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

Se connecter à Xtablo

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = AuthDivider().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = GoogleButton().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

Pas encore de compte ? S'inscrire

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func SignupScreen() 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_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

S'inscrire à Xtablo

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = AuthDivider().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = GoogleButton().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

Vous avez déjà un compte ? Se connecter

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func HomePage(displayName string, email 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_Var4 := templ.GetChildren(ctx) + if templ_7745c5c3_Var4 == nil { + templ_7745c5c3_Var4 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "XTablo
\"Xtablo\"

Bienvenue

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(displayName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/login.templ`, Line: 135, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

Session active pour ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(email) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/login.templ`, Line: 136, Col: 35} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func AuthStatus(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_Var7 := templ.GetChildren(ctx) + if templ_7745c5c3_Var7 == nil { + templ_7745c5c3_Var7 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if kind == "success" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, 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: 148, Col: 67} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if kind == "error" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, 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: 150, Col: 64} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func AuthDivider() 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_Var10 := templ.GetChildren(ctx) + if templ_7745c5c3_Var10 == nil { + templ_7745c5c3_Var10 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
Ou continuer avec
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func GoogleButton() 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_Var11 := templ.GetChildren(ctx) + if templ_7745c5c3_Var11 == nil { + templ_7745c5c3_Var11 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func AnimatedBackground() 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_Var12 := templ.GetChildren(ctx) + if templ_7745c5c3_Var12 == nil { + templ_7745c5c3_Var12 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/go-backend/justfile b/go-backend/justfile new file mode 100644 index 0000000..97bc6ea --- /dev/null +++ b/go-backend/justfile @@ -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 . diff --git a/go_backend_deprecated/main.go b/go-backend/main.go similarity index 100% rename from go_backend_deprecated/main.go rename to go-backend/main.go diff --git a/go-backend/router.go b/go-backend/router.go new file mode 100644 index 0000000..fc9cd2a --- /dev/null +++ b/go-backend/router.go @@ -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) + } +} diff --git a/go-backend/router_test.go b/go-backend/router_test.go new file mode 100644 index 0000000..fc1b908 --- /dev/null +++ b/go-backend/router_test.go @@ -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 +} diff --git a/go-backend/sqlc.yaml b/go-backend/sqlc.yaml new file mode 100644 index 0000000..e9cc5a3 --- /dev/null +++ b/go-backend/sqlc.yaml @@ -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" diff --git a/go-backend/static/logo_dark.png b/go-backend/static/logo_dark.png new file mode 100644 index 0000000..4cee136 Binary files /dev/null and b/go-backend/static/logo_dark.png differ diff --git a/go-backend/static/logo_white.png b/go-backend/static/logo_white.png new file mode 100644 index 0000000..b34373b Binary files /dev/null and b/go-backend/static/logo_white.png differ diff --git a/go-backend/static/manifest.webmanifest b/go-backend/static/manifest.webmanifest new file mode 100644 index 0000000..5a512f1 --- /dev/null +++ b/go-backend/static/manifest.webmanifest @@ -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" + } + ] +} diff --git a/go-backend/static/pwa-icons/apple-touch-icon-180x180.png b/go-backend/static/pwa-icons/apple-touch-icon-180x180.png new file mode 100644 index 0000000..78da98f Binary files /dev/null and b/go-backend/static/pwa-icons/apple-touch-icon-180x180.png differ diff --git a/go-backend/static/pwa-icons/favicon-16x16.png b/go-backend/static/pwa-icons/favicon-16x16.png new file mode 100644 index 0000000..2f3428d Binary files /dev/null and b/go-backend/static/pwa-icons/favicon-16x16.png differ diff --git a/go-backend/static/pwa-icons/favicon-32x32.png b/go-backend/static/pwa-icons/favicon-32x32.png new file mode 100644 index 0000000..f6d7041 Binary files /dev/null and b/go-backend/static/pwa-icons/favicon-32x32.png differ diff --git a/go-backend/static/styles.css b/go-backend/static/styles.css new file mode 100644 index 0000000..5286232 --- /dev/null +++ b/go-backend/static/styles.css @@ -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; + } +} diff --git a/go-backend/tools.go b/go-backend/tools.go new file mode 100644 index 0000000..c910e35 --- /dev/null +++ b/go-backend/tools.go @@ -0,0 +1,5 @@ +//go:build tools + +package tools + +import _ "github.com/sqlc-dev/sqlc/cmd/sqlc" diff --git a/go_backend_deprecated/.air.toml b/go_backend_deprecated/.air.toml deleted file mode 100644 index a7b49d9..0000000 --- a/go_backend_deprecated/.air.toml +++ /dev/null @@ -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 diff --git a/go_backend_deprecated/Dockerfile b/go_backend_deprecated/Dockerfile deleted file mode 100644 index 9e193d9..0000000 --- a/go_backend_deprecated/Dockerfile +++ /dev/null @@ -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"] \ No newline at end of file diff --git a/go_backend_deprecated/go.mod b/go_backend_deprecated/go.mod deleted file mode 100644 index 772802f..0000000 --- a/go_backend_deprecated/go.mod +++ /dev/null @@ -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 -) diff --git a/go_backend_deprecated/go.sum b/go_backend_deprecated/go.sum deleted file mode 100644 index e6239c4..0000000 --- a/go_backend_deprecated/go.sum +++ /dev/null @@ -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= diff --git a/go_backend_deprecated/internal/web/handlers/login.go b/go_backend_deprecated/internal/web/handlers/login.go deleted file mode 100644 index fdd4cad..0000000 --- a/go_backend_deprecated/internal/web/handlers/login.go +++ /dev/null @@ -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) - } - } -} diff --git a/go_backend_deprecated/internal/web/views/login.templ b/go_backend_deprecated/internal/web/views/login.templ deleted file mode 100644 index d4a875e..0000000 --- a/go_backend_deprecated/internal/web/views/login.templ +++ /dev/null @@ -1,95 +0,0 @@ -package views - -templ LoginPage() { - - - - - - - XTablo - - - - -
-
-
X
-
X
-
X
-
X
-
X
-
X
-
X
-
X
-
-
-
-
-
-
- Retour a l'accueil - -
-
-
XT
-
-
-

Se connecter a Xtablo

-
- - -
- Ou continuer avec -
- - -
-
-
-
- - -} - -templ LoginStatus(kind string, message string) { - if kind == "success" { -
{ message }
- } else if kind == "error" { - - } -} diff --git a/go_backend_deprecated/internal/web/views/login_templ.go b/go_backend_deprecated/internal/web/views/login_templ.go deleted file mode 100644 index 00c9b41..0000000 --- a/go_backend_deprecated/internal/web/views/login_templ.go +++ /dev/null @@ -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, "XTablo
X
X
X
X
X
X
X
X
XT

Se connecter a Xtablo

Ou continuer avec

Pas encore de compte ? S'inscrire

") - 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, "
") - 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, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else if kind == "error" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") - 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, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - return nil - }) -} - -var _ = templruntime.GeneratedTemplate diff --git a/go_backend_deprecated/router.go b/go_backend_deprecated/router.go deleted file mode 100644 index a6ac3c5..0000000 --- a/go_backend_deprecated/router.go +++ /dev/null @@ -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 -} diff --git a/go_backend_deprecated/router_test.go b/go_backend_deprecated/router_test.go deleted file mode 100644 index 986d3b2..0000000 --- a/go_backend_deprecated/router_test.go +++ /dev/null @@ -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()) - } -} diff --git a/go_backend_deprecated/static/styles.css b/go_backend_deprecated/static/styles.css deleted file mode 100644 index 6ea5f70..0000000 --- a/go_backend_deprecated/static/styles.css +++ /dev/null @@ -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; - } -}