Build go-backend auth app with Podman dev flow
This commit is contained in:
parent
98952ace6e
commit
0a38442d88
44 changed files with 3563 additions and 876 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -45,3 +45,6 @@ dist
|
|||
# Supabase
|
||||
supabase/.temp
|
||||
supabase/.branches
|
||||
|
||||
# Podman
|
||||
.podman-compose
|
||||
|
|
|
|||
209
docs/superpowers/plans/2026-05-07-go-backend-login.md
Normal file
209
docs/superpowers/plans/2026-05-07-go-backend-login.md
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
# Go Backend Login Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the current Vite-oriented `go_backend` app with a standalone Go web app that serves a templ-rendered XTablo login page and uses HTMX for inline login feedback on `http://localhost:3000`.
|
||||
|
||||
**Architecture:** The server remains a small `chi` application. `GET /` renders a full page from `templ`, `POST /login` returns a small server-rendered fragment for HTMX swaps, and `GET /static/*` serves local CSS. The page includes the HTMX runtime from the specified CDN URL and no custom app JavaScript.
|
||||
|
||||
**Tech Stack:** Go, chi, templ, net/http, HTMX CDN, server-served CSS
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Test Scaffold And Module Setup
|
||||
|
||||
### Task 1: Add dependencies and basic file layout
|
||||
|
||||
**Files:**
|
||||
- Modify: `go_backend/go.mod`
|
||||
- Modify: `go_backend/go.sum`
|
||||
- Create: `go_backend/internal/web/handlers/login.go`
|
||||
- Create: `go_backend/internal/web/views/layout.templ`
|
||||
- Create: `go_backend/internal/web/views/login.templ`
|
||||
- Create: `go_backend/static/styles.css`
|
||||
|
||||
- [ ] **Step 1: Add the failing tests first before any production behavior changes**
|
||||
- [ ] **Step 2: Add `templ` module dependency after the tests are in place**
|
||||
- [ ] **Step 3: Create focused directories for handlers, views, and static assets**
|
||||
- [ ] **Step 4: Keep Vite-only files untouched until replacement code is ready**
|
||||
|
||||
## Chunk 2: HTTP-Level TDD
|
||||
|
||||
### Task 2: Cover the root page
|
||||
|
||||
**Files:**
|
||||
- Create: `go_backend/router_test.go`
|
||||
- Test: `go_backend/router_test.go`
|
||||
|
||||
- [ ] **Step 1: Write a failing test for `GET /`**
|
||||
|
||||
```go
|
||||
func TestRootRendersLoginPage(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router := newRouter()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"Se connecter a Xtablo",
|
||||
`hx-post="/login"`,
|
||||
"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected body to contain %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: the new root-page assertions fail against the current Vite/Spa output.
|
||||
|
||||
- [ ] **Step 3: Implement the minimum root-page rendering path**
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: `TestRootRendersLoginPage` passes.
|
||||
|
||||
### Task 3: Cover HTMX login fragment behavior
|
||||
|
||||
**Files:**
|
||||
- Modify: `go_backend/router_test.go`
|
||||
- Test: `go_backend/router_test.go`
|
||||
|
||||
- [ ] **Step 1: Write a failing test for missing login fields**
|
||||
|
||||
```go
|
||||
func TestLoginReturnsValidationError(t *testing.T) {
|
||||
form := url.Values{}
|
||||
form.Set("email", "")
|
||||
form.Set("password", "")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router := newRouter()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("expected status 422, got %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Veuillez renseigner votre email et votre mot de passe") {
|
||||
t.Fatalf("expected validation error fragment, got %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: missing route or wrong response.
|
||||
|
||||
- [ ] **Step 3: Write a failing test for demo credential success**
|
||||
|
||||
```go
|
||||
func TestLoginReturnsSuccessMessage(t *testing.T) {
|
||||
form := url.Values{}
|
||||
form.Set("email", "demo@xtablo.com")
|
||||
form.Set("password", "xtablo-demo")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router := newRouter()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Connexion reussie") {
|
||||
t.Fatalf("expected success fragment, got %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: no success behavior exists yet.
|
||||
|
||||
- [ ] **Step 5: Implement the minimal `POST /login` validation and fragment rendering**
|
||||
- [ ] **Step 6: Run test to verify both login fragment tests pass**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: both login tests pass.
|
||||
|
||||
## Chunk 3: Replace Vite-Oriented Production Code
|
||||
|
||||
### Task 4: Simplify routing and startup
|
||||
|
||||
**Files:**
|
||||
- Modify: `go_backend/main.go`
|
||||
- Modify: `go_backend/router.go`
|
||||
- Possibly remove references from: `go_backend/internal/spahandler/handler.go`
|
||||
- Possibly remove references from: `go_backend/internal/frontend/file.go`
|
||||
|
||||
- [ ] **Step 1: Introduce a `newRouter()` function shared by tests and main**
|
||||
- [ ] **Step 2: Bind the server to `localhost:3000`**
|
||||
- [ ] **Step 3: Replace dev/prod SPA branching with direct routes for `/`, `/login`, and `/static/*`**
|
||||
- [ ] **Step 4: Remove unused Vite-specific wiring once tests stay green**
|
||||
|
||||
### Task 5: Render the login page with templ
|
||||
|
||||
**Files:**
|
||||
- Modify: `go_backend/internal/web/views/layout.templ`
|
||||
- Modify: `go_backend/internal/web/views/login.templ`
|
||||
- Modify: `go_backend/internal/web/handlers/login.go`
|
||||
- Modify: `go_backend/static/styles.css`
|
||||
|
||||
- [ ] **Step 1: Create a base page layout with head metadata, CSS link, and HTMX CDN script**
|
||||
- [ ] **Step 2: Create the login page component**
|
||||
- [ ] **Step 3: Create a reusable status fragment component for success and error states**
|
||||
- [ ] **Step 4: Match the requested visual structure with local CSS, not utility classes**
|
||||
- [ ] **Step 5: Keep copy in French and links as placeholders where needed**
|
||||
|
||||
## Chunk 4: Verification
|
||||
|
||||
### Task 6: Generate code, format, test, and build
|
||||
|
||||
**Files:**
|
||||
- Generated: `go_backend/internal/web/views/*_templ.go`
|
||||
|
||||
- [ ] **Step 1: Generate templ output**
|
||||
|
||||
Run: `templ generate`
|
||||
Expected: updated generated Go files for the templ views.
|
||||
|
||||
- [ ] **Step 2: Format the Go code**
|
||||
|
||||
Run: `gofmt -w main.go router.go internal/web/handlers/login.go internal/web/views/*_templ.go router_test.go`
|
||||
Expected: no output, files rewritten in place.
|
||||
|
||||
- [ ] **Step 3: Run the full Go test suite**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 4: Run a build verification**
|
||||
|
||||
Run: `go build ./...`
|
||||
Expected: build succeeds with exit code 0.
|
||||
|
||||
- [ ] **Step 5: Manual smoke check**
|
||||
|
||||
Run: `go run .`
|
||||
Expected: server listens on `http://localhost:3000`.
|
||||
|
||||
---
|
||||
|
||||
Plan complete and saved to `docs/superpowers/plans/2026-05-07-go-backend-login.md`. Ready to execute.
|
||||
142
docs/superpowers/specs/2026-05-07-go-backend-login-design.md
Normal file
142
docs/superpowers/specs/2026-05-07-go-backend-login-design.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# Go Backend Login Design
|
||||
|
||||
**Date:** 2026-05-07
|
||||
|
||||
**Goal**
|
||||
|
||||
Build a new standalone web app in `go_backend` that serves a server-rendered XTablo login screen using Go, `templ`, and HTMX, with no Vite pipeline and no custom application JavaScript.
|
||||
|
||||
**Scope**
|
||||
|
||||
- Replace the current SPA/Vite-oriented serving path in `go_backend`.
|
||||
- Render the main login screen at `/`.
|
||||
- Submit the login form with HTMX to `/login`.
|
||||
- Return a server-rendered HTML fragment into the login card for feedback.
|
||||
- Serve local CSS and any local static assets directly from Go.
|
||||
|
||||
**Out of Scope**
|
||||
|
||||
- Real authentication integration
|
||||
- Session management
|
||||
- OAuth / Google sign-in implementation
|
||||
- Theme switching behavior
|
||||
- Multi-page navigation beyond placeholder links
|
||||
|
||||
**Architecture**
|
||||
|
||||
The app will be a small Go HTTP service using `chi` for routing and `templ` for HTML generation. The server will expose a page route for the full login screen, a form submission route for partial updates, and a static file route for CSS and local assets.
|
||||
|
||||
The development server should listen on `localhost:3000`.
|
||||
|
||||
HTMX will be loaded from the user-specified CDN URL:
|
||||
|
||||
`https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js`
|
||||
|
||||
No Vite integration, bundling, or custom frontend JavaScript will remain in the new app.
|
||||
|
||||
**Route Design**
|
||||
|
||||
- `GET /`
|
||||
- Returns the full login page.
|
||||
- `POST /login`
|
||||
- Accepts form fields `email` and `password`.
|
||||
- Returns a small server-rendered fragment for an inline status/message region.
|
||||
- `GET /static/*`
|
||||
- Serves CSS and optional local static assets.
|
||||
|
||||
**Server Structure**
|
||||
|
||||
Proposed structure:
|
||||
|
||||
- `go_backend/main.go`
|
||||
- Server startup and configuration.
|
||||
- `go_backend/router.go`
|
||||
- Route registration.
|
||||
- `go_backend/internal/web/handlers/`
|
||||
- HTTP handlers for page rendering and login submission.
|
||||
- `go_backend/internal/web/views/`
|
||||
- `templ` page and fragment components.
|
||||
- `go_backend/static/`
|
||||
- CSS and optional local assets.
|
||||
|
||||
The code should keep routing, request handling, and view rendering separated so future auth wiring does not force template logic into the router layer.
|
||||
|
||||
**UI Design**
|
||||
|
||||
The login page should visually track the provided reference:
|
||||
|
||||
- full-screen soft gradient background
|
||||
- centered frosted/glass login card
|
||||
- top row with back link and theme-toggle placeholder button
|
||||
- centered XTablo brand area
|
||||
- French login title and surrounding copy
|
||||
- email/password fields
|
||||
- forgot-password link
|
||||
- primary submit button
|
||||
- “Ou continuer avec” separator
|
||||
- Google continuation button
|
||||
- signup prompt at the bottom
|
||||
|
||||
The background should include decorative floating logo-like ornaments implemented with CSS positioning and animation. If the repository does not already contain usable logo assets, the page should still render cleanly with text or simple shape placeholders rather than blocking implementation on image sourcing.
|
||||
|
||||
**Behavior**
|
||||
|
||||
The app remains primarily server-rendered.
|
||||
|
||||
The login form will use HTMX attributes:
|
||||
|
||||
- `hx-post="/login"`
|
||||
- `hx-target` for an inline feedback region
|
||||
- `hx-swap="innerHTML"`
|
||||
|
||||
On submit:
|
||||
|
||||
- missing fields should return an error fragment
|
||||
- invalid placeholder credentials should return an error fragment
|
||||
- a known demo credential path may return a success fragment
|
||||
|
||||
This keeps the page interactive without adding custom client-side scripts.
|
||||
|
||||
**Validation Strategy**
|
||||
|
||||
For the initial implementation:
|
||||
|
||||
- both fields required
|
||||
- simple server-side empty-value validation
|
||||
- optional demo credential branch for a success state
|
||||
|
||||
This is sufficient for a scaffold and keeps the design aligned with the user request without inventing an auth system.
|
||||
|
||||
**Testing Strategy**
|
||||
|
||||
Use TDD at the HTTP handler level.
|
||||
|
||||
Minimum tests:
|
||||
|
||||
- `GET /` returns `200` and includes core login page content
|
||||
- `POST /login` with missing fields returns an error fragment
|
||||
- `POST /login` with demo credentials returns a success fragment
|
||||
|
||||
Additional checks:
|
||||
|
||||
- the Go module builds after adding `templ`
|
||||
- generated templ code is committed or present in the workspace
|
||||
|
||||
**Risks and Mitigations**
|
||||
|
||||
- `templ` introduces code generation.
|
||||
- Mitigation: generate templates as part of the implementation flow and verify build output explicitly.
|
||||
- Replacing the current Vite setup may leave dead code.
|
||||
- Mitigation: remove or stop referencing Vite-specific handlers and dependencies during the migration.
|
||||
- Visual parity with the sample may drift without its exact assets.
|
||||
- Mitigation: match layout, hierarchy, spacing, color direction, and component treatment first; degrade gracefully on branding assets.
|
||||
|
||||
**Acceptance Criteria**
|
||||
|
||||
- Visiting `/` in `go_backend` shows a standalone login page rendered by Go.
|
||||
- The server runs at `http://localhost:3000`.
|
||||
- The page uses `templ` output rather than Vite or SPA mounting.
|
||||
- HTMX is loaded from the specified CDN URL.
|
||||
- No custom app JavaScript is added.
|
||||
- Submitting the form updates an inline message region via HTMX.
|
||||
- The app builds and targeted tests pass.
|
||||
27
go-backend/.air.toml
Normal file
27
go-backend/.air.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
#:schema https://json.schemastore.org/any.json
|
||||
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go run github.com/a-h/templ/cmd/templ@latest generate && go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate && go build -o ./tmp/main ."
|
||||
entrypoint = ["./tmp/main"]
|
||||
include_ext = ["go", "templ", "sql", "css", "html", "png", "svg", "webmanifest", "json"]
|
||||
exclude_dir = ["tmp", "vendor", ".git", "internal/db/sqlc"]
|
||||
exclude_regex = ["_templ\\.go$"]
|
||||
delay = 200
|
||||
stop_on_error = true
|
||||
send_interrupt = true
|
||||
kill_delay = "500ms"
|
||||
|
||||
[color]
|
||||
main = "magenta"
|
||||
watcher = "cyan"
|
||||
build = "yellow"
|
||||
runner = "green"
|
||||
|
||||
[log]
|
||||
time = true
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
1
go-backend/.env.example
Normal file
1
go-backend/.env.example
Normal file
|
|
@ -0,0 +1 @@
|
|||
DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable
|
||||
43
go-backend/README.md
Normal file
43
go-backend/README.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# go-backend
|
||||
|
||||
## Local Postgres
|
||||
|
||||
Start Postgres with Podman:
|
||||
|
||||
```bash
|
||||
just db-up
|
||||
```
|
||||
|
||||
Reset the local database volume and reinitialize the schema:
|
||||
|
||||
```bash
|
||||
just db-reset
|
||||
```
|
||||
|
||||
The database is exposed at `localhost:5432` and initialized from `internal/db/schema.sql`.
|
||||
The local workflow uses `podman compose`.
|
||||
If your global Docker config contains corporate credential helpers, the `just` recipes isolate compose with a local `DOCKER_CONFIG` to avoid unrelated registry auth hooks on public image pulls.
|
||||
Fresh database volumes are also seeded from `internal/db/seed.sql` with `demo@xtablo.com / xtablo-demo`.
|
||||
|
||||
Connection string:
|
||||
|
||||
```bash
|
||||
export DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable
|
||||
```
|
||||
|
||||
Run the app with hot reload:
|
||||
|
||||
```bash
|
||||
just dev
|
||||
```
|
||||
|
||||
Other useful commands:
|
||||
|
||||
```bash
|
||||
just generate
|
||||
just test
|
||||
just build
|
||||
just check
|
||||
just db-logs
|
||||
just db-down
|
||||
```
|
||||
23
go-backend/compose.yaml
Normal file
23
go-backend/compose.yaml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: xtablo-go-backend-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: xtablo
|
||||
POSTGRES_USER: xtablo
|
||||
POSTGRES_PASSWORD: xtablo
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./internal/db/schema.sql:/docker-entrypoint-initdb.d/001-auth-schema.sql:ro
|
||||
- ./internal/db/seed.sql:/docker-entrypoint-initdb.d/002-seed.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U xtablo -d xtablo"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
64
go-backend/go.mod
Normal file
64
go-backend/go.mod
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
module xtablo-backend
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require github.com/go-chi/chi/v5 v5.2.0
|
||||
|
||||
require (
|
||||
github.com/a-h/templ v0.3.1001
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/sqlc-dev/sqlc v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
filippo.io/edwards25519 v1.1.1 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||
github.com/coreos/go-semver v0.3.1 // indirect
|
||||
github.com/cubicdaiya/gonp v1.0.4 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/fatih/structtag v1.2.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
github.com/google/cel-go v0.28.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/ncruces/go-sqlite3 v0.32.0 // indirect
|
||||
github.com/ncruces/julianday v1.0.0 // indirect
|
||||
github.com/pganalyze/pg_query_go/v6 v6.2.2 // indirect
|
||||
github.com/pingcap/errors v0.11.5-0.20250523034308-74f78ae071ee // indirect
|
||||
github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect
|
||||
github.com/pingcap/log v1.1.0 // indirect
|
||||
github.com/pingcap/tidb/pkg/parser v0.0.0-20260418072757-ce92298d1124 // indirect
|
||||
github.com/riza-io/grpc-go v0.2.0 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/sqlc-dev/doubleclick v1.0.0 // indirect
|
||||
github.com/tetratelabs/wazero v1.11.0 // indirect
|
||||
github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect
|
||||
github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/rs/zerolog v1.33.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
)
|
||||
192
go-backend/go.sum
Normal file
192
go-backend/go.sum
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
|
||||
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
|
||||
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
|
||||
github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws=
|
||||
github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
|
||||
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
|
||||
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
|
||||
github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc=
|
||||
github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-sqlite3 v0.32.0 h1:hNBUXp88LrfQCsuyXLqWTbTUG35sUuktDsqhhgHvU20=
|
||||
github.com/ncruces/go-sqlite3 v0.32.0/go.mod h1:MIWTK60ONDl0oVY073zYvJP21C3Dly6P9bxVpgkLwdQ=
|
||||
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
|
||||
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
|
||||
github.com/pganalyze/pg_query_go/v6 v6.2.2 h1:O0L6zMC226R82RF3X5n0Ki6HjytDsoAzuzp4ATVAHNo=
|
||||
github.com/pganalyze/pg_query_go/v6 v6.2.2/go.mod h1:Cn6+j4870kJz3iYNsb0VsNG04vpSWgEvBwc590J4qD0=
|
||||
github.com/pingcap/errors v0.11.0/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||
github.com/pingcap/errors v0.11.5-0.20250523034308-74f78ae071ee h1:/IDPbpzkzA97t1/Z1+C3KlxbevjMeaI6BQYxvivu4u8=
|
||||
github.com/pingcap/errors v0.11.5-0.20250523034308-74f78ae071ee/go.mod h1:X2r9ueLEUZgtx2cIogM0v4Zj5uvvzhuuiu7Pn8HzMPg=
|
||||
github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 h1:tdMsjOqUR7YXHoBitzdebTvOjs/swniBTOLy5XiMtuE=
|
||||
github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86/go.mod h1:exzhVYca3WRtd6gclGNErRWb1qEgff3LYta0LvRmON4=
|
||||
github.com/pingcap/log v1.1.0 h1:ELiPxACz7vdo1qAvvaWJg1NrYFoY6gqAh/+Uo6aXdD8=
|
||||
github.com/pingcap/log v1.1.0/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4=
|
||||
github.com/pingcap/tidb/pkg/parser v0.0.0-20260418072757-ce92298d1124 h1:zYmP5fBH+i2yhhU6f5uOol6zxHtR2/sD47BsJLfy0oU=
|
||||
github.com/pingcap/tidb/pkg/parser v0.0.0-20260418072757-ce92298d1124/go.mod h1:zDLDsfNBU5+L6T4J9/OgWAHc/WZvMUjbpgHqQ/t3yKo=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ=
|
||||
github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/sqlc-dev/doubleclick v1.0.0 h1:2/OApfQ2eLgcfa/Fqs8WSMA6atH0G8j9hHbQIgMfAXI=
|
||||
github.com/sqlc-dev/doubleclick v1.0.0/go.mod h1:ODHRroSrk/rr5neRHlWMSRijqOak8YmNaO3VAZCNl5Y=
|
||||
github.com/sqlc-dev/sqlc v1.31.1 h1:+V+BjBJfFNPX/RFfL8eiZD9jk9lVJUEGGllWvnYNqbc=
|
||||
github.com/sqlc-dev/sqlc v1.31.1/go.mod h1:6ZPww/Jd3G6MzJeW6NrqizjL+52vYNaaXP9yMeJ/Nao=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
|
||||
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
|
||||
github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo=
|
||||
github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM=
|
||||
github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4=
|
||||
github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
29
go-backend/internal/db/queries.sql
Normal file
29
go-backend/internal/db/queries.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
-- name: CreateAuthUser :one
|
||||
INSERT INTO auth.users (
|
||||
id,
|
||||
email,
|
||||
encrypted_password,
|
||||
raw_user_meta_data,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
jsonb_build_object('display_name', sqlc.arg(display_name)),
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
RETURNING id;
|
||||
|
||||
-- name: GetAuthUserByEmail :one
|
||||
SELECT id, email, encrypted_password, created_at, updated_at
|
||||
FROM auth.users
|
||||
WHERE email = $1
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetPublicUserByID :one
|
||||
SELECT id, email, created_at, updated_at, display_name
|
||||
FROM public.users
|
||||
WHERE id = $1
|
||||
LIMIT 1;
|
||||
102
go-backend/internal/db/repository.go
Normal file
102
go-backend/internal/db/repository.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
sqlcdb "xtablo-backend/internal/db/sqlc"
|
||||
"xtablo-backend/internal/web/handlers"
|
||||
)
|
||||
|
||||
type PostgresAuthRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
queries *sqlcdb.Queries
|
||||
}
|
||||
|
||||
func NewPostgresAuthRepository(ctx context.Context, databaseURL string) (*PostgresAuthRepository, error) {
|
||||
if databaseURL == "" {
|
||||
return nil, errors.New("DATABASE_URL is required")
|
||||
}
|
||||
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect postgres: %w", err)
|
||||
}
|
||||
|
||||
return &PostgresAuthRepository{
|
||||
pool: pool,
|
||||
queries: sqlcdb.New(pool),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *PostgresAuthRepository) Close() {
|
||||
r.pool.Close()
|
||||
}
|
||||
|
||||
func (r *PostgresAuthRepository) CreateAuthUser(ctx context.Context, input handlers.CreateAuthUserInput) (uuid.UUID, error) {
|
||||
id := uuid.New()
|
||||
createdID, err := r.queries.CreateAuthUser(ctx, sqlcdb.CreateAuthUserParams{
|
||||
ID: id,
|
||||
Email: input.Email,
|
||||
EncryptedPassword: input.EncryptedPassword,
|
||||
DisplayName: input.DisplayName,
|
||||
})
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return uuid.Nil, handlers.ErrUserAlreadyExists
|
||||
}
|
||||
return uuid.Nil, err
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("component", "auth_store").
|
||||
Str("action", "create_user").
|
||||
Str("email", input.Email).
|
||||
Msg("auth store mutated")
|
||||
|
||||
return createdID, nil
|
||||
}
|
||||
|
||||
func (r *PostgresAuthRepository) GetAuthUserByEmail(ctx context.Context, email string) (handlers.AuthUser, error) {
|
||||
row, err := r.queries.GetAuthUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return handlers.AuthUser{}, handlers.ErrUserNotFound
|
||||
}
|
||||
return handlers.AuthUser{}, err
|
||||
}
|
||||
|
||||
return handlers.AuthUser{
|
||||
ID: row.ID,
|
||||
Email: row.Email,
|
||||
EncryptedPassword: row.EncryptedPassword,
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *PostgresAuthRepository) GetPublicUserByID(ctx context.Context, id uuid.UUID) (handlers.PublicUser, error) {
|
||||
row, err := r.queries.GetPublicUserByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return handlers.PublicUser{}, handlers.ErrUserNotFound
|
||||
}
|
||||
return handlers.PublicUser{}, err
|
||||
}
|
||||
|
||||
return handlers.PublicUser{
|
||||
ID: row.ID,
|
||||
Email: row.Email,
|
||||
DisplayName: row.DisplayName,
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
}, nil
|
||||
}
|
||||
42
go-backend/internal/db/schema.sql
Normal file
42
go-backend/internal/db/schema.sql
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
CREATE SCHEMA IF NOT EXISTS auth;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth.users (
|
||||
id uuid PRIMARY KEY,
|
||||
email text NOT NULL UNIQUE,
|
||||
encrypted_password text NOT NULL,
|
||||
raw_user_meta_data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.users (
|
||||
id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
email text NOT NULL UNIQUE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
display_name text NOT NULL
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.users (id, email, created_at, updated_at, display_name)
|
||||
VALUES (
|
||||
NEW.id,
|
||||
NEW.email,
|
||||
NEW.created_at,
|
||||
NEW.updated_at,
|
||||
COALESCE(NEW.raw_user_meta_data ->> 'display_name', split_part(NEW.email, '@', 1))
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
|
||||
CREATE TRIGGER on_auth_user_created
|
||||
AFTER INSERT ON auth.users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.handle_new_user();
|
||||
16
go-backend/internal/db/seed.sql
Normal file
16
go-backend/internal/db/seed.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
INSERT INTO auth.users (
|
||||
id,
|
||||
email,
|
||||
encrypted_password,
|
||||
raw_user_meta_data,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
'demo@xtablo.com',
|
||||
'$2a$10$/xeyC8tiOZTcw2BBOSrv.uWu.EbRMYwF7MpFcDHSS40fOoTR.QrLS',
|
||||
jsonb_build_object('display_name', 'demo'),
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (email) DO NOTHING;
|
||||
32
go-backend/internal/db/sqlc/db.go
Normal file
32
go-backend/internal/db/sqlc/db.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
|
||||
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||
QueryRow(context.Context, string, ...interface{}) pgx.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
27
go-backend/internal/db/sqlc/models.go
Normal file
27
go-backend/internal/db/sqlc/models.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type AuthUser struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
Email string `db:"email"`
|
||||
EncryptedPassword string `db:"encrypted_password"`
|
||||
RawUserMetaData []byte `db:"raw_user_meta_data"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
Email string `db:"email"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at"`
|
||||
DisplayName string `db:"display_name"`
|
||||
}
|
||||
19
go-backend/internal/db/sqlc/querier.go
Normal file
19
go-backend/internal/db/sqlc/querier.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
CreateAuthUser(ctx context.Context, arg CreateAuthUserParams) (uuid.UUID, error)
|
||||
GetAuthUserByEmail(ctx context.Context, email string) (GetAuthUserByEmailRow, error)
|
||||
GetPublicUserByID(ctx context.Context, id uuid.UUID) (User, error)
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
99
go-backend/internal/db/sqlc/queries.sql.go
Normal file
99
go-backend/internal/db/sqlc/queries.sql.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: queries.sql
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createAuthUser = `-- name: CreateAuthUser :one
|
||||
INSERT INTO auth.users (
|
||||
id,
|
||||
email,
|
||||
encrypted_password,
|
||||
raw_user_meta_data,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
jsonb_build_object('display_name', $4),
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
type CreateAuthUserParams struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
Email string `db:"email"`
|
||||
EncryptedPassword string `db:"encrypted_password"`
|
||||
DisplayName interface{} `db:"display_name"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateAuthUser(ctx context.Context, arg CreateAuthUserParams) (uuid.UUID, error) {
|
||||
row := q.db.QueryRow(ctx, createAuthUser,
|
||||
arg.ID,
|
||||
arg.Email,
|
||||
arg.EncryptedPassword,
|
||||
arg.DisplayName,
|
||||
)
|
||||
var id uuid.UUID
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const getAuthUserByEmail = `-- name: GetAuthUserByEmail :one
|
||||
SELECT id, email, encrypted_password, created_at, updated_at
|
||||
FROM auth.users
|
||||
WHERE email = $1
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
type GetAuthUserByEmailRow struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
Email string `db:"email"`
|
||||
EncryptedPassword string `db:"encrypted_password"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetAuthUserByEmail(ctx context.Context, email string) (GetAuthUserByEmailRow, error) {
|
||||
row := q.db.QueryRow(ctx, getAuthUserByEmail, email)
|
||||
var i GetAuthUserByEmailRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.EncryptedPassword,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getPublicUserByID = `-- name: GetPublicUserByID :one
|
||||
SELECT id, email, created_at, updated_at, display_name
|
||||
FROM public.users
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetPublicUserByID(ctx context.Context, id uuid.UUID) (User, error) {
|
||||
row := q.db.QueryRow(ctx, getPublicUserByID, id)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.DisplayName,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
396
go-backend/internal/web/handlers/auth.go
Normal file
396
go-backend/internal/web/handlers/auth.go
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"xtablo-backend/internal/web/views"
|
||||
)
|
||||
|
||||
const sessionCookieName = "xtablo_session"
|
||||
|
||||
var ErrUserNotFound = errors.New("user not found")
|
||||
var ErrUserAlreadyExists = errors.New("user already exists")
|
||||
|
||||
type AuthRepository interface {
|
||||
CreateAuthUser(ctx context.Context, input CreateAuthUserInput) (uuid.UUID, error)
|
||||
GetAuthUserByEmail(ctx context.Context, email string) (AuthUser, error)
|
||||
GetPublicUserByID(ctx context.Context, id uuid.UUID) (PublicUser, error)
|
||||
}
|
||||
|
||||
type CreateAuthUserInput struct {
|
||||
Email string
|
||||
EncryptedPassword string
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
type AuthUser struct {
|
||||
ID uuid.UUID
|
||||
Email string
|
||||
EncryptedPassword string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type PublicUser struct {
|
||||
ID uuid.UUID
|
||||
Email string
|
||||
DisplayName string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type AuthHandler struct {
|
||||
repo AuthRepository
|
||||
sessions *sessionStore
|
||||
}
|
||||
|
||||
type sessionStore struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]uuid.UUID
|
||||
}
|
||||
|
||||
type InMemoryAuthRepository struct {
|
||||
mu sync.RWMutex
|
||||
authUsers map[string]AuthUser
|
||||
publicUsers map[uuid.UUID]PublicUser
|
||||
}
|
||||
|
||||
func NewAuthHandler(repo AuthRepository) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
repo: repo,
|
||||
sessions: &sessionStore{sessions: map[string]uuid.UUID{}},
|
||||
}
|
||||
}
|
||||
|
||||
func NewInMemoryAuthRepository() *InMemoryAuthRepository {
|
||||
repo := &InMemoryAuthRepository{
|
||||
authUsers: map[string]AuthUser{},
|
||||
publicUsers: map[uuid.UUID]PublicUser{},
|
||||
}
|
||||
|
||||
demoHash, err := hashPassword("xtablo-demo")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if _, err := repo.CreateAuthUser(context.Background(), CreateAuthUserInput{
|
||||
Email: "demo@xtablo.com",
|
||||
EncryptedPassword: demoHash,
|
||||
DisplayName: "demo",
|
||||
}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return repo
|
||||
}
|
||||
|
||||
func (h *AuthHandler) GetHome() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := h.currentUserID(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.GetPublicUserByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := views.HomePage(user.DisplayName, user.Email).Render(r.Context(), w); err != nil {
|
||||
http.Error(w, "failed to render home page", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) PostLogout() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var email string
|
||||
if userID, ok := h.currentUserID(r); ok {
|
||||
if user, err := h.repo.GetPublicUserByID(r.Context(), userID); err == nil {
|
||||
email = user.Email
|
||||
}
|
||||
}
|
||||
|
||||
if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
|
||||
h.sessions.delete(cookie.Value, email)
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) GetLoginPage() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.currentUserID(r); ok {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := views.AuthPage(views.LoginScreen()).Render(r.Context(), w); err != nil {
|
||||
http.Error(w, "failed to render login page", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) GetSignupPage() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.currentUserID(r); ok {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := views.AuthPage(views.SignupScreen()).Render(r.Context(), w); err != nil {
|
||||
http.Error(w, "failed to render signup page", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) PostLogin() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
email := normalizeEmail(r.FormValue("email"))
|
||||
password := strings.TrimSpace(r.FormValue("password"))
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
switch {
|
||||
case email == "" || password == "":
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_ = views.AuthStatus("error", "Veuillez renseigner votre email et votre mot de passe.").Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
|
||||
authUser, err := h.repo.GetAuthUserByEmail(r.Context(), email)
|
||||
if err != nil || bcrypt.CompareHashAndPassword([]byte(authUser.EncryptedPassword), []byte(password)) != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = views.AuthStatus("error", "Identifiants invalides. Essayez demo@xtablo.com / xtablo-demo.").Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
|
||||
h.setSession(w, authUser.ID, authUser.Email)
|
||||
w.Header().Set("HX-Redirect", "/")
|
||||
_ = views.AuthStatus("success", "Connexion réussie.").Render(r.Context(), w)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) PostSignup() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
email := normalizeEmail(r.FormValue("email"))
|
||||
password := strings.TrimSpace(r.FormValue("password"))
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
switch {
|
||||
case email == "" || password == "":
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_ = views.AuthStatus("error", "Veuillez renseigner votre email et choisir un mot de passe.").Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.repo.GetAuthUserByEmail(r.Context(), email); err == nil {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_ = views.AuthStatus("error", "Un compte existe déjà avec cet email.").Render(r.Context(), w)
|
||||
return
|
||||
} else if !errors.Is(err, ErrUserNotFound) {
|
||||
http.Error(w, "failed to check existing user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
passwordHash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to hash password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
displayName := displayNameFromEmail(email)
|
||||
userID, err := h.repo.CreateAuthUser(r.Context(), CreateAuthUserInput{
|
||||
Email: email,
|
||||
EncryptedPassword: passwordHash,
|
||||
DisplayName: displayName,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUserAlreadyExists) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_ = views.AuthStatus("error", "Un compte existe déjà avec cet email.").Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
http.Error(w, "failed to create user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.setSession(w, userID, email)
|
||||
w.Header().Set("HX-Redirect", "/")
|
||||
_ = views.AuthStatus("success", "Compte créé.").Render(r.Context(), w)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) currentUserID(r *http.Request) (uuid.UUID, bool) {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return h.sessions.get(cookie.Value)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) setSession(w http.ResponseWriter, userID uuid.UUID, email string) {
|
||||
sessionID := randomToken(32)
|
||||
h.sessions.set(sessionID, userID, email)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *sessionStore) get(sessionID string) (uuid.UUID, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
userID, ok := s.sessions[sessionID]
|
||||
return userID, ok
|
||||
}
|
||||
|
||||
func (s *sessionStore) set(sessionID string, userID uuid.UUID, email string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessions[sessionID] = userID
|
||||
logStoreMutation("create_session", email, sessionID, 0, len(s.sessions))
|
||||
}
|
||||
|
||||
func (s *sessionStore) delete(sessionID string, email string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.sessions, sessionID)
|
||||
logStoreMutation("delete_session", email, sessionID, 0, len(s.sessions))
|
||||
}
|
||||
|
||||
func (r *InMemoryAuthRepository) CreateAuthUser(_ context.Context, input CreateAuthUserInput) (uuid.UUID, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, exists := r.authUsers[input.Email]; exists {
|
||||
return uuid.Nil, ErrUserAlreadyExists
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
now := time.Now().UTC()
|
||||
authUser := AuthUser{
|
||||
ID: id,
|
||||
Email: input.Email,
|
||||
EncryptedPassword: input.EncryptedPassword,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
publicUser := PublicUser{
|
||||
ID: id,
|
||||
Email: input.Email,
|
||||
DisplayName: input.DisplayName,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
r.authUsers[input.Email] = authUser
|
||||
r.publicUsers[id] = publicUser
|
||||
logStoreMutation("create_user", input.Email, "", len(r.authUsers), 0)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *InMemoryAuthRepository) GetAuthUserByEmail(_ context.Context, email string) (AuthUser, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
user, ok := r.authUsers[email]
|
||||
if !ok {
|
||||
return AuthUser{}, ErrUserNotFound
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *InMemoryAuthRepository) GetPublicUserByID(_ context.Context, id uuid.UUID) (PublicUser, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
user, ok := r.publicUsers[id]
|
||||
if !ok {
|
||||
return PublicUser{}, ErrUserNotFound
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func normalizeEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
||||
func displayNameFromEmail(email string) string {
|
||||
email = normalizeEmail(email)
|
||||
if email == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.Split(email, "@")[0]
|
||||
}
|
||||
|
||||
func hashPassword(password string) (string, error) {
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(passwordHash), nil
|
||||
}
|
||||
|
||||
func randomToken(size int) string {
|
||||
buf := make([]byte, size)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func logStoreMutation(action string, email string, sessionID string, usersCount int, sessionsCount int) {
|
||||
event := log.Info().
|
||||
Str("component", "auth_store").
|
||||
Str("action", action).
|
||||
Int("users_count", usersCount).
|
||||
Int("sessions_count", sessionsCount)
|
||||
|
||||
if email != "" {
|
||||
event = event.Str("email", email)
|
||||
}
|
||||
if sessionID != "" {
|
||||
event = event.Str("session_id", sessionID)
|
||||
}
|
||||
|
||||
event.Msg("auth store mutated")
|
||||
}
|
||||
120
go-backend/internal/web/handlers/auth_test.go
Normal file
120
go-backend/internal/web/handlers/auth_test.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestSignupLogsAuthStoreMutations(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
restore := log.Logger
|
||||
log.Logger = zerolog.New(&buf)
|
||||
defer func() {
|
||||
log.Logger = restore
|
||||
}()
|
||||
|
||||
handler := newTestAuthHandler(t)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("email", "new@xtablo.com")
|
||||
form.Set("password", "xtablo-secret")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.PostSignup().ServeHTTP(rec, req)
|
||||
|
||||
output := buf.String()
|
||||
for _, want := range []string{
|
||||
`"action":"create_user"`,
|
||||
`"email":"new@xtablo.com"`,
|
||||
`"action":"create_session"`,
|
||||
`"session_id":"`,
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("expected log output to contain %q, got %q", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignupHashesPasswordBeforeStoringUser(t *testing.T) {
|
||||
repo := NewInMemoryAuthRepository()
|
||||
handler := NewAuthHandler(repo)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("email", "new@xtablo.com")
|
||||
form.Set("password", "xtablo-secret")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.PostSignup().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
storedUser, err := repo.GetAuthUserByEmail(req.Context(), "new@xtablo.com")
|
||||
if err != nil {
|
||||
t.Fatalf("expected stored user, got error %v", err)
|
||||
}
|
||||
if storedUser.EncryptedPassword == "xtablo-secret" {
|
||||
t.Fatalf("expected stored password hash, got plaintext")
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(storedUser.EncryptedPassword), []byte("xtablo-secret")) != nil {
|
||||
t.Fatalf("expected stored password to match bcrypt hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutLogsSessionDeletion(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
restore := log.Logger
|
||||
log.Logger = zerolog.New(&buf)
|
||||
defer func() {
|
||||
log.Logger = restore
|
||||
}()
|
||||
|
||||
handler := newTestAuthHandler(t)
|
||||
|
||||
loginForm := url.Values{}
|
||||
loginForm.Set("email", "demo@xtablo.com")
|
||||
loginForm.Set("password", "xtablo-demo")
|
||||
|
||||
loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(loginForm.Encode()))
|
||||
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
loginRec := httptest.NewRecorder()
|
||||
handler.PostLogin().ServeHTTP(loginRec, loginReq)
|
||||
|
||||
sessionCookie := loginRec.Result().Cookies()[0]
|
||||
|
||||
logoutReq := httptest.NewRequest(http.MethodPost, "/logout", nil)
|
||||
logoutReq.AddCookie(sessionCookie)
|
||||
logoutRec := httptest.NewRecorder()
|
||||
handler.PostLogout().ServeHTTP(logoutRec, logoutReq)
|
||||
|
||||
output := buf.String()
|
||||
for _, want := range []string{
|
||||
`"action":"delete_session"`,
|
||||
`"email":"demo@xtablo.com"`,
|
||||
`"session_id":"`,
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("expected log output to contain %q, got %q", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newTestAuthHandler(t *testing.T) *AuthHandler {
|
||||
t.Helper()
|
||||
return NewAuthHandler(NewInMemoryAuthRepository())
|
||||
}
|
||||
219
go-backend/internal/web/views/login.templ
Normal file
219
go-backend/internal/web/views/login.templ
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
package views
|
||||
|
||||
templ AuthPage(content templ.Component) {
|
||||
<!doctype html>
|
||||
<html lang="en" class="light">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/pwa-icons/favicon-32x32.png"/>
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/pwa-icons/favicon-16x16.png"/>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/pwa-icons/apple-touch-icon-180x180.png"/>
|
||||
<link rel="manifest" href="/manifest.webmanifest"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
|
||||
<meta name="theme-color" content="#1e1b2e"/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default"/>
|
||||
<title>XTablo</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/styles.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<section aria-label="Notifications alt+T" tabindex="-1" aria-live="polite" aria-relevant="additions text" aria-atomic="false"></section>
|
||||
<div class="app-shell">
|
||||
<div class="login-screen">
|
||||
@AnimatedBackground()
|
||||
<div class="card-wrap">
|
||||
<div class="card-glow"></div>
|
||||
<div data-testid="auth-card-shell" class="auth-card-shell">
|
||||
<div class="auth-card-topbar">
|
||||
<div>
|
||||
<a href="https://www.xtablo.com" class="back-home-link">
|
||||
<svg class="back-home-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
Retour à l'accueil
|
||||
</a>
|
||||
</div>
|
||||
<button class="theme-toggle-button" aria-label="change theme (system)" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="theme-toggle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect width="20" height="14" x="2" y="3" rx="2"></rect>
|
||||
<line x1="8" x2="16" y1="21" y2="21"></line>
|
||||
<line x1="12" x2="12" y1="17" y2="21"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="brand-header">
|
||||
<img alt="Xtablo" class="brand-logo light-only" src="/logo_dark.png"/>
|
||||
<img alt="Xtablo" class="brand-logo dark-only" src="/logo_white.png"/>
|
||||
</div>
|
||||
@content
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
templ LoginScreen() {
|
||||
<div class="title-group">
|
||||
<h1>Se connecter à Xtablo</h1>
|
||||
</div>
|
||||
<div class="new-experience-link-wrap">
|
||||
<a class="new-experience-link" href="/login-v2">Découvrez la nouvelle expérience de connexion</a>
|
||||
</div>
|
||||
<div class="auth-body">
|
||||
<form class="login-form" hx-post="/login" hx-target="#login-status" hx-swap="innerHTML">
|
||||
<div id="login-status" class="status-slot"></div>
|
||||
<div class="field-stack">
|
||||
<label for="email">Email *</label>
|
||||
<input id="email" name="email" type="email" placeholder="Votre email" required/>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label for="password">Mot de passe *</label>
|
||||
<input id="password" name="password" type="password" placeholder="Votre mot de passe" required/>
|
||||
</div>
|
||||
<div class="forgot-password-row">
|
||||
<a class="forgot-password-link" href="/reset-password">Mot de passe oublié ?</a>
|
||||
</div>
|
||||
<button class="submit-button" type="submit">Se connecter</button>
|
||||
</form>
|
||||
@AuthDivider()
|
||||
@GoogleButton()
|
||||
<p class="signup-copy">
|
||||
Pas encore de compte ?
|
||||
<a class="signup-link" href="/signup">S'inscrire</a>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ SignupScreen() {
|
||||
<div class="title-group">
|
||||
<h1>S'inscrire à Xtablo</h1>
|
||||
</div>
|
||||
<div class="new-experience-link-wrap">
|
||||
<a class="new-experience-link" href="/login">Vous avez déjà un compte ?</a>
|
||||
</div>
|
||||
<div class="auth-body">
|
||||
<form class="login-form" hx-post="/signup" hx-target="#signup-status" hx-swap="innerHTML">
|
||||
<div id="signup-status" class="status-slot"></div>
|
||||
<div class="field-stack">
|
||||
<label for="signup-email">Email *</label>
|
||||
<input id="signup-email" name="email" type="email" placeholder="Votre email" required/>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label for="signup-password">Mot de passe *</label>
|
||||
<input id="signup-password" name="password" type="password" placeholder="Choisissez un mot de passe" required/>
|
||||
</div>
|
||||
<button class="submit-button" type="submit">Créer mon compte</button>
|
||||
</form>
|
||||
@AuthDivider()
|
||||
@GoogleButton()
|
||||
<p class="signup-copy">
|
||||
Vous avez déjà un compte ?
|
||||
<a class="signup-link" href="/login">Se connecter</a>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ HomePage(displayName string, email string) {
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>XTablo</title>
|
||||
<link rel="stylesheet" href="/static/styles.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<main class="home-shell">
|
||||
<section class="home-card">
|
||||
<img alt="Xtablo" class="home-logo" src="/logo_dark.png"/>
|
||||
<h1>Bienvenue</h1>
|
||||
<p>{ displayName }</p>
|
||||
<p>Session active pour { email }</p>
|
||||
<form action="/logout" method="post" class="logout-form">
|
||||
<button type="submit" class="submit-button logout-button">Se déconnecter</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
templ AuthStatus(kind string, message string) {
|
||||
if kind == "success" {
|
||||
<div class="status-banner status-success" role="status">{ message }</div>
|
||||
} else if kind == "error" {
|
||||
<div class="status-banner status-error" role="alert">{ message }</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ AuthDivider() {
|
||||
<div class="divider-row">
|
||||
<div class="divider-line"></div>
|
||||
<span class="divider-pill">Ou continuer avec</span>
|
||||
<div class="divider-line"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ GoogleButton() {
|
||||
<button class="gsi-material-button" type="button">
|
||||
<div class="gsi-material-button-state"></div>
|
||||
<div class="gsi-material-button-content-wrapper">
|
||||
<div class="gsi-material-button-icon">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" xmlns:xlink="http://www.w3.org/1999/xlink" style="display: block;">
|
||||
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"></path>
|
||||
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"></path>
|
||||
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"></path>
|
||||
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"></path>
|
||||
<path fill="none" d="M0 0h48v48H0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="gsi-material-button-contents">Continuer avec Google</span>
|
||||
<span class="visually-hidden">Sign in with Google</span>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
|
||||
templ AnimatedBackground() {
|
||||
<div class="background-layer" aria-hidden="true">
|
||||
<div class="background-logo bg-01 animate-move-right-slow opacity-04"><img alt="Xtablo" class="logo-asset size-16 animate-spin-slow light-only" src="/logo_dark.png"/><img alt="Xtablo" class="logo-asset size-16 animate-spin-slow dark-only" src="/logo_white.png"/></div>
|
||||
<div class="background-logo bg-02 animate-move-right-medium opacity-03"><img alt="Xtablo" class="logo-asset size-12 animate-bounce-gentle light-only" src="/logo_dark.png"/><img alt="Xtablo" class="logo-asset size-12 animate-bounce-gentle dark-only" src="/logo_white.png"/></div>
|
||||
<div class="background-logo bg-03 animate-move-right-fast opacity-05"><img alt="Xtablo" class="logo-asset size-20 animate-pulse-gentle light-only" src="/logo_dark.png"/><img alt="Xtablo" class="logo-asset size-20 animate-pulse-gentle dark-only" src="/logo_white.png"/></div>
|
||||
<div class="background-logo bg-04 animate-move-right-slow opacity-02"><img alt="Xtablo" class="logo-asset size-14 animate-wiggle light-only" src="/logo_dark.png"/><img alt="Xtablo" class="logo-asset size-14 animate-wiggle dark-only" src="/logo_white.png"/></div>
|
||||
<div class="background-logo bg-05 animate-move-right-medium opacity-03"><img alt="Xtablo" class="logo-asset size-18 animate-float-gentle" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-06 animate-move-diagonal-1 opacity-03"><img alt="Xtablo" class="logo-asset size-10 animate-spin-reverse" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-07 animate-move-diagonal-2 opacity-04"><img alt="Xtablo" class="logo-asset size-16 animate-scale-gentle" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-08 animate-move-diagonal-3 opacity-02"><img alt="Xtablo" class="logo-asset size-12 animate-rotate-gentle" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-09 animate-move-down-slow opacity-03"><img alt="Xtablo" class="logo-asset size-14 animate-bounce-soft" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-10 animate-move-down-medium opacity-04"><img alt="Xtablo" class="logo-asset size-16 animate-sway" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-11 animate-orbit-1 opacity-02"><img alt="Xtablo" class="logo-asset size-08" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-12 animate-orbit-2 opacity-03"><img alt="Xtablo" class="logo-asset size-10" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-13 animate-orbit-3 opacity-02"><img alt="Xtablo" class="logo-asset size-06" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-14 animate-orbit-4 opacity-03"><img alt="Xtablo" class="logo-asset size-12 animate-spin-fast" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-15 animate-orbit-5 opacity-02"><img alt="Xtablo" class="logo-asset size-07 animate-pulse-fast" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-16 animate-zigzag-1 opacity-04"><img alt="Xtablo" class="logo-asset size-14 animate-wobble" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-17 animate-zigzag-2 opacity-03"><img alt="Xtablo" class="logo-asset size-11 animate-shake" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-18 animate-zigzag-3 opacity-05"><img alt="Xtablo" class="logo-asset size-16 animate-bounce-crazy" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-19 animate-spiral-1 opacity-03"><img alt="Xtablo" class="logo-asset size-09 animate-spin-wobble" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-20 animate-spiral-2 opacity-04"><img alt="Xtablo" class="logo-asset size-13 animate-flip" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-21 animate-float-random-1 opacity-02"><img alt="Xtablo" class="logo-asset size-08 animate-twirl" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-22 animate-float-random-2 opacity-03"><img alt="Xtablo" class="logo-asset size-10 animate-dance" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-23 animate-float-random-3 opacity-04"><img alt="Xtablo" class="logo-asset size-12 animate-jiggle" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-24 animate-float-random-4 opacity-02"><img alt="Xtablo" class="logo-asset size-09 animate-vibrate" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-25 animate-wave-1 opacity-03"><img alt="Xtablo" class="logo-asset size-11 animate-swing" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-26 animate-wave-2 opacity-04"><img alt="Xtablo" class="logo-asset size-13 animate-pendulum" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-27 animate-wave-3 opacity-02"><img alt="Xtablo" class="logo-asset size-10 animate-elastic" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-28 animate-wave-4 opacity-05"><img alt="Xtablo" class="logo-asset size-15 animate-rubber" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-29 animate-corner-shoot-1 opacity-03"><img alt="Xtablo" class="logo-asset size-12 animate-rocket" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-30 animate-corner-shoot-2 opacity-04"><img alt="Xtablo" class="logo-asset size-14 animate-comet" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-31 animate-corner-shoot-3 opacity-02"><img alt="Xtablo" class="logo-asset size-10 animate-meteor" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-32 animate-corner-shoot-4 opacity-05"><img alt="Xtablo" class="logo-asset size-16 animate-blast" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-33 animate-bounce-ball-1 opacity-04"><img alt="Xtablo" class="logo-asset size-08 animate-spin-bounce" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-34 animate-bounce-ball-2 opacity-03"><img alt="Xtablo" class="logo-asset size-11 animate-flip-bounce" src="/logo_dark.png"/></div>
|
||||
<div class="background-logo bg-35 animate-bounce-ball-3 opacity-05"><img alt="Xtablo" class="logo-asset size-13 animate-scale-bounce" src="/logo_dark.png"/></div>
|
||||
</div>
|
||||
}
|
||||
342
go-backend/internal/web/views/login_templ.go
Normal file
342
go-backend/internal/web/views/login_templ.go
Normal file
File diff suppressed because one or more lines are too long
61
go-backend/justfile
Normal file
61
go-backend/justfile
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
set shell := ["bash", "-cu"]
|
||||
|
||||
database_url := "postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable"
|
||||
compose_config_dir := ".podman-compose"
|
||||
|
||||
default:
|
||||
@just --list
|
||||
|
||||
compose-config:
|
||||
mkdir -p {{compose_config_dir}}
|
||||
printf '%s\n' '{"auths":{}}' > {{compose_config_dir}}/config.json
|
||||
|
||||
machine-up:
|
||||
@if command -v podman >/dev/null 2>&1; then \
|
||||
if ! podman machine inspect podman-machine-default 2>/dev/null | grep -q '"State": "running"'; then \
|
||||
podman machine start podman-machine-default; \
|
||||
fi; \
|
||||
else \
|
||||
echo "podman is required" >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
db-up: machine-up compose-config
|
||||
DOCKER_CONFIG="$PWD/{{compose_config_dir}}" podman compose up -d postgres
|
||||
|
||||
db-down:
|
||||
@if command -v podman >/dev/null 2>&1; then \
|
||||
DOCKER_CONFIG="$PWD/{{compose_config_dir}}" podman compose down; \
|
||||
else \
|
||||
echo "podman is required" >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
db-reset: compose-config
|
||||
just machine-up
|
||||
DOCKER_CONFIG="$PWD/{{compose_config_dir}}" podman compose down -v
|
||||
DOCKER_CONFIG="$PWD/{{compose_config_dir}}" podman compose up -d postgres
|
||||
|
||||
db-logs: machine-up compose-config
|
||||
DOCKER_CONFIG="$PWD/{{compose_config_dir}}" podman compose logs -f postgres
|
||||
|
||||
generate:
|
||||
go run github.com/a-h/templ/cmd/templ@latest generate
|
||||
go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate
|
||||
|
||||
fmt:
|
||||
gofmt -w .
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
build:
|
||||
go build ./...
|
||||
|
||||
check: generate test build
|
||||
|
||||
dev: db-up
|
||||
DATABASE_URL='{{database_url}}' air -c .air.toml
|
||||
|
||||
run: db-up
|
||||
DATABASE_URL='{{database_url}}' go run .
|
||||
56
go-backend/router.go
Normal file
56
go-backend/router.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"xtablo-backend/internal/db"
|
||||
"xtablo-backend/internal/web/handlers"
|
||||
|
||||
chi "github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func newRouter() http.Handler {
|
||||
databaseURL := os.Getenv("DATABASE_URL")
|
||||
repo, err := db.NewPostgresAuthRepository(context.Background(), databaseURL)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return newRouterWithHandler(handlers.NewAuthHandler(repo))
|
||||
}
|
||||
|
||||
func newTestRouter() http.Handler {
|
||||
return newRouterWithHandler(handlers.NewAuthHandler(handlers.NewInMemoryAuthRepository()))
|
||||
}
|
||||
|
||||
func newRouterWithHandler(authHandler *handlers.AuthHandler) http.Handler {
|
||||
mux := chi.NewRouter()
|
||||
staticFS := os.DirFS("static")
|
||||
|
||||
// Views
|
||||
mux.Get("/", authHandler.GetHome())
|
||||
mux.Get("/login", authHandler.GetLoginPage())
|
||||
mux.Get("/signup", authHandler.GetSignupPage())
|
||||
mux.Post("/login", authHandler.PostLogin())
|
||||
mux.Post("/signup", authHandler.PostSignup())
|
||||
mux.Post("/logout", authHandler.PostLogout())
|
||||
|
||||
mux.Handle("/static/*", http.StripPrefix("/static/", http.FileServerFS(os.DirFS("static"))))
|
||||
mux.Handle("/pwa-icons/*", http.StripPrefix("/pwa-icons/", http.FileServerFS(os.DirFS("static/pwa-icons"))))
|
||||
mux.HandleFunc("/logo_dark.png", serveStaticFile(staticFS, "logo_dark.png", "image/png"))
|
||||
mux.HandleFunc("/logo_white.png", serveStaticFile(staticFS, "logo_white.png", "image/png"))
|
||||
mux.HandleFunc("/manifest.webmanifest", serveStaticFile(staticFS, "manifest.webmanifest", "application/manifest+json"))
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
func serveStaticFile(fileSystem fs.FS, path string, contentType string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
http.ServeFileFS(w, r, fileSystem, path)
|
||||
}
|
||||
}
|
||||
254
go-backend/router_test.go
Normal file
254
go-backend/router_test.go
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRootRedirectsToLoginWhenUnauthenticated(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router := newTestRouter()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected status 303, got %d", rec.Code)
|
||||
}
|
||||
if location := rec.Header().Get("Location"); location != "/login" {
|
||||
t.Fatalf("expected redirect to /login, got %q", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginPageRenders(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router := newTestRouter()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"Se connecter à Xtablo",
|
||||
`hx-post="/login"`,
|
||||
"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js",
|
||||
`href="/pwa-icons/favicon-32x32.png"`,
|
||||
`href="/pwa-icons/favicon-16x16.png"`,
|
||||
`href="/pwa-icons/apple-touch-icon-180x180.png"`,
|
||||
`href="/manifest.webmanifest"`,
|
||||
`src="/logo_dark.png"`,
|
||||
`src="/logo_white.png"`,
|
||||
`data-testid="auth-card-shell"`,
|
||||
"Découvrez la nouvelle expérience de connexion",
|
||||
"Mot de passe oublié ?",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected body to contain %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignupPageRenders(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/signup", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router := newTestRouter()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"S'inscrire à Xtablo",
|
||||
`hx-post="/signup"`,
|
||||
"Vous avez déjà un compte ?",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected body to contain %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrandingAssetsAreServed(t *testing.T) {
|
||||
testCases := []string{
|
||||
"/logo_dark.png",
|
||||
"/logo_white.png",
|
||||
"/pwa-icons/favicon-32x32.png",
|
||||
"/pwa-icons/favicon-16x16.png",
|
||||
"/pwa-icons/apple-touch-icon-180x180.png",
|
||||
"/manifest.webmanifest",
|
||||
}
|
||||
|
||||
router := newTestRouter()
|
||||
|
||||
for _, path := range testCases {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected %s to return 200, got %d", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginReturnsValidationError(t *testing.T) {
|
||||
form := url.Values{}
|
||||
form.Set("email", "")
|
||||
form.Set("password", "")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router := newTestRouter()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("expected status 422, got %d", rec.Code)
|
||||
}
|
||||
|
||||
if !strings.Contains(rec.Body.String(), "Veuillez renseigner votre email et votre mot de passe") {
|
||||
t.Fatalf("expected validation error fragment, got %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCreatesSessionAndRedirects(t *testing.T) {
|
||||
form := url.Values{}
|
||||
form.Set("email", "demo@xtablo.com")
|
||||
form.Set("password", "xtablo-demo")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router := newTestRouter()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
if redirect := rec.Header().Get("HX-Redirect"); redirect != "/" {
|
||||
t.Fatalf("expected HX-Redirect to /, got %q", redirect)
|
||||
}
|
||||
sessionCookie := findCookie(rec.Result().Cookies(), "xtablo_session")
|
||||
if sessionCookie == nil {
|
||||
t.Fatalf("expected session cookie to be set")
|
||||
}
|
||||
|
||||
homeReq := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
homeReq.AddCookie(sessionCookie)
|
||||
homeRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(homeRec, homeReq)
|
||||
|
||||
if homeRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected authenticated root status 200, got %d", homeRec.Code)
|
||||
}
|
||||
if !strings.Contains(homeRec.Body.String(), "Bienvenue") {
|
||||
t.Fatalf("expected authenticated home page, got %q", homeRec.Body.String())
|
||||
}
|
||||
if !strings.Contains(homeRec.Body.String(), `action="/logout"`) {
|
||||
t.Fatalf("expected authenticated home page to include logout form, got %q", homeRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignupCreatesUserSessionAndRedirects(t *testing.T) {
|
||||
form := url.Values{}
|
||||
form.Set("email", "new@xtablo.com")
|
||||
form.Set("password", "xtablo-secret")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router := newTestRouter()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
if redirect := rec.Header().Get("HX-Redirect"); redirect != "/" {
|
||||
t.Fatalf("expected HX-Redirect to /, got %q", redirect)
|
||||
}
|
||||
sessionCookie := findCookie(rec.Result().Cookies(), "xtablo_session")
|
||||
if sessionCookie == nil {
|
||||
t.Fatalf("expected session cookie to be set")
|
||||
}
|
||||
|
||||
loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
loginRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(loginRec, loginReq)
|
||||
if loginRec.Header().Get("HX-Redirect") != "/" {
|
||||
t.Fatalf("expected signed up user to be able to log in")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutClearsSessionAndRedirectsToLogin(t *testing.T) {
|
||||
form := url.Values{}
|
||||
form.Set("email", "demo@xtablo.com")
|
||||
form.Set("password", "xtablo-demo")
|
||||
|
||||
loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
loginRec := httptest.NewRecorder()
|
||||
|
||||
router := newTestRouter()
|
||||
router.ServeHTTP(loginRec, loginReq)
|
||||
|
||||
sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session")
|
||||
if sessionCookie == nil {
|
||||
t.Fatalf("expected session cookie to be set")
|
||||
}
|
||||
|
||||
logoutReq := httptest.NewRequest(http.MethodPost, "/logout", nil)
|
||||
logoutReq.AddCookie(sessionCookie)
|
||||
logoutRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(logoutRec, logoutReq)
|
||||
|
||||
if logoutRec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected logout status 303, got %d", logoutRec.Code)
|
||||
}
|
||||
if location := logoutRec.Header().Get("Location"); location != "/login" {
|
||||
t.Fatalf("expected logout redirect to /login, got %q", location)
|
||||
}
|
||||
|
||||
clearedCookie := findCookie(logoutRec.Result().Cookies(), "xtablo_session")
|
||||
if clearedCookie == nil {
|
||||
t.Fatalf("expected cleared session cookie")
|
||||
}
|
||||
if clearedCookie.MaxAge >= 0 && clearedCookie.Value != "" {
|
||||
t.Fatalf("expected cleared session cookie to be expired, got %+v", clearedCookie)
|
||||
}
|
||||
|
||||
homeReq := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
homeReq.AddCookie(sessionCookie)
|
||||
homeRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(homeRec, homeReq)
|
||||
|
||||
if homeRec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected logged-out root access to redirect, got %d", homeRec.Code)
|
||||
}
|
||||
if location := homeRec.Header().Get("Location"); location != "/login" {
|
||||
t.Fatalf("expected logged-out root redirect to /login, got %q", location)
|
||||
}
|
||||
}
|
||||
|
||||
func findCookie(cookies []*http.Cookie, name string) *http.Cookie {
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == name {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
28
go-backend/sqlc.yaml
Normal file
28
go-backend/sqlc.yaml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
version: "2"
|
||||
|
||||
sql:
|
||||
- engine: "postgresql"
|
||||
schema: "internal/db/schema.sql"
|
||||
queries: "internal/db/queries.sql"
|
||||
gen:
|
||||
go:
|
||||
package: "sqlc"
|
||||
out: "internal/db/sqlc"
|
||||
sql_package: "pgx/v5"
|
||||
emit_interface: true
|
||||
emit_db_tags: true
|
||||
overrides:
|
||||
- db_type: "uuid"
|
||||
go_type:
|
||||
import: "github.com/google/uuid"
|
||||
type: "UUID"
|
||||
- db_type: "pg_catalog.uuid"
|
||||
nullable: true
|
||||
go_type:
|
||||
import: "github.com/google/uuid"
|
||||
type: "UUID"
|
||||
pointer: true
|
||||
- db_type: "pg_catalog.timestamptz"
|
||||
go_type:
|
||||
import: "time"
|
||||
type: "Time"
|
||||
BIN
go-backend/static/logo_dark.png
Normal file
BIN
go-backend/static/logo_dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
BIN
go-backend/static/logo_white.png
Normal file
BIN
go-backend/static/logo_white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
19
go-backend/static/manifest.webmanifest
Normal file
19
go-backend/static/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "XTablo",
|
||||
"short_name": "XTablo",
|
||||
"display": "standalone",
|
||||
"background_color": "#f8fafc",
|
||||
"theme_color": "#1e1b2e",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/pwa-icons/favicon-32x32.png",
|
||||
"sizes": "32x32",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/pwa-icons/apple-touch-icon-180x180.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
go-backend/static/pwa-icons/apple-touch-icon-180x180.png
Normal file
BIN
go-backend/static/pwa-icons/apple-touch-icon-180x180.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
go-backend/static/pwa-icons/favicon-16x16.png
Normal file
BIN
go-backend/static/pwa-icons/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 600 B |
BIN
go-backend/static/pwa-icons/favicon-32x32.png
Normal file
BIN
go-backend/static/pwa-icons/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
993
go-backend/static/styles.css
Normal file
993
go-backend/static/styles.css
Normal file
|
|
@ -0,0 +1,993 @@
|
|||
:root {
|
||||
--background: hsl(0 0% 100%);
|
||||
--foreground: hsl(0 0% 9%);
|
||||
--muted-foreground: hsl(0 0% 43.5%);
|
||||
--border: hsl(0 0% 90.9%);
|
||||
--input: hsl(0 0% 90.9%);
|
||||
--card: rgba(255, 255, 255, 0.8);
|
||||
--accent: hsl(0 0% 96.1%);
|
||||
--primary: #1e1b2e;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #804eec;
|
||||
--ring: rgba(30, 27, 46, 0.35);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.light-only {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dark-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.app-shell,
|
||||
.login-screen {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.login-screen {
|
||||
align-items: center;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(128, 78, 236, 0.08), transparent 30%),
|
||||
linear-gradient(160deg, rgba(30, 27, 46, 0.05), transparent 42%),
|
||||
linear-gradient(to bottom right, rgba(30, 27, 46, 0.08), var(--background), rgba(128, 78, 236, 0.04));
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
padding: 2rem 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.background-layer {
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.background-logo {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.logo-asset {
|
||||
display: block;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.size-06 { width: 1.5rem; height: 1.5rem; }
|
||||
.size-07 { width: 1.75rem; height: 1.75rem; }
|
||||
.size-08 { width: 2rem; height: 2rem; }
|
||||
.size-09 { width: 2.25rem; height: 2.25rem; }
|
||||
.size-10 { width: 2.5rem; height: 2.5rem; }
|
||||
.size-11 { width: 2.75rem; height: 2.75rem; }
|
||||
.size-12 { width: 3rem; height: 3rem; }
|
||||
.size-13 { width: 3.25rem; height: 3.25rem; }
|
||||
.size-14 { width: 3.5rem; height: 3.5rem; }
|
||||
.size-15 { width: 3.75rem; height: 3.75rem; }
|
||||
.size-16 { width: 4rem; height: 4rem; }
|
||||
.size-18 { width: 4.5rem; height: 4.5rem; }
|
||||
.size-20 { width: 5rem; height: 5rem; }
|
||||
|
||||
.opacity-02 { opacity: 0.2; }
|
||||
.opacity-03 { opacity: 0.3; }
|
||||
.opacity-04 { opacity: 0.4; }
|
||||
.opacity-05 { opacity: 0.5; }
|
||||
|
||||
.bg-01 { top: 25%; left: 0; }
|
||||
.bg-02 { top: 33%; left: 0; }
|
||||
.bg-03 { top: 50%; left: 0; }
|
||||
.bg-04 { top: 66%; left: 0; }
|
||||
.bg-05 { top: 75%; left: 0; }
|
||||
.bg-06 { top: 0; left: 25%; }
|
||||
.bg-07 { top: 0; left: 50%; }
|
||||
.bg-08 { top: 0; left: 75%; }
|
||||
.bg-09 { top: 0; left: 16.66%; }
|
||||
.bg-10 { top: 0; left: 83.33%; }
|
||||
.bg-11,
|
||||
.bg-12,
|
||||
.bg-13,
|
||||
.bg-14,
|
||||
.bg-15 {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
}
|
||||
.bg-16 { top: 25%; left: 0; }
|
||||
.bg-17 { top: 50%; left: 0; }
|
||||
.bg-18 { top: 75%; left: 0; }
|
||||
.bg-19 { top: 0; left: 25%; }
|
||||
.bg-20 { top: 0; left: 75%; }
|
||||
.bg-21 { top: 16.66%; left: 33.33%; }
|
||||
.bg-22 { top: 33.33%; left: 66.66%; }
|
||||
.bg-23 { top: 66.66%; left: 25%; }
|
||||
.bg-24 { top: 83.33%; left: 75%; }
|
||||
.bg-25 { top: 12.5%; left: 0; }
|
||||
.bg-26 { top: 37.5%; left: 0; }
|
||||
.bg-27 { top: 62.5%; left: 0; }
|
||||
.bg-28 { top: 87.5%; left: 0; }
|
||||
.bg-29 { top: 0; left: 0; }
|
||||
.bg-30 { top: 0; right: 0; }
|
||||
.bg-31 { bottom: 0; left: 0; }
|
||||
.bg-32 { bottom: 0; right: 0; }
|
||||
.bg-33 { top: 20%; left: 20%; }
|
||||
.bg-34 { top: 40%; left: 80%; }
|
||||
.bg-35 { top: 80%; left: 40%; }
|
||||
|
||||
.card-wrap {
|
||||
max-width: 32rem;
|
||||
position: relative;
|
||||
transition: transform 0.2s ease-out;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.card-glow {
|
||||
background: linear-gradient(to bottom right, rgba(30, 27, 46, 0.1), rgba(30, 27, 46, 0.05), rgba(128, 78, 236, 0.1));
|
||||
border-radius: 1rem;
|
||||
filter: blur(24px);
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.auth-card-shell {
|
||||
backdrop-filter: blur(12px);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.1);
|
||||
padding: 1.25rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.auth-card-topbar {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.back-home-link {
|
||||
align-items: center;
|
||||
color: var(--muted-foreground);
|
||||
display: inline-flex;
|
||||
font-size: 0.875rem;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.back-home-link:hover,
|
||||
.theme-toggle-button:hover,
|
||||
.signup-link:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.back-home-icon {
|
||||
height: 1rem;
|
||||
margin-right: 0.5rem;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.theme-toggle-button {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
height: 2.25rem;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
width: 2.25rem;
|
||||
}
|
||||
|
||||
.theme-toggle-button:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.theme-toggle-icon {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
.brand-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: block;
|
||||
height: 4rem;
|
||||
object-fit: contain;
|
||||
width: 4rem;
|
||||
}
|
||||
|
||||
.title-group {
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title-group h1 {
|
||||
font-size: clamp(1.5rem, 4vw, 1.875rem);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.new-experience-link-wrap {
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.new-experience-link {
|
||||
color: #804eec;
|
||||
display: inline-flex;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.new-experience-link:hover,
|
||||
.forgot-password-link:hover {
|
||||
color: #6f3fd4;
|
||||
}
|
||||
|
||||
.auth-body {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin: 0 auto;
|
||||
max-width: 28rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-stack {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.field-stack label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.field-stack input {
|
||||
background: var(--background);
|
||||
border: 1px solid var(--input);
|
||||
border-radius: 0.375rem;
|
||||
color: var(--foreground);
|
||||
height: 2.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-stack input:focus {
|
||||
box-shadow: 0 0 0 1px var(--ring);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.field-stack input::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.forgot-password-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.forgot-password-link {
|
||||
color: #7c3aed;
|
||||
font-size: 0.875rem;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
align-items: center;
|
||||
background: var(--primary);
|
||||
border: 0;
|
||||
border-radius: 0.375rem;
|
||||
color: var(--primary-foreground);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
height: 2.25rem;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem;
|
||||
transition: opacity 0.2s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.submit-button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.divider-row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin: 0.5rem 0 0;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
border-top: 1px solid var(--border);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.divider-pill {
|
||||
background: var(--background);
|
||||
border-radius: 999px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
padding: 0.25rem 1rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.signup-copy {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.signup-link {
|
||||
border-radius: 0.375rem;
|
||||
color: var(--foreground);
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
margin-left: 0.2rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.signup-link:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.status-slot {
|
||||
min-height: 0.25rem;
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
border: 1px solid;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background: hsl(143 85% 96%);
|
||||
border-color: hsl(145 92% 87%);
|
||||
color: hsl(140 100% 27%);
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background: hsl(359 100% 97%);
|
||||
border-color: hsl(359 100% 94%);
|
||||
color: hsl(360 100% 45%);
|
||||
}
|
||||
|
||||
.gsi-material-button {
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-appearance: none;
|
||||
-webkit-user-select: none;
|
||||
background-color: #fff;
|
||||
background-image: none;
|
||||
border: 1px solid #747775;
|
||||
border-radius: 20px;
|
||||
box-sizing: border-box;
|
||||
color: #1f1f1f;
|
||||
cursor: pointer;
|
||||
font-family: "Roboto", Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
height: 40px;
|
||||
letter-spacing: 0.25px;
|
||||
max-width: 400px;
|
||||
min-width: min-content;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
padding: 0 12px;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
transition:
|
||||
background-color 0.218s,
|
||||
border-color 0.218s,
|
||||
box-shadow 0.218s;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gsi-material-button .gsi-material-button-icon {
|
||||
height: 20px;
|
||||
margin-right: 12px;
|
||||
min-width: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.gsi-material-button .gsi-material-button-content-wrapper {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
height: 100%;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gsi-material-button .gsi-material-button-contents {
|
||||
flex-grow: 1;
|
||||
font-family: "Roboto", Arial, sans-serif;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.gsi-material-button .gsi-material-button-state {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transition: opacity 0.218s;
|
||||
}
|
||||
|
||||
.gsi-material-button:not(:disabled):active .gsi-material-button-state,
|
||||
.gsi-material-button:not(:disabled):focus .gsi-material-button-state {
|
||||
background-color: #303030;
|
||||
opacity: 0.12;
|
||||
}
|
||||
|
||||
.gsi-material-button:not(:disabled):hover {
|
||||
box-shadow:
|
||||
0 1px 2px 0 rgba(60, 64, 67, 0.3),
|
||||
0 1px 3px 1px rgba(60, 64, 67, 0.15);
|
||||
}
|
||||
|
||||
.gsi-material-button:not(:disabled):hover .gsi-material-button-state {
|
||||
background-color: #303030;
|
||||
opacity: 0.08;
|
||||
}
|
||||
|
||||
.home-shell {
|
||||
align-items: center;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(128, 78, 236, 0.08), transparent 30%),
|
||||
linear-gradient(160deg, rgba(30, 27, 46, 0.05), transparent 42%),
|
||||
linear-gradient(to bottom right, rgba(30, 27, 46, 0.08), var(--background), rgba(128, 78, 236, 0.04));
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.home-card {
|
||||
backdrop-filter: blur(12px);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.1);
|
||||
max-width: 28rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.home-card h1 {
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.home-card p {
|
||||
color: var(--muted-foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.home-logo {
|
||||
display: block;
|
||||
height: 4rem;
|
||||
margin: 0 auto 1rem;
|
||||
object-fit: contain;
|
||||
width: 4rem;
|
||||
}
|
||||
|
||||
.logout-form {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
max-width: 14rem;
|
||||
}
|
||||
|
||||
@keyframes move-right-slow {
|
||||
from { transform: translateX(-6rem); }
|
||||
to { transform: translateX(calc(100vw + 6rem)); }
|
||||
}
|
||||
|
||||
@keyframes move-right-medium {
|
||||
from { transform: translateX(-5rem); }
|
||||
to { transform: translateX(calc(100vw + 5rem)); }
|
||||
}
|
||||
|
||||
@keyframes move-right-fast {
|
||||
from { transform: translateX(-7rem); }
|
||||
to { transform: translateX(calc(100vw + 7rem)); }
|
||||
}
|
||||
|
||||
@keyframes move-down-slow {
|
||||
from { transform: translateY(-6rem); }
|
||||
to { transform: translateY(calc(100vh + 6rem)); }
|
||||
}
|
||||
|
||||
@keyframes move-down-medium {
|
||||
from { transform: translateY(-5rem); }
|
||||
to { transform: translateY(calc(100vh + 5rem)); }
|
||||
}
|
||||
|
||||
@keyframes move-diagonal-1 {
|
||||
from { transform: translate(-4rem, -4rem); }
|
||||
to { transform: translate(52vw, 70vh); }
|
||||
}
|
||||
|
||||
@keyframes move-diagonal-2 {
|
||||
from { transform: translate(0, -4rem); }
|
||||
to { transform: translate(-18vw, 76vh); }
|
||||
}
|
||||
|
||||
@keyframes move-diagonal-3 {
|
||||
from { transform: translate(0, -4rem); }
|
||||
to { transform: translate(12vw, 72vh); }
|
||||
}
|
||||
|
||||
@keyframes orbit-1 {
|
||||
from { transform: translate(-50%, -50%) rotate(0deg) translateX(11rem); }
|
||||
to { transform: translate(-50%, -50%) rotate(360deg) translateX(11rem); }
|
||||
}
|
||||
|
||||
@keyframes orbit-2 {
|
||||
from { transform: translate(-50%, -50%) rotate(0deg) translateX(7rem); }
|
||||
to { transform: translate(-50%, -50%) rotate(-360deg) translateX(7rem); }
|
||||
}
|
||||
|
||||
@keyframes orbit-3 {
|
||||
from { transform: translate(-50%, -50%) rotate(0deg) translateX(5rem); }
|
||||
to { transform: translate(-50%, -50%) rotate(360deg) translateX(5rem); }
|
||||
}
|
||||
|
||||
@keyframes orbit-4 {
|
||||
from { transform: translate(-50%, -50%) rotate(0deg) translateX(15rem); }
|
||||
to { transform: translate(-50%, -50%) rotate(360deg) translateX(15rem); }
|
||||
}
|
||||
|
||||
@keyframes orbit-5 {
|
||||
from { transform: translate(-50%, -50%) rotate(0deg) translateX(8rem); }
|
||||
to { transform: translate(-50%, -50%) rotate(-360deg) translateX(8rem); }
|
||||
}
|
||||
|
||||
@keyframes zigzag-1 {
|
||||
0% { transform: translateX(-6rem) translateY(0); }
|
||||
25% { transform: translateX(25vw) translateY(-3rem); }
|
||||
50% { transform: translateX(50vw) translateY(3rem); }
|
||||
75% { transform: translateX(75vw) translateY(-2rem); }
|
||||
100% { transform: translateX(calc(100vw + 6rem)) translateY(1rem); }
|
||||
}
|
||||
|
||||
@keyframes zigzag-2 {
|
||||
0% { transform: translateX(-5rem) translateY(0); }
|
||||
25% { transform: translateX(25vw) translateY(2rem); }
|
||||
50% { transform: translateX(50vw) translateY(-3rem); }
|
||||
75% { transform: translateX(75vw) translateY(1.5rem); }
|
||||
100% { transform: translateX(calc(100vw + 5rem)) translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes zigzag-3 {
|
||||
0% { transform: translateX(-7rem) translateY(0); }
|
||||
20% { transform: translateX(20vw) translateY(-4rem); }
|
||||
40% { transform: translateX(40vw) translateY(4rem); }
|
||||
60% { transform: translateX(60vw) translateY(-2rem); }
|
||||
80% { transform: translateX(80vw) translateY(3rem); }
|
||||
100% { transform: translateX(calc(100vw + 7rem)) translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes spiral-1 {
|
||||
0% { transform: translate(0, 0) rotate(0deg) scale(0.6); }
|
||||
100% { transform: translate(90vw, 90vh) rotate(360deg) scale(1.3); }
|
||||
}
|
||||
|
||||
@keyframes spiral-2 {
|
||||
0% { transform: translate(0, 0) rotate(0deg) scale(1.4); }
|
||||
100% { transform: translate(-70vw, 90vh) rotate(-360deg) scale(0.7); }
|
||||
}
|
||||
|
||||
@keyframes float-random-1 {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(1.4rem, -1rem); }
|
||||
}
|
||||
|
||||
@keyframes float-random-2 {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(-1.2rem, 1.1rem); }
|
||||
}
|
||||
|
||||
@keyframes float-random-3 {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(0.9rem, -1.4rem); }
|
||||
}
|
||||
|
||||
@keyframes float-random-4 {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(-1rem, 0.8rem); }
|
||||
}
|
||||
|
||||
@keyframes wave-1 {
|
||||
from { transform: translateX(-4rem); }
|
||||
to { transform: translateX(calc(100vw + 4rem)); }
|
||||
}
|
||||
|
||||
@keyframes wave-2 {
|
||||
from { transform: translateX(-5rem); }
|
||||
to { transform: translateX(calc(100vw + 5rem)); }
|
||||
}
|
||||
|
||||
@keyframes wave-3 {
|
||||
from { transform: translateX(-4rem); }
|
||||
to { transform: translateX(calc(100vw + 4rem)); }
|
||||
}
|
||||
|
||||
@keyframes wave-4 {
|
||||
from { transform: translateX(-6rem); }
|
||||
to { transform: translateX(calc(100vw + 6rem)); }
|
||||
}
|
||||
|
||||
@keyframes corner-shoot-1 {
|
||||
from { transform: translate(0, 0); }
|
||||
to { transform: translate(110vw, 110vh); }
|
||||
}
|
||||
|
||||
@keyframes corner-shoot-2 {
|
||||
from { transform: translate(0, 0); }
|
||||
to { transform: translate(-110vw, 110vh); }
|
||||
}
|
||||
|
||||
@keyframes corner-shoot-3 {
|
||||
from { transform: translate(0, 0); }
|
||||
to { transform: translate(110vw, -110vh); }
|
||||
}
|
||||
|
||||
@keyframes corner-shoot-4 {
|
||||
from { transform: translate(0, 0); }
|
||||
to { transform: translate(-110vw, -110vh); }
|
||||
}
|
||||
|
||||
@keyframes bounce-ball-1 {
|
||||
0%, 100% { transform: translateY(0) translateX(0); }
|
||||
50% { transform: translateY(-5rem) translateX(2rem); }
|
||||
}
|
||||
|
||||
@keyframes bounce-ball-2 {
|
||||
0%, 100% { transform: translateY(0) translateX(0); }
|
||||
50% { transform: translateY(4rem) translateX(-2rem); }
|
||||
}
|
||||
|
||||
@keyframes bounce-ball-3 {
|
||||
0%, 100% { transform: translateY(0) translateX(0); }
|
||||
50% { transform: translateY(-4rem) translateX(1.5rem); }
|
||||
}
|
||||
|
||||
@keyframes spin-slow {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes spin-reverse {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(-360deg); }
|
||||
}
|
||||
|
||||
@keyframes bounce-gentle {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-0.6rem); }
|
||||
}
|
||||
|
||||
@keyframes pulse-gentle {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.08); opacity: 0.85; }
|
||||
}
|
||||
|
||||
@keyframes wiggle {
|
||||
0%, 100% { transform: rotate(0deg); }
|
||||
25% { transform: rotate(-4deg); }
|
||||
75% { transform: rotate(4deg); }
|
||||
}
|
||||
|
||||
@keyframes float-gentle {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-0.8rem); }
|
||||
}
|
||||
|
||||
@keyframes scale-gentle {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.12); }
|
||||
}
|
||||
|
||||
@keyframes rotate-gentle {
|
||||
0%, 100% { transform: rotate(0deg); }
|
||||
50% { transform: rotate(12deg); }
|
||||
}
|
||||
|
||||
@keyframes bounce-soft {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-0.9rem); }
|
||||
}
|
||||
|
||||
@keyframes sway {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
50% { transform: translateX(0.8rem); }
|
||||
}
|
||||
|
||||
@keyframes spin-fast {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse-fast {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.2); }
|
||||
}
|
||||
|
||||
@keyframes wobble {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
15% { transform: translateX(-0.35rem) rotate(-5deg); }
|
||||
30% { transform: translateX(0.25rem) rotate(4deg); }
|
||||
45% { transform: translateX(-0.2rem) rotate(-2deg); }
|
||||
60% { transform: translateX(0.12rem) rotate(1deg); }
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-0.12rem); }
|
||||
75% { transform: translateX(0.12rem); }
|
||||
}
|
||||
|
||||
@keyframes bounce-crazy {
|
||||
0%, 100% { transform: translateY(0) scale(1); }
|
||||
25% { transform: translateY(-0.5rem) scale(1.05); }
|
||||
75% { transform: translateY(0.2rem) scale(0.98); }
|
||||
}
|
||||
|
||||
@keyframes spin-wobble {
|
||||
0% { transform: rotate(0deg) scale(1); }
|
||||
50% { transform: rotate(180deg) scale(1.12); }
|
||||
100% { transform: rotate(360deg) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes flip {
|
||||
0%, 100% { transform: rotateY(0deg); }
|
||||
50% { transform: rotateY(180deg); }
|
||||
}
|
||||
|
||||
@keyframes twirl {
|
||||
0%, 100% { transform: rotate(0deg) scale(1); }
|
||||
50% { transform: rotate(180deg) scale(1.15); }
|
||||
}
|
||||
|
||||
@keyframes dance {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
25% { transform: translateY(-0.3rem) rotate(-6deg); }
|
||||
75% { transform: translateY(0.3rem) rotate(6deg); }
|
||||
}
|
||||
|
||||
@keyframes jiggle {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-0.18rem); }
|
||||
50% { transform: translateX(0.18rem); }
|
||||
75% { transform: translateX(-0.1rem); }
|
||||
}
|
||||
|
||||
@keyframes vibrate {
|
||||
0%, 100% { transform: translate(0); }
|
||||
20% { transform: translate(-1px, 1px); }
|
||||
40% { transform: translate(1px, -1px); }
|
||||
60% { transform: translate(-1px, -1px); }
|
||||
80% { transform: translate(1px, 1px); }
|
||||
}
|
||||
|
||||
@keyframes swing {
|
||||
0%, 100% { transform: rotate(0deg); }
|
||||
50% { transform: rotate(10deg); }
|
||||
}
|
||||
|
||||
@keyframes pendulum {
|
||||
0%, 100% { transform: rotate(-8deg); }
|
||||
50% { transform: rotate(8deg); }
|
||||
}
|
||||
|
||||
@keyframes elastic {
|
||||
0%, 100% { transform: scale(1); }
|
||||
30% { transform: scale(1.15, 0.9); }
|
||||
60% { transform: scale(0.95, 1.08); }
|
||||
}
|
||||
|
||||
@keyframes rubber {
|
||||
0%, 100% { transform: scale(1); }
|
||||
35% { transform: scale(1.2, 0.9); }
|
||||
65% { transform: scale(0.9, 1.15); }
|
||||
}
|
||||
|
||||
@keyframes rocket {
|
||||
0%, 100% { transform: translateY(0) rotate(-8deg); }
|
||||
50% { transform: translateY(-0.8rem) rotate(-12deg); }
|
||||
}
|
||||
|
||||
@keyframes comet {
|
||||
0%, 100% { transform: translateX(0) rotate(12deg); }
|
||||
50% { transform: translateX(0.8rem) rotate(18deg); }
|
||||
}
|
||||
|
||||
@keyframes meteor {
|
||||
0%, 100% { transform: translateY(0) rotate(8deg); }
|
||||
50% { transform: translateY(-0.7rem) rotate(14deg); }
|
||||
}
|
||||
|
||||
@keyframes blast {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.2) rotate(10deg); }
|
||||
}
|
||||
|
||||
@keyframes spin-bounce {
|
||||
0%, 100% { transform: rotate(0deg) translateY(0); }
|
||||
50% { transform: rotate(180deg) translateY(-0.5rem); }
|
||||
}
|
||||
|
||||
@keyframes flip-bounce {
|
||||
0%, 100% { transform: rotateY(0deg) translateY(0); }
|
||||
50% { transform: rotateY(180deg) translateY(-0.5rem); }
|
||||
}
|
||||
|
||||
@keyframes scale-bounce {
|
||||
0%, 100% { transform: scale(1) translateY(0); }
|
||||
50% { transform: scale(1.14) translateY(-0.55rem); }
|
||||
}
|
||||
|
||||
.animate-move-right-slow { animation: move-right-slow 25s linear infinite; }
|
||||
.animate-move-right-medium { animation: move-right-medium 20s linear infinite; }
|
||||
.animate-move-right-fast { animation: move-right-fast 15s linear infinite; }
|
||||
.animate-move-down-slow { animation: move-down-slow 30s linear infinite; }
|
||||
.animate-move-down-medium { animation: move-down-medium 25s linear infinite; }
|
||||
.animate-move-diagonal-1 { animation: move-diagonal-1 35s linear infinite; }
|
||||
.animate-move-diagonal-2 { animation: move-diagonal-2 28s linear infinite; }
|
||||
.animate-move-diagonal-3 { animation: move-diagonal-3 32s linear infinite; }
|
||||
.animate-orbit-1 { animation: orbit-1 20s linear infinite; }
|
||||
.animate-orbit-2 { animation: orbit-2 25s linear infinite reverse; }
|
||||
.animate-orbit-3 { animation: orbit-3 15s linear infinite; }
|
||||
.animate-orbit-4 { animation: orbit-4 22s linear infinite; }
|
||||
.animate-orbit-5 { animation: orbit-5 18s linear infinite; }
|
||||
.animate-zigzag-1 { animation: zigzag-1 18s linear infinite; }
|
||||
.animate-zigzag-2 { animation: zigzag-2 22s linear infinite; }
|
||||
.animate-zigzag-3 { animation: zigzag-3 16s linear infinite; }
|
||||
.animate-spiral-1 { animation: spiral-1 30s linear infinite; }
|
||||
.animate-spiral-2 { animation: spiral-2 25s linear infinite; }
|
||||
.animate-float-random-1 { animation: float-random-1 8s ease-in-out infinite; }
|
||||
.animate-float-random-2 { animation: float-random-2 10s ease-in-out infinite; }
|
||||
.animate-float-random-3 { animation: float-random-3 12s ease-in-out infinite; }
|
||||
.animate-float-random-4 { animation: float-random-4 9s ease-in-out infinite; }
|
||||
.animate-wave-1 { animation: wave-1 20s linear infinite; }
|
||||
.animate-wave-2 { animation: wave-2 24s linear infinite; }
|
||||
.animate-wave-3 { animation: wave-3 18s linear infinite; }
|
||||
.animate-wave-4 { animation: wave-4 26s linear infinite; }
|
||||
.animate-corner-shoot-1 { animation: corner-shoot-1 15s linear infinite; }
|
||||
.animate-corner-shoot-2 { animation: corner-shoot-2 18s linear infinite; }
|
||||
.animate-corner-shoot-3 { animation: corner-shoot-3 20s linear infinite; }
|
||||
.animate-corner-shoot-4 { animation: corner-shoot-4 16s linear infinite; }
|
||||
.animate-bounce-ball-1 { animation: bounce-ball-1 12s ease-in-out infinite; }
|
||||
.animate-bounce-ball-2 { animation: bounce-ball-2 14s ease-in-out infinite; }
|
||||
.animate-bounce-ball-3 { animation: bounce-ball-3 10s ease-in-out infinite; }
|
||||
.animate-spin-slow { animation: spin-slow 8s linear infinite; }
|
||||
.animate-spin-reverse { animation: spin-reverse 6s linear infinite; }
|
||||
.animate-bounce-gentle { animation: bounce-gentle 3s ease-in-out infinite; }
|
||||
.animate-bounce-soft { animation: bounce-soft 4s ease-in-out infinite; }
|
||||
.animate-pulse-gentle { animation: pulse-gentle 4s ease-in-out infinite; }
|
||||
.animate-wiggle { animation: wiggle 2s ease-in-out infinite; }
|
||||
.animate-float-gentle { animation: float-gentle 5s ease-in-out infinite; }
|
||||
.animate-scale-gentle { animation: scale-gentle 6s ease-in-out infinite; }
|
||||
.animate-rotate-gentle { animation: rotate-gentle 8s ease-in-out infinite; }
|
||||
.animate-sway { animation: sway 3s ease-in-out infinite; }
|
||||
.animate-spin-fast { animation: spin-fast 2s linear infinite; }
|
||||
.animate-pulse-fast { animation: pulse-fast 1.5s ease-in-out infinite; }
|
||||
.animate-wobble { animation: wobble 2s ease-in-out infinite; }
|
||||
.animate-shake { animation: shake 0.5s ease-in-out infinite; }
|
||||
.animate-bounce-crazy { animation: bounce-crazy 1s ease-in-out infinite; }
|
||||
.animate-spin-wobble { animation: spin-wobble 4s ease-in-out infinite; }
|
||||
.animate-flip { animation: flip 3s ease-in-out infinite; }
|
||||
.animate-twirl { animation: twirl 5s ease-in-out infinite; }
|
||||
.animate-dance { animation: dance 3s ease-in-out infinite; }
|
||||
.animate-jiggle { animation: jiggle 1s ease-in-out infinite; }
|
||||
.animate-vibrate { animation: vibrate 0.3s ease-in-out infinite; }
|
||||
.animate-swing { animation: swing 2.8s ease-in-out infinite; }
|
||||
.animate-pendulum { animation: pendulum 2.4s ease-in-out infinite; }
|
||||
.animate-elastic { animation: elastic 2.2s ease-in-out infinite; }
|
||||
.animate-rubber { animation: rubber 2.5s ease-in-out infinite; }
|
||||
.animate-rocket { animation: rocket 1.8s ease-in-out infinite; }
|
||||
.animate-comet { animation: comet 2s ease-in-out infinite; }
|
||||
.animate-meteor { animation: meteor 1.7s ease-in-out infinite; }
|
||||
.animate-blast { animation: blast 2.2s ease-in-out infinite; }
|
||||
.animate-spin-bounce { animation: spin-bounce 2.4s ease-in-out infinite; }
|
||||
.animate-flip-bounce { animation: flip-bounce 2.6s ease-in-out infinite; }
|
||||
.animate-scale-bounce { animation: scale-bounce 2.1s ease-in-out infinite; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.login-screen {
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.auth-card-shell {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
}
|
||||
5
go-backend/tools.go
Normal file
5
go-backend/tools.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//go:build tools
|
||||
|
||||
package tools
|
||||
|
||||
import _ "github.com/sqlc-dev/sqlc/cmd/sqlc"
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
package views
|
||||
|
||||
templ LoginPage() {
|
||||
<!doctype html>
|
||||
<html lang="fr" class="light">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<meta name="theme-color" content="#1e1b2e"/>
|
||||
<title>XTablo</title>
|
||||
<link rel="stylesheet" href="/static/styles.css"/>
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-shell">
|
||||
<div class="page-background">
|
||||
<div class="orb orb-a"><span>X</span></div>
|
||||
<div class="orb orb-b"><span>X</span></div>
|
||||
<div class="orb orb-c"><span>X</span></div>
|
||||
<div class="orb orb-d"><span>X</span></div>
|
||||
<div class="orb orb-e"><span>X</span></div>
|
||||
<div class="orb orb-f"><span>X</span></div>
|
||||
<div class="orb orb-g"><span>X</span></div>
|
||||
<div class="orb orb-h"><span>X</span></div>
|
||||
</div>
|
||||
<main class="auth-stage">
|
||||
<section class="auth-card">
|
||||
<div class="auth-glow"></div>
|
||||
<div class="card-body">
|
||||
<div class="card-topbar">
|
||||
<a class="back-link" href="https://www.xtablo.com">Retour a l'accueil</a>
|
||||
<button type="button" class="theme-button" aria-label="theme placeholder">
|
||||
<span class="theme-button-monitor"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="brand-lockup">
|
||||
<div class="brand-mark">XT</div>
|
||||
</div>
|
||||
<div class="headline-block">
|
||||
<h1>Se connecter a Xtablo</h1>
|
||||
</div>
|
||||
<div class="spotlight-link-wrap">
|
||||
<a class="spotlight-link" href="/login-v2">Decouvrez la nouvelle experience de connexion</a>
|
||||
</div>
|
||||
<form
|
||||
class="login-form"
|
||||
hx-post="/login"
|
||||
hx-target="#login-status"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<div id="login-status" class="status-slot"></div>
|
||||
<div class="field-group">
|
||||
<label for="email">Email *</label>
|
||||
<input id="email" name="email" type="email" placeholder="Votre email" required/>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label for="password">Mot de passe *</label>
|
||||
<input id="password" name="password" type="password" placeholder="Votre mot de passe" required/>
|
||||
</div>
|
||||
<div class="aux-row">
|
||||
<a href="/reset-password">Mot de passe oublie ?</a>
|
||||
</div>
|
||||
<button class="primary-button" type="submit">Se connecter</button>
|
||||
</form>
|
||||
<div class="divider">
|
||||
<span>Ou continuer avec</span>
|
||||
</div>
|
||||
<button type="button" class="google-button">
|
||||
<span class="google-mark" aria-hidden="true">
|
||||
<span class="google-mark-blue"></span>
|
||||
<span class="google-mark-red"></span>
|
||||
<span class="google-mark-yellow"></span>
|
||||
<span class="google-mark-green"></span>
|
||||
</span>
|
||||
<span>Continuer avec Google</span>
|
||||
</button>
|
||||
<p class="signup-copy">
|
||||
Pas encore de compte ?
|
||||
<a href="/signup">S'inscrire</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
templ LoginStatus(kind string, message string) {
|
||||
if kind == "success" {
|
||||
<div class="status-banner status-success" role="status">{ message }</div>
|
||||
} else if kind == "error" {
|
||||
<div class="status-banner status-error" role="alert">{ message }</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package views
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
func LoginPage() templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"fr\" class=\"light\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><meta name=\"theme-color\" content=\"#1e1b2e\"><title>XTablo</title><link rel=\"stylesheet\" href=\"/static/styles.css\"><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js\"></script></head><body><div class=\"page-shell\"><div class=\"page-background\"><div class=\"orb orb-a\"><span>X</span></div><div class=\"orb orb-b\"><span>X</span></div><div class=\"orb orb-c\"><span>X</span></div><div class=\"orb orb-d\"><span>X</span></div><div class=\"orb orb-e\"><span>X</span></div><div class=\"orb orb-f\"><span>X</span></div><div class=\"orb orb-g\"><span>X</span></div><div class=\"orb orb-h\"><span>X</span></div></div><main class=\"auth-stage\"><section class=\"auth-card\"><div class=\"auth-glow\"></div><div class=\"card-body\"><div class=\"card-topbar\"><a class=\"back-link\" href=\"https://www.xtablo.com\">Retour a l'accueil</a> <button type=\"button\" class=\"theme-button\" aria-label=\"theme placeholder\"><span class=\"theme-button-monitor\"></span></button></div><div class=\"brand-lockup\"><div class=\"brand-mark\">XT</div></div><div class=\"headline-block\"><h1>Se connecter a Xtablo</h1></div><div class=\"spotlight-link-wrap\"><a class=\"spotlight-link\" href=\"/login-v2\">Decouvrez la nouvelle experience de connexion</a></div><form class=\"login-form\" hx-post=\"/login\" hx-target=\"#login-status\" hx-swap=\"innerHTML\"><div id=\"login-status\" class=\"status-slot\"></div><div class=\"field-group\"><label for=\"email\">Email *</label> <input id=\"email\" name=\"email\" type=\"email\" placeholder=\"Votre email\" required></div><div class=\"field-group\"><label for=\"password\">Mot de passe *</label> <input id=\"password\" name=\"password\" type=\"password\" placeholder=\"Votre mot de passe\" required></div><div class=\"aux-row\"><a href=\"/reset-password\">Mot de passe oublie ?</a></div><button class=\"primary-button\" type=\"submit\">Se connecter</button></form><div class=\"divider\"><span>Ou continuer avec</span></div><button type=\"button\" class=\"google-button\"><span class=\"google-mark\" aria-hidden=\"true\"><span class=\"google-mark-blue\"></span> <span class=\"google-mark-red\"></span> <span class=\"google-mark-yellow\"></span> <span class=\"google-mark-green\"></span></span> <span>Continuer avec Google</span></button><p class=\"signup-copy\">Pas encore de compte ? <a href=\"/signup\">S'inscrire</a></p></div></section></main></div></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func LoginStatus(kind string, message string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var2 == nil {
|
||||
templ_7745c5c3_Var2 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
if kind == "success" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"status-banner status-success\" role=\"status\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(message)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/login.templ`, Line: 91, Col: 67}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if kind == "error" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"status-banner status-error\" role=\"alert\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(message)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views/login.templ`, Line: 93, Col: 64}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue