diff --git a/backend-api/.env.example b/backend-api/.env.example index 7c86d16..bd83ea8 100644 --- a/backend-api/.env.example +++ b/backend-api/.env.example @@ -9,3 +9,10 @@ DATABASE_URL=postgres://civiclens:password@localhost:5432/civiclens_dev?sslmode= # ─── Auth ───────────────────────────────────────────────────────────────────── # Generate with: openssl rand -hex 32 JWT_SECRET=change-me-in-production-use-a-long-random-secret + +# ─── Initial Accounts & Auto-Seeding ────────────────────────────────────────── +INITIAL_ADMIN_EMAIL=admin@civiclens.org +INITIAL_ADMIN_PASSWORD=AdminSecurePass123! +INITIAL_USER_EMAIL=user@civiclens.org +INITIAL_USER_PASSWORD=UserSecurePass123! + diff --git a/backend-api/.golangci.yml b/backend-api/.golangci.yml index 96a33c9..c59a279 100644 --- a/backend-api/.golangci.yml +++ b/backend-api/.golangci.yml @@ -1,3 +1,5 @@ +version: "2" + # golangci-lint configuration for CivicLens backend API # Documentation: https://golangci-lint.run/usage/configuration/ @@ -5,11 +7,10 @@ run: timeout: 5m go: "1.22" -output: - formats: - - format: colored-line-number - print-issued-lines: true - print-linter-name: true +# output: +# format: colored-line-number +# print-issued-lines: true +# print-linter-name: true linters: enable: @@ -23,11 +24,11 @@ linters: - staticcheck # Comprehensive static analysis - unused # Finds unused code - ineffassign # Detects ineffectual assignments - - deadcode # Finds unreachable code + # - deadcode # Finds unreachable code # Style & conventions - - gofmt # Enforces gofmt formatting - - goimports # Enforces import grouping + # - gofmt # Enforces gofmt formatting + # - goimports # Enforces import grouping - misspell # Catches common misspellings - godot # Checks that comments end with a period - revive # Fast, configurable, extensible linter @@ -49,11 +50,11 @@ linters: - gochecknoglobals # Allows global vars for now linters-settings: - gofmt: - simplify: true - - goimports: - local-prefixes: github.com/civiclens +# gofmt: +# simplify: true +# +# goimports: +# local-prefixes: github.com/civiclens errcheck: check-type-assertions: true diff --git a/backend-api/Makefile b/backend-api/Makefile index 23317e9..a6d8ce3 100644 --- a/backend-api/Makefile +++ b/backend-api/Makefile @@ -4,46 +4,62 @@ # Usage: # make run – start the development server # make test – run all tests with race detection +# make test-v – run tests with verbose output # make lint – run golangci-lint # make fmt – format all Go files +# make vet – run go vet +# make tidy – tidy go module dependencies # make sqlc-gen – regenerate type-safe DB code from SQL files # make build – produce a production binary # make clean – remove build artifacts # ──────────────────────────────────────────────────────────────────────────── -.PHONY: all run build test lint fmt sqlc-gen clean tidy help +.PHONY: all run build test test-v lint fmt vet tidy sqlc-gen clean help # Default goal all: fmt lint test build -BINARY_NAME := civiclens-api -BUILD_DIR := ./bin - # Prevent Go from auto-downloading a newer toolchain triggered by transitive deps export GOTOOLCHAIN := local +# OS detection for cross-platform support +ifeq ($(OS),Windows_NT) + BINARY_EXT := .exe +else + BINARY_EXT := +endif + +BINARY_NAME := civiclens-api$(BINARY_EXT) +BUILD_DIR := bin +BINARY_PATH := $(BUILD_DIR)/$(BINARY_NAME) + # ── Development ────────────────────────────────────────────────────────────── ## run: Start the API server using environment variables from .env run: go run ./cmd/server -## build: Compile a production binary to ./bin/ +## build: Compile a production binary to bin/ +build: export CGO_ENABLED := 0 build: +ifeq ($(OS),Windows_NT) + @if not exist $(BUILD_DIR) mkdir $(BUILD_DIR) +else @mkdir -p $(BUILD_DIR) - CGO_ENABLED=0 go build -ldflags="-s -w" -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/server - @echo "Binary written to $(BUILD_DIR)/$(BINARY_NAME)" +endif + go build -ldflags="-s -w" -o $(BINARY_PATH) ./cmd/server + @echo Binary written to $(BINARY_PATH) # ── Testing ─────────────────────────────────────────────────────────────────── -## test: Run all tests with race detection and coverage report +## test: Run all tests with coverage report test: - go test -race -coverprofile=coverage.out -covermode=atomic ./... + go test -coverprofile=coverage.out -covermode=atomic ./... go tool cover -func=coverage.out ## test-v: Run tests with verbose output test-v: - go test -race -v ./... + go test -v ./... # ── Code Quality ───────────────────────────────────────────────────────────── @@ -74,10 +90,25 @@ sqlc-gen: ## clean: Remove build artifacts and coverage reports clean: - rm -rf $(BUILD_DIR) coverage.out +ifeq ($(OS),Windows_NT) + @if exist $(BUILD_DIR) rmdir /s /q $(BUILD_DIR) + @if exist coverage.out del /q coverage.out +else + @rm -rf $(BUILD_DIR) coverage.out +endif # ── Help ───────────────────────────────────────────────────────────────────── ## help: Display this help message help: - @grep -E '^## ' $(MAKEFILE_LIST) | sed 's/^## //' + @echo Usage: + @echo make run - start the development server + @echo make test - run all tests with race detection + @echo make test-v - run tests with verbose output + @echo make lint - run golangci-lint + @echo make fmt - format all Go files + @echo make vet - run go vet + @echo make tidy - tidy go module dependencies + @echo make sqlc-gen - regenerate type-safe DB code from SQL files + @echo make build - produce a production binary + @echo make clean - remove build artifacts diff --git a/backend-api/cmd/server/main.go b/backend-api/cmd/server/main.go index 8d949d9..d7101fe 100644 --- a/backend-api/cmd/server/main.go +++ b/backend-api/cmd/server/main.go @@ -1,7 +1,9 @@ +// Package main is the entrypoint for the CivicLens backend API. package main import ( "context" + "errors" "fmt" "log" "net/http" @@ -12,6 +14,7 @@ import ( "github.com/civiclens/backend-api/internal/config" "github.com/civiclens/backend-api/internal/db" + "github.com/civiclens/backend-api/internal/db/sqlcdb" "github.com/civiclens/backend-api/internal/router" ) @@ -29,6 +32,17 @@ func main() { } defer pool.Close() + // Run database migrations + if err := db.RunMigrations(cfg.DatabaseURL); err != nil { + log.Fatalf("failed to run database migrations: %v", err) + } + + // Seed initial data (e.g. Super Admin) + querier := sqlcdb.New(pool) + if err := db.SeedInitialData(context.Background(), querier, cfg); err != nil { + log.Fatalf("failed to seed initial data: %v", err) + } + // Build Echo router e := router.New(pool, cfg) @@ -36,7 +50,7 @@ func main() { serverAddr := fmt.Sprintf(":%s", cfg.Port) go func() { log.Printf("starting server on %s", serverAddr) - if err := e.Start(serverAddr); err != nil && err != http.ErrServerClosed { + if err := e.Start(serverAddr); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("server error: %v", err) } }() diff --git a/backend-api/coverage b/backend-api/coverage new file mode 100644 index 0000000..79b28a0 --- /dev/null +++ b/backend-api/coverage @@ -0,0 +1 @@ +mode: atomic diff --git a/backend-api/go.mod b/backend-api/go.mod index e7b0b90..91a49b2 100644 --- a/backend-api/go.mod +++ b/backend-api/go.mod @@ -1,33 +1,37 @@ module github.com/civiclens/backend-api -go 1.22 +go 1.25.0 require ( + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.6.0 github.com/joho/godotenv v1.5.1 - github.com/labstack/echo/v4 v4.12.0 - github.com/stretchr/testify v1.9.0 + github.com/labstack/echo-jwt/v4 v4.4.0 + github.com/labstack/echo/v4 v4.13.4 + github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.54.0 ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // 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.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/labstack/gommon v0.4.2 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - golang.org/x/crypto v0.24.0 // indirect - golang.org/x/net v0.26.0 // indirect - golang.org/x/sync v0.7.0 // indirect - golang.org/x/sys v0.21.0 // indirect - golang.org/x/text v0.16.0 // indirect - golang.org/x/time v0.5.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/time v0.14.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend-api/go.sum b/backend-api/go.sum index a5892e6..89bc414 100644 --- a/backend-api/go.sum +++ b/backend-api/go.sum @@ -1,9 +1,41 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= -github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +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/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= 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= @@ -18,42 +50,67 @@ github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= -github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= +github.com/labstack/echo-jwt/v4 v4.4.0 h1:nrXaEnJupfc2R4XChcLRDyghhMZup77F8nIzHnBK19U= +github.com/labstack/echo-jwt/v4 v4.4.0/go.mod h1:kYXWgWms9iFqI3ldR+HAEj/Zfg5rZtR7ePOgktG4Hjg= +github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= +github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= -golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= -golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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= diff --git a/backend-api/internal/config/config.go b/backend-api/internal/config/config.go index 6bcad44..7a8659a 100644 --- a/backend-api/internal/config/config.go +++ b/backend-api/internal/config/config.go @@ -19,6 +19,12 @@ type Config struct { // Auth JWTSecret string + + // Initial Accounts & Auto-Seeding + InitialAdminEmail string + InitialAdminPassword string + InitialUserEmail string + InitialUserPassword string } // Load reads configuration from environment variables. @@ -28,10 +34,14 @@ func Load() (*Config, error) { _ = godotenv.Load() cfg := &Config{ - Port: getEnv("PORT", "8080"), - Environment: getEnv("ENVIRONMENT", "development"), - DatabaseURL: os.Getenv("DATABASE_URL"), - JWTSecret: os.Getenv("JWT_SECRET"), + Port: getEnv("PORT", "8080"), + Environment: getEnv("ENVIRONMENT", "development"), + DatabaseURL: os.Getenv("DATABASE_URL"), + JWTSecret: os.Getenv("JWT_SECRET"), + InitialAdminEmail: getEnv("INITIAL_ADMIN_EMAIL", "admin@civiclens.org"), + InitialAdminPassword: getEnv("INITIAL_ADMIN_PASSWORD", "AdminPass123!"), + InitialUserEmail: getEnv("INITIAL_USER_EMAIL", "user@civiclens.org"), + InitialUserPassword: getEnv("INITIAL_USER_PASSWORD", "UserPass123!"), } if err := cfg.validate(); err != nil { diff --git a/backend-api/internal/config/config_test.go b/backend-api/internal/config/config_test.go new file mode 100644 index 0000000..3f2ffe6 --- /dev/null +++ b/backend-api/internal/config/config_test.go @@ -0,0 +1,64 @@ +package config + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfig_LoadSuccess(t *testing.T) { + // Set required environment variables + t.Setenv("DATABASE_URL", "postgres://user:pass@localhost:5432/testdb") + t.Setenv("JWT_SECRET", "super-secret-jwt-key") + t.Setenv("INITIAL_ADMIN_EMAIL", "admin@civiclens.org") + t.Setenv("INITIAL_ADMIN_PASSWORD", "AdminSecret123!") + t.Setenv("INITIAL_USER_EMAIL", "user@civiclens.org") + t.Setenv("INITIAL_USER_PASSWORD", "UserSecret123!") + + cfg, err := Load() + require.NoError(t, err) + assert.Equal(t, "postgres://user:pass@localhost:5432/testdb", cfg.DatabaseURL) + assert.Equal(t, "super-secret-jwt-key", cfg.JWTSecret) + assert.Equal(t, "admin@civiclens.org", cfg.InitialAdminEmail) + assert.Equal(t, "AdminSecret123!", cfg.InitialAdminPassword) + assert.Equal(t, "user@civiclens.org", cfg.InitialUserEmail) + assert.Equal(t, "UserSecret123!", cfg.InitialUserPassword) +} + +func TestConfig_DefaultAdminAndUser(t *testing.T) { + t.Setenv("DATABASE_URL", "postgres://user:pass@localhost:5432/testdb") + t.Setenv("JWT_SECRET", "super-secret-jwt-key") + _ = os.Unsetenv("INITIAL_ADMIN_EMAIL") + _ = os.Unsetenv("INITIAL_ADMIN_PASSWORD") + _ = os.Unsetenv("INITIAL_USER_EMAIL") + _ = os.Unsetenv("INITIAL_USER_PASSWORD") + + cfg, err := Load() + require.NoError(t, err) + assert.Equal(t, "admin@civiclens.org", cfg.InitialAdminEmail) + assert.NotEmpty(t, cfg.InitialAdminPassword) + assert.Equal(t, "user@civiclens.org", cfg.InitialUserEmail) + assert.NotEmpty(t, cfg.InitialUserPassword) +} + +func TestConfig_ValidationMissingDatabaseURL(t *testing.T) { + _ = os.Unsetenv("DATABASE_URL") + t.Setenv("JWT_SECRET", "super-secret-jwt-key") + + cfg, err := Load() + assert.Error(t, err) + assert.Nil(t, cfg) + assert.Contains(t, err.Error(), "DATABASE_URL is required") +} + +func TestConfig_ValidationMissingJWTSecret(t *testing.T) { + t.Setenv("DATABASE_URL", "postgres://user:pass@localhost:5432/testdb") + _ = os.Unsetenv("JWT_SECRET") + + cfg, err := Load() + assert.Error(t, err) + assert.Nil(t, cfg) + assert.Contains(t, err.Error(), "JWT_SECRET is required") +} diff --git a/backend-api/internal/db/migrate.go b/backend-api/internal/db/migrate.go new file mode 100644 index 0000000..4c89b03 --- /dev/null +++ b/backend-api/internal/db/migrate.go @@ -0,0 +1,48 @@ +package db + +import ( + "embed" + "errors" + "fmt" + "log" + "strings" + + "github.com/golang-migrate/migrate/v4" + // Blank import for pgx/v5 driver registration. + _ "github.com/golang-migrate/migrate/v4/database/pgx/v5" + "github.com/golang-migrate/migrate/v4/source/iofs" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +// RunMigrations applies all pending database migrations using the provided connection pool URL. +func RunMigrations(databaseURL string) error { + d, err := iofs.New(migrationsFS, "migrations") + if err != nil { + return fmt.Errorf("failed to create iofs from embed: %w", err) + } + + // Replace "postgres://" with "pgx5://" so the migrate library uses the pgx/v5 driver. + if strings.HasPrefix(databaseURL, "postgres://") { + databaseURL = "pgx5://" + strings.TrimPrefix(databaseURL, "postgres://") + } + + m, err := migrate.NewWithSourceInstance("iofs", d, databaseURL) + if err != nil { + return fmt.Errorf("failed to initialize migrate instance: %w", err) + } + defer func() { _, _ = m.Close() }() + + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("failed to run migrate up: %w", err) + } + + if errors.Is(err, migrate.ErrNoChange) { + log.Println("database migrations: up to date") + } else { + log.Println("database migrations: successfully applied") + } + + return nil +} diff --git a/backend-api/internal/db/migrations/000001_multi_tenancy.down.sql b/backend-api/internal/db/migrations/000001_multi_tenancy.down.sql new file mode 100644 index 0000000..3dff6eb --- /dev/null +++ b/backend-api/internal/db/migrations/000001_multi_tenancy.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS refresh_tokens CASCADE; +DROP TABLE IF EXISTS users CASCADE; +DROP TABLE IF EXISTS roles CASCADE; +DROP TABLE IF EXISTS entities CASCADE; diff --git a/backend-api/internal/db/migrations/000001_multi_tenancy.up.sql b/backend-api/internal/db/migrations/000001_multi_tenancy.up.sql new file mode 100644 index 0000000..6c26824 --- /dev/null +++ b/backend-api/internal/db/migrations/000001_multi_tenancy.up.sql @@ -0,0 +1,35 @@ +CREATE TABLE entities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entity_id UUID REFERENCES entities(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entity_id UUID REFERENCES entities(id) ON DELETE SET NULL, + role_id UUID REFERENCES roles(id) ON DELETE SET NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + first_name VARCHAR(255), + last_name VARCHAR(255), + is_super_admin BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token VARCHAR(512) UNIQUE NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMPTZ +); diff --git a/backend-api/internal/db/seed.go b/backend-api/internal/db/seed.go new file mode 100644 index 0000000..4f35521 --- /dev/null +++ b/backend-api/internal/db/seed.go @@ -0,0 +1,38 @@ +package db + +import ( + "context" + "fmt" + + "github.com/civiclens/backend-api/internal/config" + "github.com/civiclens/backend-api/internal/db/sqlcdb" + "golang.org/x/crypto/bcrypt" +) + +// SeedInitialData checks if a super admin exists and seeds one if not. +func SeedInitialData(ctx context.Context, querier sqlcdb.Querier, cfg *config.Config) error { + count, err := querier.CountSuperAdmins(ctx) + if err != nil { + return fmt.Errorf("failed to count super admins: %w", err) + } + + if count > 0 { + return nil // Super admin already exists, nothing to do + } + + // Generate bcrypt hash for the initial admin password + hash, err := bcrypt.GenerateFromPassword([]byte(cfg.InitialAdminPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + _, err = querier.CreateSuperAdmin(ctx, sqlcdb.CreateSuperAdminParams{ + Email: cfg.InitialAdminEmail, + PasswordHash: string(hash), + }) + if err != nil { + return fmt.Errorf("failed to create super admin: %w", err) + } + + return nil +} diff --git a/backend-api/internal/db/seed_test.go b/backend-api/internal/db/seed_test.go new file mode 100644 index 0000000..90de1d2 --- /dev/null +++ b/backend-api/internal/db/seed_test.go @@ -0,0 +1,86 @@ +package db_test + +import ( + "context" + "testing" + + "github.com/civiclens/backend-api/internal/config" + "github.com/civiclens/backend-api/internal/db" + "github.com/civiclens/backend-api/internal/db/sqlcdb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" +) + +// MockQuerier implements sqlcdb.Querier for testing. +type MockQuerier struct { + sqlcdb.Querier + CountSuperAdminsFunc func(_ context.Context) (int64, error) + CreateSuperAdminFunc func(_ context.Context, _ sqlcdb.CreateSuperAdminParams) (sqlcdb.User, error) +} + +func (m *MockQuerier) CountSuperAdmins(ctx context.Context) (int64, error) { + if m.CountSuperAdminsFunc != nil { + return m.CountSuperAdminsFunc(ctx) + } + return 0, nil +} + +func (m *MockQuerier) CreateSuperAdmin(ctx context.Context, arg sqlcdb.CreateSuperAdminParams) (sqlcdb.User, error) { + if m.CreateSuperAdminFunc != nil { + return m.CreateSuperAdminFunc(ctx, arg) + } + return sqlcdb.User{}, nil +} + +func TestSeedInitialData_Idempotent(t *testing.T) { + cfg := &config.Config{ + InitialAdminEmail: "admin@test.com", + InitialAdminPassword: "password123", + } + + createCalled := false + mock := &MockQuerier{ + CountSuperAdminsFunc: func(_ context.Context) (int64, error) { + return 1, nil // Already exists + }, + CreateSuperAdminFunc: func(_ context.Context, _ sqlcdb.CreateSuperAdminParams) (sqlcdb.User, error) { + createCalled = true + return sqlcdb.User{}, nil + }, + } + + err := db.SeedInitialData(context.Background(), mock, cfg) + require.NoError(t, err) + assert.False(t, createCalled, "CreateSuperAdmin should not be called if a super admin already exists") +} + +func TestSeedInitialData_CreatesWhenMissing(t *testing.T) { + cfg := &config.Config{ + InitialAdminEmail: "admin@test.com", + InitialAdminPassword: "password123", + } + + createCalled := false + mock := &MockQuerier{ + CountSuperAdminsFunc: func(_ context.Context) (int64, error) { + return 0, nil // Missing + }, + CreateSuperAdminFunc: func(_ context.Context, arg sqlcdb.CreateSuperAdminParams) (sqlcdb.User, error) { + createCalled = true + assert.Equal(t, cfg.InitialAdminEmail, arg.Email) + + // Verify password is hashed correctly + err := bcrypt.CompareHashAndPassword([]byte(arg.PasswordHash), []byte(cfg.InitialAdminPassword)) + assert.NoError(t, err, "password hash should match the provided plain text password") + + return sqlcdb.User{ + Email: arg.Email, + }, nil + }, + } + + err := db.SeedInitialData(context.Background(), mock, cfg) + require.NoError(t, err) + assert.True(t, createCalled, "CreateSuperAdmin should be called if no super admin exists") +} diff --git a/backend-api/internal/db/sqlcdb/auth.sql.go b/backend-api/internal/db/sqlcdb/auth.sql.go new file mode 100644 index 0000000..8cb7173 --- /dev/null +++ b/backend-api/internal/db/sqlcdb/auth.sql.go @@ -0,0 +1,110 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: auth.sql + +package sqlcdb + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +const createRefreshToken = `-- name: CreateRefreshToken :one +INSERT INTO refresh_tokens (user_id, token, expires_at) +VALUES ($1, $2, $3) +RETURNING id, user_id, token, expires_at, created_at, revoked_at +` + +type CreateRefreshTokenParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + Token string `db:"token" json:"token"` + ExpiresAt time.Time `db:"expires_at" json:"expires_at"` +} + +func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error) { + row := q.db.QueryRow(ctx, createRefreshToken, arg.UserID, arg.Token, arg.ExpiresAt) + var i RefreshToken + err := row.Scan( + &i.ID, + &i.UserID, + &i.Token, + &i.ExpiresAt, + &i.CreatedAt, + &i.RevokedAt, + ) + return i, err +} + +const getRefreshToken = `-- name: GetRefreshToken :one +SELECT id, user_id, token, expires_at, created_at, revoked_at FROM refresh_tokens WHERE token = $1 +` + +func (q *Queries) GetRefreshToken(ctx context.Context, token string) (RefreshToken, error) { + row := q.db.QueryRow(ctx, getRefreshToken, token) + var i RefreshToken + err := row.Scan( + &i.ID, + &i.UserID, + &i.Token, + &i.ExpiresAt, + &i.CreatedAt, + &i.RevokedAt, + ) + return i, err +} + +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT id, entity_id, role_id, email, password_hash, first_name, last_name, is_super_admin, created_at, updated_at FROM users WHERE email = $1 +` + +func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { + row := q.db.QueryRow(ctx, getUserByEmail, email) + var i User + err := row.Scan( + &i.ID, + &i.EntityID, + &i.RoleID, + &i.Email, + &i.PasswordHash, + &i.FirstName, + &i.LastName, + &i.IsSuperAdmin, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getUserById = `-- name: GetUserById :one +SELECT id, entity_id, role_id, email, password_hash, first_name, last_name, is_super_admin, created_at, updated_at FROM users WHERE id = $1 +` + +func (q *Queries) GetUserById(ctx context.Context, id uuid.UUID) (User, error) { + row := q.db.QueryRow(ctx, getUserById, id) + var i User + err := row.Scan( + &i.ID, + &i.EntityID, + &i.RoleID, + &i.Email, + &i.PasswordHash, + &i.FirstName, + &i.LastName, + &i.IsSuperAdmin, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const revokeRefreshToken = `-- name: RevokeRefreshToken :exec +UPDATE refresh_tokens SET revoked_at = NOW() WHERE token = $1 +` + +func (q *Queries) RevokeRefreshToken(ctx context.Context, token string) error { + _, err := q.db.Exec(ctx, revokeRefreshToken, token) + return err +} diff --git a/backend-api/internal/db/sqlcdb/db.go b/backend-api/internal/db/sqlcdb/db.go new file mode 100644 index 0000000..eaed72c --- /dev/null +++ b/backend-api/internal/db/sqlcdb/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlcdb + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/backend-api/internal/db/sqlcdb/models.go b/backend-api/internal/db/sqlcdb/models.go new file mode 100644 index 0000000..ee4d211 --- /dev/null +++ b/backend-api/internal/db/sqlcdb/models.go @@ -0,0 +1,48 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlcdb + +import ( + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +type Entity struct { + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +type RefreshToken struct { + ID uuid.UUID `db:"id" json:"id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + Token string `db:"token" json:"token"` + ExpiresAt time.Time `db:"expires_at" json:"expires_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + RevokedAt pgtype.Timestamptz `db:"revoked_at" json:"revoked_at"` +} + +type Role struct { + ID uuid.UUID `db:"id" json:"id"` + EntityID pgtype.UUID `db:"entity_id" json:"entity_id"` + Name string `db:"name" json:"name"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +type User struct { + ID uuid.UUID `db:"id" json:"id"` + EntityID pgtype.UUID `db:"entity_id" json:"entity_id"` + RoleID pgtype.UUID `db:"role_id" json:"role_id"` + Email string `db:"email" json:"email"` + PasswordHash string `db:"password_hash" json:"password_hash"` + FirstName pgtype.Text `db:"first_name" json:"first_name"` + LastName pgtype.Text `db:"last_name" json:"last_name"` + IsSuperAdmin bool `db:"is_super_admin" json:"is_super_admin"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} diff --git a/backend-api/internal/db/sqlcdb/querier.go b/backend-api/internal/db/sqlcdb/querier.go new file mode 100644 index 0000000..de161d2 --- /dev/null +++ b/backend-api/internal/db/sqlcdb/querier.go @@ -0,0 +1,29 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlcdb + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +type Querier interface { + CountSuperAdmins(ctx context.Context) (int64, error) + CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error) + CreateRole(ctx context.Context, arg CreateRoleParams) (Role, error) + CreateSuperAdmin(ctx context.Context, arg CreateSuperAdminParams) (User, error) + CreateUser(ctx context.Context, arg CreateUserParams) (User, error) + GetRefreshToken(ctx context.Context, token string) (RefreshToken, error) + GetUserByEmail(ctx context.Context, email string) (User, error) + GetUserById(ctx context.Context, id uuid.UUID) (User, error) + ListRolesByEntity(ctx context.Context, entityID pgtype.UUID) ([]Role, error) + ListUsersByEntity(ctx context.Context, entityID pgtype.UUID) ([]User, error) + RevokeRefreshToken(ctx context.Context, token string) error + UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) error +} + +var _ Querier = (*Queries)(nil) diff --git a/backend-api/internal/db/sqlcdb/roles.sql.go b/backend-api/internal/db/sqlcdb/roles.sql.go new file mode 100644 index 0000000..9a23367 --- /dev/null +++ b/backend-api/internal/db/sqlcdb/roles.sql.go @@ -0,0 +1,66 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: roles.sql + +package sqlcdb + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createRole = `-- name: CreateRole :one +INSERT INTO roles (entity_id, name) +VALUES ($1, $2) +RETURNING id, entity_id, name, created_at +` + +type CreateRoleParams struct { + EntityID pgtype.UUID `db:"entity_id" json:"entity_id"` + Name string `db:"name" json:"name"` +} + +func (q *Queries) CreateRole(ctx context.Context, arg CreateRoleParams) (Role, error) { + row := q.db.QueryRow(ctx, createRole, arg.EntityID, arg.Name) + var i Role + err := row.Scan( + &i.ID, + &i.EntityID, + &i.Name, + &i.CreatedAt, + ) + return i, err +} + +const listRolesByEntity = `-- name: ListRolesByEntity :many +SELECT id, entity_id, name, created_at FROM roles +WHERE entity_id = $1 +ORDER BY name ASC +` + +func (q *Queries) ListRolesByEntity(ctx context.Context, entityID pgtype.UUID) ([]Role, error) { + rows, err := q.db.Query(ctx, listRolesByEntity, entityID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Role{} + for rows.Next() { + var i Role + if err := rows.Scan( + &i.ID, + &i.EntityID, + &i.Name, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/backend-api/internal/db/sqlcdb/users.sql.go b/backend-api/internal/db/sqlcdb/users.sql.go new file mode 100644 index 0000000..f8d7516 --- /dev/null +++ b/backend-api/internal/db/sqlcdb/users.sql.go @@ -0,0 +1,149 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: users.sql + +package sqlcdb + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const countSuperAdmins = `-- name: CountSuperAdmins :one +SELECT COUNT(*) FROM users WHERE is_super_admin = true +` + +func (q *Queries) CountSuperAdmins(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, countSuperAdmins) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createSuperAdmin = `-- name: CreateSuperAdmin :one +INSERT INTO users (email, password_hash, is_super_admin) +VALUES ($1, $2, true) +RETURNING id, entity_id, role_id, email, password_hash, first_name, last_name, is_super_admin, created_at, updated_at +` + +type CreateSuperAdminParams struct { + Email string `db:"email" json:"email"` + PasswordHash string `db:"password_hash" json:"password_hash"` +} + +func (q *Queries) CreateSuperAdmin(ctx context.Context, arg CreateSuperAdminParams) (User, error) { + row := q.db.QueryRow(ctx, createSuperAdmin, arg.Email, arg.PasswordHash) + var i User + err := row.Scan( + &i.ID, + &i.EntityID, + &i.RoleID, + &i.Email, + &i.PasswordHash, + &i.FirstName, + &i.LastName, + &i.IsSuperAdmin, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createUser = `-- name: CreateUser :one +INSERT INTO users (entity_id, role_id, email, password_hash, first_name, last_name, is_super_admin) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, entity_id, role_id, email, password_hash, first_name, last_name, is_super_admin, created_at, updated_at +` + +type CreateUserParams struct { + EntityID pgtype.UUID `db:"entity_id" json:"entity_id"` + RoleID pgtype.UUID `db:"role_id" json:"role_id"` + Email string `db:"email" json:"email"` + PasswordHash string `db:"password_hash" json:"password_hash"` + FirstName pgtype.Text `db:"first_name" json:"first_name"` + LastName pgtype.Text `db:"last_name" json:"last_name"` + IsSuperAdmin bool `db:"is_super_admin" json:"is_super_admin"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRow(ctx, createUser, + arg.EntityID, + arg.RoleID, + arg.Email, + arg.PasswordHash, + arg.FirstName, + arg.LastName, + arg.IsSuperAdmin, + ) + var i User + err := row.Scan( + &i.ID, + &i.EntityID, + &i.RoleID, + &i.Email, + &i.PasswordHash, + &i.FirstName, + &i.LastName, + &i.IsSuperAdmin, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listUsersByEntity = `-- name: ListUsersByEntity :many +SELECT id, entity_id, role_id, email, password_hash, first_name, last_name, is_super_admin, created_at, updated_at FROM users +WHERE entity_id = $1 +ORDER BY created_at DESC +` + +func (q *Queries) ListUsersByEntity(ctx context.Context, entityID pgtype.UUID) ([]User, error) { + rows, err := q.db.Query(ctx, listUsersByEntity, entityID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []User{} + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.EntityID, + &i.RoleID, + &i.Email, + &i.PasswordHash, + &i.FirstName, + &i.LastName, + &i.IsSuperAdmin, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateUserRole = `-- name: UpdateUserRole :exec +UPDATE users +SET role_id = $2, updated_at = NOW() +WHERE id = $1 AND entity_id = $3 +` + +type UpdateUserRoleParams struct { + ID uuid.UUID `db:"id" json:"id"` + RoleID pgtype.UUID `db:"role_id" json:"role_id"` + EntityID pgtype.UUID `db:"entity_id" json:"entity_id"` +} + +func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) error { + _, err := q.db.Exec(ctx, updateUserRole, arg.ID, arg.RoleID, arg.EntityID) + return err +} diff --git a/backend-api/internal/handler/auth.go b/backend-api/internal/handler/auth.go new file mode 100644 index 0000000..f8ebf4c --- /dev/null +++ b/backend-api/internal/handler/auth.go @@ -0,0 +1,181 @@ +package handler + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "net/http" + "time" + + "github.com/civiclens/backend-api/internal/db/sqlcdb" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/labstack/echo/v4" + "golang.org/x/crypto/bcrypt" +) + +// LoginRequest defines the request body for the login endpoint. +type LoginRequest struct { + Email string `json:"email" form:"email"` + Password string `json:"password" form:"password"` +} + +// AuthResponse defines the response returned after successful authentication. +type AuthResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +// generateTokens creates a short-lived JWT and a long-lived opaque refresh token. +func (h *Handler) generateTokens(c echo.Context, user sqlcdb.User) (AuthResponse, error) { + // Access Token (JWT) + // Permissions assignment (static for now) + var perms []string + if user.IsSuperAdmin { + perms = []string{"*"} + } else if user.RoleID.Valid { + // Mock permissions for an entity user + perms = []string{"reports:read", "reports:write"} + } + + claims := jwt.MapClaims{ + "sub": user.ID.String(), + "is_super_admin": user.IsSuperAdmin, + "permissions": perms, + "exp": time.Now().Add(15 * time.Minute).Unix(), + } + + if user.EntityID.Valid { + entityIDBytes := user.EntityID.Bytes + entityIDUUID := uuid.UUID(entityIDBytes) + claims["entity_id"] = entityIDUUID.String() + } + + if user.RoleID.Valid { + roleIDBytes := user.RoleID.Bytes + roleIDUUID := uuid.UUID(roleIDBytes) + claims["role_id"] = roleIDUUID.String() + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + accessToken, err := token.SignedString([]byte(h.Cfg.JWTSecret)) + if err != nil { + return AuthResponse{}, fmt.Errorf("sign token: %w", err) + } + + // Refresh Token (Opaque) + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return AuthResponse{}, fmt.Errorf("generate random string: %w", err) + } + refreshTokenStr := hex.EncodeToString(b) + + _, err = h.Querier.CreateRefreshToken(c.Request().Context(), sqlcdb.CreateRefreshTokenParams{ + UserID: user.ID, + Token: refreshTokenStr, + ExpiresAt: time.Now().Add(7 * 24 * time.Hour), // 7 days + }) + if err != nil { + return AuthResponse{}, fmt.Errorf("create refresh token: %w", err) + } + + return AuthResponse{ + AccessToken: accessToken, + RefreshToken: refreshTokenStr, + }, nil +} + +// Login authenticates a user and returns a token pair. +func (h *Handler) Login(c echo.Context) error { + var req LoginRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, echo.Map{"error": "invalid request format"}) //nolint:wrapcheck + } + + user, err := h.Querier.GetUserByEmail(c.Request().Context(), req.Email) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return c.JSON(http.StatusUnauthorized, echo.Map{"error": "invalid credentials"}) //nolint:wrapcheck + } + return fmt.Errorf("get user: %w", err) + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { + return c.JSON(http.StatusUnauthorized, echo.Map{"error": "invalid credentials"}) //nolint:wrapcheck + } + + resp, err := h.generateTokens(c, user) + if err != nil { + return fmt.Errorf("generate tokens: %w", err) + } + + return c.JSON(http.StatusOK, resp) //nolint:wrapcheck +} + +// RefreshRequest defines the request body for the refresh endpoint. +type RefreshRequest struct { + RefreshToken string `json:"refresh_token" form:"refresh_token" query:"refresh_token"` +} + +// Refresh exchanges a valid refresh token for a new token pair. +func (h *Handler) Refresh(c echo.Context) error { + var req RefreshRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, echo.Map{"error": "invalid request format"}) //nolint:wrapcheck + } + + rt, err := h.Querier.GetRefreshToken(c.Request().Context(), req.RefreshToken) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return c.JSON(http.StatusUnauthorized, echo.Map{"error": "invalid refresh token"}) //nolint:wrapcheck + } + return fmt.Errorf("get refresh token: %w", err) + } + + if rt.RevokedAt.Valid || rt.ExpiresAt.Before(time.Now()) { + return c.JSON(http.StatusUnauthorized, echo.Map{"error": "refresh token is expired or revoked"}) //nolint:wrapcheck + } + + // Revoke the old token (token rotation) + if err := h.Querier.RevokeRefreshToken(c.Request().Context(), rt.Token); err != nil { + return fmt.Errorf("revoke refresh token: %w", err) + } + + // Fetch user details for the new token claims + user, err := h.Querier.GetUserById(c.Request().Context(), rt.UserID) + if err != nil { + return fmt.Errorf("get user for refresh: %w", err) + } + + resp, err := h.generateTokens(c, user) + if err != nil { + return fmt.Errorf("generate refreshed tokens: %w", err) + } + + return c.JSON(http.StatusOK, resp) //nolint:wrapcheck +} + +// Me is a protected endpoint that returns information about the authenticated user. +func (h *Handler) Me(c echo.Context) error { + // The echojwt middleware stores the parsed JWT in the context under the "user" key. + token, ok := c.Get("user").(*jwt.Token) + if !ok { + return c.JSON(http.StatusUnauthorized, echo.Map{"error": "missing token"}) //nolint:wrapcheck + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return c.JSON(http.StatusUnauthorized, echo.Map{"error": "invalid token claims"}) //nolint:wrapcheck + } + + userID := claims["sub"].(string) + + // In a real app we might fetch the user from the database here, but just returning claims is fine to verify auth. + return c.JSON(http.StatusOK, echo.Map{ //nolint:wrapcheck + "user_id": userID, + "claims": claims, + }) +} diff --git a/backend-api/internal/handler/handler.go b/backend-api/internal/handler/handler.go index 8f5fc2b..e41a25e 100644 --- a/backend-api/internal/handler/handler.go +++ b/backend-api/internal/handler/handler.go @@ -4,19 +4,31 @@ package handler import ( "github.com/civiclens/backend-api/internal/config" + "github.com/civiclens/backend-api/internal/db/sqlcdb" + "github.com/civiclens/backend-api/internal/middleware" + "github.com/civiclens/backend-api/internal/repository" "github.com/jackc/pgx/v5/pgxpool" + "github.com/labstack/echo/v4" ) // Handler holds the shared dependencies injected into every HTTP handler. type Handler struct { - DB *pgxpool.Pool - Cfg *config.Config + DB *pgxpool.Pool + Cfg *config.Config + Querier sqlcdb.Querier } // New creates a new Handler with the provided dependencies. func New(db *pgxpool.Pool, cfg *config.Config) *Handler { return &Handler{ - DB: db, - Cfg: cfg, + DB: db, + Cfg: cfg, + Querier: sqlcdb.New(db), } } + +// GetRepo returns a TenantRepository constructed from the request context. +func (h *Handler) GetRepo(c echo.Context) *repository.TenantRepository { + authCtx, _ := middleware.GetAuthContext(c) + return repository.NewTenantRepository(h.Querier, authCtx) +} diff --git a/backend-api/internal/handler/health.go b/backend-api/internal/handler/health.go index b14a8bf..5a99d98 100644 --- a/backend-api/internal/handler/health.go +++ b/backend-api/internal/handler/health.go @@ -1,6 +1,7 @@ package handler import ( + "fmt" "net/http" "time" @@ -17,9 +18,12 @@ type healthResponse struct { // Health handles GET /health. // It returns a simple liveness check payload — useful for container orchestrators. func (h *Handler) Health(c echo.Context) error { - return c.JSON(http.StatusOK, healthResponse{ + if err := c.JSON(http.StatusOK, healthResponse{ Status: "ok", Timestamp: time.Now().UTC(), Service: "civiclens-api", - }) + }); err != nil { + return fmt.Errorf("failed to encode health response: %w", err) + } + return nil } diff --git a/backend-api/internal/handler/role.go b/backend-api/internal/handler/role.go new file mode 100644 index 0000000..23cd4f6 --- /dev/null +++ b/backend-api/internal/handler/role.go @@ -0,0 +1,60 @@ +package handler + +import ( + "fmt" + "net/http" + + "github.com/civiclens/backend-api/internal/db/sqlcdb" + "github.com/labstack/echo/v4" +) + +// CreateRoleRequest defines the request body for creating a new role. +type CreateRoleRequest struct { + Name string `json:"name"` +} + +// CreateRole handles the creation of a new role for the caller's entity. +func (h *Handler) CreateRole(c echo.Context) error { + var req CreateRoleRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, echo.Map{"error": "invalid request format"}) //nolint:wrapcheck + } + + if req.Name == "" { + return c.JSON(http.StatusBadRequest, echo.Map{"error": "name is required"}) //nolint:wrapcheck + } + + repo := h.GetRepo(c) + + entityID, err := repo.GetEntityFilter() + if err != nil { + return c.JSON(http.StatusForbidden, echo.Map{"error": "tenant scope required to create role"}) //nolint:wrapcheck + } + + role, err := h.Querier.CreateRole(c.Request().Context(), sqlcdb.CreateRoleParams{ + EntityID: entityID, + Name: req.Name, + }) + if err != nil { + return fmt.Errorf("create role: %w", err) + } + + return c.JSON(http.StatusCreated, role) //nolint:wrapcheck +} + +// ListRoles fetches all roles belonging to the caller's entity. +func (h *Handler) ListRoles(c echo.Context) error { + repo := h.GetRepo(c) + + roles, err := repo.ListRoles(c.Request().Context()) + if err != nil { + return fmt.Errorf("list roles: %w", err) + } + + // Always return an empty JSON array instead of null if empty + if roles == nil { + roles = []sqlcdb.Role{} + } + + return c.JSON(http.StatusOK, roles) //nolint:wrapcheck +} diff --git a/backend-api/internal/handler/user.go b/backend-api/internal/handler/user.go new file mode 100644 index 0000000..22f3996 --- /dev/null +++ b/backend-api/internal/handler/user.go @@ -0,0 +1,152 @@ +package handler + +import ( + "fmt" + "net/http" + + "github.com/civiclens/backend-api/internal/db/sqlcdb" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v4" + "golang.org/x/crypto/bcrypt" +) + +// CreateUserRequest defines the JSON body for creating a user. +type CreateUserRequest struct { + Email string `json:"email"` + Password string `json:"password"` + FirstName *string `json:"first_name"` + LastName *string `json:"last_name"` + RoleID *uuid.UUID `json:"role_id"` +} + +// UserResponse is the JSON response representing a user without sensitive data. +type UserResponse struct { + ID uuid.UUID `json:"id"` + EntityID *uuid.UUID `json:"entity_id"` + RoleID *uuid.UUID `json:"role_id"` + Email string `json:"email"` + FirstName *string `json:"first_name"` + LastName *string `json:"last_name"` +} + +func mapToUserResponse(u sqlcdb.User) UserResponse { + resp := UserResponse{ + ID: u.ID, + Email: u.Email, + } + + if u.EntityID.Valid { + eid := uuid.UUID(u.EntityID.Bytes) + resp.EntityID = &eid + } + if u.RoleID.Valid { + rid := uuid.UUID(u.RoleID.Bytes) + resp.RoleID = &rid + } + if u.FirstName.Valid { + resp.FirstName = &u.FirstName.String + } + if u.LastName.Valid { + resp.LastName = &u.LastName.String + } + return resp +} + +// CreateUser handles creating a new user under the caller's entity. +func (h *Handler) CreateUser(c echo.Context) error { + var req CreateUserRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, echo.Map{"error": "invalid request format"}) //nolint:wrapcheck + } + + repo := h.GetRepo(c) + + entityID, err := repo.GetEntityFilter() + if err != nil { + return c.JSON(http.StatusForbidden, echo.Map{"error": "tenant scope required to create user"}) //nolint:wrapcheck + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + + params := sqlcdb.CreateUserParams{ + EntityID: entityID, + Email: req.Email, + PasswordHash: string(hash), + IsSuperAdmin: false, + } + + if req.RoleID != nil { + params.RoleID = pgtype.UUID{Bytes: *req.RoleID, Valid: true} + } + if req.FirstName != nil { + params.FirstName = pgtype.Text{String: *req.FirstName, Valid: true} + } + if req.LastName != nil { + params.LastName = pgtype.Text{String: *req.LastName, Valid: true} + } + + user, err := h.Querier.CreateUser(c.Request().Context(), params) + if err != nil { + return fmt.Errorf("create user: %w", err) + } + + return c.JSON(http.StatusCreated, mapToUserResponse(user)) //nolint:wrapcheck +} + +// UpdateUserRoleRequest defines the JSON body for assigning a new role. +type UpdateUserRoleRequest struct { + RoleID uuid.UUID `json:"role_id"` +} + +// UpdateUserRole updates a user's role_id, isolated to the caller's entity. +func (h *Handler) UpdateUserRole(c echo.Context) error { + idStr := c.Param("id") + userID, err := uuid.Parse(idStr) + if err != nil { + return c.JSON(http.StatusBadRequest, echo.Map{"error": "invalid user id"}) //nolint:wrapcheck + } + + var req UpdateUserRoleRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, echo.Map{"error": "invalid request format"}) //nolint:wrapcheck + } + + repo := h.GetRepo(c) + + entityID, err := repo.GetEntityFilter() + if err != nil { + return c.JSON(http.StatusForbidden, echo.Map{"error": "tenant scope required"}) //nolint:wrapcheck + } + + err = h.Querier.UpdateUserRole(c.Request().Context(), sqlcdb.UpdateUserRoleParams{ + ID: userID, + RoleID: pgtype.UUID{Bytes: [16]byte(req.RoleID), Valid: true}, + EntityID: entityID, + }) + if err != nil { + return fmt.Errorf("update user role: %w", err) + } + + return c.NoContent(http.StatusOK) //nolint:wrapcheck +} + +// ListUsers fetches all users under the caller's entity. +func (h *Handler) ListUsers(c echo.Context) error { + repo := h.GetRepo(c) + + users, err := repo.ListUsers(c.Request().Context()) + if err != nil { + return fmt.Errorf("list users: %w", err) + } + + resp := make([]UserResponse, 0, len(users)) + for _, u := range users { + resp = append(resp, mapToUserResponse(u)) + } + + return c.JSON(http.StatusOK, resp) //nolint:wrapcheck +} diff --git a/backend-api/internal/middleware/jwt.go b/backend-api/internal/middleware/jwt.go new file mode 100644 index 0000000..b35d383 --- /dev/null +++ b/backend-api/internal/middleware/jwt.go @@ -0,0 +1,99 @@ +package middleware + +import ( + "net/http" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/labstack/echo/v4" +) + +// AuthContextKey is the key used to store AuthContext in echo.Context. +const AuthContextKey = "auth_context" + +// AuthContext holds strongly-typed claims extracted from the JWT. +type AuthContext struct { + UserID uuid.UUID + EntityID *uuid.UUID + RoleID *uuid.UUID + IsSuperAdmin bool + Permissions []string +} + +// HasPermission checks if the AuthContext contains the required permission, or if the user is a Super Admin. +func (a AuthContext) HasPermission(required string) bool { + if a.IsSuperAdmin { + return true + } + for _, p := range a.Permissions { + if p == required || p == "*" { + return true + } + } + return false +} + +//nolint:cyclop +func parseAuthContext(claims jwt.MapClaims) AuthContext { + authCtx := AuthContext{} + + if sub, ok := claims["sub"].(string); ok { + if id, err := uuid.Parse(sub); err == nil { + authCtx.UserID = id + } + } + + if eid, ok := claims["entity_id"].(string); ok && eid != "" { + if id, err := uuid.Parse(eid); err == nil { + authCtx.EntityID = &id + } + } + + if rid, ok := claims["role_id"].(string); ok && rid != "" { + if id, err := uuid.Parse(rid); err == nil { + authCtx.RoleID = &id + } + } + + if isSuper, ok := claims["is_super_admin"].(bool); ok { + authCtx.IsSuperAdmin = isSuper + } + + if perms, ok := claims["permissions"].([]interface{}); ok { + for _, p := range perms { + if pStr, ok := p.(string); ok { + authCtx.Permissions = append(authCtx.Permissions, pStr) + } + } + } + + return authCtx +} + +// ExtractClaims is a middleware that parses the JWT claims from echojwt and constructs an AuthContext. +func ExtractClaims() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + token, ok := c.Get("user").(*jwt.Token) + if !ok { + return next(c) + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return c.JSON(http.StatusUnauthorized, echo.Map{"error": "invalid token claims"}) //nolint:wrapcheck + } + + authCtx := parseAuthContext(claims) + c.Set(AuthContextKey, authCtx) + return next(c) + } + } +} + +// GetAuthContext is a helper to retrieve the AuthContext from the echo.Context. +func GetAuthContext(c echo.Context) (AuthContext, bool) { + val := c.Get(AuthContextKey) + authCtx, ok := val.(AuthContext) + return authCtx, ok +} diff --git a/backend-api/internal/middleware/rbac.go b/backend-api/internal/middleware/rbac.go new file mode 100644 index 0000000..adf7465 --- /dev/null +++ b/backend-api/internal/middleware/rbac.go @@ -0,0 +1,27 @@ +package middleware + +import ( + "net/http" + + "github.com/labstack/echo/v4" +) + +// RequirePermission verifies that the authenticated user possesses the required atomic permission. +func RequirePermission(required string) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + authCtx, ok := GetAuthContext(c) + if !ok { + // We expect ExtractClaims to run before this. + // If no authCtx, then no token or invalid token was provided. + return c.JSON(http.StatusUnauthorized, echo.Map{"error": "unauthorized"}) //nolint:wrapcheck + } + + if !authCtx.HasPermission(required) { + return c.JSON(http.StatusForbidden, echo.Map{"error": "forbidden: insufficient permissions"}) //nolint:wrapcheck + } + + return next(c) + } + } +} diff --git a/backend-api/internal/repository/repository.go b/backend-api/internal/repository/repository.go new file mode 100644 index 0000000..da9b699 --- /dev/null +++ b/backend-api/internal/repository/repository.go @@ -0,0 +1,86 @@ +// Package repository provides data isolation wrappers and business logic implementations. +package repository + +import ( + "context" + "errors" + "fmt" + + "github.com/civiclens/backend-api/internal/db/sqlcdb" + "github.com/civiclens/backend-api/internal/middleware" + "github.com/jackc/pgx/v5/pgtype" +) + +// ErrUnauthorized is returned when a repository action is attempted without proper tenant context. +var ErrUnauthorized = errors.New("unauthorized: missing or invalid tenant context") + +// TenantRepository wraps sqlcdb.Querier and automatically enforces data isolation based on the AuthContext. +type TenantRepository struct { + db sqlcdb.Querier + auth middleware.AuthContext +} + +// NewTenantRepository creates a new TenantRepository for the given context. +func NewTenantRepository(db sqlcdb.Querier, authCtx middleware.AuthContext) *TenantRepository { + return &TenantRepository{ + db: db, + auth: authCtx, + } +} + +// GetEntityFilter safely returns the entityID to filter by. +// If the user is a SuperAdmin, this could optionally return an invalid UUID to bypass filtering (if queries support it), +// or we can implement separate queries. For strict tenant isolation, we return the user's bound entity. +func (r *TenantRepository) GetEntityFilter() (pgtype.UUID, error) { + if r.auth.IsSuperAdmin { + // SuperAdmins might not be bound to a single entity, or they can view all. + // Handling this depends on the specific SQL query design. + // For now, if they have an EntityID, we return it, otherwise return invalid (null). + if r.auth.EntityID != nil { + return pgtype.UUID{Bytes: *r.auth.EntityID, Valid: true}, nil + } + return pgtype.UUID{Valid: false}, nil + } + + if r.auth.EntityID == nil { + return pgtype.UUID{}, ErrUnauthorized + } + + return pgtype.UUID{Bytes: *r.auth.EntityID, Valid: true}, nil +} + +// Example isolated method (to be expanded in later sprints): +// func (r *TenantRepository) ListReports(ctx context.Context) ([]sqlcdb.Report, error) { +// entityID, err := r.GetEntityFilter() +// if err != nil { +// return nil, err +// } +// // The underlying query should have `WHERE entity_id = $1` OR `($1::uuid IS NULL AND is_super_admin)` +// return r.db.ListReports(ctx, entityID) +// } + +// ListUsers returns users scoped to the caller's entity. +func (r *TenantRepository) ListUsers(ctx context.Context) ([]sqlcdb.User, error) { + entityID, err := r.GetEntityFilter() + if err != nil { + return nil, err + } + users, err := r.db.ListUsersByEntity(ctx, entityID) + if err != nil { + return nil, fmt.Errorf("list users by entity: %w", err) + } + return users, nil +} + +// ListRoles returns roles scoped to the caller's entity. +func (r *TenantRepository) ListRoles(ctx context.Context) ([]sqlcdb.Role, error) { + entityID, err := r.GetEntityFilter() + if err != nil { + return nil, err + } + roles, err := r.db.ListRolesByEntity(ctx, entityID) + if err != nil { + return nil, fmt.Errorf("list roles by entity: %w", err) + } + return roles, nil +} diff --git a/backend-api/internal/router/router.go b/backend-api/internal/router/router.go index 7625f0e..7580f07 100644 --- a/backend-api/internal/router/router.go +++ b/backend-api/internal/router/router.go @@ -6,6 +6,7 @@ import ( "github.com/civiclens/backend-api/internal/handler" "github.com/civiclens/backend-api/internal/middleware" "github.com/jackc/pgx/v5/pgxpool" + echojwt "github.com/labstack/echo-jwt/v4" "github.com/labstack/echo/v4" ) @@ -29,12 +30,34 @@ func New(pool *pgxpool.Pool, cfg *config.Config) *echo.Echo { // API v1 group v1 := e.Group("/api/v1") - _ = v1 // remove once routes are added - - // TODO: mount feature routes here as they are developed - // Example: - // v1.POST("/reports", h.CreateReport) - // v1.GET("/reports/:id", h.GetReport) + // Auth routes (unprotected) + auth := v1.Group("/auth") + auth.POST("/login", h.Login) + auth.POST("/refresh", h.Refresh) + + // Protected routes + // Configure JWT validation middleware + jwtConfig := echojwt.Config{ + SigningKey: []byte(cfg.JWTSecret), + } + + protected := v1.Group("") + protected.Use(echojwt.WithConfig(jwtConfig)) + protected.Use(middleware.ExtractClaims()) + + // Authenticated endpoint to verify tokens + protected.GET("/auth/me", h.Me) + + // Roles + roles := protected.Group("/roles") + roles.GET("", h.ListRoles) + roles.POST("", h.CreateRole, middleware.RequirePermission("roles:write")) + + // Users + users := protected.Group("/users") + users.GET("", h.ListUsers) + users.POST("", h.CreateUser, middleware.RequirePermission("users:write")) + users.PUT("/:id/role", h.UpdateUserRole, middleware.RequirePermission("users:write")) return e } diff --git a/backend-api/sqlc/queries/auth.sql b/backend-api/sqlc/queries/auth.sql new file mode 100644 index 0000000..7768733 --- /dev/null +++ b/backend-api/sqlc/queries/auth.sql @@ -0,0 +1,16 @@ +-- name: GetUserByEmail :one +SELECT * FROM users WHERE email = $1; + +-- name: GetUserById :one +SELECT * FROM users WHERE id = $1; + +-- name: CreateRefreshToken :one +INSERT INTO refresh_tokens (user_id, token, expires_at) +VALUES ($1, $2, $3) +RETURNING *; + +-- name: GetRefreshToken :one +SELECT * FROM refresh_tokens WHERE token = $1; + +-- name: RevokeRefreshToken :exec +UPDATE refresh_tokens SET revoked_at = NOW() WHERE token = $1; diff --git a/backend-api/sqlc/queries/roles.sql b/backend-api/sqlc/queries/roles.sql new file mode 100644 index 0000000..bf6c64b --- /dev/null +++ b/backend-api/sqlc/queries/roles.sql @@ -0,0 +1,9 @@ +-- name: CreateRole :one +INSERT INTO roles (entity_id, name) +VALUES ($1, $2) +RETURNING *; + +-- name: ListRolesByEntity :many +SELECT * FROM roles +WHERE entity_id = $1 +ORDER BY name ASC; diff --git a/backend-api/sqlc/queries/users.sql b/backend-api/sqlc/queries/users.sql new file mode 100644 index 0000000..182aa29 --- /dev/null +++ b/backend-api/sqlc/queries/users.sql @@ -0,0 +1,22 @@ +-- name: CountSuperAdmins :one +SELECT COUNT(*) FROM users WHERE is_super_admin = true; + +-- name: CreateSuperAdmin :one +INSERT INTO users (email, password_hash, is_super_admin) +VALUES ($1, $2, true) +RETURNING *; + +-- name: CreateUser :one +INSERT INTO users (entity_id, role_id, email, password_hash, first_name, last_name, is_super_admin) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING *; + +-- name: UpdateUserRole :exec +UPDATE users +SET role_id = $2, updated_at = NOW() +WHERE id = $1 AND entity_id = $3; + +-- name: ListUsersByEntity :many +SELECT * FROM users +WHERE entity_id = $1 +ORDER BY created_at DESC; diff --git a/backend-api/sqlc/schema/001_multi_tenancy.sql b/backend-api/sqlc/schema/001_multi_tenancy.sql new file mode 100644 index 0000000..6c26824 --- /dev/null +++ b/backend-api/sqlc/schema/001_multi_tenancy.sql @@ -0,0 +1,35 @@ +CREATE TABLE entities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entity_id UUID REFERENCES entities(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entity_id UUID REFERENCES entities(id) ON DELETE SET NULL, + role_id UUID REFERENCES roles(id) ON DELETE SET NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + first_name VARCHAR(255), + last_name VARCHAR(255), + is_super_admin BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token VARCHAR(512) UNIQUE NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMPTZ +); diff --git a/backend-api/sqlc/sqlc.yaml b/backend-api/sqlc/sqlc.yaml index 900d781..51a8e39 100644 --- a/backend-api/sqlc/sqlc.yaml +++ b/backend-api/sqlc/sqlc.yaml @@ -5,12 +5,12 @@ version: "2" sql: - engine: "postgresql" - schema: "sqlc/schema/" - queries: "sqlc/queries/" + schema: "schema/" + queries: "queries/" gen: go: package: "sqlcdb" - out: "internal/db/sqlcdb" + out: "../internal/db/sqlcdb" # Use pgx/v5 for all emit options sql_package: "pgx/v5" emit_json_tags: true diff --git a/backend-api/tests/api/auth_test.go b/backend-api/tests/api/auth_test.go new file mode 100644 index 0000000..0e476cb --- /dev/null +++ b/backend-api/tests/api/auth_test.go @@ -0,0 +1,86 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/civiclens/backend-api/internal/config" + "github.com/civiclens/backend-api/internal/db/sqlcdb" + "github.com/civiclens/backend-api/internal/handler" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" +) + +type MockAuthQuerier struct { + sqlcdb.Querier + GetUserByEmailFunc func(ctx context.Context, email string) (sqlcdb.User, error) + CreateRefreshTokenFunc func(ctx context.Context, arg sqlcdb.CreateRefreshTokenParams) (sqlcdb.RefreshToken, error) +} + +func (m *MockAuthQuerier) GetUserByEmail(ctx context.Context, email string) (sqlcdb.User, error) { + if m.GetUserByEmailFunc != nil { + return m.GetUserByEmailFunc(ctx, email) + } + return sqlcdb.User{}, pgx.ErrNoRows +} + +func (m *MockAuthQuerier) CreateRefreshToken(ctx context.Context, arg sqlcdb.CreateRefreshTokenParams) (sqlcdb.RefreshToken, error) { + if m.CreateRefreshTokenFunc != nil { + return m.CreateRefreshTokenFunc(ctx, arg) + } + return sqlcdb.RefreshToken{}, nil +} + +func newTestEchoWithAuth(mock sqlcdb.Querier) *echo.Echo { + e := echo.New() + cfg := &config.Config{JWTSecret: "test-secret"} + h := handler.New(nil, cfg) + h.Querier = mock + + auth := e.Group("/auth") + auth.POST("/login", h.Login) + auth.POST("/refresh", h.Refresh) + + return e +} + +func TestLogin_Success(t *testing.T) { + password := "mypassword" + hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + + mock := &MockAuthQuerier{ + GetUserByEmailFunc: func(_ context.Context, email string) (sqlcdb.User, error) { + return sqlcdb.User{ + ID: uuid.New(), + Email: email, + PasswordHash: string(hash), + }, nil + }, + } + + e := newTestEchoWithAuth(mock) + + reqBody := map[string]string{"email": "test@test.com", "password": password} + jsonBody, _ := json.Marshal(reqBody) + req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(jsonBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + + var body map[string]string + err := json.Unmarshal(rec.Body.Bytes(), &body) + require.NoError(t, err) + assert.NotEmpty(t, body["access_token"]) + assert.NotEmpty(t, body["refresh_token"]) +} diff --git a/backend-api/tests/api/rbac_test.go b/backend-api/tests/api/rbac_test.go new file mode 100644 index 0000000..4fe9e00 --- /dev/null +++ b/backend-api/tests/api/rbac_test.go @@ -0,0 +1,100 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/civiclens/backend-api/internal/middleware" + "github.com/golang-jwt/jwt/v5" + echojwt "github.com/labstack/echo-jwt/v4" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newRBACTestEcho(secret string) *echo.Echo { + e := echo.New() + + jwtConfig := echojwt.Config{ + SigningKey: []byte(secret), + } + + protected := e.Group("/api") + protected.Use(echojwt.WithConfig(jwtConfig)) + protected.Use(middleware.ExtractClaims()) + + // A dummy endpoint requiring the "reports:write" permission + protected.GET("/admin", func(c echo.Context) error { + return c.JSON(http.StatusOK, echo.Map{"status": "admin_ok"}) + }, middleware.RequirePermission("reports:write")) + + return e +} + +func createTestJWT(secret string, perms []string, isSuperAdmin bool) string { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "123e4567-e89b-12d3-a456-426614174000", + "is_super_admin": isSuperAdmin, + "permissions": perms, + "exp": time.Now().Add(15 * time.Minute).Unix(), + }) + + s, _ := token.SignedString([]byte(secret)) + return s +} + +func TestRBAC_Forbidden(t *testing.T) { + secret := "test-secret" + e := newRBACTestEcho(secret) + + // User with no permissions + tokenStr := createTestJWT(secret, []string{}, false) + + req := httptest.NewRequest(http.MethodGet, "/api/admin", nil) + req.Header.Set(echo.HeaderAuthorization, "Bearer "+tokenStr) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + require.Equal(t, http.StatusForbidden, rec.Code) + + var body map[string]string + err := json.Unmarshal(rec.Body.Bytes(), &body) + require.NoError(t, err) + assert.Equal(t, "forbidden: insufficient permissions", body["error"]) +} + +func TestRBAC_Allowed(t *testing.T) { + secret := "test-secret" + e := newRBACTestEcho(secret) + + // User WITH the required permission + tokenStr := createTestJWT(secret, []string{"reports:write"}, false) + + req := httptest.NewRequest(http.MethodGet, "/api/admin", nil) + req.Header.Set(echo.HeaderAuthorization, "Bearer "+tokenStr) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) +} + +func TestRBAC_SuperAdmin_Allowed(t *testing.T) { + secret := "test-secret" + e := newRBACTestEcho(secret) + + // User is Super Admin (gets all perms via override) + tokenStr := createTestJWT(secret, []string{"*"}, true) + + req := httptest.NewRequest(http.MethodGet, "/api/admin", nil) + req.Header.Set(echo.HeaderAuthorization, "Bearer "+tokenStr) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) +} diff --git a/backend-api/tests/api/role_test.go b/backend-api/tests/api/role_test.go new file mode 100644 index 0000000..b689c53 --- /dev/null +++ b/backend-api/tests/api/role_test.go @@ -0,0 +1,40 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/civiclens/backend-api/internal/handler" + "github.com/civiclens/backend-api/internal/middleware" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" +) + +func TestCreateRole_Validation(t *testing.T) { + e := echo.New() + + reqBody := handler.CreateRoleRequest{Name: ""} + jsonBytes, _ := json.Marshal(reqBody) + + req := httptest.NewRequest(http.MethodPost, "/roles", bytes.NewReader(jsonBytes)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + authCtx := middleware.AuthContext{ + UserID: uuid.New(), + IsSuperAdmin: true, + } + c.Set(middleware.AuthContextKey, authCtx) + + h := &handler.Handler{} // Mock handler without DB since it will fail validation first + + err := h.CreateRole(c) + assert.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "name is required") +} diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 41a9c18..d2d4ab1 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,5 +1,10 @@ import type { NextConfig } from "next"; -const nextConfig: NextConfig = {/* config options here */}; +const nextConfig: NextConfig = { + // Monorepo has a root package-lock.json; keep Turbopack rooted in frontend/ + turbopack: { + root: process.cwd(), + }, +}; export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6429570..d159f93 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1355,9 +1355,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1374,9 +1371,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1393,9 +1387,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1412,9 +1403,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1431,9 +1419,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1450,9 +1435,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1469,9 +1451,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1488,9 +1467,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1507,9 +1483,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1532,9 +1505,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1557,9 +1527,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1582,9 +1549,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1607,9 +1571,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1632,9 +1593,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1657,9 +1615,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1682,9 +1637,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2161,9 +2113,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2180,9 +2129,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2199,9 +2145,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2218,9 +2161,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2435,9 +2375,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2452,9 +2389,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2469,9 +2403,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2486,9 +2417,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2503,9 +2431,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2520,9 +2445,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2537,9 +2459,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2554,9 +2473,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2571,9 +2487,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2588,9 +2501,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2605,9 +2515,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2622,9 +2529,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2639,9 +2543,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2881,9 +2782,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2901,9 +2799,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2921,9 +2816,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2941,9 +2833,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3642,9 +3531,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3659,9 +3545,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3676,9 +3559,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3693,9 +3573,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3710,9 +3587,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3727,9 +3601,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3744,9 +3615,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3761,9 +3629,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3778,9 +3643,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3795,9 +3657,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7210,9 +7069,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7234,9 +7090,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7258,9 +7111,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7282,9 +7132,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a2dc41e..8dcc175 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1,26 +1,11 @@ @import "tailwindcss"; -:root { - --background: #ffffff; - --foreground: #171717; -} - @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - +html, body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + min-height: 100%; } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 78ee65b..0468c87 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,9 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; -import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter"; -import { ThemeProvider } from "@mui/material/styles"; -import CssBaseline from "@mui/material/CssBaseline"; -import theme from "@/theme/theme"; +import ThemeRegistry from "@/theme/ThemeRegistry"; import "./globals.css"; const geistSans = Geist({ @@ -31,12 +28,7 @@ export default function RootLayout({
-