Compare commits

...

4 Commits

Author SHA1 Message Date
Tom Peltonen ca81ec7524 Merge branch 'main' of https://tea.merebox.com/Sku61/kereru-forms 2026-10-24 11:03:02 +11:00
Tom Peltonen 56602612c8 Added foreignkey
Used checkboxes adnd not radio
2026-10-24 10:57:57 +11:00
Tom Peltonen be2cfc8bfd Added OAuth and login 2026-10-24 06:36:02 +11:00
Tom Peltonen 5c9a7a0f64 Initial commt for working copy generated 2026-10-23 23:24:54 +11:00
74 changed files with 20869 additions and 1 deletions

14
.dockerignore 100644
View File

@ -0,0 +1,14 @@
.git
.gitignore
README.md
docs
deploy
**/node_modules
**/dist
**/*.db
**/*.db-shm
**/*.db-wal
**/*.log
**/.env
backend/kereru-forms
backend/kereru-forms.exe

30
.gitignore vendored 100644
View File

@ -0,0 +1,30 @@
# Dependencies
node_modules/
frontend/dist/
backend/kereru-forms
backend/kereru-forms.exe
# Databases
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite3
data/
# Env files (keep examples)
.env
.env.local
!.env.example
# Logs / OS / editors
*.log
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp
# Test / coverage
coverage/
*.out

150
README.md
View File

@ -1,2 +1,150 @@
# kereru-forms
# Kererū Forms
Simple, versioned surveys with a Go + OpenAPI backend and a React frontend.
- **Backend**: Go 1.26, Gin, OpenAPI-generated server (`oapi-codegen`), SQLite via `modernc.org/sqlite` (pure Go), zerolog, OpenTelemetry.
- **Frontend**: React 18, Vite, TypeScript, Tailwind CSS, React Router, Vitest.
- **Deployment**: single container serving both the API and the built SPA, plus Docker Compose, Podman and Kubernetes manifests.
## Quick start
### 1. Backend
```bash
cd backend
go mod download
go run ./cmd/kereru-forms --listen-addr 0.0.0.0 --port 8080
```
The API listens on <http://localhost:8080>. The SQLite database (`kereru.db`) and its schema are created automatically.
### 2. Frontend
```bash
cd frontend
npm install
npm run dev
```
Open <http://localhost:5173>. Vite proxies `/api/*` to `http://localhost:8080/api/*`. The API is also available without the `/api` prefix for probes and direct clients.
With no OIDC provider configured, management sign-in uses the local development password `admin` and grants both admin and view roles. Change `auth.localPassword` (or `KERERU_AUTH_LOCAL_PASSWORD`) before exposing the service.
## Configuration
Settings are resolved in this order (highest wins):
1. CLI flags
2. Environment variables
3. `.env` file next to the executable, then the working directory
4. YAML/JSON file passed with `--conf`
5. Built-in defaults
```bash
kereru-forms --conf ./config/config.example.yaml
```
| Flag | Environment variable | Default |
| --- | --- | --- |
| `--conf` | – | – |
| `--log-level` | `KERERU_LOG_LEVEL` | `info` |
| `--log-format` | `KERERU_LOG_FORMAT` | `console` |
| `--listen-addr` | `KERERU_LISTEN_ADDR` | `0.0.0.0` |
| `--listen-addr6` | `KERERU_LISTEN_ADDR6` | – |
| `--port` | `KERERU_PORT` | `8080` |
| `--db-driver` | `KERERU_DB_DRIVER` | `sqlite` |
| `--db-dsn` | `KERERU_DB_DSN` | `file:kereru.db?...` |
| `--static-dir` | `KERERU_STATIC_DIR` | – |
| – | `KERERU_TELEMETRY_ENABLED` | `false` |
| – | `OTEL_EXPORTER_OTLP_ENDPOINT` | `localhost:4318` |
| – | `KERERU_CORS_ORIGINS` | `http://localhost:5173` |
| – | `KERERU_ENVIRONMENT` | `development` |
| – | `KERERU_AUTH_LOCAL_PASSWORD` | `admin` |
| – | `KERERU_AUTH_LOCAL_TOKEN_SECRET` | random per process |
| – | `KERERU_AUTH_LOCAL_TOKEN_TTL_HOURS` | `8` |
| – | `KERERU_AUTH_REQUIRE_SUBMISSION_AUTH` | `false` |
| – | `KERERU_OIDC_ISSUER_URL` | – |
| – | `KERERU_OIDC_CLIENT_ID` | – |
| – | `KERERU_OIDC_SCOPES` | `openid,profile,email` |
| – | `KERERU_OIDC_ROLE_CLAIM` | `roles` |
| – | `KERERU_OIDC_ADMIN_ROLE` | `admin` |
| – | `KERERU_OIDC_VIEW_ROLE` | `view` |
Both an IPv4 and an IPv6 listener may be configured at the same time. Set `--listen-addr ""` to disable the IPv4 listener.
Frontend build-time setting: `VITE_API_BASE_URL` (defaults to `/api`; set to `http://localhost:8080` when the frontend is hosted separately).
Sample files live in [`config/`](config).
## Authentication
Management is always protected. Two modes are supported:
- **Local password fallback** when `auth.oidc.issuerUrl` is empty. The configured password grants both `admin` and `view` roles, which is intended for local development and testing.
- **OAuth/OIDC** when an issuer is configured. The React SPA uses the authorization-code flow with PKCE and presents the provider's ID token as a bearer token. Configure a **public SPA client** in Okta/Keycloak and register the frontend callback URL, for example `http://localhost:5173/auth/callback` in development or `https://forms.example.com/auth/callback` in production.
The backend validates the ID token signature, issuer, audience, expiry and configured role claim. Role mappings are configurable:
- `adminRole` (`admin` by default) is required for form create/update/delete, activation, versioning and access-code management.
- `viewRole` (`view` by default) is required to list or read submissions. An admin-only token is deliberately denied submission viewing.
- `requireSubmissionAuth: true` requires any authenticated user (no role) to list/open forms and submit responses.
Common role claim examples: `roles`, `groups`, or `realm_access.roles` for Keycloak. Set `localTokenSecret` to a stable value if local-password tokens must survive restarts or multiple replicas.
Local tokens and OIDC ID tokens are kept in browser `sessionStorage` and sent as `Authorization: Bearer …`.
## API
The contract is defined in [`backend/openapi/openapi.yaml`](backend/openapi/openapi.yaml) and the server stubs are generated with:
```bash
cd backend
oapi-codegen -config oapi-codegen.yaml openapi/openapi.yaml
```
Interactive API documentation is available at <http://localhost:5173/docs>. Swagger UI and the OpenAPI specification are bundled with the frontend, so the docs work without an internet connection. Forms support optional linked PDFs, digital signatures, external reference identifiers and offline PDF uploads.
Key routes:
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/health` | Health and version |
| `GET` | `/auth/config` | Browser authentication mode / OIDC details |
| `POST` | `/auth/login` | Local password login fallback |
| `GET` | `/auth/me` | Current user and role flags |
| `POST` | `/auth/logout` | Stateless logout acknowledgement |
| `GET`/`POST` | `/forms` | List / create forms (create requires admin) |
| `GET`/`PUT`/`DELETE` | `/forms/{formId}` | Read / update / delete a form version |
| `POST` | `/forms/{formId}/activate` | Activate a form version |
| `POST` | `/forms/{formId}/inactivate` | Inactivate a form version |
| `POST` | `/forms/{formId}/versions` | Clone into a new version |
| `GET` | `/forms/{formId}/pdf` | Printable PDF of the definition |
| `GET`/`POST` | `/forms/{formId}/submissions` | List / submit responses |
| `GET` | `/submissions/{submissionId}` | Submission details |
| `GET` | `/submissions/{submissionId}/offline-pdf` | Download an uploaded offline PDF |
| `GET`/`POST` | `/forms/{formId}/access-codes` | List / generate single-use codes |
The specification is served at `/openapi.yaml` in development and production builds.
## Tests
```bash
cd backend && go test ./...
cd frontend && npm test
```
## Docker / Podman / Kubernetes
See [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) and [`deploy/k8s.yaml`](deploy/k8s.yaml).
## Documentation
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — components, data model, request flow, extension points.
- [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) — development, Docker, Podman and Kubernetes instructions.
## Security note
Management endpoints require a bearer JWT. In local-password mode change the default `admin` password and set a stable `localTokenSecret`;
in OIDC mode enforce MFA and role assignment in the identity provider. Use HTTPS in production, keep `corsOrigins` narrow,
and enable `requireSubmissionAuth` if forms must not be publicly accessible. Access codes protect
individual form submissions, not administration.

View File

@ -0,0 +1,134 @@
// Command kereru-forms serves the Kererū Forms HTTP API.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"kereru.forms/internal/api"
"kereru.forms/internal/auth"
"kereru.forms/internal/config"
"kereru.forms/internal/database"
"kereru.forms/internal/logging"
"kereru.forms/internal/telemetry"
)
// version is overridden at build time with -ldflags "-X main.version=...".
var version = "1.0.0"
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "kereru-forms: %v\n", err)
os.Exit(1)
}
}
func run() error {
cfg, err := config.Load(os.Args[1:])
if err != nil {
return err
}
logger := logging.New(cfg.LogLevel, cfg.LogFormat, cfg.Environment)
logger.Info().
Str("version", version).
Str("environment", cfg.Environment).
Strs("listen", cfg.Addresses()).
Str("database_driver", cfg.DBDriver).
Msg("starting kereru-forms")
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
tel, err := telemetry.Setup(ctx, telemetry.Options{
Enabled: cfg.TelemetryEnabled,
Endpoint: cfg.OTLPEndpoint,
ServiceName: "kereru-forms",
Environment: cfg.Environment,
Version: version,
})
if err != nil {
return fmt.Errorf("setup telemetry: %w", err)
}
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := tel.Shutdown(shutdownCtx); err != nil {
logger.Warn().Err(err).Msg("telemetry shutdown")
}
}()
store, err := database.Open(cfg.DBDriver, cfg.DBDSN)
if err != nil {
return fmt.Errorf("open database: %w", err)
}
defer func() {
if err := store.Close(); err != nil {
logger.Warn().Err(err).Msg("database close")
}
}()
authService, err := auth.New(ctx, cfg.Auth)
if err != nil {
return fmt.Errorf("setup authentication: %w", err)
}
logger.Info().
Bool("oidc", cfg.Auth.OIDCEnabled()).
Bool("submission_auth_required", cfg.Auth.RequireSubmissionAuth).
Msg("authentication configured")
if !cfg.Auth.OIDCEnabled() && cfg.Auth.LocalPassword == "admin" {
logger.Warn().Msg("using the default local password; set KERERU_AUTH_LOCAL_PASSWORD or auth.localPassword")
}
server := api.NewServer(store, logger, version, authService)
router := api.NewRouter(server, logger, api.RouterOptions{
CORSOrigins: cfg.CORSOrigins,
ServiceName: "kereru-forms",
StaticDir: cfg.StaticDir,
})
addrs := cfg.Addresses()
servers := make([]*http.Server, 0, len(addrs))
errCh := make(chan error, len(addrs))
for _, addr := range addrs {
srv := &http.Server{
Addr: addr,
Handler: router,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
servers = append(servers, srv)
go func(srv *http.Server) {
logger.Info().Str("addr", srv.Addr).Msg("http server listening")
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- fmt.Errorf("listen on %s: %w", srv.Addr, err)
}
}(srv)
}
select {
case <-ctx.Done():
logger.Info().Msg("shutdown signal received")
case err := <-errCh:
logger.Error().Err(err).Msg("http server failed")
stop()
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
for _, srv := range servers {
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Warn().Err(err).Str("addr", srv.Addr).Msg("http server shutdown")
}
}
logger.Info().Msg("kereru-forms stopped")
return nil
}

82
backend/go.mod 100644
View File

@ -0,0 +1,82 @@
module kereru.forms
go 1.26.0
require (
github.com/getkin/kin-openapi v0.152.0
github.com/gin-gonic/gin v1.12.0
github.com/go-pdf/fpdf v0.9.0
github.com/google/uuid v1.6.0
github.com/oapi-codegen/runtime v1.8.0
github.com/rs/zerolog v1.35.1
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.72.0
go.opentelemetry.io/otel v1.47.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.47.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0
go.opentelemetry.io/otel/sdk v1.47.0
go.opentelemetry.io/otel/sdk/metric v1.47.0
go.opentelemetry.io/otel/trace v1.47.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.61.0
)
require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.4 // indirect
github.com/bytedance/sonic/loader v0.5.2 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/coreos/go-oidc/v3 v3.21.0 // indirect
github.com/dustin/go-humanize v1.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/gin-contrib/sse v1.1.2 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
github.com/go-playground/locales v0.14.2 // indirect
github.com/go-playground/universal-translator v0.18.2 // indirect
github.com/go-playground/validator/v10 v10.30.5 // indirect
github.com/goccy/go-json v0.11.2 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/oasdiff/yaml v0.1.1 // indirect
github.com/oasdiff/yaml3 v0.0.14 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.63.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.2 // indirect
go.mongodb.org/mongo-driver/v2 v2.9.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 // indirect
go.opentelemetry.io/otel/log v1.47.0 // indirect
go.opentelemetry.io/otel/metric v1.47.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
golang.org/x/arch v0.31.0 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect
google.golang.org/grpc v1.83.2 // indirect
google.golang.org/protobuf v1.36.12 // indirect
modernc.org/libc v1.79.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)

236
backend/go.sum 100644
View File

@ -0,0 +1,236 @@
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.4 h1:FgtV/4aBHpla9AxuMpuuzVUpa/Cf3izufkxNmnEzdI8=
github.com/bytedance/sonic v1.15.4/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc=
github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo=
github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
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/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
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/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/getkin/kin-openapi v0.152.0 h1:XRVo77lMRyOGMHf1paMu9lxX3wzuQDZeprssZJnz/x8=
github.com/getkin/kin-openapi v0.152.0/go.mod h1:1+BHDzstro+P5CKtPy1X4PfofnFgmRe6uvMy9+r9fKY=
github.com/gin-contrib/sse v1.1.2 h1:MU2fgl1RrdYTMcgJLtz2kJF+vPg3xrqaaKfUUU18tCo=
github.com/gin-contrib/sse v1.1.2/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/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-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.2 h1:d8UmcrM6Nip0hfGZKLGpAvZH37XB4TS0xzK9B56YNCY=
github.com/go-playground/locales v0.14.2/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dmcjwYAJhmX8mPZRW4rAVU=
github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk=
github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ=
github.com/goccy/go-json v0.11.2 h1:jdZv93Tt4ioR8yW1CoNsvSxrcZlCXAUU1aZXN7gpXUA=
github.com/goccy/go-json v0.11.2/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
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/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
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/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
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.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
github.com/oapi-codegen/runtime v1.8.0 h1:RJzEgQSJQADEPaG1cYuSCKYai/cCVRE2xnkrqhJs/3c=
github.com/oapi-codegen/runtime v1.8.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.63.0 h1:LIFGHI4PFUhhw2dDD1ARHdCff143ffMHwZtbnbuJ78A=
github.com/quic-go/quic-go v0.63.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.9.1 h1:jewiFs2m1/VOQp8qhFshX6hWZ+EAXDhZHXExAUMcOgQ=
go.mongodb.org/mongo-driver/v2 v2.9.1/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA=
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/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.72.0 h1:Vgm7vrC26vvecffpDcy2UINqBvASUg5qkfZle3Yrkug=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.72.0/go.mod h1:zgCChxie/dA3qG7IgZGswax6voxkOR4+33msLT/RgNg=
go.opentelemetry.io/contrib/propagators/b3 v1.47.0 h1:vlKDmtjhe1R0d7kdov/+KVTKWOKeGLyohB+iXoDPRMQ=
go.opentelemetry.io/contrib/propagators/b3 v1.47.0/go.mod h1:0zRiXIfXU30PKYMsdbqYbGK8iHF8KqyPi0qdXT2wAuE=
go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM=
go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.47.0 h1:7kszRN4Ze5GeAkUnBjBSUIunVBMA+mZIG04auueqguM=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.47.0/go.mod h1:CLzakYy8jOdkBfcveCWYkeKJxujcF4xPhh2Z7IfE98U=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSdDdqt5h7Zw9YWmRlWRhI0jd54=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0/go.mod h1:Ao2mz688LH/tFf0yMAenidq6k2YNSx6SIY2q6jDACck=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 h1:aXZXsZ012wOgVkqaiQv+Z/d8V0xKjmg70F6m4CE94lc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0/go.mod h1:ziu1gUIJhAaxM3EV1e3Ralk5AAyL1UOYz3lJJ4VSI38=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 h1:N3YQCxjxQ/bMjyc3heladfRm9t9RTksGQH8z4w6yU/0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0/go.mod h1:Mp8HOFqcaUyypCuGv9IhDdTHnJ56lSudSHMd+pVSCEA=
go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI=
go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw=
go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY=
go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU=
go.opentelemetry.io/otel/metric/x v0.69.0 h1:DjRLr15H83v+hCW7JA9NoJvOkYTtmq5YoDRbe9deYpM=
go.opentelemetry.io/otel/metric/x v0.69.0/go.mod h1:uVvsMPMFFyj/HUQfrUnH3JjnOQ1dwFDorgFLRBasM0k=
go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I=
go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0=
go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw=
go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw=
go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM=
go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
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/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/arch v0.31.0 h1:22MlEb14/O/EPCYHFxsDdv5TuLD5dMjT5e2QeJw4ULk=
golang.org/x/arch v0.31.0/go.mod h1:KcJSod3cqT2dKcjBxqTyGfbumNikqU9p5tHJinPJnuY=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
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-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE=
google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.61.0 h1:sQi63oLQ9uquAUA4CzOisH35Q3pEszkPBuHM0tFL8CY=
modernc.org/sqlite v1.61.0/go.mod h1:f4gr5tbpx2umklaT/QVtbi/50hNdM+4ng+2zMo8KXJ0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@ -0,0 +1,641 @@
package api
import (
"bytes"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/rs/zerolog"
"kereru.forms/internal/database"
)
func newTestRouter(t *testing.T) (*gin.Engine, database.Store) {
t.Helper()
path := filepath.Join(t.TempDir(), "api-test.db")
store, err := database.Open("sqlite", "file:"+path+"?_pragma=foreign_keys(1)")
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
server := NewServer(store, zerolog.Nop(), "test-version")
router := NewRouter(server, zerolog.Nop(), RouterOptions{CORSOrigins: []string{"http://localhost:5173"}})
return router, store
}
func doRequest(t *testing.T, router *gin.Engine, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != nil {
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal request: %v", err)
}
reader = bytes.NewReader(payload)
}
req := httptest.NewRequest(method, path, reader)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("User-Agent", "kereru-test-agent")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func decode[T any](t *testing.T, w *httptest.ResponseRecorder) T {
t.Helper()
var out T
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("decode response %q: %v", w.Body.String(), err)
}
return out
}
func boolPtr(b bool) *bool { return &b }
func f32Ptr(f float32) *float32 { return &f }
func strPtr(s string) *string { return &s }
func createSampleForm(t *testing.T, router *gin.Engine) Form {
t.Helper()
options := []QuestionOption{
{Value: "red", Label: "Red"},
{Value: "blue", Label: "Blue"},
}
body := CreateFormRequest{
Title: "Customer feedback",
Description: strPtr("A short survey"),
Introduction: strPtr("Thanks for helping us improve."),
RequiresAccessCode: boolPtr(true),
LogoUrl: strPtr("https://example.com/logo.png"),
Questions: []QuestionInput{
{Key: "name", Label: "Your name", Type: Text, Required: true},
{Key: "happy", Label: "Are you happy?", Type: Boolean, Required: true},
{Key: "score", Label: "Score", Type: Number, MinValue: f32Ptr(1), MaxValue: f32Ptr(10)},
{Key: "colour", Label: "Favourite colour", Type: Select, Options: &options},
{Key: "when", Label: "Date", Type: Date},
},
}
w := doRequest(t, router, http.MethodPost, "/forms", body)
if w.Code != http.StatusCreated {
t.Fatalf("create form: status %d body %s", w.Code, w.Body.String())
}
return decode[Form](t, w)
}
func TestHealth(t *testing.T) {
router, _ := newTestRouter(t)
for _, path := range []string{"/health", "/api/health"} {
w := doRequest(t, router, http.MethodGet, path, nil)
if w.Code != http.StatusOK {
t.Fatalf("health %s: status %d", path, w.Code)
}
health := decode[Health](t, w)
if health.Status != "ok" || health.Version != "test-version" {
t.Fatalf("unexpected health: %+v", health)
}
if health.UptimeSeconds == nil {
t.Fatal("uptimeSeconds should be present")
}
}
}
func TestFormLifecycle(t *testing.T) {
router, _ := newTestRouter(t)
form := createSampleForm(t, router)
if form.Version != 1 || !form.IsActive || len(form.Questions) != 5 {
t.Fatalf("unexpected form: %+v", form)
}
w := doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String(), nil)
if w.Code != http.StatusOK {
t.Fatalf("get form: %d %s", w.Code, w.Body.String())
}
w = doRequest(t, router, http.MethodGet, "/forms", nil)
if w.Code != http.StatusOK {
t.Fatalf("list forms: %d", w.Code)
}
forms := decode[[]FormSummary](t, w)
if len(forms) != 1 || forms[0].QuestionCount != 5 {
t.Fatalf("unexpected forms: %+v", forms)
}
if forms[0].LogoUrl == nil || *forms[0].LogoUrl != "https://example.com/logo.png" {
t.Fatalf("logo url missing from summary: %+v", forms[0])
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/access-codes",
CreateAccessCodesRequest{Count: 2, Prefix: strPtr("demo")})
if w.Code != http.StatusCreated {
t.Fatalf("create codes: %d %s", w.Code, w.Body.String())
}
codes := decode[[]AccessCode](t, w)
if len(codes) != 2 || !strings.HasPrefix(codes[0].Code, "DEMO-") {
t.Fatalf("unexpected codes: %+v", codes)
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/inactivate", nil)
if w.Code != http.StatusOK {
t.Fatalf("inactivate: %d %s", w.Code, w.Body.String())
}
if inactive := decode[Form](t, w); inactive.IsActive {
t.Fatal("form should be inactive")
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/activate", nil)
if w.Code != http.StatusOK {
t.Fatalf("activate: %d", w.Code)
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/versions", nil)
if w.Code != http.StatusCreated {
t.Fatalf("create version: %d %s", w.Code, w.Body.String())
}
v2 := decode[Form](t, w)
if v2.Version != 2 || v2.FormGroupId != form.FormGroupId {
t.Fatalf("unexpected version: %+v", v2)
}
if v2.LogoUrl == nil || *v2.LogoUrl != "https://example.com/logo.png" {
t.Fatalf("new version should keep the logo: %+v", v2)
}
w = doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String(), nil)
old := decode[Form](t, w)
if old.IsActive {
t.Fatal("previous version should be inactive")
}
}
func TestSubmitFlow(t *testing.T) {
router, _ := newTestRouter(t)
form := createSampleForm(t, router)
byKey := map[string]Question{}
for _, q := range form.Questions {
byKey[q.Key] = q
}
answers := []Answer{
{QuestionId: byKey["name"].Id, Value: "Ada"},
{QuestionId: byKey["happy"].Id, Value: true},
{QuestionId: byKey["score"].Id, Value: 8},
{QuestionId: byKey["colour"].Id, Value: "blue"},
{QuestionId: byKey["when"].Id, Value: "2026-10-23"},
}
w := doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/access-codes",
CreateAccessCodesRequest{Count: 1})
if w.Code != http.StatusCreated {
t.Fatalf("create code: %d", w.Code)
}
code := decode[[]AccessCode](t, w)[0].Code
submit := SubmitFormRequest{
UserId: "ada@example.com",
UserIdType: Email,
Answers: answers,
Tracking: &ClientTracking{Locale: strPtr("en-NZ")},
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit)
if w.Code != http.StatusBadRequest {
t.Fatalf("missing access code should be 400, got %d %s", w.Code, w.Body.String())
}
submit.AccessCode = &code
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit)
if w.Code != http.StatusCreated {
t.Fatalf("submit: %d %s", w.Code, w.Body.String())
}
submission := decode[Submission](t, w)
if submission.FormVersion != 1 || len(submission.Answers) != 5 {
t.Fatalf("unexpected submission: %+v", submission)
}
var storedDate string
for _, answer := range submission.Answers {
if answer.QuestionKey == "when" {
storedDate, _ = answer.Value.(string)
}
}
if storedDate != "2026-10-23" {
t.Fatalf("date answer not stored correctly: %+v", submission.Answers)
}
if submission.Tracking == nil || submission.Tracking.UserAgent == nil ||
*submission.Tracking.UserAgent != "kereru-test-agent" {
t.Fatalf("server-side tracking missing: %+v", submission.Tracking)
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit)
if w.Code != http.StatusConflict {
t.Fatalf("reused code should be 409, got %d %s", w.Code, w.Body.String())
}
w = doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String()+"/submissions", nil)
if w.Code != http.StatusOK {
t.Fatalf("list submissions: %d", w.Code)
}
subs := decode[[]SubmissionSummary](t, w)
if len(subs) != 1 || subs[0].Id != submission.Id {
t.Fatalf("unexpected submissions: %+v", subs)
}
w = doRequest(t, router, http.MethodGet, "/submissions/"+submission.Id.String(), nil)
if w.Code != http.StatusOK {
t.Fatalf("get submission: %d", w.Code)
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/inactivate", nil)
if w.Code != http.StatusOK {
t.Fatalf("inactivate: %d", w.Code)
}
second := SubmitFormRequest{
UserId: "ada@example.com",
UserIdType: Email,
Answers: answers,
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", second)
if w.Code != http.StatusGone {
t.Fatalf("inactive form should be 410, got %d %s", w.Code, w.Body.String())
}
}
func TestSubmitValidation(t *testing.T) {
router, _ := newTestRouter(t)
form := createSampleForm(t, router)
byKey := map[string]Question{}
for _, q := range form.Questions {
byKey[q.Key] = q
}
cases := []struct {
name string
answers []Answer
}{
{
name: "missing required",
answers: []Answer{{QuestionId: byKey["score"].Id, Value: 5}},
},
{
name: "number out of range",
answers: []Answer{
{QuestionId: byKey["name"].Id, Value: "Ada"},
{QuestionId: byKey["happy"].Id, Value: true},
{QuestionId: byKey["score"].Id, Value: 42},
},
},
{
name: "bad select option",
answers: []Answer{
{QuestionId: byKey["name"].Id, Value: "Ada"},
{QuestionId: byKey["happy"].Id, Value: true},
{QuestionId: byKey["colour"].Id, Value: "green"},
},
},
{
name: "bad date",
answers: []Answer{
{QuestionId: byKey["name"].Id, Value: "Ada"},
{QuestionId: byKey["happy"].Id, Value: true},
{QuestionId: byKey["when"].Id, Value: "23/10/2026"},
},
},
{
name: "wrong type",
answers: []Answer{
{QuestionId: byKey["name"].Id, Value: "Ada"},
{QuestionId: byKey["happy"].Id, Value: "yes"},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions",
SubmitFormRequest{UserId: "ada@example.com", UserIdType: Email, Answers: tc.answers})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d %s", w.Code, w.Body.String())
}
})
}
}
func TestCreateFormValidation(t *testing.T) {
router, _ := newTestRouter(t)
options := []QuestionOption{{Value: "a", Label: "A"}}
cases := []CreateFormRequest{
{Title: "", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Text}}},
{Title: "Test", Questions: []QuestionInput{{Key: "1bad", Label: "A", Type: Text}}},
{Title: "Test", Questions: []QuestionInput{{Key: "dup", Label: "A", Type: Text}, {Key: "dup", Label: "B", Type: Text}}},
{Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: "datetime"}}},
{Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Select}}},
{Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Number, MinValue: f32Ptr(10), MaxValue: f32Ptr(1)}}},
{Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Select, Options: &options}, {Key: "a", Label: "B", Type: Text}}},
{Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Text}}, LogoUrl: strPtr("javascript:alert(1)")},
{Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Text}}, LogoUrl: strPtr("not a url")},
}
for i, body := range cases {
w := doRequest(t, router, http.MethodPost, "/forms", body)
if w.Code != http.StatusBadRequest {
t.Fatalf("case %d: expected 400, got %d %s", i, w.Code, w.Body.String())
}
}
}
func TestPDFAndCORS(t *testing.T) {
router, _ := newTestRouter(t)
form := createSampleForm(t, router)
w := doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String()+"/pdf", nil)
if w.Code != http.StatusOK {
t.Fatalf("pdf: %d %s", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); ct != "application/pdf" {
t.Fatalf("content type = %q", ct)
}
if !bytes.HasPrefix(w.Body.Bytes(), []byte("%PDF")) {
t.Fatal("response is not a PDF")
}
req := httptest.NewRequest(http.MethodOptions, "/forms", nil)
req.Header.Set("Origin", "http://localhost:5173")
req.Header.Set("Access-Control-Request-Method", "POST")
corsW := httptest.NewRecorder()
router.ServeHTTP(corsW, req)
if corsW.Code != http.StatusNoContent {
t.Fatalf("preflight status = %d", corsW.Code)
}
if got := corsW.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:5173" {
t.Fatalf("allow origin = %q", got)
}
}
func TestNotFound(t *testing.T) {
router, _ := newTestRouter(t)
missing := uuid.NewString()
for _, tc := range []struct {
method string
path string
}{
{http.MethodGet, "/forms/" + missing},
{http.MethodDelete, "/forms/" + missing},
{http.MethodPost, "/forms/" + missing + "/inactivate"},
{http.MethodGet, "/forms/" + missing + "/submissions"},
{http.MethodGet, "/submissions/" + missing},
{http.MethodGet, "/submissions/" + missing + "/offline-pdf"},
} {
w := doRequest(t, router, tc.method, tc.path, nil)
if w.Code != http.StatusNotFound {
t.Fatalf("%s %s: expected 404, got %d", tc.method, tc.path, w.Code)
}
}
}
func createSignatureForm(t *testing.T, router *gin.Engine) Form {
t.Helper()
body := CreateFormRequest{
Title: "Signed declaration",
SignatureRequired: boolPtr(true),
Questions: []QuestionInput{
{Key: "name", Label: "Your name", Type: Text, Required: true},
},
}
w := doRequest(t, router, http.MethodPost, "/forms", body)
if w.Code != http.StatusCreated {
t.Fatalf("create signature form: %d %s", w.Code, w.Body.String())
}
return decode[Form](t, w)
}
func pngDataURL() string {
data := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(data)
}
func pdfDataURL(data []byte) string {
return "data:application/pdf;base64," + base64.StdEncoding.EncodeToString(data)
}
func TestUpdateFormCreatesVersion(t *testing.T) {
router, _ := newTestRouter(t)
form := createSampleForm(t, router)
update := UpdateFormRequest{
Title: "Customer feedback v2",
PdfUrl: strPtr("https://example.com/prebuilt.pdf"),
ExternalReferenceRequired: boolPtr(true),
Questions: []QuestionInput{
{Key: "name", Label: "Your name", Type: Text, Required: true},
},
}
w := doRequest(t, router, http.MethodPut, "/forms/"+form.Id.String(), update)
if w.Code != http.StatusOK {
t.Fatalf("update form: %d %s", w.Code, w.Body.String())
}
updated := decode[Form](t, w)
if updated.Id == form.Id {
t.Fatal("editing should create a new form id")
}
if updated.Version != 2 || updated.FormGroupId != form.FormGroupId {
t.Fatalf("unexpected versioned form: %+v", updated)
}
if updated.PdfUrl == nil || *updated.PdfUrl != "https://example.com/prebuilt.pdf" {
t.Fatalf("pdf url not saved: %+v", updated.PdfUrl)
}
if updated.SignatureRequired == nil || *updated.SignatureRequired {
t.Fatalf("signature flag should preserve false: %+v", updated.SignatureRequired)
}
if updated.ExternalReferenceRequired == nil || !*updated.ExternalReferenceRequired ||
updated.ExternalReferenceLabel == nil || *updated.ExternalReferenceLabel != "External reference" {
t.Fatalf("external reference was not enabled with a default label: %+v", updated)
}
if len(updated.Questions) != 1 || updated.Questions[0].Id.String() == "" {
t.Fatalf("expected a fresh question: %+v", updated.Questions)
}
w = doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String(), nil)
if w.Code != http.StatusOK {
t.Fatalf("get old form: %d", w.Code)
}
old := decode[Form](t, w)
if old.IsActive {
t.Fatal("previous version should be inactive")
}
w = doRequest(t, router, http.MethodGet, "/forms?includeInactive=true", nil)
if w.Code != http.StatusOK {
t.Fatalf("list forms: %d", w.Code)
}
forms := decode[[]FormSummary](t, w)
if len(forms) != 2 {
t.Fatalf("expected two versions, got %+v", forms)
}
}
func TestExternalReference(t *testing.T) {
router, _ := newTestRouter(t)
body := CreateFormRequest{
Title: "Invoice approval",
ExternalReferenceRequired: boolPtr(true),
ExternalReferenceLabel: strPtr("Invoice number"),
Questions: []QuestionInput{
{Key: "note", Label: "Note", Type: Text, Required: true},
},
}
w := doRequest(t, router, http.MethodPost, "/forms", body)
if w.Code != http.StatusCreated {
t.Fatalf("create form: %d %s", w.Code, w.Body.String())
}
form := decode[Form](t, w)
if form.ExternalReferenceRequired == nil || !*form.ExternalReferenceRequired ||
form.ExternalReferenceLabel == nil || *form.ExternalReferenceLabel != "Invoice number" {
t.Fatalf("unexpected external reference config: %+v", form)
}
submit := SubmitFormRequest{
UserId: "ada@example.com",
UserIdType: Email,
Answers: []Answer{{
QuestionId: form.Questions[0].Id,
Value: "Approved",
}},
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit)
if w.Code != http.StatusBadRequest {
t.Fatalf("missing external reference should be 400, got %d %s", w.Code, w.Body.String())
}
submit.ExternalReference = strPtr("INV-42")
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit)
if w.Code != http.StatusCreated {
t.Fatalf("submit with reference: %d %s", w.Code, w.Body.String())
}
submission := decode[Submission](t, w)
if submission.ExternalReference == nil || *submission.ExternalReference != "INV-42" {
t.Fatalf("external reference not returned: %+v", submission.ExternalReference)
}
w = doRequest(t, router, http.MethodGet, "/submissions/"+submission.Id.String(), nil)
if w.Code != http.StatusOK {
t.Fatalf("get submission: %d %s", w.Code, w.Body.String())
}
stored := decode[Submission](t, w)
if stored.ExternalReference == nil || *stored.ExternalReference != "INV-42" {
t.Fatalf("external reference not persisted: %+v", stored.ExternalReference)
}
w = doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String()+"/submissions", nil)
if w.Code != http.StatusOK {
t.Fatalf("list submissions: %d %s", w.Code, w.Body.String())
}
summaries := decode[[]SubmissionSummary](t, w)
if len(summaries) != 1 || summaries[0].ExternalReference == nil ||
*summaries[0].ExternalReference != "INV-42" {
t.Fatalf("reference missing from summary: %+v", summaries)
}
}
func TestSignatureSubmission(t *testing.T) {
router, _ := newTestRouter(t)
form := createSignatureForm(t, router)
questionID := form.Questions[0].Id
base := SubmitFormRequest{
UserId: "ada@example.com",
UserIdType: Email,
Answers: []Answer{{QuestionId: questionID, Value: "Ada"}},
}
w := doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", base)
if w.Code != http.StatusBadRequest {
t.Fatalf("missing signature should be 400, got %d %s", w.Code, w.Body.String())
}
base.Signature = &SignatureInput{
Type: Drawn,
DataUrl: pngDataURL(),
Name: "Ada Lovelace",
Title: strPtr("Analyst"),
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", base)
if w.Code != http.StatusCreated {
t.Fatalf("signed submission: %d %s", w.Code, w.Body.String())
}
submission := decode[Submission](t, w)
if submission.Signature == nil || submission.Signature.Name != "Ada Lovelace" ||
submission.Signature.Title == nil || *submission.Signature.Title != "Analyst" ||
submission.Signature.SignedAt.IsZero() {
t.Fatalf("signature not returned: %+v", submission.Signature)
}
w = doRequest(t, router, http.MethodGet, "/submissions/"+submission.Id.String(), nil)
if w.Code != http.StatusOK {
t.Fatalf("get submission: %d %s", w.Code, w.Body.String())
}
stored := decode[Submission](t, w)
if stored.Signature == nil || stored.Signature.DataUrl != pngDataURL() {
t.Fatalf("signature not persisted: %+v", stored.Signature)
}
base.Signature = &SignatureInput{Type: Drawn, DataUrl: pngDataURL(), Name: ""}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", base)
if w.Code != http.StatusBadRequest {
t.Fatalf("empty signature name should be 400, got %d %s", w.Code, w.Body.String())
}
}
func TestOfflinePDFSubmission(t *testing.T) {
router, _ := newTestRouter(t)
form := createSignatureForm(t, router)
pdf := []byte("%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n")
submit := SubmitFormRequest{
UserId: "ada@example.com",
UserIdType: Email,
OfflinePdf: strPtr(pdfDataURL(pdf)),
OfflinePdfName: strPtr("signed-declaration.pdf"),
}
invalid := submit
invalid.OfflinePdf = strPtr(pdfDataURL([]byte("not a pdf")))
w := doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", invalid)
if w.Code != http.StatusBadRequest {
t.Fatalf("invalid pdf should be 400, got %d %s", w.Code, w.Body.String())
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit)
if w.Code != http.StatusCreated {
t.Fatalf("offline submission: %d %s", w.Code, w.Body.String())
}
submission := decode[Submission](t, w)
if len(submission.Answers) != 0 || submission.OfflinePdfName == nil ||
*submission.OfflinePdfName != "signed-declaration.pdf" || submission.Signature != nil {
t.Fatalf("unexpected offline submission: %+v", submission)
}
w = doRequest(t, router, http.MethodGet, "/submissions/"+submission.Id.String()+"/offline-pdf", nil)
if w.Code != http.StatusOK {
t.Fatalf("download offline pdf: %d %s", w.Code, w.Body.String())
}
if got := w.Header().Get("Content-Type"); got != "application/pdf" {
t.Fatalf("content type = %q", got)
}
if got := w.Header().Get("Content-Disposition"); !strings.Contains(got, "signed-declaration.pdf") {
t.Fatalf("content disposition = %q", got)
}
if !bytes.Equal(w.Body.Bytes(), pdf) {
t.Fatalf("downloaded pdf differs from upload")
}
}

View File

@ -0,0 +1,121 @@
package api
import (
"context"
"errors"
"github.com/gin-gonic/gin"
openapi_types "github.com/oapi-codegen/runtime/types"
"kereru.forms/internal/auth"
)
func (s *Server) GetAuthConfig(ctx context.Context, _ GetAuthConfigRequestObject) (GetAuthConfigResponseObject, error) {
_, span := s.tracer.Start(ctx, "GetAuthConfig")
defer span.End()
if s.auth == nil {
return GetAuthConfig200JSONResponse(AuthConfig{
Mode: AuthConfigMode("password"),
}), nil
}
cfg := s.auth.PublicConfig()
out := AuthConfig{
Mode: AuthConfigMode(cfg.Mode),
RequireSubmissionAuth: cfg.RequireSubmissionAuth,
}
if cfg.OIDC != nil {
out.Oidc = &OIDCClientConfig{
IssuerUrl: cfg.OIDC.IssuerURL,
AuthorizationEndpoint: cfg.OIDC.AuthorizationEndpoint,
TokenEndpoint: cfg.OIDC.TokenEndpoint,
ClientId: cfg.OIDC.ClientID,
Scopes: cfg.OIDC.Scopes,
}
}
return GetAuthConfig200JSONResponse(out), nil
}
func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginResponseObject, error) {
_, span := s.tracer.Start(ctx, "Login")
defer span.End()
if s.auth == nil {
return Login400JSONResponse{ErrorJSONResponse: errJSON("authentication is not configured", "auth_not_configured")}, nil
}
if request.Body == nil {
return Login400JSONResponse{ErrorJSONResponse: errJSON("password is required", "invalid_request")}, nil
}
token, user, err := s.auth.Login(request.Body.Password)
switch {
case errors.Is(err, auth.ErrLocalLoginDisabled):
return Login400JSONResponse{ErrorJSONResponse: errJSON("local password login is disabled", "local_login_disabled")}, nil
case errors.Is(err, auth.ErrInvalidCredentials):
return Login401JSONResponse(Error{
Error: "invalid credentials",
Code: stringPtr("invalid_credentials"),
}), nil
case err != nil:
s.logger.Error().Err(err).Msg("local login")
return Login400JSONResponse{ErrorJSONResponse: errJSON("could not sign in", "internal_error")}, nil
default:
return Login200JSONResponse(LoginResponse{
Token: token,
User: toAPIAuthUser(user),
}), nil
}
}
func (s *Server) GetCurrentUser(ctx context.Context, _ GetCurrentUserRequestObject) (GetCurrentUserResponseObject, error) {
_, span := s.tracer.Start(ctx, "GetCurrentUser")
defer span.End()
ginCtx, ok := ctx.(*gin.Context)
if !ok {
return GetCurrentUser401JSONResponse{ErrorJSONResponse: errJSON("authentication required", "unauthenticated")}, nil
}
user, ok := CurrentUser(ginCtx)
if !ok {
return GetCurrentUser401JSONResponse{ErrorJSONResponse: errJSON("authentication required", "unauthenticated")}, nil
}
return GetCurrentUser200JSONResponse(AuthMeResponse{
Authenticated: true,
User: authUserPtr(toAPIAuthUser(user)),
}), nil
}
func (s *Server) Logout(context.Context, LogoutRequestObject) (LogoutResponseObject, error) {
return Logout204Response{}, nil
}
func toAPIAuthUser(user auth.User) AuthUser {
roles := user.Roles
if roles == nil {
roles = []string{}
}
out := AuthUser{
Id: user.ID,
Roles: roles,
CanAdmin: user.CanAdmin,
CanView: user.CanView,
}
if user.Name != "" {
name := user.Name
out.Name = &name
}
if user.Email != "" {
email := openapi_types.Email(user.Email)
out.Email = &email
}
return out
}
func authUserPtr(user AuthUser) *AuthUser {
return &user
}
func stringPtr(value string) *string {
return &value
}

View File

@ -0,0 +1,206 @@
package api
import (
"context"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"kereru.forms/internal/auth"
)
type authUserContextKey struct{}
type accessPermission int
const (
permissionAuthenticated accessPermission = iota
permissionAdmin
permissionView
)
type accessRule struct {
requiresAuth bool
permission accessPermission
}
func authMiddleware(service *auth.Service) gin.HandlerFunc {
return func(c *gin.Context) {
path := normalizeAPIPath(c.Request.URL.Path)
if path == "/auth/me" {
// /auth/me reports authentication state, so a missing or invalid
// token is handled by the endpoint rather than aborting here.
if rawToken, ok := bearerToken(c.GetHeader("Authorization")); ok {
if user, err := service.Authenticate(c.Request.Context(), rawToken); err == nil {
attachAuthUser(c, user)
}
}
c.Next()
return
}
rule := accessRuleFor(c.Request.Method, c.Request.URL.Path, service.RequiresSubmissionAuth())
if !rule.requiresAuth {
c.Next()
return
}
rawToken, ok := bearerToken(c.GetHeader("Authorization"))
if !ok {
abortUnauthenticated(c)
return
}
user, err := service.Authenticate(c.Request.Context(), rawToken)
if err != nil {
abortUnauthenticated(c)
return
}
if rule.permission == permissionAdmin && !user.CanAdmin {
abortForbidden(c)
return
}
if rule.permission == permissionView && !user.CanView {
abortForbidden(c)
return
}
attachAuthUser(c, user)
c.Next()
}
}
func attachAuthUser(c *gin.Context, user auth.User) {
c.Set("auth.user", user)
ctx := context.WithValue(c.Request.Context(), authUserContextKey{}, user)
c.Request = c.Request.WithContext(ctx)
}
// CurrentUser returns the authenticated user attached by authMiddleware.
func CurrentUser(c *gin.Context) (auth.User, bool) {
value, exists := c.Get("auth.user")
if !exists {
return auth.User{}, false
}
user, ok := value.(auth.User)
return user, ok
}
func accessRuleFor(method, requestPath string, requireSubmissionAuth bool) accessRule {
if method == http.MethodOptions {
return accessRule{}
}
path := normalizeAPIPath(requestPath)
if path == "/health" || path == "/auth" || strings.HasPrefix(path, "/auth/") {
return accessRule{}
}
segments := splitAPIPath(path)
if len(segments) == 0 {
return accessRule{}
}
switch segments[0] {
case "forms":
switch {
case len(segments) == 1:
if method == http.MethodPost {
return adminRule()
}
if method == http.MethodGet && requireSubmissionAuth {
return authenticatedRule()
}
case len(segments) == 2:
switch method {
case http.MethodPut, http.MethodDelete:
return adminRule()
case http.MethodGet:
if requireSubmissionAuth {
return authenticatedRule()
}
}
case len(segments) == 3:
switch segments[2] {
case "activate", "inactivate", "versions", "access-codes":
return adminRule()
case "submissions":
if method == http.MethodPost {
if requireSubmissionAuth {
return authenticatedRule()
}
return accessRule{}
}
if method == http.MethodGet {
return viewRule()
}
case "pdf":
if method == http.MethodGet && requireSubmissionAuth {
return authenticatedRule()
}
}
}
case "submissions":
if len(segments) == 2 && method == http.MethodGet {
return viewRule()
}
if len(segments) == 3 && segments[2] == "offline-pdf" && method == http.MethodGet {
return viewRule()
}
}
return accessRule{}
}
func adminRule() accessRule {
return accessRule{requiresAuth: true, permission: permissionAdmin}
}
func viewRule() accessRule {
return accessRule{requiresAuth: true, permission: permissionView}
}
func authenticatedRule() accessRule {
return accessRule{requiresAuth: true, permission: permissionAuthenticated}
}
func normalizeAPIPath(requestPath string) string {
path := requestPath
if path == "/api" {
return "/"
}
if strings.HasPrefix(path, "/api/") {
path = strings.TrimPrefix(path, "/api")
}
return path
}
func splitAPIPath(path string) []string {
trimmed := strings.Trim(path, "/")
if trimmed == "" {
return nil
}
return strings.Split(trimmed, "/")
}
func bearerToken(header string) (string, bool) {
parts := strings.Fields(header)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return "", false
}
return parts[1], parts[1] != ""
}
func abortUnauthenticated(c *gin.Context) {
c.Header("WWW-Authenticate", "Bearer")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "authentication required",
"code": "unauthenticated",
})
}
func abortForbidden(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "insufficient permissions",
"code": "forbidden",
})
}

View File

@ -0,0 +1,205 @@
package api
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/rs/zerolog"
"kereru.forms/internal/auth"
"kereru.forms/internal/config"
"kereru.forms/internal/database"
)
const authTestSecret = "api-auth-test-secret"
func newAuthTestRouter(t *testing.T, requireSubmissionAuth bool) (*gin.Engine, database.Store) {
t.Helper()
path := filepath.Join(t.TempDir(), "auth-api-test.db")
store, err := database.Open("sqlite", "file:"+path+"?_pragma=foreign_keys(1)")
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
authCfg := config.Default().Auth
authCfg.LocalPassword = "test-password"
authCfg.LocalTokenSecret = authTestSecret
authCfg.RequireSubmissionAuth = requireSubmissionAuth
service, err := auth.New(t.Context(), authCfg)
if err != nil {
t.Fatalf("new auth service: %v", err)
}
server := NewServer(store, zerolog.Nop(), "test-version", service)
router := NewRouter(server, zerolog.Nop(), RouterOptions{CORSOrigins: []string{"http://localhost:5173"}})
return router, store
}
func doAuthedRequest(t *testing.T, router *gin.Engine, method, path string, body any, token string) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != nil {
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal request: %v", err)
}
reader = bytes.NewReader(payload)
}
req := httptest.NewRequest(method, path, reader)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func testLocalToken(t *testing.T, roles ...string) string {
t.Helper()
now := time.Now()
claims := jwt.MapClaims{
"iss": "kereru-forms",
"sub": "test-user",
"name": "Test User",
"roles": roles,
"iat": now.Unix(),
"exp": now.Add(time.Hour).Unix(),
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(authTestSecret))
if err != nil {
t.Fatalf("sign token: %v", err)
}
return signed
}
func createAuthTestForm(t *testing.T, router *gin.Engine, token string) Form {
t.Helper()
body := CreateFormRequest{
Title: "Auth test form",
Questions: []QuestionInput{{
Key: "name",
Label: "Your name",
Type: Text,
Required: true,
}},
}
w := doAuthedRequest(t, router, http.MethodPost, "/forms", body, token)
if w.Code != http.StatusCreated {
t.Fatalf("create form: status %d body %s", w.Code, w.Body.String())
}
return decode[Form](t, w)
}
func TestLoginAndCurrentUser(t *testing.T) {
router, _ := newAuthTestRouter(t, false)
configResponse := doAuthedRequest(t, router, http.MethodGet, "/auth/config", nil, "")
if configResponse.Code != http.StatusOK {
t.Fatalf("auth config: status %d body %s", configResponse.Code, configResponse.Body.String())
}
authConfig := decode[AuthConfig](t, configResponse)
if authConfig.Mode != AuthConfigMode("password") {
t.Fatalf("unexpected auth mode %q", authConfig.Mode)
}
badLogin := doAuthedRequest(t, router, http.MethodPost, "/auth/login", LoginRequest{Password: "wrong"}, "")
if badLogin.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for bad password, got %d %s", badLogin.Code, badLogin.Body.String())
}
login := doAuthedRequest(t, router, http.MethodPost, "/auth/login", LoginRequest{Password: "test-password"}, "")
if login.Code != http.StatusOK {
t.Fatalf("login: status %d body %s", login.Code, login.Body.String())
}
loginBody := decode[LoginResponse](t, login)
if loginBody.Token == "" || !loginBody.User.CanAdmin || !loginBody.User.CanView {
t.Fatalf("unexpected login response: %+v", loginBody)
}
me := doAuthedRequest(t, router, http.MethodGet, "/auth/me", nil, loginBody.Token)
if me.Code != http.StatusOK {
t.Fatalf("me: status %d body %s", me.Code, me.Body.String())
}
meBody := decode[AuthMeResponse](t, me)
if !meBody.Authenticated || meBody.User == nil || meBody.User.Id != "local" {
t.Fatalf("unexpected me response: %+v", meBody)
}
anonymousMe := doAuthedRequest(t, router, http.MethodGet, "/auth/me", nil, "")
if anonymousMe.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for anonymous me, got %d", anonymousMe.Code)
}
}
func TestManagementRoleEnforcement(t *testing.T) {
router, _ := newAuthTestRouter(t, false)
adminToken := testLocalToken(t, "admin")
viewToken := testLocalToken(t, "view")
if w := doAuthedRequest(t, router, http.MethodPost, "/forms", CreateFormRequest{Title: "Anonymous"}, ""); w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 without token, got %d", w.Code)
}
if w := doAuthedRequest(t, router, http.MethodPost, "/forms", CreateFormRequest{Title: "View only"}, viewToken); w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for view-only create, got %d %s", w.Code, w.Body.String())
}
form := createAuthTestForm(t, router, adminToken)
submissionsPath := "/forms/" + form.Id.String() + "/submissions"
if w := doAuthedRequest(t, router, http.MethodGet, submissionsPath, nil, ""); w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for anonymous submissions, got %d", w.Code)
}
if w := doAuthedRequest(t, router, http.MethodGet, submissionsPath, nil, adminToken); w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for admin-only submissions, got %d %s", w.Code, w.Body.String())
}
if w := doAuthedRequest(t, router, http.MethodGet, submissionsPath, nil, viewToken); w.Code != http.StatusOK {
t.Fatalf("expected 200 for view submissions, got %d %s", w.Code, w.Body.String())
}
if w := doAuthedRequest(t, router, http.MethodPut, "/forms/"+form.Id.String(), CreateFormRequest{Title: "Updated"}, viewToken); w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for view-only update, got %d %s", w.Code, w.Body.String())
}
}
func TestOptionalSubmissionAuthentication(t *testing.T) {
router, _ := newAuthTestRouter(t, true)
adminToken := testLocalToken(t, "admin")
if w := doAuthedRequest(t, router, http.MethodGet, "/forms", nil, ""); w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 listing forms without auth, got %d", w.Code)
}
if w := doAuthedRequest(t, router, http.MethodGet, "/forms", nil, adminToken); w.Code != http.StatusOK {
t.Fatalf("expected 200 listing forms with auth, got %d %s", w.Code, w.Body.String())
}
form := createAuthTestForm(t, router, adminToken)
submit := SubmitFormRequest{
UserId: "ada@example.com",
UserIdType: Email,
Answers: []Answer{{
QuestionId: form.Questions[0].Id,
Value: "Ada",
}},
}
submitPath := "/forms/" + form.Id.String() + "/submissions"
if w := doAuthedRequest(t, router, http.MethodPost, submitPath, submit, ""); w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 submitting without auth, got %d %s", w.Code, w.Body.String())
}
if w := doAuthedRequest(t, router, http.MethodPost, submitPath, submit, adminToken); w.Code != http.StatusCreated {
t.Fatalf("expected admin-only user to submit as any authenticated user, got %d %s", w.Code, w.Body.String())
}
if w := doAuthedRequest(t, router, http.MethodGet, "/forms/"+form.Id.String()+"/pdf", nil, ""); w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 downloading form PDF without auth, got %d", w.Code)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,202 @@
package api
import (
"encoding/json"
"github.com/google/uuid"
"kereru.forms/internal/database"
)
func toAPIForm(f *database.Form) Form {
questions := make([]Question, 0, len(f.Questions))
for _, q := range f.Questions {
questions = append(questions, toAPIQuestion(q))
}
return Form{
Id: uuid.MustParse(f.ID),
FormGroupId: uuid.MustParse(f.FormGroupID),
Version: f.Version,
Title: f.Title,
Description: f.Description,
Introduction: f.Introduction,
IsActive: f.IsActive,
RequiresAccessCode: f.RequiresAccessCode,
SignatureRequired: boolPtrIfSet(f.SignatureRequired, true),
ExternalReferenceRequired: boolPtrIfSet(f.ExternalReferenceRequired, true),
ExternalReferenceLabel: f.ExternalReferenceLabel,
LogoUrl: f.LogoURL,
PdfUrl: f.PDFURL,
CompanyName: f.CompanyName,
ContactEmail: f.ContactEmail,
ContactPhone: f.ContactPhone,
ContactUrl: f.ContactURL,
CreatedAt: f.CreatedAt,
UpdatedAt: f.UpdatedAt,
Questions: questions,
}
}
func toAPIQuestion(q database.Question) Question {
out := Question{
Id: uuid.MustParse(q.ID),
Key: q.Key,
Label: q.Label,
Description: q.Description,
Type: QuestionType(q.Type),
Required: q.Required,
Placeholder: q.Placeholder,
Multiline: boolPtrIfSet(q.Multiline, QuestionType(q.Type) == Text),
}
if q.MinValue != nil {
v := float32(*q.MinValue)
out.MinValue = &v
}
if q.MaxValue != nil {
v := float32(*q.MaxValue)
out.MaxValue = &v
}
if len(q.Options) > 0 {
opts := make([]QuestionOption, 0, len(q.Options))
for _, o := range q.Options {
opts = append(opts, QuestionOption{Value: o.Value, Label: o.Label})
}
out.Options = &opts
}
return out
}
func boolPtrIfSet(v bool, include bool) *bool {
if !include && !v {
return nil
}
b := v
return &b
}
func toAPIFormSummary(f database.FormSummary) FormSummary {
return FormSummary{
Id: uuid.MustParse(f.ID),
FormGroupId: uuid.MustParse(f.FormGroupID),
Version: f.Version,
Title: f.Title,
Description: f.Description,
LogoUrl: f.LogoURL,
PdfUrl: f.PDFURL,
ExternalReferenceRequired: boolPtrIfSet(f.ExternalReferenceRequired, true),
ExternalReferenceLabel: f.ExternalReferenceLabel,
IsActive: f.IsActive,
QuestionCount: f.QuestionCount,
CreatedAt: f.CreatedAt,
UpdatedAt: f.UpdatedAt,
}
}
func toAPIAnswerRecord(a database.Answer) AnswerRecord {
var value interface{}
_ = json.Unmarshal(a.Value, &value)
return AnswerRecord{
QuestionId: uuid.MustParse(a.QuestionID),
QuestionKey: a.QuestionKey,
Value: value,
}
}
func toAPISubmission(s *database.Submission) Submission {
answers := make([]AnswerRecord, 0, len(s.Answers))
for _, a := range s.Answers {
answers = append(answers, toAPIAnswerRecord(a))
}
out := Submission{
Id: uuid.MustParse(s.ID),
FormId: uuid.MustParse(s.FormID),
FormVersion: s.FormVersion,
UserId: s.UserID,
UserIdType: UserIdType(s.UserIDType),
SubmittedAt: s.SubmittedAt,
Answers: answers,
ExternalReference: s.ExternalReference,
OfflinePdfName: s.OfflinePDFName,
}
if s.Signature != nil {
out.Signature = &Signature{
Type: SignatureType(s.Signature.Type),
DataUrl: s.Signature.DataURL,
Name: s.Signature.Name,
Title: s.Signature.Title,
SignedAt: s.Signature.SignedAt,
}
}
if hasTracking(s) {
out.Tracking = &Tracking{
IpAddress: s.IPAddress,
UserAgent: s.UserAgent,
Locale: s.Locale,
Timezone: s.Timezone,
Place: s.Place,
Referer: s.Referer,
}
}
return out
}
func hasTracking(s *database.Submission) bool {
return s.IPAddress != nil || s.UserAgent != nil || s.Locale != nil ||
s.Timezone != nil || s.Place != nil || s.Referer != nil
}
func toAPISubmissionSummary(s database.SubmissionSummary) SubmissionSummary {
return SubmissionSummary{
Id: uuid.MustParse(s.ID),
FormId: uuid.MustParse(s.FormID),
FormVersion: s.FormVersion,
UserId: s.UserID,
UserIdType: UserIdType(s.UserIDType),
ExternalReference: s.ExternalReference,
SubmittedAt: s.SubmittedAt,
}
}
func toAPIAccessCode(c database.AccessCode) AccessCode {
return AccessCode{
Id: uuid.MustParse(c.ID),
FormId: uuid.MustParse(c.FormID),
Code: c.Code,
Used: c.Used,
UsedAt: c.UsedAt,
CreatedAt: c.CreatedAt,
}
}
func questionInputsToRecords(inputs []QuestionInput) []database.Question {
out := make([]database.Question, 0, len(inputs))
for i, q := range inputs {
rec := database.Question{
Position: i,
Key: q.Key,
Label: q.Label,
Description: q.Description,
Type: string(q.Type),
Required: q.Required,
Placeholder: q.Placeholder,
}
if q.MinValue != nil {
v := float64(*q.MinValue)
rec.MinValue = &v
}
if q.MaxValue != nil {
v := float64(*q.MaxValue)
rec.MaxValue = &v
}
if q.Multiline != nil {
rec.Multiline = *q.Multiline
}
if q.Options != nil {
for _, o := range *q.Options {
rec.Options = append(rec.Options, database.QuestionOption{Value: o.Value, Label: o.Label})
}
}
out = append(out, rec)
}
return out
}

View File

@ -0,0 +1,165 @@
package api
import (
"context"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/rs/zerolog"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
"kereru.forms/internal/logging"
)
// RouterOptions configures cross-cutting HTTP behaviour.
type RouterOptions struct {
CORSOrigins []string
ServiceName string
// StaticDir, when set, serves a built frontend with SPA fallback.
StaticDir string
}
// NewRouter builds the Gin engine with all middleware and API routes.
func NewRouter(server *Server, logger zerolog.Logger, opts RouterOptions) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
serviceName := opts.ServiceName
if serviceName == "" {
serviceName = "kereru-forms"
}
r := gin.New()
r.RedirectTrailingSlash = false
// Ensure *gin.Context behaves as the request context for derived values.
r.ContextWithFallback = true
r.Use(otelgin.Middleware(serviceName))
r.Use(gin.Recovery())
r.Use(requestIDMiddleware())
r.Use(withRequestMetadata())
r.Use(logging.GinMiddleware(logger))
r.Use(corsMiddleware(opts.CORSOrigins))
if server.auth != nil {
r.Use(authMiddleware(server.auth))
}
strict := NewStrictHandler(server, nil)
// The API is exposed at the root (for probes and direct clients) and under
// /api so the SPA can use a stable same-origin prefix in every deployment.
RegisterHandlers(r, strict)
RegisterHandlers(r.Group("/api"), strict)
if opts.StaticDir != "" {
serveStatic(r, opts.StaticDir, logger)
}
return r
}
var apiPrefixes = []string{"/health", "/forms", "/submissions", "/api"}
func isAPIPath(path string) bool {
for _, prefix := range apiPrefixes {
if path == prefix || strings.HasPrefix(path, prefix+"/") {
return true
}
}
return false
}
// serveStatic serves files from dir and falls back to index.html for client
// side routes so the SPA can be hosted by the backend binary.
func serveStatic(r *gin.Engine, dir string, logger zerolog.Logger) {
index := filepath.Join(dir, "index.html")
r.NoRoute(func(c *gin.Context) {
if isAPIPath(c.Request.URL.Path) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found", "code": "not_found"})
return
}
clean := filepath.Clean(strings.TrimPrefix(c.Request.URL.Path, "/"))
candidate := filepath.Join(dir, clean)
if clean != "." && !strings.HasPrefix(candidate, filepath.Clean(dir)) {
c.Status(http.StatusNotFound)
return
}
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
c.File(candidate)
return
}
if _, err := os.Stat(index); err != nil {
logger.Warn().Err(err).Str("dir", dir).Msg("static frontend not found")
c.Status(http.StatusNotFound)
return
}
c.File(index)
})
}
func requestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
id := strings.TrimSpace(c.GetHeader("X-Request-ID"))
if id == "" {
id = uuid.NewString()
}
c.Header("X-Request-ID", id)
logging.SetRequestID(c, id)
c.Next()
}
}
func withRequestMetadata() gin.HandlerFunc {
return func(c *gin.Context) {
meta := requestMetadata{}
if ip := strings.TrimSpace(c.ClientIP()); ip != "" {
meta.ip = &ip
}
if ua := strings.TrimSpace(c.Request.UserAgent()); ua != "" {
meta.userAgent = &ua
}
c.Set(metaKey, meta)
ctx := context.WithValue(c.Request.Context(), metaKey, meta)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
func corsMiddleware(origins []string) gin.HandlerFunc {
allowAll := false
allowed := make(map[string]struct{}, len(origins))
for _, o := range origins {
o = strings.TrimSpace(o)
if o == "*" {
allowAll = true
continue
}
if o != "" {
allowed[o] = struct{}{}
}
}
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin != "" {
if allowAll {
c.Header("Access-Control-Allow-Origin", "*")
} else if _, ok := allowed[origin]; ok {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Vary", "Origin")
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID")
c.Header("Access-Control-Expose-Headers", "X-Request-ID")
c.Header("Access-Control-Max-Age", "600")
}
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,327 @@
// Package auth implements password fallback and OAuth/OIDC bearer-token
// authentication for Kereru Forms.
package auth
import (
"context"
"crypto/rand"
"crypto/subtle"
"errors"
"fmt"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"kereru.forms/internal/config"
)
const localIssuer = "kereru-forms"
var (
// ErrInvalidCredentials is returned when a local password is incorrect.
ErrInvalidCredentials = errors.New("invalid credentials")
// ErrInvalidToken is returned when a bearer token cannot be verified.
ErrInvalidToken = errors.New("invalid or expired token")
// ErrLocalLoginDisabled is returned when password login is used with OIDC.
ErrLocalLoginDisabled = errors.New("local password login is disabled")
)
// User is the authenticated principal exposed to the API and frontend.
type User struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Roles []string `json:"roles"`
CanAdmin bool `json:"canAdmin"`
CanView bool `json:"canView"`
}
// OIDCClientConfig is the browser-safe subset of OIDC configuration.
type OIDCClientConfig struct {
IssuerURL string `json:"issuerUrl"`
AuthorizationEndpoint string `json:"authorizationEndpoint"`
TokenEndpoint string `json:"tokenEndpoint"`
ClientID string `json:"clientId"`
Scopes []string `json:"scopes"`
}
// PublicConfig describes how the frontend should authenticate.
type PublicConfig struct {
Mode string `json:"mode"`
RequireSubmissionAuth bool `json:"requireSubmissionAuth"`
OIDC *OIDCClientConfig `json:"oidc,omitempty"`
}
// Service verifies bearer tokens and issues local password-mode tokens.
type Service struct {
cfg config.AuthConfig
localSecret []byte
localTTL time.Duration
oidcProvider *oidc.Provider
oidcVerifier *oidc.IDTokenVerifier
}
// New builds an authentication service. When an OIDC issuer is configured,
// provider discovery is performed immediately so configuration errors fail
// fast at startup.
func New(ctx context.Context, cfg config.AuthConfig) (*Service, error) {
service := &Service{
cfg: cfg,
localTTL: time.Duration(cfg.LocalTokenTTLHours) * time.Hour,
}
if secret := strings.TrimSpace(cfg.LocalTokenSecret); secret != "" {
service.localSecret = []byte(secret)
} else {
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, fmt.Errorf("generate local token secret: %w", err)
}
service.localSecret = secret
}
if cfg.OIDCEnabled() {
discoveryCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
provider, err := oidc.NewProvider(discoveryCtx, strings.TrimSpace(cfg.OIDC.IssuerURL))
if err != nil {
return nil, fmt.Errorf("discover OIDC provider: %w", err)
}
service.oidcProvider = provider
service.oidcVerifier = provider.Verifier(&oidc.Config{
ClientID: strings.TrimSpace(cfg.OIDC.ClientID),
})
}
return service, nil
}
// PublicConfig returns browser-safe authentication configuration.
func (s *Service) PublicConfig() PublicConfig {
out := PublicConfig{
Mode: "password",
RequireSubmissionAuth: s.cfg.RequireSubmissionAuth,
}
if s.oidcProvider != nil {
endpoint := s.oidcProvider.Endpoint()
out.Mode = "oidc"
out.OIDC = &OIDCClientConfig{
IssuerURL: s.cfg.OIDC.IssuerURL,
AuthorizationEndpoint: endpoint.AuthURL,
TokenEndpoint: endpoint.TokenURL,
ClientID: s.cfg.OIDC.ClientID,
Scopes: append([]string(nil), s.cfg.OIDC.Scopes...),
}
}
return out
}
// LocalLoginEnabled reports whether password fallback is active.
func (s *Service) LocalLoginEnabled() bool {
return s.oidcProvider == nil
}
// RequiresSubmissionAuth reports whether filling in forms requires login.
func (s *Service) RequiresSubmissionAuth() bool {
return s.cfg.RequireSubmissionAuth
}
// Login validates the local password and returns a signed JWT and user.
func (s *Service) Login(password string) (string, User, error) {
if s.oidcProvider != nil {
return "", User{}, ErrLocalLoginDisabled
}
if !passwordMatches(s.cfg.LocalPassword, password) {
return "", User{}, ErrInvalidCredentials
}
roles := s.localRoles()
user := s.user("local", "Local administrator", "", roles)
token, err := s.issueLocalToken(user)
if err != nil {
return "", User{}, err
}
return token, user, nil
}
// Authenticate verifies a bearer token and returns its roles.
func (s *Service) Authenticate(ctx context.Context, rawToken string) (User, error) {
rawToken = strings.TrimSpace(rawToken)
if rawToken == "" {
return User{}, ErrInvalidToken
}
if s.oidcVerifier != nil {
return s.authenticateOIDC(ctx, rawToken)
}
return s.authenticateLocal(rawToken)
}
func (s *Service) authenticateLocal(rawToken string) (User, error) {
parsed, err := jwt.Parse(rawToken, func(token *jwt.Token) (any, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, fmt.Errorf("unexpected signing method %q", token.Method.Alg())
}
return s.localSecret, nil
},
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
jwt.WithIssuer(localIssuer),
jwt.WithExpirationRequired(),
)
if err != nil || !parsed.Valid {
return User{}, ErrInvalidToken
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
return User{}, ErrInvalidToken
}
sub, _ := claims["sub"].(string)
if strings.TrimSpace(sub) == "" {
return User{}, ErrInvalidToken
}
name, _ := claims["name"].(string)
email, _ := claims["email"].(string)
roles := claimStrings(claims["roles"])
return s.user(sub, name, email, roles), nil
}
func (s *Service) authenticateOIDC(ctx context.Context, rawToken string) (User, error) {
idToken, err := s.oidcVerifier.Verify(ctx, rawToken)
if err != nil {
return User{}, ErrInvalidToken
}
var claims map[string]any
if err := idToken.Claims(&claims); err != nil {
return User{}, ErrInvalidToken
}
sub := strings.TrimSpace(idToken.Subject)
if sub == "" {
sub, _ = claims["sub"].(string)
}
if strings.TrimSpace(sub) == "" {
return User{}, ErrInvalidToken
}
name := firstString(claims, "name", "preferred_username")
email, _ := claims["email"].(string)
roles := claimValues(claims, s.cfg.OIDC.RoleClaim)
return s.user(sub, name, email, roles), nil
}
func (s *Service) user(id, name, email string, roles []string) User {
roles = normalizeRoles(roles)
return User{
ID: id,
Name: name,
Email: email,
Roles: roles,
CanAdmin: containsRole(roles, s.cfg.OIDC.AdminRole),
CanView: containsRole(roles, s.cfg.OIDC.ViewRole),
}
}
func (s *Service) localRoles() []string {
return normalizeRoles([]string{s.cfg.OIDC.AdminRole, s.cfg.OIDC.ViewRole})
}
func (s *Service) issueLocalToken(user User) (string, error) {
now := time.Now()
claims := jwt.MapClaims{
"iss": localIssuer,
"sub": user.ID,
"name": user.Name,
"roles": user.Roles,
"iat": now.Unix(),
"exp": now.Add(s.localTTL).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(s.localSecret)
if err != nil {
return "", fmt.Errorf("sign local token: %w", err)
}
return signed, nil
}
func passwordMatches(configured, supplied string) bool {
if strings.HasPrefix(configured, "$2") {
return bcrypt.CompareHashAndPassword([]byte(configured), []byte(supplied)) == nil
}
return subtle.ConstantTimeCompare([]byte(configured), []byte(supplied)) == 1
}
func claimValues(claims map[string]any, path string) []string {
var current any = claims
for _, segment := range strings.Split(path, ".") {
segment = strings.TrimSpace(segment)
if segment == "" {
return nil
}
next, ok := current.(map[string]any)
if !ok {
return nil
}
current, ok = next[segment]
if !ok {
return nil
}
}
return claimStrings(current)
}
func claimStrings(value any) []string {
switch typed := value.(type) {
case string:
return normalizeRoles([]string{typed})
case []string:
return normalizeRoles(typed)
case []any:
roles := make([]string, 0, len(typed))
for _, item := range typed {
if role, ok := item.(string); ok {
roles = append(roles, role)
}
}
return normalizeRoles(roles)
default:
return nil
}
}
func normalizeRoles(roles []string) []string {
seen := make(map[string]struct{}, len(roles))
out := make([]string, 0, len(roles))
for _, role := range roles {
role = strings.TrimSpace(role)
if role == "" {
continue
}
if _, exists := seen[role]; exists {
continue
}
seen[role] = struct{}{}
out = append(out, role)
}
return out
}
func containsRole(roles []string, wanted string) bool {
wanted = strings.TrimSpace(wanted)
for _, role := range roles {
if role == wanted {
return true
}
}
return false
}
func firstString(claims map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := claims[key].(string); ok && strings.TrimSpace(value) != "" {
return value
}
}
return ""
}

View File

@ -0,0 +1,82 @@
package auth
import (
"context"
"errors"
"testing"
"golang.org/x/crypto/bcrypt"
"kereru.forms/internal/config"
)
func TestLocalPasswordLoginAndAuthenticate(t *testing.T) {
cfg := config.Default().Auth
cfg.LocalPassword = "secret"
cfg.LocalTokenSecret = "test-secret"
service, err := New(context.Background(), cfg)
if err != nil {
t.Fatalf("new auth service: %v", err)
}
token, user, err := service.Login("secret")
if err != nil {
t.Fatalf("login: %v", err)
}
if token == "" {
t.Fatal("expected a token")
}
if !user.CanAdmin || !user.CanView {
t.Fatalf("expected admin and view roles, got %+v", user)
}
authenticated, err := service.Authenticate(context.Background(), token)
if err != nil {
t.Fatalf("authenticate: %v", err)
}
if authenticated.ID != "local" || !authenticated.CanAdmin || !authenticated.CanView {
t.Fatalf("unexpected authenticated user: %+v", authenticated)
}
if _, _, err := service.Login("wrong"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("expected invalid credentials, got %v", err)
}
if _, err := service.Authenticate(context.Background(), token+"x"); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token, got %v", err)
}
}
func TestLocalPasswordSupportsBcrypt(t *testing.T) {
hash, err := bcrypt.GenerateFromPassword([]byte("hashed-secret"), bcrypt.MinCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
cfg := config.Default().Auth
cfg.LocalPassword = string(hash)
cfg.LocalTokenSecret = "test-secret"
service, err := New(context.Background(), cfg)
if err != nil {
t.Fatalf("new auth service: %v", err)
}
if _, _, err := service.Login("hashed-secret"); err != nil {
t.Fatalf("login with bcrypt password: %v", err)
}
}
func TestClaimValuesSupportsNestedPathsAndArrays(t *testing.T) {
claims := map[string]any{
"roles": []any{"admin", "view", "admin"},
"realm_access": map[string]any{
"roles": []any{"viewer", "auditor"},
},
}
if got := claimValues(claims, "roles"); len(got) != 2 || got[0] != "admin" || got[1] != "view" {
t.Fatalf("unexpected flat roles: %#v", got)
}
if got := claimValues(claims, "realm_access.roles"); len(got) != 2 || got[0] != "viewer" || got[1] != "auditor" {
t.Fatalf("unexpected nested roles: %#v", got)
}
if got := claimValues(claims, "missing.roles"); got != nil {
t.Fatalf("expected nil for missing path, got %#v", got)
}
}

View File

@ -0,0 +1,383 @@
// Package config loads application configuration from defaults, a YAML/JSON
// document, environment variables (optionally via a .env file) and CLI flags.
//
// Precedence, highest first:
// 1. CLI flags (--conf, --log-level, --listen-addr, --listen-addr6, --port)
// 2. Environment variables
// 3. .env file located next to the executable or in the working directory
// 4. Configuration file supplied with --conf
// 5. Built-in defaults
package config
import (
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
// Config holds every runtime setting supported by kereru-forms.
type Config struct {
// LogLevel is the minimum log level (trace, debug, info, warn, error).
LogLevel string `yaml:"logLevel" json:"logLevel"`
// LogFormat is "json" or "console".
LogFormat string `yaml:"logFormat" json:"logFormat"`
// Environment is a free-form deployment label (development, staging, production).
Environment string `yaml:"environment" json:"environment"`
// HTTPAddr is the IPv4 address to listen on. Empty disables that listener.
HTTPAddr string `yaml:"listenAddr" json:"listenAddr"`
// HTTPAddr6 is the IPv6 address to listen on. Empty disables that listener.
HTTPAddr6 string `yaml:"listenAddr6" json:"listenAddr6"`
// Port is the TCP port shared by both listeners.
Port string `yaml:"port" json:"port"`
// DBDriver selects the storage backend. Currently "sqlite" is implemented.
DBDriver string `yaml:"dbDriver" json:"dbDriver"`
// DBDSN is the driver-specific connection string.
DBDSN string `yaml:"dbDsn" json:"dbDsn"`
// TelemetryEnabled turns on OpenTelemetry tracing/metrics export.
TelemetryEnabled bool `yaml:"telemetryEnabled" json:"telemetryEnabled"`
// OTLPEndpoint is the OTLP/HTTP collector endpoint (host:port or URL).
OTLPEndpoint string `yaml:"otlpEndpoint" json:"otlpEndpoint"`
// CORSOrigins is the list of allowed browser origins ("*" allows all).
CORSOrigins []string `yaml:"corsOrigins" json:"corsOrigins"`
// StaticDir optionally serves a built frontend from this directory.
StaticDir string `yaml:"staticDir" json:"staticDir"`
// Auth configures management/submission authentication.
Auth AuthConfig `yaml:"auth" json:"auth"`
}
// AuthConfig controls password fallback and OAuth/OIDC authentication.
type AuthConfig struct {
// LocalPassword is used when no OIDC issuer is configured. A bcrypt hash is
// also accepted. It is ignored when OIDC is enabled.
LocalPassword string `yaml:"localPassword" json:"localPassword"`
// RequireSubmissionAuth requires any authenticated user to list/open forms
// and submit responses. Management endpoints are always protected.
RequireSubmissionAuth bool `yaml:"requireSubmissionAuth" json:"requireSubmissionAuth"`
// LocalTokenSecret signs local password-mode JWTs. Empty generates a random
// secret at startup (tokens then do not survive a restart or multiple replicas).
LocalTokenSecret string `yaml:"localTokenSecret" json:"localTokenSecret"`
// LocalTokenTTLHours is the lifetime of local password-mode tokens.
LocalTokenTTLHours int `yaml:"localTokenTtlHours" json:"localTokenTtlHours"`
// OIDC configures an external OpenID Connect provider.
OIDC OIDCConfig `yaml:"oidc" json:"oidc"`
}
// OIDCConfig describes the external OpenID Connect provider used by the SPA.
type OIDCConfig struct {
IssuerURL string `yaml:"issuerUrl" json:"issuerUrl"`
ClientID string `yaml:"clientId" json:"clientId"`
Scopes []string `yaml:"scopes" json:"scopes"`
// RoleClaim is a dot-separated claim path, e.g. "roles" or
// "realm_access.roles". The value may be a string or an array of strings.
RoleClaim string `yaml:"roleClaim" json:"roleClaim"`
AdminRole string `yaml:"adminRole" json:"adminRole"`
ViewRole string `yaml:"viewRole" json:"viewRole"`
}
// OIDCEnabled reports whether external OIDC authentication is configured.
func (c AuthConfig) OIDCEnabled() bool {
return strings.TrimSpace(c.OIDC.IssuerURL) != ""
}
// Default returns the built-in configuration.
func Default() *Config {
return &Config{
LogLevel: "info",
LogFormat: "console",
Environment: "development",
HTTPAddr: "0.0.0.0",
HTTPAddr6: "",
Port: "8080",
DBDriver: "sqlite",
DBDSN: "file:kereru.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)",
TelemetryEnabled: false,
OTLPEndpoint: "localhost:4318",
CORSOrigins: []string{"http://localhost:5173"},
StaticDir: "",
Auth: AuthConfig{
LocalPassword: "admin",
LocalTokenTTLHours: 8,
OIDC: OIDCConfig{
Scopes: []string{"openid", "profile", "email"},
RoleClaim: "roles",
AdminRole: "admin",
ViewRole: "view",
},
},
}
}
// Load builds the configuration from args (usually os.Args[1:]).
func Load(args []string) (*Config, error) {
cfg := Default()
dotEnvPath := LoadDotEnv()
_ = dotEnvPath
confPath := scanConf(args)
if confPath != "" {
if err := loadFile(cfg, confPath); err != nil {
return nil, err
}
}
applyEnv(cfg)
fs := flag.NewFlagSet("kereru-forms", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
var (
flagConf = fs.String("conf", confPath, "path to a YAML or JSON configuration file")
flagLevel = fs.String("log-level", cfg.LogLevel, "log level: trace, debug, info, warn, error")
flagFormat = fs.String("log-format", cfg.LogFormat, "log format: console or json")
flagAddr = fs.String("listen-addr", cfg.HTTPAddr, "IPv4 listen address (empty to disable)")
flagAddr6 = fs.String("listen-addr6", cfg.HTTPAddr6, "IPv6 listen address (empty to disable)")
flagPort = fs.String("port", cfg.Port, "TCP listen port")
flagDB = fs.String("db-dsn", cfg.DBDSN, "database connection string")
flagDriver = fs.String("db-driver", cfg.DBDriver, "database driver (sqlite)")
flagStatic = fs.String("static-dir", cfg.StaticDir, "directory containing a built frontend (optional)")
)
fs.Usage = func() {
fmt.Fprintf(fs.Output(), "Usage: kereru-forms [flags]\n\nFlags:\n")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
return nil, err
}
_ = flagConf
cfg.LogLevel = *flagLevel
cfg.LogFormat = *flagFormat
cfg.HTTPAddr = *flagAddr
cfg.HTTPAddr6 = *flagAddr6
cfg.Port = *flagPort
cfg.DBDSN = *flagDB
cfg.DBDriver = *flagDriver
cfg.StaticDir = *flagStatic
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
}
// Validate checks that the configuration is usable.
func (c *Config) Validate() error {
if strings.TrimSpace(c.Port) == "" {
return errors.New("port must not be empty")
}
if _, err := strconv.Atoi(c.Port); err != nil {
return fmt.Errorf("port %q is not a number", c.Port)
}
if c.HTTPAddr == "" && c.HTTPAddr6 == "" {
return errors.New("at least one of listenAddr or listenAddr6 must be set")
}
if strings.TrimSpace(c.DBDriver) == "" {
return errors.New("dbDriver must not be empty")
}
if strings.TrimSpace(c.DBDSN) == "" {
return errors.New("dbDsn must not be empty")
}
if c.Auth.LocalTokenTTLHours <= 0 {
return errors.New("auth.localTokenTtlHours must be greater than zero")
}
if strings.TrimSpace(c.Auth.OIDC.AdminRole) == "" {
return errors.New("auth.oidc.adminRole must not be empty")
}
if strings.TrimSpace(c.Auth.OIDC.ViewRole) == "" {
return errors.New("auth.oidc.viewRole must not be empty")
}
if strings.TrimSpace(c.Auth.OIDC.RoleClaim) == "" {
return errors.New("auth.oidc.roleClaim must not be empty")
}
if c.Auth.OIDCEnabled() {
if strings.TrimSpace(c.Auth.OIDC.ClientID) == "" {
return errors.New("auth.oidc.clientId is required when auth.oidc.issuerUrl is set")
}
hasOpenID := false
for _, scope := range c.Auth.OIDC.Scopes {
if strings.TrimSpace(scope) == "openid" {
hasOpenID = true
break
}
}
if !hasOpenID {
return errors.New("auth.oidc.scopes must include openid")
}
} else if strings.TrimSpace(c.Auth.LocalPassword) == "" {
return errors.New("auth.localPassword must not be empty when OIDC is not configured")
}
return nil
}
// Addresses returns the listen addresses (host:port) for enabled listeners.
func (c *Config) Addresses() []string {
var addrs []string
if c.HTTPAddr != "" {
addrs = append(addrs, joinHostPort(c.HTTPAddr, c.Port))
}
if c.HTTPAddr6 != "" {
addrs = append(addrs, joinHostPort(c.HTTPAddr6, c.Port))
}
return addrs
}
func joinHostPort(host, port string) string {
if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
return "[" + host + "]:" + port
}
return host + ":" + port
}
func scanConf(args []string) string {
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--conf" || arg == "-conf" {
if i+1 < len(args) {
return args[i+1]
}
}
if strings.HasPrefix(arg, "--conf=") {
return strings.TrimPrefix(arg, "--conf=")
}
if strings.HasPrefix(arg, "-conf=") {
return strings.TrimPrefix(arg, "-conf=")
}
}
return ""
}
func loadFile(cfg *Config, path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read config %s: %w", path, err)
}
if strings.EqualFold(filepath.Ext(path), ".json") {
if err := yaml.Unmarshal(data, cfg); err != nil {
return fmt.Errorf("parse JSON config %s: %w", path, err)
}
return nil
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return fmt.Errorf("parse YAML config %s: %w", path, err)
}
return nil
}
func applyEnv(cfg *Config) {
setString(&cfg.LogLevel, "KERERU_LOG_LEVEL")
setString(&cfg.LogFormat, "KERERU_LOG_FORMAT")
setString(&cfg.Environment, "KERERU_ENVIRONMENT")
setString(&cfg.HTTPAddr, "KERERU_LISTEN_ADDR")
setString(&cfg.HTTPAddr6, "KERERU_LISTEN_ADDR6")
setString(&cfg.Port, "KERERU_PORT")
setString(&cfg.DBDriver, "KERERU_DB_DRIVER")
setString(&cfg.DBDSN, "KERERU_DB_DSN")
setString(&cfg.StaticDir, "KERERU_STATIC_DIR")
setString(&cfg.Auth.LocalPassword, "KERERU_AUTH_LOCAL_PASSWORD")
setString(&cfg.Auth.LocalTokenSecret, "KERERU_AUTH_LOCAL_TOKEN_SECRET")
if v, ok := os.LookupEnv("KERERU_AUTH_REQUIRE_SUBMISSION_AUTH"); ok {
if b, err := strconv.ParseBool(v); err == nil {
cfg.Auth.RequireSubmissionAuth = b
}
}
if v, ok := os.LookupEnv("KERERU_AUTH_LOCAL_TOKEN_TTL_HOURS"); ok {
if n, err := strconv.Atoi(v); err == nil {
cfg.Auth.LocalTokenTTLHours = n
}
}
setString(&cfg.Auth.OIDC.IssuerURL, "KERERU_OIDC_ISSUER_URL")
setString(&cfg.Auth.OIDC.ClientID, "KERERU_OIDC_CLIENT_ID")
setString(&cfg.Auth.OIDC.RoleClaim, "KERERU_OIDC_ROLE_CLAIM")
setString(&cfg.Auth.OIDC.AdminRole, "KERERU_OIDC_ADMIN_ROLE")
setString(&cfg.Auth.OIDC.ViewRole, "KERERU_OIDC_VIEW_ROLE")
if v, ok := os.LookupEnv("KERERU_OIDC_SCOPES"); ok {
var scopes []string
for _, part := range strings.Split(v, ",") {
if p := strings.TrimSpace(part); p != "" {
scopes = append(scopes, p)
}
}
if len(scopes) > 0 {
cfg.Auth.OIDC.Scopes = scopes
}
}
if v, ok := os.LookupEnv("KERERU_TELEMETRY_ENABLED"); ok {
if b, err := strconv.ParseBool(v); err == nil {
cfg.TelemetryEnabled = b
}
}
setString(&cfg.OTLPEndpoint, "OTEL_EXPORTER_OTLP_ENDPOINT")
if v, ok := os.LookupEnv("KERERU_CORS_ORIGINS"); ok {
var origins []string
for _, part := range strings.Split(v, ",") {
if p := strings.TrimSpace(part); p != "" {
origins = append(origins, p)
}
}
if len(origins) > 0 {
cfg.CORSOrigins = origins
}
}
}
func setString(dst *string, key string) {
if v, ok := os.LookupEnv(key); ok {
*dst = v
}
}
// LoadDotEnv reads a .env file from the directory containing the executable
// or, failing that, the working directory. Existing environment variables are never
// overwritten. It returns the path that was loaded, if any.
func LoadDotEnv() string {
candidateDirs := []string{}
if exe, err := os.Executable(); err == nil {
candidateDirs = append(candidateDirs, filepath.Dir(exe))
}
if wd, err := os.Getwd(); err == nil {
candidateDirs = append(candidateDirs, wd)
}
for _, dir := range candidateDirs {
path := filepath.Join(dir, ".env")
data, err := os.ReadFile(path)
if err != nil {
continue
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
line = strings.TrimPrefix(line, "export ")
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.Trim(strings.TrimSpace(value), `"'`)
if key == "" {
continue
}
if _, exists := os.LookupEnv(key); !exists {
_ = os.Setenv(key, value)
}
}
return path
}
return ""
}

View File

@ -0,0 +1,96 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestDefaults(t *testing.T) {
cfg, err := Load(nil)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Port != "8080" {
t.Fatalf("Port = %q, want 8080", cfg.Port)
}
if cfg.DBDriver != "sqlite" {
t.Fatalf("DBDriver = %q, want sqlite", cfg.DBDriver)
}
if got := cfg.Addresses(); len(got) != 1 || got[0] != "0.0.0.0:8080" {
t.Fatalf("Addresses = %v, want [0.0.0.0:8080]", got)
}
}
func TestPrecedence(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
content := `
logLevel: debug
port: "9090"
listenAddr: "127.0.0.1"
dbDsn: "file:from-file.db"
corsOrigins:
- "https://forms.example.com"
`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("KERERU_LOG_LEVEL", "warn")
t.Setenv("KERERU_PORT", "9091")
cfg, err := Load([]string{"--conf", path, "--port", "9092"})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.LogLevel != "warn" {
t.Fatalf("LogLevel = %q, want env override warn", cfg.LogLevel)
}
if cfg.Port != "9092" {
t.Fatalf("Port = %q, want flag override 9092", cfg.Port)
}
if cfg.HTTPAddr != "127.0.0.1" {
t.Fatalf("HTTPAddr = %q, want file value", cfg.HTTPAddr)
}
if cfg.DBDSN != "file:from-file.db" {
t.Fatalf("DBDSN = %q, want file value", cfg.DBDSN)
}
if len(cfg.CORSOrigins) != 1 || cfg.CORSOrigins[0] != "https://forms.example.com" {
t.Fatalf("CORSOrigins = %v", cfg.CORSOrigins)
}
}
func TestJSONConfigAndIPv6(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
content := `{"listenAddr":"","listenAddr6":"::1","port":"8081"}`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load([]string{"--conf=" + path})
if err != nil {
t.Fatalf("Load: %v", err)
}
addrs := cfg.Addresses()
if len(addrs) != 1 || addrs[0] != "[::1]:8081" {
t.Fatalf("Addresses = %v, want [[::1]:8081]", addrs)
}
}
func TestValidateRequiresListener(t *testing.T) {
cfg := Default()
cfg.HTTPAddr = ""
cfg.HTTPAddr6 = ""
if err := cfg.Validate(); err == nil {
t.Fatal("expected validation error when no listener is configured")
}
}
func TestInvalidPort(t *testing.T) {
if _, err := Load([]string{"--port", "not-a-port"}); err == nil {
t.Fatal("expected validation error for invalid port")
}
}

View File

@ -0,0 +1,161 @@
package database
import (
"context"
"fmt"
"github.com/google/uuid"
)
func newID() string { return uuid.NewString() }
type migration struct {
version int
name string
statements string
}
var migrations = []migration{
{
version: 1,
name: "initial schema",
statements: `
CREATE TABLE IF NOT EXISTS forms (
id TEXT PRIMARY KEY,
form_group_id TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
title TEXT NOT NULL,
description TEXT,
introduction TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
requires_access_code INTEGER NOT NULL DEFAULT 0,
logo_url TEXT,
company_name TEXT,
contact_email TEXT,
contact_phone TEXT,
contact_url TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (form_group_id, version)
);
CREATE INDEX IF NOT EXISTS idx_forms_group ON forms (form_group_id);
CREATE INDEX IF NOT EXISTS idx_forms_active ON forms (is_active);
CREATE TABLE IF NOT EXISTS questions (
id TEXT PRIMARY KEY,
form_id TEXT NOT NULL REFERENCES forms (id) ON DELETE CASCADE,
position INTEGER NOT NULL,
question_key TEXT NOT NULL,
label TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL,
required INTEGER NOT NULL DEFAULT 0,
options_json TEXT,
min_value REAL,
max_value REAL,
placeholder TEXT,
multiline INTEGER NOT NULL DEFAULT 0,
UNIQUE (form_id, question_key)
);
CREATE INDEX IF NOT EXISTS idx_questions_form ON questions (form_id);
CREATE TABLE IF NOT EXISTS submissions (
id TEXT PRIMARY KEY,
form_id TEXT NOT NULL REFERENCES forms (id) ON DELETE CASCADE,
form_version INTEGER NOT NULL,
user_id TEXT NOT NULL,
user_id_type TEXT NOT NULL,
submitted_at TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT,
locale TEXT,
timezone TEXT,
place TEXT,
referer TEXT
);
CREATE INDEX IF NOT EXISTS idx_submissions_form ON submissions (form_id);
CREATE INDEX IF NOT EXISTS idx_submissions_user ON submissions (user_id);
CREATE TABLE IF NOT EXISTS answers (
id TEXT PRIMARY KEY,
submission_id TEXT NOT NULL REFERENCES submissions (id) ON DELETE CASCADE,
question_id TEXT NOT NULL,
question_key TEXT NOT NULL,
value_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_answers_submission ON answers (submission_id);
CREATE TABLE IF NOT EXISTS access_codes (
id TEXT PRIMARY KEY,
form_id TEXT NOT NULL REFERENCES forms (id) ON DELETE CASCADE,
code TEXT NOT NULL UNIQUE,
used INTEGER NOT NULL DEFAULT 0,
used_at TEXT,
submission_id TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_codes_form ON access_codes (form_id);
`,
},
{
version: 2,
name: "signatures and offline pdf",
statements: `
ALTER TABLE forms ADD COLUMN pdf_url TEXT;
ALTER TABLE forms ADD COLUMN signature_required INTEGER NOT NULL DEFAULT 0;
ALTER TABLE submissions ADD COLUMN signature_json TEXT;
ALTER TABLE submissions ADD COLUMN offline_pdf_name TEXT;
ALTER TABLE submissions ADD COLUMN offline_pdf BLOB;
`,
},
{
version: 3,
name: "external reference",
statements: `
ALTER TABLE forms ADD COLUMN external_reference_required INTEGER NOT NULL DEFAULT 0;
ALTER TABLE forms ADD COLUMN external_reference_label TEXT;
ALTER TABLE submissions ADD COLUMN external_reference TEXT;
`,
},
}
func (s *sqliteStore) migrate(ctx context.Context) error {
if _, err := s.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)`); err != nil {
return err
}
for _, m := range migrations {
var exists int
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, m.version).Scan(&exists)
if err != nil {
return err
}
if exists > 0 {
continue
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, m.statements); err != nil {
_ = tx.Rollback()
return fmt.Errorf("apply migration %d (%s): %w", m.version, m.name, err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO schema_migrations (version, name) VALUES (?, ?)`,
m.version, m.name); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}

View File

@ -0,0 +1,642 @@
package database
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
_ "modernc.org/sqlite"
)
// Open creates a Store for the requested driver. SQLite is fully supported;
// the driver switch is the extension point for PostgreSQL and MariaDB.
func Open(driver, dsn string) (Store, error) {
switch strings.ToLower(strings.TrimSpace(driver)) {
case "", "sqlite", "sqlite3":
return openSQLite(dsn)
default:
return nil, fmt.Errorf("unsupported database driver %q (supported: sqlite)", driver)
}
}
type sqliteStore struct {
db *sql.DB
}
func openSQLite(dsn string) (*sqliteStore, error) {
if err := ensureSQLiteDir(dsn); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// SQLite allows a single writer; a small pool keeps behaviour predictable.
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(0)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
store := &sqliteStore{db: db}
if err := store.migrate(ctx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("migrate sqlite: %w", err)
}
return store, nil
}
func ensureSQLiteDir(dsn string) error {
path := dsn
if idx := strings.Index(path, "?"); idx >= 0 {
path = path[:idx]
}
path = strings.TrimPrefix(path, "file:")
if path == "" || path == ":memory:" || strings.HasPrefix(path, ":memory:") {
return nil
}
dir := filepath.Dir(path)
if dir == "." || dir == "" {
return nil
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create database directory %s: %w", dir, err)
}
return nil
}
func (s *sqliteStore) Close() error { return s.db.Close() }
func (s *sqliteStore) Ping(ctx context.Context) error { return s.db.PingContext(ctx) }
func formatTime(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) }
func parseTime(v string) time.Time {
t, err := time.Parse(time.RFC3339Nano, v)
if err != nil {
return time.Time{}
}
return t.UTC()
}
func nullString(v *string) sql.NullString {
if v == nil || *v == "" {
return sql.NullString{}
}
return sql.NullString{String: *v, Valid: true}
}
func fromNullString(v sql.NullString) *string {
if !v.Valid {
return nil
}
s := v.String
return &s
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func (s *sqliteStore) CreateForm(ctx context.Context, form *Form) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if err := insertForm(ctx, tx, form); err != nil {
return err
}
for i := range form.Questions {
if err := insertQuestion(ctx, tx, &form.Questions[i]); err != nil {
return err
}
}
return tx.Commit()
}
func (s *sqliteStore) UpdateForm(ctx context.Context, form *Form) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx, `UPDATE forms SET
title = ?, description = ?, introduction = ?, is_active = ?,
requires_access_code = ?, signature_required = ?,
external_reference_required = ?, external_reference_label = ?,
logo_url = ?, pdf_url = ?, company_name = ?, contact_email = ?,
contact_phone = ?, contact_url = ?, updated_at = ?
WHERE id = ?`,
form.Title, nullString(form.Description), nullString(form.Introduction),
boolToInt(form.IsActive), boolToInt(form.RequiresAccessCode),
boolToInt(form.SignatureRequired), boolToInt(form.ExternalReferenceRequired),
nullString(form.ExternalReferenceLabel), nullString(form.LogoURL),
nullString(form.PDFURL), nullString(form.CompanyName),
nullString(form.ContactEmail), nullString(form.ContactPhone),
nullString(form.ContactURL), formatTime(form.UpdatedAt), form.ID,
)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
if _, err := tx.ExecContext(ctx, `DELETE FROM questions WHERE form_id = ?`, form.ID); err != nil {
return err
}
for i := range form.Questions {
form.Questions[i].FormID = form.ID
if err := insertQuestion(ctx, tx, &form.Questions[i]); err != nil {
return err
}
}
return tx.Commit()
}
func insertForm(ctx context.Context, tx *sql.Tx, form *Form) error {
_, err := tx.ExecContext(ctx, `INSERT INTO forms (
id, form_group_id, version, title, description, introduction, is_active,
requires_access_code, signature_required, external_reference_required,
external_reference_label, logo_url, pdf_url, company_name, contact_email,
contact_phone, contact_url, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
form.ID, form.FormGroupID, form.Version, form.Title,
nullString(form.Description), nullString(form.Introduction),
boolToInt(form.IsActive), boolToInt(form.RequiresAccessCode),
boolToInt(form.SignatureRequired), boolToInt(form.ExternalReferenceRequired),
nullString(form.ExternalReferenceLabel), nullString(form.LogoURL),
nullString(form.PDFURL), nullString(form.CompanyName),
nullString(form.ContactEmail), nullString(form.ContactPhone),
nullString(form.ContactURL), formatTime(form.CreatedAt), formatTime(form.UpdatedAt),
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
return ErrConflict
}
return err
}
return nil
}
func insertQuestion(ctx context.Context, tx *sql.Tx, q *Question) error {
options, err := json.Marshal(q.Options)
if err != nil {
return err
}
var minV, maxV sql.NullFloat64
if q.MinValue != nil {
minV = sql.NullFloat64{Float64: *q.MinValue, Valid: true}
}
if q.MaxValue != nil {
maxV = sql.NullFloat64{Float64: *q.MaxValue, Valid: true}
}
_, err = tx.ExecContext(ctx, `INSERT INTO questions (
id, form_id, position, question_key, label, description, type, required,
options_json, min_value, max_value, placeholder, multiline
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
q.ID, q.FormID, q.Position, q.Key, q.Label, nullString(q.Description),
q.Type, boolToInt(q.Required), string(options), minV, maxV,
nullString(q.Placeholder), boolToInt(q.Multiline),
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
return ErrConflict
}
return err
}
return nil
}
const formColumns = `id, form_group_id, version, title, description, introduction,
is_active, requires_access_code, signature_required, external_reference_required,
external_reference_label, logo_url, pdf_url, company_name, contact_email,
contact_phone, contact_url, created_at, updated_at`
func (s *sqliteStore) GetForm(ctx context.Context, id string) (*Form, error) {
row := s.db.QueryRowContext(ctx, `SELECT `+formColumns+` FROM forms WHERE id = ?`, id)
form, err := scanForm(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
questions, err := s.listQuestions(ctx, id)
if err != nil {
return nil, err
}
form.Questions = questions
return form, nil
}
func scanForm(row interface{ Scan(...any) error }) (*Form, error) {
var (
form Form
description, introduction, logoURL sql.NullString
pdfURL, externalReferenceLabel sql.NullString
companyName, email, phone, contactURL sql.NullString
createdAt, updatedAt string
isActive, requiresAccessCode int
signatureRequired, externalRefReq int
)
err := row.Scan(
&form.ID, &form.FormGroupID, &form.Version, &form.Title,
&description, &introduction, &isActive, &requiresAccessCode,
&signatureRequired, &externalRefReq, &externalReferenceLabel,
&logoURL, &pdfURL, &companyName, &email, &phone, &contactURL,
&createdAt, &updatedAt,
)
if err != nil {
return nil, err
}
form.Description = fromNullString(description)
form.Introduction = fromNullString(introduction)
form.LogoURL = fromNullString(logoURL)
form.PDFURL = fromNullString(pdfURL)
form.ExternalReferenceLabel = fromNullString(externalReferenceLabel)
form.CompanyName = fromNullString(companyName)
form.ContactEmail = fromNullString(email)
form.ContactPhone = fromNullString(phone)
form.ContactURL = fromNullString(contactURL)
form.IsActive = isActive == 1
form.RequiresAccessCode = requiresAccessCode == 1
form.SignatureRequired = signatureRequired == 1
form.ExternalReferenceRequired = externalRefReq == 1
form.CreatedAt = parseTime(createdAt)
form.UpdatedAt = parseTime(updatedAt)
return &form, nil
}
func (s *sqliteStore) listQuestions(ctx context.Context, formID string) ([]Question, error) {
rows, err := s.db.QueryContext(ctx, `SELECT
id, form_id, position, question_key, label, description, type, required,
options_json, min_value, max_value, placeholder, multiline
FROM questions WHERE form_id = ? ORDER BY position`, formID)
if err != nil {
return nil, err
}
defer rows.Close()
var questions []Question
for rows.Next() {
var (
q Question
description, placeholder sql.NullString
optionsJSON string
minV, maxV sql.NullFloat64
required, multiline int
)
if err := rows.Scan(&q.ID, &q.FormID, &q.Position, &q.Key, &q.Label,
&description, &q.Type, &required, &optionsJSON, &minV, &maxV,
&placeholder, &multiline); err != nil {
return nil, err
}
q.Description = fromNullString(description)
q.Placeholder = fromNullString(placeholder)
q.Required = required == 1
q.Multiline = multiline == 1
if minV.Valid {
v := minV.Float64
q.MinValue = &v
}
if maxV.Valid {
v := maxV.Float64
q.MaxValue = &v
}
if optionsJSON != "" && optionsJSON != "null" {
if err := json.Unmarshal([]byte(optionsJSON), &q.Options); err != nil {
return nil, fmt.Errorf("decode options for question %s: %w", q.ID, err)
}
}
questions = append(questions, q)
}
return questions, rows.Err()
}
func (s *sqliteStore) ListForms(ctx context.Context, includeInactive bool) ([]FormSummary, error) {
query := `SELECT f.id, f.form_group_id, f.version, f.title, f.description, f.logo_url,
f.pdf_url, f.external_reference_required, f.external_reference_label,
f.is_active, f.created_at, f.updated_at,
(SELECT COUNT(*) FROM questions q WHERE q.form_id = f.id) AS question_count
FROM forms f`
if !includeInactive {
query += ` WHERE f.is_active = 1`
}
query += ` ORDER BY f.updated_at DESC`
rows, err := s.db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
summaries := make([]FormSummary, 0)
for rows.Next() {
var (
summary FormSummary
description sql.NullString
logoURL sql.NullString
pdfURL sql.NullString
externalRefLabel sql.NullString
createdAt string
updatedAt string
isActive int
externalRefRequired int
)
if err := rows.Scan(&summary.ID, &summary.FormGroupID, &summary.Version,
&summary.Title, &description, &logoURL, &pdfURL,
&externalRefRequired, &externalRefLabel, &isActive, &createdAt, &updatedAt,
&summary.QuestionCount); err != nil {
return nil, err
}
summary.Description = fromNullString(description)
summary.LogoURL = fromNullString(logoURL)
summary.PDFURL = fromNullString(pdfURL)
summary.ExternalReferenceLabel = fromNullString(externalRefLabel)
summary.ExternalReferenceRequired = externalRefRequired == 1
summary.IsActive = isActive == 1
summary.CreatedAt = parseTime(createdAt)
summary.UpdatedAt = parseTime(updatedAt)
summaries = append(summaries, summary)
}
return summaries, rows.Err()
}
func (s *sqliteStore) SetFormActive(ctx context.Context, id string, active bool) error {
res, err := s.db.ExecContext(ctx,
`UPDATE forms SET is_active = ?, updated_at = ? WHERE id = ?`,
boolToInt(active), formatTime(time.Now()), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *sqliteStore) DeleteForm(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM forms WHERE id = ?`, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *sqliteStore) CreateSubmission(ctx context.Context, sub *Submission, accessCode string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var signatureJSON sql.NullString
if sub.Signature != nil {
encoded, err := json.Marshal(sub.Signature)
if err != nil {
return err
}
signatureJSON = sql.NullString{String: string(encoded), Valid: true}
}
var offlinePDF any
if len(sub.OfflinePDF) > 0 {
offlinePDF = sub.OfflinePDF
}
_, err = tx.ExecContext(ctx, `INSERT INTO submissions (
id, form_id, form_version, user_id, user_id_type, submitted_at,
ip_address, user_agent, locale, timezone, place, referer,
external_reference, signature_json, offline_pdf_name, offline_pdf
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
sub.ID, sub.FormID, sub.FormVersion, sub.UserID, sub.UserIDType,
formatTime(sub.SubmittedAt), nullString(sub.IPAddress),
nullString(sub.UserAgent), nullString(sub.Locale), nullString(sub.Timezone),
nullString(sub.Place), nullString(sub.Referer),
nullString(sub.ExternalReference), signatureJSON,
nullString(sub.OfflinePDFName), offlinePDF,
)
if err != nil {
return err
}
for i := range sub.Answers {
if _, err := tx.ExecContext(ctx, `INSERT INTO answers (
id, submission_id, question_id, question_key, value_json
) VALUES (?, ?, ?, ?, ?)`,
newID(), sub.ID, sub.Answers[i].QuestionID,
sub.Answers[i].QuestionKey, string(sub.Answers[i].Value),
); err != nil {
return err
}
}
if accessCode != "" {
res, err := tx.ExecContext(ctx, `UPDATE access_codes
SET used = 1, used_at = ?, submission_id = ?
WHERE form_id = ? AND code = ? AND used = 0`,
formatTime(time.Now()), sub.ID, sub.FormID, accessCode)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
var used int
err := tx.QueryRowContext(ctx,
`SELECT used FROM access_codes WHERE form_id = ? AND code = ?`,
sub.FormID, accessCode).Scan(&used)
if errors.Is(err, sql.ErrNoRows) {
return ErrAccessCode
}
if err != nil {
return err
}
return ErrCodeAlreadyIn
}
}
return tx.Commit()
}
func (s *sqliteStore) GetSubmission(ctx context.Context, id string) (*Submission, error) {
var (
sub Submission
submittedAt string
ipAddress, userAgent, locale, timezone sql.NullString
place, referer sql.NullString
signatureJSON sql.NullString
offlinePDFName sql.NullString
externalReference sql.NullString
)
err := s.db.QueryRowContext(ctx, `SELECT id, form_id, form_version, user_id,
user_id_type, submitted_at, ip_address, user_agent, locale, timezone,
place, referer, external_reference, signature_json, offline_pdf_name
FROM submissions WHERE id = ?`, id).Scan(
&sub.ID, &sub.FormID, &sub.FormVersion, &sub.UserID, &sub.UserIDType,
&submittedAt, &ipAddress, &userAgent, &locale, &timezone, &place, &referer,
&externalReference, &signatureJSON, &offlinePDFName,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
sub.SubmittedAt = parseTime(submittedAt)
sub.IPAddress = fromNullString(ipAddress)
sub.UserAgent = fromNullString(userAgent)
sub.Locale = fromNullString(locale)
sub.Timezone = fromNullString(timezone)
sub.Place = fromNullString(place)
sub.Referer = fromNullString(referer)
sub.ExternalReference = fromNullString(externalReference)
sub.OfflinePDFName = fromNullString(offlinePDFName)
if signatureJSON.Valid && signatureJSON.String != "" {
var signature Signature
if err := json.Unmarshal([]byte(signatureJSON.String), &signature); err != nil {
return nil, fmt.Errorf("decode signature for submission %s: %w", id, err)
}
sub.Signature = &signature
}
rows, err := s.db.QueryContext(ctx, `SELECT question_id, question_key, value_json
FROM answers WHERE submission_id = ?`, id)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var (
a Answer
valueJSON string
)
if err := rows.Scan(&a.QuestionID, &a.QuestionKey, &valueJSON); err != nil {
return nil, err
}
a.Value = json.RawMessage(valueJSON)
sub.Answers = append(sub.Answers, a)
}
return &sub, rows.Err()
}
func (s *sqliteStore) GetSubmissionOfflinePDF(ctx context.Context, id string) ([]byte, string, error) {
var (
data []byte
filename sql.NullString
)
err := s.db.QueryRowContext(ctx,
`SELECT offline_pdf, offline_pdf_name FROM submissions WHERE id = ?`, id).
Scan(&data, &filename)
if errors.Is(err, sql.ErrNoRows) {
return nil, "", ErrNotFound
}
if err != nil {
return nil, "", err
}
if len(data) == 0 {
return nil, "", ErrNotFound
}
name := "submission.pdf"
if filename.Valid && strings.TrimSpace(filename.String) != "" {
name = filepath.Base(filename.String)
}
return data, name, nil
}
func (s *sqliteStore) ListSubmissions(ctx context.Context, formID string) ([]SubmissionSummary, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, form_id, form_version,
user_id, user_id_type, external_reference, submitted_at FROM submissions
WHERE form_id = ? ORDER BY submitted_at DESC`, formID)
if err != nil {
return nil, err
}
defer rows.Close()
summaries := make([]SubmissionSummary, 0)
for rows.Next() {
var (
summary SubmissionSummary
submittedAt string
externalReference sql.NullString
)
if err := rows.Scan(&summary.ID, &summary.FormID, &summary.FormVersion,
&summary.UserID, &summary.UserIDType, &externalReference, &submittedAt); err != nil {
return nil, err
}
summary.ExternalReference = fromNullString(externalReference)
summary.SubmittedAt = parseTime(submittedAt)
summaries = append(summaries, summary)
}
return summaries, rows.Err()
}
func (s *sqliteStore) CreateAccessCodes(ctx context.Context, codes []AccessCode) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
for _, code := range codes {
_, err := tx.ExecContext(ctx, `INSERT INTO access_codes
(id, form_id, code, used, created_at) VALUES (?, ?, ?, ?, ?)`,
code.ID, code.FormID, code.Code, boolToInt(code.Used), formatTime(code.CreatedAt))
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
return ErrConflict
}
return err
}
}
return tx.Commit()
}
func (s *sqliteStore) ListAccessCodes(ctx context.Context, formID string) ([]AccessCode, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, form_id, code, used,
used_at, created_at FROM access_codes WHERE form_id = ? ORDER BY created_at`, formID)
if err != nil {
return nil, err
}
defer rows.Close()
codes := make([]AccessCode, 0)
for rows.Next() {
var (
code AccessCode
usedAt sql.NullString
createdAt string
used int
)
if err := rows.Scan(&code.ID, &code.FormID, &code.Code, &used,
&usedAt, &createdAt); err != nil {
return nil, err
}
code.Used = used == 1
code.CreatedAt = parseTime(createdAt)
if usedAt.Valid {
t := parseTime(usedAt.String)
code.UsedAt = &t
}
codes = append(codes, code)
}
return codes, rows.Err()
}

View File

@ -0,0 +1,227 @@
package database
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"testing"
"time"
)
func testStore(t *testing.T) (*sqliteStore, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "kereru-test.db")
store, err := Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)")
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
return store.(*sqliteStore), path
}
func sampleForm() *Form {
now := time.Now().UTC()
return &Form{
ID: "11111111-1111-1111-1111-111111111111",
FormGroupID: "22222222-2222-2222-2222-222222222222",
Version: 1,
Title: "Customer feedback",
Description: strPtr("Tell us how we did"),
IsActive: true,
RequiresAccessCode: false,
CreatedAt: now,
UpdatedAt: now,
Questions: []Question{
{
ID: "33333333-3333-3333-3333-333333333333",
FormID: "11111111-1111-1111-1111-111111111111",
Position: 0,
Key: "happy",
Label: "Are you happy?",
Type: "boolean",
Required: true,
},
{
ID: "44444444-4444-4444-4444-444444444444",
FormID: "11111111-1111-1111-1111-111111111111",
Position: 1,
Key: "score",
Label: "Score",
Type: "number",
},
},
}
}
func strPtr(s string) *string { return &s }
func TestMigrateIsIdempotent(t *testing.T) {
_, path := testStore(t)
second, err := Open("sqlite", "file:"+path)
if err != nil {
t.Fatalf("re-open: %v", err)
}
_ = second.Close()
}
func TestFormCRUD(t *testing.T) {
store, _ := testStore(t)
ctx := context.Background()
form := sampleForm()
if err := store.CreateForm(ctx, form); err != nil {
t.Fatalf("CreateForm: %v", err)
}
got, err := store.GetForm(ctx, form.ID)
if err != nil {
t.Fatalf("GetForm: %v", err)
}
if got.Title != form.Title || len(got.Questions) != 2 {
t.Fatalf("unexpected form: %+v", got)
}
if got.Questions[0].Key != "happy" || got.Questions[1].Key != "score" {
t.Fatalf("questions out of order: %+v", got.Questions)
}
forms, err := store.ListForms(ctx, false)
if err != nil {
t.Fatalf("ListForms: %v", err)
}
if len(forms) != 1 || forms[0].QuestionCount != 2 {
t.Fatalf("ListForms = %+v", forms)
}
got.Title = "Updated title"
got.Questions = got.Questions[:1]
got.UpdatedAt = time.Now().UTC()
if err := store.UpdateForm(ctx, got); err != nil {
t.Fatalf("UpdateForm: %v", err)
}
updated, err := store.GetForm(ctx, form.ID)
if err != nil {
t.Fatalf("GetForm after update: %v", err)
}
if updated.Title != "Updated title" || len(updated.Questions) != 1 {
t.Fatalf("update not applied: %+v", updated)
}
if err := store.SetFormActive(ctx, form.ID, false); err != nil {
t.Fatalf("SetFormActive: %v", err)
}
active, err := store.ListForms(ctx, false)
if err != nil {
t.Fatalf("ListForms active: %v", err)
}
if len(active) != 0 {
t.Fatalf("expected no active forms, got %+v", active)
}
all, err := store.ListForms(ctx, true)
if err != nil {
t.Fatalf("ListForms all: %v", err)
}
if len(all) != 1 || all[0].IsActive {
t.Fatalf("expected one inactive form, got %+v", all)
}
if err := store.DeleteForm(ctx, form.ID); err != nil {
t.Fatalf("DeleteForm: %v", err)
}
if _, err := store.GetForm(ctx, form.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
func TestSubmissionsAndAccessCodes(t *testing.T) {
store, _ := testStore(t)
ctx := context.Background()
form := sampleForm()
form.RequiresAccessCode = true
if err := store.CreateForm(ctx, form); err != nil {
t.Fatalf("CreateForm: %v", err)
}
now := time.Now().UTC()
code := AccessCode{
ID: "55555555-5555-5555-5555-555555555555",
FormID: form.ID,
Code: "TEST-ABCD-EFGH",
CreatedAt: now,
}
if err := store.CreateAccessCodes(ctx, []AccessCode{code}); err != nil {
t.Fatalf("CreateAccessCodes: %v", err)
}
codes, err := store.ListAccessCodes(ctx, form.ID)
if err != nil {
t.Fatalf("ListAccessCodes: %v", err)
}
if len(codes) != 1 || codes[0].Used {
t.Fatalf("unexpected codes: %+v", codes)
}
value, _ := json.Marshal(true)
sub := &Submission{
ID: "66666666-6666-6666-6666-666666666666",
FormID: form.ID,
FormVersion: form.Version,
UserID: "person@example.com",
UserIDType: "email",
SubmittedAt: now,
Answers: []Answer{
{QuestionID: form.Questions[0].ID, QuestionKey: "happy", Value: value},
},
}
if err := store.CreateSubmission(ctx, sub, "WRONG"); !errors.Is(err, ErrAccessCode) {
t.Fatalf("expected ErrAccessCode, got %v", err)
}
if err := store.CreateSubmission(ctx, sub, code.Code); err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
got, err := store.GetSubmission(ctx, sub.ID)
if err != nil {
t.Fatalf("GetSubmission: %v", err)
}
if len(got.Answers) != 1 || got.Answers[0].QuestionKey != "happy" {
t.Fatalf("unexpected submission: %+v", got)
}
codes, _ = store.ListAccessCodes(ctx, form.ID)
if !codes[0].Used || codes[0].UsedAt == nil {
t.Fatalf("access code should be marked used: %+v", codes[0])
}
reuse := *sub
reuse.ID = "77777777-7777-7777-7777-777777777777"
if err := store.CreateSubmission(ctx, &reuse, code.Code); !errors.Is(err, ErrCodeAlreadyIn) {
t.Fatalf("expected ErrCodeAlreadyIn, got %v", err)
}
subs, err := store.ListSubmissions(ctx, form.ID)
if err != nil {
t.Fatalf("ListSubmissions: %v", err)
}
if len(subs) != 1 {
t.Fatalf("expected 1 submission, got %d", len(subs))
}
if err := store.DeleteForm(ctx, form.ID); err != nil {
t.Fatalf("DeleteForm with submissions: %v", err)
}
if _, err := store.GetSubmission(ctx, sub.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("submission should cascade delete, got %v", err)
}
if codes, err := store.ListAccessCodes(ctx, form.ID); err != nil || len(codes) != 0 {
t.Fatalf("access codes should cascade delete, got %v err=%v", codes, err)
}
}
func TestUnsupportedDriver(t *testing.T) {
if _, err := Open("oracle", "dsn"); err == nil {
t.Fatal("expected error for unsupported driver")
}
}

View File

@ -0,0 +1,164 @@
// Package database provides the persistence layer for kereru-forms.
//
// The Store interface deliberately hides SQL details so additional providers
// (PostgreSQL, MariaDB) can be added later without changing the API layer.
package database
import (
"context"
"encoding/json"
"errors"
"time"
)
// Common persistence errors.
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
ErrAccessCode = errors.New("access code is not valid")
ErrCodeAlreadyIn = errors.New("access code has already been used")
)
// QuestionOption is a selectable value for a select question.
type QuestionOption struct {
Value string `json:"value"`
Label string `json:"label"`
}
// Question is a single question belonging to a form version.
type Question struct {
ID string
FormID string
Position int
Key string
Label string
Description *string
Type string
Required bool
Options []QuestionOption
MinValue *float64
MaxValue *float64
Placeholder *string
Multiline bool
}
// Form is a concrete version of a logical form.
type Form struct {
ID string
FormGroupID string
Version int
Title string
Description *string
Introduction *string
IsActive bool
RequiresAccessCode bool
SignatureRequired bool
ExternalReferenceRequired bool
ExternalReferenceLabel *string
LogoURL *string
PDFURL *string
CompanyName *string
ContactEmail *string
ContactPhone *string
ContactURL *string
CreatedAt time.Time
UpdatedAt time.Time
Questions []Question
}
// FormSummary is a lightweight form listing entry.
type FormSummary struct {
ID string
FormGroupID string
Version int
Title string
Description *string
LogoURL *string
PDFURL *string
ExternalReferenceRequired bool
ExternalReferenceLabel *string
IsActive bool
QuestionCount int
CreatedAt time.Time
UpdatedAt time.Time
}
// Answer is a captured answer inside a submission.
type Answer struct {
QuestionID string
QuestionKey string
Value json.RawMessage
}
// Signature is a captured digital signature.
type Signature struct {
Type string `json:"type"`
DataURL string `json:"dataUrl"`
Name string `json:"name"`
Title *string `json:"title,omitempty"`
SignedAt time.Time `json:"signedAt"`
}
// Submission is a completed form response.
type Submission struct {
ID string
FormID string
FormVersion int
UserID string
UserIDType string
SubmittedAt time.Time
IPAddress *string
UserAgent *string
Locale *string
Timezone *string
Place *string
Referer *string
ExternalReference *string
Signature *Signature
OfflinePDFName *string
OfflinePDF []byte
Answers []Answer
}
// SubmissionSummary is a lightweight submission listing entry.
type SubmissionSummary struct {
ID string
FormID string
FormVersion int
UserID string
UserIDType string
ExternalReference *string
SubmittedAt time.Time
}
// AccessCode is a single-use code granting access to a form.
type AccessCode struct {
ID string
FormID string
Code string
Used bool
UsedAt *time.Time
CreatedAt time.Time
}
// Store is the persistence contract used by the API layer.
type Store interface {
Close() error
CreateForm(ctx context.Context, form *Form) error
UpdateForm(ctx context.Context, form *Form) error
GetForm(ctx context.Context, id string) (*Form, error)
ListForms(ctx context.Context, includeInactive bool) ([]FormSummary, error)
SetFormActive(ctx context.Context, id string, active bool) error
DeleteForm(ctx context.Context, id string) error
CreateSubmission(ctx context.Context, submission *Submission, accessCode string) error
GetSubmission(ctx context.Context, id string) (*Submission, error)
GetSubmissionOfflinePDF(ctx context.Context, id string) ([]byte, string, error)
ListSubmissions(ctx context.Context, formID string) ([]SubmissionSummary, error)
CreateAccessCodes(ctx context.Context, codes []AccessCode) error
ListAccessCodes(ctx context.Context, formID string) ([]AccessCode, error)
Ping(ctx context.Context) error
}

View File

@ -0,0 +1,89 @@
// Package logging configures the application's structured logger.
package logging
import (
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
)
// New builds a zerolog logger for the given level and format.
//
// level is one of: trace, debug, info, warn, error, fatal, panic.
// format is "json" for machine-readable output, anything else produces a
// human-friendly console output (useful for local development).
func New(level, format, environment string) zerolog.Logger {
lvl, err := zerolog.ParseLevel(strings.ToLower(strings.TrimSpace(level)))
if err != nil || lvl == zerolog.NoLevel {
lvl = zerolog.InfoLevel
}
var writer zerolog.LevelWriter
if strings.EqualFold(format, "json") {
writer = zerolog.MultiLevelWriter(os.Stdout)
} else {
console := zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339}
writer = zerolog.MultiLevelWriter(console)
}
logger := zerolog.New(writer).
Level(lvl).
With().
Timestamp().
Str("service", "kereru-forms").
Str("environment", environment).
Logger()
zerolog.SetGlobalLevel(lvl)
return logger
}
// GinMiddleware logs every HTTP request with useful observability fields.
func GinMiddleware(logger zerolog.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
if raw := c.Request.URL.RawQuery; raw != "" {
path = path + "?" + raw
}
c.Next()
event := logger.Info()
if c.Writer.Status() >= 500 {
event = logger.Error()
} else if c.Writer.Status() >= 400 {
event = logger.Warn()
}
event.
Str("method", c.Request.Method).
Str("path", path).
Int("status", c.Writer.Status()).
Dur("latency", time.Since(start)).
Str("client_ip", c.ClientIP()).
Str("user_agent", c.Request.UserAgent()).
Str("request_id", RequestID(c)).
Msg("http request")
}
}
const requestIDKey = "request_id"
// RequestID returns the correlation id assigned to the current request.
func RequestID(c *gin.Context) string {
if v, ok := c.Get(requestIDKey); ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// SetRequestID stores the correlation id on the context.
func SetRequestID(c *gin.Context, id string) {
c.Set(requestIDKey, id)
}

View File

@ -0,0 +1,475 @@
// Package pdf renders a printable representation of a form definition.
package pdf
import (
"bytes"
"fmt"
"strings"
"time"
"github.com/go-pdf/fpdf"
"github.com/google/uuid"
"kereru.forms/internal/database"
)
const (
pdfLeftMargin = 18.0
pdfPageWidth = 210.0
pdfPageHeight = 297.0
pdfBottomEdge = pdfPageHeight - 18
pdfContentWide = pdfPageWidth - pdfLeftMargin*2
pdfLabelWidth = 112.0
pdfAnswerX = pdfLeftMargin + pdfLabelWidth + 4
pdfAnswerWidth = pdfContentWide - pdfLabelWidth - 4
)
// Generate builds a PDF document for the supplied form. The document doubles
// as a printable offline form: it includes a submitter details area and space
// for handwritten answers.
func Generate(form *database.Form) ([]byte, error) {
if form == nil {
return nil, fmt.Errorf("form is nil")
}
doc := fpdf.New("P", "mm", "A4", "")
doc.SetMargins(pdfLeftMargin, pdfLeftMargin, pdfLeftMargin)
doc.SetAutoPageBreak(true, 18)
doc.SetTitle(sanitize(form.Title), false)
doc.SetAuthor(sanitize(deref(form.CompanyName, "Kereru Forms")), false)
doc.SetCreator("Kereru Forms", false)
doc.SetSubject(fmt.Sprintf("Form version %d", form.Version), false)
doc.SetFooterFunc(func() {
doc.SetY(-14)
doc.SetFont("Helvetica", "I", 8)
doc.SetTextColor(120, 120, 120)
doc.CellFormat(0, 6,
fmt.Sprintf("Kereru Forms - %s - page %d", sanitize(form.Title), doc.PageNo()),
"", 0, "C", false, 0, "")
})
doc.AddPage()
doc.SetTextColor(20, 20, 20)
if form.CompanyName != nil && *form.CompanyName != "" {
doc.SetFont("Helvetica", "", 11)
doc.SetTextColor(90, 90, 90)
doc.MultiCell(0, 6, sanitize(*form.CompanyName), "", "L", false)
}
doc.SetFont("Helvetica", "B", 20)
doc.SetTextColor(20, 20, 20)
doc.MultiCell(0, 9, sanitize(form.Title), "", "L", false)
doc.SetFont("Helvetica", "", 9)
doc.SetTextColor(110, 110, 110)
doc.MultiCell(0, 5, fmt.Sprintf("Version %d - form id %s", form.Version, form.ID), "", "L", false)
doc.Ln(2)
if form.Description != nil && strings.TrimSpace(*form.Description) != "" {
doc.SetDrawColor(220, 220, 220)
doc.SetFont("Helvetica", "", 10)
doc.SetTextColor(60, 60, 60)
doc.MultiCell(0, 5, sanitize(*form.Description), "", "L", false)
doc.Ln(2)
}
if form.Introduction != nil && strings.TrimSpace(*form.Introduction) != "" {
doc.SetFont("Helvetica", "B", 11)
doc.SetTextColor(20, 20, 20)
doc.CellFormat(0, 7, "Introduction", "", 1, "L", false, 0, "")
doc.SetFont("Helvetica", "", 10)
doc.SetTextColor(60, 60, 60)
doc.MultiCell(0, 5, sanitize(*form.Introduction), "", "L", false)
doc.Ln(2)
}
if hasContact(form) {
doc.SetFont("Helvetica", "B", 11)
doc.SetTextColor(20, 20, 20)
doc.CellFormat(0, 7, "Contact", "", 1, "L", false, 0, "")
doc.SetFont("Helvetica", "", 10)
doc.SetTextColor(60, 60, 60)
if form.ContactEmail != nil && *form.ContactEmail != "" {
doc.MultiCell(0, 5, sanitize("Email: "+*form.ContactEmail), "", "L", false)
}
if form.ContactPhone != nil && *form.ContactPhone != "" {
doc.MultiCell(0, 5, sanitize("Phone: "+*form.ContactPhone), "", "L", false)
}
if form.ContactURL != nil && *form.ContactURL != "" {
doc.MultiCell(0, 5, sanitize("Web: "+*form.ContactURL), "", "L", false)
}
doc.Ln(2)
}
doc.SetDrawColor(210, 210, 210)
doc.Line(pdfLeftMargin, doc.GetY(), pdfPageWidth-pdfLeftMargin, doc.GetY())
doc.Ln(4)
renderSubmitterDetails(doc, form)
doc.SetFont("Helvetica", "B", 12)
doc.SetTextColor(20, 20, 20)
doc.CellFormat(0, 8, "Questions", "", 1, "L", false, 0, "")
doc.Ln(1)
if len(form.Questions) == 0 {
doc.SetFont("Helvetica", "I", 10)
doc.SetTextColor(110, 110, 110)
doc.MultiCell(0, 5, "This form does not contain any questions yet.", "", "L", false)
}
for i, q := range form.Questions {
renderQuestion(doc, i+1, q)
}
if form.SignatureRequired {
renderSignatureSection(doc)
}
doc.Ln(4)
doc.SetFont("Helvetica", "I", 8)
doc.SetTextColor(130, 130, 130)
doc.MultiCell(0, 4, fmt.Sprintf(
"Generated %s - %s", time.Now().UTC().Format(time.RFC3339), uuid.NewString()),
"", "L", false)
var buf bytes.Buffer
if err := doc.Output(&buf); err != nil {
return nil, fmt.Errorf("render pdf: %w", err)
}
return buf.Bytes(), nil
}
func renderSignatureSection(doc *fpdf.Fpdf) {
ensureSpace(doc, 46)
doc.SetFont("Helvetica", "B", 12)
doc.SetTextColor(20, 20, 20)
doc.CellFormat(0, 8, "Digital signature", "", 1, "L", false, 0, "")
doc.SetFont("Helvetica", "I", 8)
doc.SetTextColor(120, 120, 120)
doc.MultiCell(0, 4, "Completed in the online form as a drawn or uploaded signature.", "", "L", false)
doc.Ln(2)
y := doc.GetY()
drawnField(doc, pdfLeftMargin, y, "Name:", 90)
drawnField(doc, pdfLeftMargin+95, y, "Title (optional):", 79)
doc.SetY(y + 11)
drawnField(doc, pdfLeftMargin, doc.GetY(), "Signature:", pdfContentWide)
doc.SetY(doc.GetY() + 11)
}
// renderSubmitterDetails draws the section a respondent completes by hand when
// the form is filled in offline.
func renderSubmitterDetails(doc *fpdf.Fpdf, form *database.Form) {
ensureSpace(doc, 62)
doc.SetFont("Helvetica", "B", 12)
doc.SetTextColor(20, 20, 20)
doc.CellFormat(0, 8, "Submitter details", "", 1, "L", false, 0, "")
doc.SetFont("Helvetica", "I", 8)
doc.SetTextColor(120, 120, 120)
doc.MultiCell(0, 4, "Complete this form offline, then return it using the contact details above.", "", "L", false)
doc.Ln(2)
y := doc.GetY()
drawnField(doc, pdfLeftMargin, y, "Name / identifier:", pdfContentWide)
y += 9
doc.SetFont("Helvetica", "", 9)
doc.SetTextColor(90, 90, 90)
doc.SetXY(pdfLeftMargin, y)
doc.CellFormat(26, 6, "Identifier type:", "", 0, "L", false, 0, "")
x := pdfLeftMargin + 28
for _, label := range []string{"Email", "Username", "Phone", "ForeignKey", "GUID"} {
drawCheckbox(doc, x, y+1)
doc.SetXY(x+5, y)
doc.CellFormat(0, 6, label, "", 0, "L", false, 0, "")
x += 5 + doc.GetStringWidth(label) + 8
}
doc.SetY(y + 9)
y = doc.GetY()
fieldWidth := 82.0
if form.RequiresAccessCode {
drawnField(doc, pdfLeftMargin, y, "Access code:", fieldWidth)
}
drawnField(doc, pdfLeftMargin+90, y, "Date completed:", fieldWidth)
doc.SetY(y + 9)
if form.ExternalReferenceRequired {
label := "External reference"
if form.ExternalReferenceLabel != nil && *form.ExternalReferenceLabel != "" {
label = *form.ExternalReferenceLabel
}
y = doc.GetY()
drawnField(doc, pdfLeftMargin, y, sanitize(label)+":", pdfContentWide)
doc.SetY(y + 9)
}
y = doc.GetY()
drawnField(doc, pdfLeftMargin, y, "Signature:", pdfContentWide)
doc.SetY(y + 10)
doc.SetDrawColor(210, 210, 210)
doc.Line(pdfLeftMargin, doc.GetY(), pdfPageWidth-pdfLeftMargin, doc.GetY())
doc.Ln(4)
}
func renderQuestion(doc *fpdf.Fpdf, number int, q database.Question) {
label := fmt.Sprintf("%d. %s", number, sanitize(q.Label))
if q.Required {
label += " *"
}
if q.Type == "text" {
renderTextQuestion(doc, label, q)
return
}
labelHeight := measureLabel(doc, label, q)
rowHeight := max(labelHeight, answerBlockHeight(q))
doc.SetFont("Helvetica", "", 10)
startY := doc.GetY()
if startY+rowHeight > pdfBottomEdge {
doc.AddPage()
startY = doc.GetY()
}
doc.SetXY(pdfLeftMargin, startY)
doc.SetFont("Helvetica", "B", 10)
doc.SetTextColor(25, 25, 25)
doc.MultiCell(pdfLabelWidth, 5, label, "", "L", false)
doc.SetFont("Helvetica", "", 8)
doc.SetTextColor(120, 120, 120)
doc.MultiCell(pdfLabelWidth, 4, "Key: "+sanitize(q.Key)+" - "+typeDescription(q), "", "L", false)
if q.Description != nil && *q.Description != "" {
doc.SetFont("Helvetica", "I", 9)
doc.SetTextColor(90, 90, 90)
doc.MultiCell(pdfLabelWidth, 4, sanitize(*q.Description), "", "L", false)
}
if q.Placeholder != nil && *q.Placeholder != "" {
doc.SetFont("Helvetica", "I", 9)
doc.SetTextColor(140, 140, 140)
doc.MultiCell(pdfLabelWidth, 4, "Hint: "+sanitize(*q.Placeholder), "", "L", false)
}
renderAnswerBlock(doc, startY, q)
doc.SetY(max(doc.GetY(), startY+rowHeight) + 3)
}
func renderTextQuestion(doc *fpdf.Fpdf, label string, q database.Question) {
lines := 1
if q.Multiline {
lines = 4
}
needed := 8.0 + float64(lines)*9 + 6
if q.Description != nil && *q.Description != "" {
needed += 5
}
ensureSpace(doc, needed)
doc.SetFont("Helvetica", "B", 10)
doc.SetTextColor(25, 25, 25)
doc.MultiCell(0, 5, label, "", "L", false)
doc.SetFont("Helvetica", "", 8)
doc.SetTextColor(120, 120, 120)
doc.MultiCell(0, 4, "Key: "+sanitize(q.Key)+" - "+typeDescription(q), "", "L", false)
if q.Description != nil && *q.Description != "" {
doc.SetFont("Helvetica", "I", 9)
doc.SetTextColor(90, 90, 90)
doc.MultiCell(0, 4, sanitize(*q.Description), "", "L", false)
}
if q.Placeholder != nil && *q.Placeholder != "" {
doc.SetFont("Helvetica", "I", 9)
doc.SetTextColor(140, 140, 140)
doc.MultiCell(0, 4, "Hint: "+sanitize(*q.Placeholder), "", "L", false)
}
doc.SetDrawColor(190, 190, 190)
doc.Ln(2)
for i := 0; i < lines; i++ {
doc.CellFormat(0, 7, "", "B", 1, "L", false, 0, "")
doc.Ln(2)
}
doc.Ln(2)
}
func renderAnswerBlock(doc *fpdf.Fpdf, startY float64, q database.Question) {
switch q.Type {
case "boolean":
drawCheckbox(doc, pdfAnswerX, startY+1)
doc.SetFont("Helvetica", "", 10)
doc.SetTextColor(50, 50, 50)
doc.SetXY(pdfAnswerX+6, startY)
doc.CellFormat(20, 6, "True", "", 0, "L", false, 0, "")
drawCheckbox(doc, pdfAnswerX+30, startY+1)
doc.SetXY(pdfAnswerX+36, startY)
doc.CellFormat(20, 6, "False", "", 0, "L", false, 0, "")
case "select":
y := startY
for _, opt := range q.Options {
drawCheckbox(doc, pdfAnswerX, y+1)
doc.SetXY(pdfAnswerX+6, y)
doc.SetFont("Helvetica", "", 9)
doc.SetTextColor(50, 50, 50)
doc.MultiCell(pdfAnswerWidth-6, 5, sanitize(opt.Label), "", "L", false)
y = doc.GetY()
}
case "number":
doc.SetDrawColor(150, 150, 150)
doc.Line(pdfAnswerX, startY+6, pdfAnswerX+48, startY+6)
case "date":
doc.SetFont("Helvetica", "", 8)
doc.SetTextColor(140, 140, 140)
doc.SetXY(pdfAnswerX+50, startY+1)
doc.CellFormat(0, 4, "YYYY-MM-DD", "", 0, "L", false, 0, "")
doc.SetDrawColor(150, 150, 150)
doc.Line(pdfAnswerX, startY+6, pdfAnswerX+48, startY+6)
}
}
func measureLabel(doc *fpdf.Fpdf, label string, q database.Question) float64 {
doc.SetFont("Helvetica", "B", 10)
height := float64(len(doc.SplitText(label, pdfLabelWidth))) * 5
doc.SetFont("Helvetica", "", 8)
hint := "Key: " + sanitize(q.Key) + " - " + typeDescription(q)
height += float64(len(doc.SplitText(hint, pdfLabelWidth))) * 4
if q.Description != nil && *q.Description != "" {
doc.SetFont("Helvetica", "I", 9)
height += float64(len(doc.SplitText(sanitize(*q.Description), pdfLabelWidth))) * 4
}
if q.Placeholder != nil && *q.Placeholder != "" {
doc.SetFont("Helvetica", "I", 9)
height += float64(len(doc.SplitText("Hint: "+sanitize(*q.Placeholder), pdfLabelWidth))) * 4
}
return height + 2
}
func answerBlockHeight(q database.Question) float64 {
switch q.Type {
case "boolean", "number", "date":
return 10
case "select":
return float64(max(1, len(q.Options))) * 6
default:
return 10
}
}
func ensureSpace(doc *fpdf.Fpdf, needed float64) {
if doc.GetY()+needed > pdfBottomEdge {
doc.AddPage()
}
}
func drawnField(doc *fpdf.Fpdf, x, y float64, label string, width float64) {
doc.SetFont("Helvetica", "", 9)
doc.SetTextColor(90, 90, 90)
runes := []rune(label)
for len(runes) > 0 && doc.GetStringWidth(string(runes))+2 > width-12 {
runes = runes[:len(runes)-1]
}
label = string(runes)
labelWidth := doc.GetStringWidth(label) + 2
doc.SetXY(x, y)
doc.CellFormat(labelWidth, 6, label, "", 0, "L", false, 0, "")
doc.SetDrawColor(150, 150, 150)
doc.CellFormat(width-labelWidth, 6, "", "B", 0, "L", false, 0, "")
}
func drawCheckbox(doc *fpdf.Fpdf, x, y float64) {
doc.SetDrawColor(120, 120, 120)
doc.Rect(x, y+1.2, 4, 4, "D")
}
func typeDescription(q database.Question) string {
switch q.Type {
case "boolean":
return "True / false"
case "number":
desc := "Number"
if q.MinValue != nil || q.MaxValue != nil {
desc += " ("
if q.MinValue != nil {
desc += fmt.Sprintf("min %g", *q.MinValue)
}
if q.MinValue != nil && q.MaxValue != nil {
desc += ", "
}
if q.MaxValue != nil {
desc += fmt.Sprintf("max %g", *q.MaxValue)
}
desc += ")"
}
return desc
case "select":
return "Select one option"
case "text":
if q.Multiline {
return "Text (multiline)"
}
return "Text (single line)"
case "date":
return "Date (YYYY-MM-DD)"
default:
return q.Type
}
}
func hasContact(form *database.Form) bool {
vals := []*string{form.ContactEmail, form.ContactPhone, form.ContactURL}
for _, v := range vals {
if v != nil && strings.TrimSpace(*v) != "" {
return true
}
}
return false
}
func deref(v *string, fallback string) string {
if v == nil || *v == "" {
return fallback
}
return *v
}
var replacements = map[rune]string{
'ā': "a", 'Ā': "A", 'ē': "e", 'Ē': "E", 'ī': "i", 'Ī': "I",
'ō': "o", 'Ō': "O", 'ū': "u", 'Ū': "U",
'‘': "'", '’': "'", '“': `"`, '”': `"`,
'–': "-", '—': "-", '…': "...", '\u00a0': " ",
}
// sanitize maps text to the WinAnsi/Latin-1 range supported by the built-in
// PDF fonts. Characters outside that range are transliterated or replaced.
func sanitize(s string) string {
return strings.Map(func(r rune) rune {
if r >= 32 && r <= 255 {
return r
}
return '?'
}, replaceRunes(s))
}
func replaceRunes(s string) string {
var b strings.Builder
for _, r := range s {
if rep, ok := replacements[r]; ok {
b.WriteString(rep)
continue
}
b.WriteRune(r)
}
return b.String()
}

View File

@ -0,0 +1,76 @@
package pdf
import (
"bytes"
"strings"
"testing"
"time"
"kereru.forms/internal/database"
)
func formFixture() *database.Form {
now := time.Now()
desc := "A short description"
intro := "Welcome to the survey"
email := "hello@example.com"
placeholder := "Type here"
multiline := true
return &database.Form{
ID: "11111111-1111-1111-1111-111111111111",
FormGroupID: "22222222-2222-2222-2222-222222222222",
Version: 3,
Title: "Kererū feedback",
Description: &desc,
Introduction: &intro,
IsActive: true,
ContactEmail: &email,
CreatedAt: now,
UpdatedAt: now,
Questions: []database.Question{
{ID: "1", Key: "name", Label: "Your name", Type: "text", Required: true, Placeholder: &placeholder},
{ID: "2", Key: "notes", Label: "Notes", Type: "text", Multiline: multiline},
{ID: "3", Key: "happy", Label: "Happy?", Type: "boolean"},
{ID: "4", Key: "score", Label: "Score", Type: "number", MinValue: floatPtr(1), MaxValue: floatPtr(5)},
{ID: "5", Key: "colour", Label: "Colour", Type: "select", Options: []database.QuestionOption{
{Value: "red", Label: "Red"},
{Value: "blue", Label: "Blue"},
}},
{ID: "6", Key: "when", Label: "When", Type: "date", Required: true},
},
}
}
func floatPtr(f float64) *float64 { return &f }
func TestGenerate(t *testing.T) {
data, err := Generate(formFixture())
if err != nil {
t.Fatalf("Generate: %v", err)
}
if len(data) < 500 {
t.Fatalf("pdf is suspiciously small: %d bytes", len(data))
}
if !bytes.HasPrefix(data, []byte("%PDF")) {
t.Fatal("output does not start with %PDF")
}
if !bytes.Contains(data, []byte("%%EOF")) {
t.Fatal("output does not contain the EOF marker")
}
}
func TestSanitize(t *testing.T) {
got := sanitize("Kererū — “quoted” …")
if strings.ContainsAny(got, "ū—") {
t.Fatalf("sanitize left unsupported characters: %q", got)
}
if got != `Kereru - "quoted" ...` {
t.Fatalf("sanitize = %q", got)
}
}
func TestGenerateNil(t *testing.T) {
if _, err := Generate(nil); err == nil {
t.Fatal("expected error for nil form")
}
}

View File

@ -0,0 +1,125 @@
// Package telemetry wires OpenTelemetry tracing and metrics.
package telemetry
import (
"context"
"strings"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
)
// Options configures the telemetry providers.
type Options struct {
Enabled bool
Endpoint string
ServiceName string
Environment string
Version string
}
// Provider owns the configured OpenTelemetry providers.
type Provider struct {
Enabled bool
shutdown []func(context.Context) error
}
// Setup installs global tracer and meter providers. When disabled, the
// OpenTelemetry no-op providers remain in place and Shutdown is a no-op.
func Setup(ctx context.Context, opts Options) (*Provider, error) {
p := &Provider{Enabled: opts.Enabled}
if !opts.Enabled {
return p, nil
}
serviceName := opts.ServiceName
if serviceName == "" {
serviceName = "kereru-forms"
}
endpoint := opts.Endpoint
if endpoint == "" {
endpoint = "localhost:4318"
}
res, err := resource.New(ctx,
resource.WithAttributes(
attribute.String("service.name", serviceName),
attribute.String("service.version", opts.Version),
attribute.String("deployment.environment", opts.Environment),
),
)
if err != nil {
return nil, err
}
traceExporter, err := otlptracehttp.New(ctx, traceOptions(endpoint)...)
if err != nil {
return nil, err
}
tp := trace.NewTracerProvider(
trace.WithBatcher(traceExporter),
trace.WithResource(res),
)
otel.SetTracerProvider(tp)
p.shutdown = append(p.shutdown, tp.Shutdown)
metricExporter, err := otlpmetrichttp.New(ctx, metricOptions(endpoint)...)
if err != nil {
_ = tp.Shutdown(ctx)
return nil, err
}
mp := metric.NewMeterProvider(
metric.WithReader(metric.NewPeriodicReader(metricExporter,
metric.WithInterval(15*time.Second))),
metric.WithResource(res),
)
otel.SetMeterProvider(mp)
p.shutdown = append(p.shutdown, mp.Shutdown)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{}, propagation.Baggage{},
))
return p, nil
}
// Shutdown flushes telemetry. Safe to call when telemetry is disabled.
func (p *Provider) Shutdown(ctx context.Context) error {
if p == nil {
return nil
}
var firstErr error
for _, fn := range p.shutdown {
if err := fn(ctx); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func traceOptions(endpoint string) []otlptracehttp.Option {
if strings.Contains(endpoint, "://") {
return []otlptracehttp.Option{otlptracehttp.WithEndpointURL(endpoint)}
}
return []otlptracehttp.Option{
otlptracehttp.WithEndpoint(endpoint),
otlptracehttp.WithInsecure(),
}
}
func metricOptions(endpoint string) []otlpmetrichttp.Option {
if strings.Contains(endpoint, "://") {
return []otlpmetrichttp.Option{otlpmetrichttp.WithEndpointURL(endpoint)}
}
return []otlpmetrichttp.Option{
otlpmetrichttp.WithEndpoint(endpoint),
otlpmetrichttp.WithInsecure(),
}
}

View File

@ -0,0 +1,9 @@
package: api
generate:
models: true
gin-server: true
strict-server: true
embedded-spec: true
output: internal/api/generated.go
output-options:
skip-prune: false

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
# Kererū Forms environment sample
# Copy this file to .env next to the executable (or in the working directory).
# Values are never overwritten by existing environment variables.
# Logging
KERERU_LOG_LEVEL=info
KERERU_LOG_FORMAT=console
KERERU_ENVIRONMENT=development
# HTTP listeners (IPv4 and/or IPv6)
KERERU_LISTEN_ADDR=0.0.0.0
KERERU_LISTEN_ADDR6=
KERERU_PORT=8080
# Database (SQLite by default; swap to PostgreSQL/MariaDB later via dbDriver)
KERERU_DB_DRIVER=sqlite
KERERU_DB_DSN=file:kereru.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)
# Optional: directory with the built SPA (served with history fallback)
KERERU_STATIC_DIR=
# Telemetry (OpenTelemetry OTLP)
KERERU_TELEMETRY_ENABLED=false
OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4318
# CORS allowed origins (comma separated). Use "*" to allow all.
KERERU_CORS_ORIGINS=http://localhost:5173

View File

@ -0,0 +1,34 @@
{
"logLevel": "info",
"logFormat": "console",
"environment": "development",
"listenAddr": "0.0.0.0",
"listenAddr6": "",
"port": "8080",
"dbDriver": "sqlite",
"dbDsn": "file:kereru.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)",
"staticDir": "",
"telemetryEnabled": false,
"otlpEndpoint": "localhost:4318",
"corsOrigins": [
"http://localhost:5173"
],
"auth": {
"localPassword": "admin",
"requireSubmissionAuth": false,
"localTokenSecret": "",
"localTokenTtlHours": 8,
"oidc": {
"issuerUrl": "",
"clientId": "",
"scopes": [
"openid",
"profile",
"email"
],
"roleClaim": "roles",
"adminRole": "admin",
"viewRole": "view"
}
}
}

View File

@ -0,0 +1,45 @@
# Kererū Forms configuration (YAML)
# Pass with: kereru-forms --conf ./config.yaml
logLevel: info
logFormat: console
environment: development
listenAddr: 0.0.0.0
listenAddr6: ""
port: "8080"
dbDriver: sqlite
dbDsn: "file:kereru.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"
# Optional: directory with the built SPA (served with history fallback)
staticDir: ""
telemetryEnabled: false
otlpEndpoint: "localhost:4318"
corsOrigins:
- "http://localhost:5173"
# Authentication. If oidc.issuerUrl is empty, the local password is used and
# grants both admin and view roles. If an OIDC issuer is configured, local
# password login is disabled and the SPA uses authorization-code flow + PKCE.
auth:
localPassword: "admin"
requireSubmissionAuth: false
# Empty generates a random secret per process. Set a stable value to keep
# local-password tokens valid across restarts or multiple replicas.
localTokenSecret: ""
localTokenTtlHours: 8
oidc:
issuerUrl: ""
clientId: ""
scopes:
- openid
- profile
- email
# Dot-separated claim path. Examples: "roles", "groups",
# "realm_access.roles" (Keycloak).
roleClaim: "roles"
adminRole: "admin"
viewRole: "view"

138
deploy/k8s.yaml 100644
View File

@ -0,0 +1,138 @@
apiVersion: v1
kind: Namespace
metadata:
name: kereru-forms
---
apiVersion: v1
kind: ConfigMap
metadata:
name: kereru-forms-config
namespace: kereru-forms
data:
KERERU_LOG_LEVEL: info
KERERU_LOG_FORMAT: json
KERERU_ENVIRONMENT: production
KERERU_LISTEN_ADDR: 0.0.0.0
KERERU_PORT: "8080"
KERERU_DB_DRIVER: sqlite
KERERU_DB_DSN: "file:/data/kereru.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"
KERERU_STATIC_DIR: /app/static
KERERU_CORS_ORIGINS: "*"
KERERU_TELEMETRY_ENABLED: "false"
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector.observability.svc.cluster.local:4318"
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: kereru-forms-data
namespace: kereru-forms
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: kereru-forms
namespace: kereru-forms
labels:
app: kereru-forms
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: kereru-forms
template:
metadata:
labels:
app: kereru-forms
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
containers:
- name: kereru-forms
image: kereru-forms:1.0.0
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
envFrom:
- configMapRef:
name: kereru-forms-config
volumeMounts:
- name: data
mountPath: /data
- name: tmp
mountPath: /tmp
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 15
periodSeconds: 20
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumes:
- name: data
persistentVolumeClaim:
claimName: kereru-forms-data
- name: tmp
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: kereru-forms
namespace: kereru-forms
spec:
selector:
app: kereru-forms
ports:
- name: http
port: 80
targetPort: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: kereru-forms
namespace: kereru-forms
annotations:
kubernetes.io/ingress.class: nginx
spec:
rules:
- host: forms.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: kereru-forms
port:
name: http

43
docker/Dockerfile 100644
View File

@ -0,0 +1,43 @@
# syntax=docker/dockerfile:1.7
# --- Backend build ---
FROM golang:1.26 AS backend-builder
WORKDIR /app/backend
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/. ./
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w -X main.version=1.0.0" -o /kereru-forms ./cmd/kereru-forms
# --- Frontend build ---
FROM node:22 AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/. ./
COPY backend/openapi /app/backend/openapi
RUN npm run build
# --- Runtime ---
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -g 10001 -S kereru \
&& adduser -u 10001 -S -G kereru kereru \
&& mkdir -p /data /app/static \
&& chown -R kereru:kereru /data /app
WORKDIR /app
ENV KERERU_LOG_LEVEL=info \
KERERU_LOG_FORMAT=json \
KERERU_ENVIRONMENT=production \
KERERU_LISTEN_ADDR=0.0.0.0 \
KERERU_PORT=8080 \
KERERU_DB_DRIVER=sqlite \
KERERU_DB_DSN="file:/data/kereru.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)" \
KERERU_STATIC_DIR=/app/static \
KERERU_CORS_ORIGINS="*"
COPY --from=backend-builder /kereru-forms /app/kereru-forms
COPY --from=frontend-builder /app/frontend/dist /app/static
VOLUME ["/data"]
EXPOSE 8080
USER kereru:kereru
ENTRYPOINT ["/app/kereru-forms"]
CMD ["--listen-addr", "0.0.0.0", "--port", "8080"]

View File

@ -0,0 +1,33 @@
name: kereru-forms
services:
kereru-forms:
build:
context: ..
dockerfile: docker/Dockerfile
image: kereru-forms:1.0.0
container_name: kereru-forms
restart: unless-stopped
ports:
- "8080:8080"
environment:
KERERU_LOG_LEVEL: info
KERERU_LOG_FORMAT: json
KERERU_ENVIRONMENT: production
KERERU_LISTEN_ADDR: 0.0.0.0
KERERU_PORT: 8080
KERERU_DB_DRIVER: sqlite
KERERU_DB_DSN: "file:/data/kereru.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"
KERERU_CORS_ORIGINS: "http://localhost:8080"
KERERU_TELEMETRY_ENABLED: "false"
volumes:
- kereru-data:/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
kereru-data:

158
docs/AGENTS.md 100644
View File

@ -0,0 +1,158 @@
# Design
Note **AI AGENT NOT TO UPDATE THIS FILE**
This is the design for a simple forms site to be used to capture a number of survey type questions
fom a person. The forms will be like Adove PDF forms and allow for configuration of questions to
ask and an answer. The questions can be multiline text and do not contain formatting commands
within the question.
The answer can be of types:
1. True or false
2. Numeric answer
3. Drop down options
4. Free form text
5. Date field
Project name is ``Kererū forms`` and its internal identifier is ``kereru.forms``.
## Technology stack
The design is based around a modern web UI using React + Vite + Tailwind CSS as the frontend and a Golang backend.
The database is any standard SQL DB and the initial verion is using SQLite. A SQL provider
will allow future flexibility for other database SQL variants such as:
* Postgres
* MariaDB
## Backend
The backend executable server name is to be ``kereru-forms``.
The backend is to expose APIs that are defined in an OpenAPI file
which is used to generate the golang code. A health service is
to be included.
Include telemetry, logging and observability.
## Frontend
Allow for themeing the UI for light, dark and system.
Past data captured is not presented again to the user inputing the data
A management section exists for viewing past data forms completions, and
managing forms. Forms can be inactivated and can have versions.
A form can contain an introduction section explaining purpose, background
and further contact or support details.
There is the ability by the user supplying the form to request a
PDF version that is generated from the form definition.
The PDF form can be generated by the person submitting the form
so that the PDF form can be completed offline and even printed.
The PDF form is to have an area for including the submitters details.
Alternatively the PDF form can be a prebuilt Adbove PDF document
linked to the survey definition.
The form can be configured to request a unique user code for filling in
the form and once used the code cannot be used again.
The questions are to be presented on one line with question at left and answer options on right,
except for free form text which is input below the question.
A form can have a digital signature defined at the end which can capture
a drawn sigature, a picture file with signature, name of person and
optional title.
Include an option at the start of the survey to upload a PDF form that
has been completed offline by the user. This upload will still need to
capture the identity of the person uploading the PDF document.
Include UI for displaying the OpenAPI definition as a web page under path /docs.
The UI for OpenAPI definition is not to be linked to an external service
providing the rendering and must be able to be run locally for
offline dvelopment.
For management section when viewing past submissions and answers, show
the question along with the answwer.
## Database Schema
The schema is to allow for multiple forms and capture the user input
using an user identifier such as email, username, international phone number,
foreign key or GUID. The identifier is captured at the start.
Forms can be versioned and include a logo and company name plus contact details that is
linked to the form. Existing forms can be edited which will generate a new version number
for the form definition.
A form can be configured to require an external reference identifier such as
invoice number, account number, policy number. This identifier will be used
to verfiy and link the form to an external consumer of the data.
Each form capture is to gather details about the time, place, IP, browser as tracking detais.
## Authentication
Include OAuth authentication for the management services including viewing past submissions.
The OAuth is to be configurable to use external service such as Okta, KeyCloak. If no serviec provided
then a simple password is to be used so that the application can be tested locally.
Accessing the form submission can optionally be configured to require authentication against the
OAuth identity provider. Access for submission does not require a role claim just authentication.
Access to manage forms for CRUD purposes requires an admin role claim in the JWT. Access to view
forms that have been submitted requires a view role claim in the JWT. Uusers with admin role claim
only cannot view submissions.
## Configuration
The solution is to include configuration. Configuration is supplied via a JSON or YAML
file.
The configuration file is loaded first and can be specified as argument --conf to
the backend program. Once the configuration file is loaded the values are overridden
by envionment variables that can also be supplied as a .env file in the path of
the executable.
The following arguments can be supplied to executable as arguments, overriding
the configuration and environment inputs.
* configuration file location
* logging level
* listening address either IPv4 and/or IPv6. Both can be supplied.
* listening port
## Testing
Create test cases for code in both frontend and backend.
## Containerisation
The solution is to be containerised and has
* Dockerfile
* Docker compose file
Instructions for Docker, Podman and Kubernetes setup are to be provided.
## Documentation
The design documentation and design decisions are to be captured in
document ``ARCHITECTURE.md`` in folder ``docs``.
Create a sample environment and configuration file.
## Development
Create developer instructions for local execution and producton deployment.
The frontend is to be executable separately from the backend
using ``npm run dev`` for local development
The backend is to be executed in local development with standard
Golang command to run.

View File

@ -0,0 +1,144 @@
# Architecture
## Overview
```
┌──────────────────────────────┐ ┌──────────────────────────────────────────┐
│ React SPA (Vite, Tailwind) │─────▶│ kereru-forms (Go, Gin) │
│ - form filling │ HTTP │ ┌────────────┐ ┌────────────────────┐ │
│ - management │ │ │ OpenAPI │ │ Strict handlers │ │
│ - light/dark/system theme │ │ │ generated │─▶│ (validation, PDF, │ │
└──────────────────────────────┘ │ │ router │ │ access codes) │ │
│ └────────────┘ └─────────┬──────────┘ │
│ │ │
│ ┌────────────┐ ┌────────▼──────────┐ │
│ │ Telemetry │ │ Store interface │ │
│ │ (OTLP) │ │ SQLite adapter │ │
│ └────────────┘ └────────┬──────────┘ │
└───────────────────────────│──────────────┘
▼
SQLite database file
```
The API is mounted both at the root (for Kubernetes probes and direct clients) and under `/api` (used by the SPA). The backend binary can also serve the built SPA (`KERERU_STATIC_DIR`) with history fallback, so a single container is enough for most deployments.
## Backend layout
```
backend/
├── openapi/openapi.yaml Contract: paths, schemas, enums
├── oapi-codegen.yaml Code generation settings
├── cmd/kereru-forms/main.go Wiring, listeners, graceful shutdown
└── internal/
├── api/ Generated server + strict handlers + router + auth middleware
├── auth/ Local password and OIDC token verification, roles
├── config/ YAML/JSON + .env + env + flags, precedence
├── database/ Store interface, SQLite implementation, migrations
├── logging/ zerolog setup + Gin access logging
├── pdf/ PDF rendering of a form definition
└── telemetry/ OpenTelemetry traces and metrics (OTLP/HTTP)
```
### Request flow
1. Gin middleware: telemetry span (`otelgin`), panic recovery, request id, request metadata (client IP/user agent), structured access log, CORS, then authentication/authorisation.
2. `api.RegisterHandlers` routes to the **generated strict handler**, which performs OpenAPI request decoding/validation.
3. `api.Server` methods validate business rules and call the `database.Store`.
4. Responses are mapped back to generated types; errors use the `Error` schema (`{error, code}`).
### Authentication
The API uses bearer JWTs with no server-side session state. `internal/auth` validates two token types:
- **OIDC**: discovery is performed at startup against the configured issuer. ID tokens are verified by `go-oidc` (signature, issuer, audience, expiry), and roles are read from a configurable claim path (for example `roles`, `groups` or `realm_access.roles`).
- **Local password fallback**: used only when no OIDC issuer is configured. The password is compared with bcrypt (or constant-time plain comparison) and the server issues a short-lived HS256 JWT. This mode grants both admin and view roles for local testing.
The SPA performs the authorization-code flow with PKCE and sends the ID token as `Authorization: Bearer …`. It stores the token in `sessionStorage` and clears it on a 401. Because the SPA is a public client, no client secret is supported.
Authorisation is enforced by `api/auth_middleware.go` before the strict handlers:
| Capability | Required role |
| --- | --- |
| Form CRUD, activation, versioning, access-code management | admin |
| List/read submissions and download offline PDFs | view |
| List/open/submit forms and download form PDFs when `requireSubmissionAuth` is enabled | any authenticated user |
Admin-only users cannot view submissions by design. `/auth/config`, `/auth/login`, `/auth/me`, `/auth/logout` and `/health` remain public; `/auth/me` optionally authenticates so the SPA can distinguish signed-out from signed-in.
### Configuration
`internal/config` implements a deterministic precedence chain: defaults → YAML/JSON file (`--conf`) → `.env` (next to executable or cwd; never overrides existing env) → environment variables → CLI flags. The `--conf` flag is pre-scanned so the file is available as defaults for the remaining flags. Both IPv4 and IPv6 listeners are supported and started concurrently.
The `auth` block selects local-password or OIDC mode (`oidc.issuerUrl` non-empty), maps role claims, optionally requires authentication for submissions, and controls local token secret/TTL.
### Data model
`forms` represents one concrete version. `form_group_id` groups all versions of the same logical form; `(form_group_id, version)` is unique. Creating a new version clones the current definition, increments `version`, activates the new row and inactivates the previous one. Historical submissions keep `form_version` and denormalised `question_key` values, so they remain readable after questions change.
| Table | Purpose |
| --- | --- |
| `forms` | Form version metadata, contact details, state |
| `questions` | Ordered questions with type-specific configuration (JSON options, bounds) |
| `submissions` | Response header: user identifier, form version, server-captured tracking |
| `answers` | One row per answered question, value stored as JSON |
| `access_codes` | Single-use codes (`used`, `used_at`, owning submission) |
| `schema_migrations` | Applied schema versions |
All timestamps are stored as RFC 3339 UTC strings. IDs are UUIDs (TEXT).
### Validation
Form definitions: non-empty title, unique keys (`[A-Za-z][A-Za-z0-9_-]*`), supported question types, select questions require unique non-empty option values, numeric bounds ordered.
Submissions: identifier type must be `email|username|phone|foreignkey|guid`; email identifiers are parsed; answers must reference questions of that form; duplicates rejected; required questions enforced; type checks for boolean/number/select/text; numeric bounds; select values must exist; text length capped and single-line questions reject newlines.
Access codes are consumed in the same SQL transaction as the submission insert. The conditional `UPDATE ... WHERE used = 0` makes reuse safe under concurrency; failure rolls back the submission.
### Observability
- **Logs**: zerolog, JSON or console, level configurable. Access logs include method, path, status, latency, client IP, user agent and `X-Request-ID`.
- **Traces/metrics**: OpenTelemetry SDK with OTLP/HTTP exporters. Spans cover HTTP requests (`otelgin`) and individual use cases (`GetForm`, `SubmitForm`, `GetFormPdf`, …) with form/submission attributes. Metrics are exported every 15s. Telemetry is opt-in (`KERERU_TELEMETRY_ENABLED`).
- **Health**: `GET /health` returns status, version and uptime for liveness/readiness probes.
### PDF
`internal/pdf` renders the form definition with `go-pdf/fpdf`: title, description, introduction, contact block, numbered questions, type hints, options and answer lines. Built-in PDF fonts cover WinAnsi; macrons and common typographic characters are transliterated so the output is always valid. For full Unicode, embed a TTF font instead.
## Frontend layout
```
frontend/src/
├── api/… (reserved)
├── auth/ AuthProvider, useAuth, ManagementGate, AccessDenied
├── components/ Layout, ThemeToggle, QuestionField, StatusBadge, SignInPrompt
├── hooks/useTheme light/dark/system with localStorage + matchMedia
├── lib/api.ts Typed fetch client, bearer auth, ApiError, PDF download
├── lib/oidc.ts Authorization code + PKCE callback handling
├── lib/session.ts Token storage and auth change events
├── lib/types.ts Mirrors the OpenAPI schemas
├── pages/ Home, FillForm, Login, OidcCallback, Manage, FormBuilder, FormDetail, SubmissionDetail
└── test/ Vitest + Testing Library
```
`AuthProvider` loads `/auth/config` and `/auth/me` on start, exposes password/OIDC login and logout, and refreshes on auth-change events. Management routes are wrapped in `ManagementGate`; the home and fill pages show a sign-in prompt when submission authentication is required.
Theming uses Tailwind's `class` dark mode. An inline script in `index.html` applies the stored preference before paint to avoid a flash. The `useTheme` hook keeps `documentElement.dark`, `localStorage` and the OS preference in sync.
Forms may carry a company name, contact details and a logo URL; the UI renders the logo on the home, fill and detail views. Remote logos are not embedded in the generated PDF.
The fill flow asks for the respondent identifier and (optionally) an access code first, then renders the questions by type. On success the local answers are cleared and only the confirmation is shown — previously entered data is never re-presented. The management section lists active and inactive versions and supports activation, versioning, deletion, PDF download, access-code generation and submission review.
## Extending the database
`database.Store` is the boundary used by the API layer. To add PostgreSQL or MariaDB:
1. Add a new adapter implementing `Store` (or generalise the SQL adapter with a dialect for placeholders and upserts).
2. Extend `database.Open` with the driver case and adjust migrations (TEXT timestamps and JSON strings map cleanly to `timestamptz`/`jsonb` and `varchar`/`json`).
3. Keep transaction semantics for submission + access-code consumption.
## Known limitations / next steps
- OIDC assumes a public SPA client (no client secret) and an IdP that allows token-endpoint CORS; confidential-client flows are not implemented.
- SQLite is a single-writer store; for horizontal scaling use a network database adapter and remove the single-connection pool limit.
- Access codes are generated with 8 characters from a 32-symbol alphabet (~40 bits) — sufficient for single-use gating, not for secrets.
- PDF output uses built-in fonts and does not embed form logos (see above).

161
docs/DEPLOYMENT.md 100644
View File

@ -0,0 +1,161 @@
# Deployment
## Local development
### Backend
```bash
cd backend
go mod download
go run ./cmd/kereru-forms \
--listen-addr 127.0.0.1 \
--port 8080 \
--log-format console \
--db-dsn "file:./kereru-dev.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"
```
Useful flags:
```bash
kereru-forms --help
```
### Frontend
```bash
cd frontend
npm install
npm run dev
```
Vite serves on <http://localhost:5173> and proxies `/api/*` to `http://localhost:8080`. To point at another API host, set `VITE_API_BASE_URL` at build/dev time:
```bash
VITE_API_BASE_URL=http://localhost:8080 npm run dev
```
## Production build without containers
```bash
# Backend binary
cd backend
CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=1.0.0" -o kereru-forms ./cmd/kereru-forms
# Frontend assets
cd ../frontend
npm ci
npm run build
```
Run the binary with the SPA directory and a persistent database:
```bash
KERERU_STATIC_DIR=../frontend/dist \
KERERU_DB_DSN="file:/var/lib/kereru/kereru.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)" \
KERERU_LOG_FORMAT=json \
KERERU_ENVIRONMENT=production \
./kereru-forms --listen-addr 0.0.0.0 --port 8080
```
The binary serves the API at `/health`, `/forms`, `/submissions` and the SPA for every other path.
## Docker
Build from the repository root so both `backend/` and `frontend/` are in context:
```bash
docker build -f docker/Dockerfile -t kereru-forms:1.0.0 .
docker run -d --name kereru-forms \
-p 8080:8080 \
-v kereru-data:/data \
-e KERERU_CORS_ORIGINS="https://forms.example.com" \
kereru-forms:1.0.0
```
Or with Compose:
```bash
docker compose -f docker/docker-compose.yml up -d --build
```
The image runs as an unprivileged user, stores SQLite data in `/data`, and serves the built frontend from `/app/static`.
## Podman
The image is OCI-compatible. Use rootless Podman:
```bash
podman build -f docker/Dockerfile -t kereru-forms:1.0.0 .
podman run -d --name kereru-forms \
-p 8080:8080 \
-v kereru-data:/data:Z \
-e KERERU_CORS_ORIGINS="*" \
kereru-forms:1.0.0
```
With Compose (either `podman-compose` or `docker compose` using the Podman socket):
```bash
podman-compose -f docker/docker-compose.yml up -d --build
```
`podman generate systemd --new --name kereru-forms` (or a Quadlet unit) can turn the container into a system service.
## Kubernetes
1. Build, tag and push the image to a registry reachable by the cluster:
```bash
docker build -f docker/Dockerfile -t registry.example.com/kereru-forms:1.0.0 .
docker push registry.example.com/kereru-forms:1.0.0
```
2. Update `image:` and the Ingress host in [`deploy/k8s.yaml`](../deploy/k8s.yaml).
3. Apply:
```bash
kubectl apply -f deploy/k8s.yaml
kubectl -n kereru-forms rollout status deployment/kereru-forms
```
The manifest includes a namespace, config map, 1 GiB `ReadWriteOnce` PVC mounted at `/data`, a `Recreate` deployment (SQLite is single-writer), liveness/readiness probes on `/health`, a service on port 80 and an Ingress. A writable `/tmp` emptyDir is mounted because the container root filesystem is read-only.
### Scaling notes
SQLite requires a single writer. Keep `replicas: 1` and use the `Recreate` strategy. To scale horizontally, implement a network database adapter (PostgreSQL/MariaDB) behind `database.Store`, then switch `dbDriver`/`dbDsn` and lift the Deployment restrictions.
Use a `ReadWriteMany` volume only if you replace SQLite; do not share a SQLite file across pods.
## Configuration in production
Prefer environment variables (twelve-factor) or a mounted YAML/JSON file:
```bash
kereru-forms --conf /etc/kereru/config.yaml
```
Typical production settings:
- `KERERU_LOG_FORMAT=json`
- `KERERU_ENVIRONMENT=production`
- `KERERU_CORS_ORIGINS=https://forms.example.com`
- `KERERU_TELEMETRY_ENABLED=true` and `OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318`
- `KERERU_STATIC_DIR=/app/static`
- SQLite DSN on a persistent volume: `file:/data/kereru.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)`
- OIDC: `KERERU_OIDC_ISSUER_URL`, `KERERU_OIDC_CLIENT_ID`, `KERERU_OIDC_ROLE_CLAIM`, `KERERU_OIDC_ADMIN_ROLE`, `KERERU_OIDC_VIEW_ROLE`
- Optional submission protection: `KERERU_AUTH_REQUIRE_SUBMISSION_AUTH=true`
- Local-password mode (only when no OIDC issuer is set): `KERERU_AUTH_LOCAL_PASSWORD` and a stable `KERERU_AUTH_LOCAL_TOKEN_SECRET`
### Backups
For SQLite, back up the database file while the service is stopped, or use the SQLite online backup API/`.backup` command. Keep the volume and file permissions owned by the runtime user (UID used in the container image).
### Security
- Terminate TLS at the ingress/load balancer.
- Management routes require a JWT. Configure OIDC for production and enforce MFA/role assignment in the identity provider.
- If using the local password fallback, change `KERERU_AUTH_LOCAL_PASSWORD` from `admin` and set a stable `KERERU_AUTH_LOCAL_TOKEN_SECRET`.
- Register the frontend `/auth/callback` URL with the OIDC provider and allow the frontend origin as a web/CORS origin in Okta/Keycloak.
- Set `KERERU_CORS_ORIGINS` to the exact frontend origin in production.
- Do not expose the SQLite file over a network share.

View File

@ -0,0 +1,25 @@
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Kererū Forms — simple, versioned surveys." />
<title>Kererū Forms</title>
<script>
;(function () {
try {
var stored = localStorage.getItem('kereru-theme') || 'system'
var dark =
stored === 'dark' ||
(stored === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
if (dark) document.documentElement.classList.add('dark')
} catch (e) {}
})()
</script>
</head>
<body class="h-full">
<div id="root" class="min-h-full"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6308
frontend/package-lock.json generated 100644

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,36 @@
{
"name": "kereru-forms-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"swagger-ui-react": "^5.34.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^26.6.5",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/swagger-ui-react": "^5.18.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"jsdom": "^25.0.1",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.6.3",
"vite": "^5.4.11",
"vitest": "^2.1.8"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#129c5b"/>
<path d="M18 16h20a10 10 0 0 1 0 20H26v12h-8V16Zm8 7v6h12a3 3 0 0 0 0-6H26Z" fill="#fff"/>
<circle cx="47" cy="47" r="5" fill="#b5f5d3"/>
</svg>

After

Width:  |  Height:  |  Size: 266 B

View File

@ -0,0 +1,82 @@
import { Suspense, lazy } from 'react'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import { AuthProvider } from './auth/AuthContext'
import { ManagementGate } from './auth/gates'
import { Layout } from './components/Layout'
import { FillFormPage } from './pages/FillFormPage'
import { FormBuilderPage } from './pages/FormBuilderPage'
import { FormDetailPage } from './pages/FormDetailPage'
import { HomePage } from './pages/HomePage'
import { LoginPage } from './pages/LoginPage'
import { ManagePage } from './pages/ManagePage'
import { OidcCallbackPage } from './pages/OidcCallbackPage'
import { SubmissionDetailPage } from './pages/SubmissionDetailPage'
const DocsPage = lazy(() =>
import('./pages/DocsPage').then((module) => ({ default: module.DocsPage })),
)
export function App() {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
<Route element={<Layout />}>
<Route index element={<HomePage />} />
<Route path="forms/:formId" element={<FillFormPage />} />
<Route path="login" element={<LoginPage />} />
<Route path="auth/callback" element={<OidcCallbackPage />} />
<Route
path="manage"
element={
<ManagementGate>
<ManagePage />
</ManagementGate>
}
/>
<Route
path="manage/new"
element={
<ManagementGate role="admin">
<FormBuilderPage />
</ManagementGate>
}
/>
<Route
path="manage/forms/:formId"
element={
<ManagementGate>
<FormDetailPage />
</ManagementGate>
}
/>
<Route
path="manage/forms/:formId/edit"
element={
<ManagementGate role="admin">
<FormBuilderPage />
</ManagementGate>
}
/>
<Route
path="submissions/:submissionId"
element={
<ManagementGate role="view">
<SubmissionDetailPage />
</ManagementGate>
}
/>
<Route
path="docs"
element={
<Suspense fallback={<p className="text-slate-500 dark:text-slate-400">Loading API docs…</p>}>
<DocsPage />
</Suspense>
}
/>
</Route>
</Routes>
</AuthProvider>
</BrowserRouter>
)
}

View File

@ -0,0 +1,130 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { api, ApiError } from '../lib/api'
import { beginOidcLogin } from '../lib/oidc'
import {
AUTH_CHANGED_EVENT,
clearAuthToken,
getAuthToken,
setAuthToken,
} from '../lib/session'
import type { AuthConfig, AuthUser } from '../lib/types'
interface AuthContextValue {
loading: boolean
config: AuthConfig | null
user: AuthUser | null
error: string | null
loginWithPassword: (password: string) => Promise<void>
loginWithOidc: (returnTo: string) => Promise<void>
logout: () => Promise<void>
refresh: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true)
const [config, setConfig] = useState<AuthConfig | null>(null)
const [user, setUser] = useState<AuthUser | null>(null)
const [error, setError] = useState<string | null>(null)
const refresh = useCallback(async () => {
if (!getAuthToken()) {
setUser(null)
return
}
try {
const result = await api.me()
setUser(result.authenticated ? (result.user ?? null) : null)
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
clearAuthToken()
}
setUser(null)
}
}, [])
useEffect(() => {
let cancelled = false
async function initialise() {
try {
const result = await api.getAuthConfig()
if (cancelled) return
setConfig(result)
await refresh()
} catch (err) {
if (!cancelled) {
setError(err instanceof ApiError ? err.message : 'Could not load authentication configuration')
}
} finally {
if (!cancelled) setLoading(false)
}
}
void initialise()
const onAuthChanged = () => {
if (getAuthToken()) {
void refresh()
} else {
setUser(null)
}
}
window.addEventListener(AUTH_CHANGED_EVENT, onAuthChanged)
return () => {
cancelled = true
window.removeEventListener(AUTH_CHANGED_EVENT, onAuthChanged)
}
}, [refresh])
const loginWithPassword = useCallback(async (password: string) => {
const result = await api.login(password)
setAuthToken(result.token)
setUser(result.user)
setError(null)
}, [])
const loginWithOidc = useCallback(
async (returnTo: string) => {
if (!config?.oidc) {
throw new Error('OIDC is not configured')
}
await beginOidcLogin(config.oidc, returnTo)
},
[config],
)
const logout = useCallback(async () => {
try {
await api.logout()
} catch {
// Tokens are stateless; local cleanup is still sufficient.
}
clearAuthToken()
setUser(null)
}, [])
const value = useMemo(
() => ({
loading,
config,
user,
error,
loginWithPassword,
loginWithOidc,
logout,
refresh,
}),
[loading, config, user, error, loginWithPassword, loginWithOidc, logout, refresh],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used inside AuthProvider')
}
return context
}

View File

@ -0,0 +1,49 @@
import type { ReactNode } from 'react'
import { Link, Navigate, useLocation } from 'react-router-dom'
import { useAuth } from './AuthContext'
import type { AuthUser } from '../lib/types'
type RequiredRole = 'admin' | 'view' | 'management'
export function ManagementGate({
children,
role = 'management',
}: {
children: ReactNode
role?: RequiredRole
}) {
const { loading, user } = useAuth()
const location = useLocation()
if (loading) {
return <p className="text-slate-500 dark:text-slate-400">Checking sign-in…</p>
}
if (!user) {
return <Navigate to="/login" replace state={{ from: location.pathname + location.search }} />
}
if (!hasRole(user, role)) {
return <AccessDenied role={role} />
}
return <>{children}</>
}
function hasRole(user: AuthUser, role: RequiredRole): boolean {
if (role === 'admin') return user.canAdmin
if (role === 'view') return user.canView
return user.canAdmin || user.canView
}
function AccessDenied({ role }: { role: RequiredRole }) {
const roleName = role === 'management' ? 'management' : `${role} role`
return (
<div className="card mx-auto max-w-xl space-y-3 text-center">
<h1 className="text-xl font-bold">Access denied</h1>
<p className="text-slate-500 dark:text-slate-400">
Your account does not have the {roleName} permission required for this page.
</p>
<Link to="/" className="btn-primary mx-auto w-fit">
Back to forms
</Link>
</div>
)
}

View File

@ -0,0 +1,79 @@
import { Link, NavLink, Outlet } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { ThemeToggle } from './ThemeToggle'
const navLinkClass = ({ isActive }: { isActive: boolean }) =>
`rounded-lg px-3 py-2 text-sm font-medium transition ${
isActive
? 'bg-brand-50 text-brand-700 dark:bg-brand-950 dark:text-brand-300'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white'
}`
export function Layout() {
const { user, logout } = useAuth()
const canManage = Boolean(user?.canAdmin || user?.canView)
return (
<div className="flex min-h-full flex-col">
<header className="border-b border-slate-200 bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80">
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-3 px-4 py-3 sm:px-6">
<Link to="/" className="flex items-center gap-2 text-lg font-bold tracking-tight">
<span
aria-hidden="true"
className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-600 text-sm font-black text-white"
>
K
</span>
<span>
Kererū <span className="text-brand-600 dark:text-brand-400">Forms</span>
</span>
</Link>
<nav className="order-3 flex w-full items-center gap-1 sm:order-2 sm:ml-4 sm:w-auto" aria-label="Main">
<NavLink to="/" end className={navLinkClass}>
Forms
</NavLink>
{canManage && (
<NavLink to="/manage" className={navLinkClass}>
Management
</NavLink>
)}
<NavLink to="/docs" className={navLinkClass}>
API docs
</NavLink>
</nav>
<div className="order-2 ml-auto flex items-center gap-2 sm:order-3">
{user ? (
<div className="flex items-center gap-2">
<span className="hidden max-w-40 truncate text-sm text-slate-500 sm:inline dark:text-slate-400">
{user.name ?? user.email ?? user.id}
</span>
<button
type="button"
className="btn-secondary py-1 text-xs"
onClick={() => void logout()}
>
Sign out
</button>
</div>
) : (
<Link to="/login" className="btn-secondary py-1 text-xs">
Sign in
</Link>
)}
<ThemeToggle />
</div>
</div>
</header>
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-8 sm:px-6">
<Outlet />
</main>
<footer className="border-t border-slate-200 py-6 text-center text-xs text-slate-500 dark:border-slate-800 dark:text-slate-400">
Kererū Forms · simple, versioned surveys
</footer>
</div>
)
}

View File

@ -0,0 +1,164 @@
import type { ReactNode } from 'react'
import type { Question } from '../lib/types'
interface QuestionFieldProps {
question: Question
value: unknown
onChange: (value: unknown) => void
}
export function QuestionField({ question, value, onChange }: QuestionFieldProps) {
const id = `q-${question.id}`
const descriptionId = question.description ? `${id}-description` : undefined
const isText = question.type === 'text'
const label = (
<label htmlFor={id} className="block text-sm font-semibold text-slate-900 dark:text-slate-100">
{question.label}
{question.required && (
<span className="ml-1 text-red-600 dark:text-red-400" aria-label="required">
*
</span>
)}
</label>
)
const description = question.description ? (
<p id={descriptionId} className="text-sm text-slate-500 dark:text-slate-400">
{question.description}
</p>
) : null
let control: ReactNode = null
if (question.type === 'boolean') {
control = (
<div id={id} className="flex gap-6 pt-1" role="checkboxgroup" aria-label={question.label}>
{[
{ label: 'True', checked: value === true },
{ label: 'False', checked: value === false },
].map((option) => (
<label key={option.label} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name={`checkbox-${question.id}`}
checked={option.checked}
onChange={() => onChange(option.label === 'True')}
className="h-4 w-4 accent-brand-600"
/>
{option.label}
</label>
))}
</div>
)
}
if (question.type === 'number') {
control = (
<div className="space-y-1">
<input
id={id}
type="number"
className="input max-w-xs"
value={value === undefined || value === null ? '' : String(value)}
min={question.minValue}
max={question.maxValue}
step="any"
placeholder={question.placeholder}
aria-describedby={descriptionId}
onChange={(event) => {
const raw = event.target.value
onChange(raw === '' ? undefined : Number(raw))
}}
/>
{(question.minValue !== undefined || question.maxValue !== undefined) && (
<p className="text-xs text-slate-500 dark:text-slate-400">
{question.minValue !== undefined && `Minimum ${question.minValue}`}
{question.minValue !== undefined && question.maxValue !== undefined && ' · '}
{question.maxValue !== undefined && `Maximum ${question.maxValue}`}
</p>
)}
</div>
)
}
if (question.type === 'date') {
control = (
<input
id={id}
type="date"
className="input max-w-xs"
value={typeof value === 'string' ? value : ''}
aria-describedby={descriptionId}
onChange={(event) => onChange(event.target.value || undefined)}
/>
)
}
if (question.type === 'select') {
control = (
<select
id={id}
className="input max-w-sm"
value={typeof value === 'string' ? value : ''}
aria-describedby={descriptionId}
onChange={(event) => onChange(event.target.value || undefined)}
>
<option value="">Select an option…</option>
{(question.options ?? []).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)
}
if (question.type === 'text' && question.multiline) {
control = (
<textarea
id={id}
className="input min-h-[6rem]"
rows={4}
value={typeof value === 'string' ? value : ''}
placeholder={question.placeholder}
aria-describedby={descriptionId}
onChange={(event) => onChange(event.target.value)}
/>
)
}
if (question.type === 'text' && !question.multiline) {
control = (
<input
id={id}
type="text"
className="input"
value={typeof value === 'string' ? value : ''}
placeholder={question.placeholder}
aria-describedby={descriptionId}
onChange={(event) => onChange(event.target.value)}
/>
)
}
if (isText) {
return (
<div className="space-y-2">
{label}
{description}
{control}
</div>
)
}
return (
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] sm:items-start">
<div className="space-y-1">
{label}
{description}
</div>
<div>{control}</div>
</div>
)
}

View File

@ -0,0 +1,18 @@
import { Link, useLocation } from 'react-router-dom'
export function SignInPrompt({ message }: { message: string }) {
const location = useLocation()
return (
<div className="card space-y-3 text-center">
<h2 className="text-xl font-bold">Sign in required</h2>
<p className="text-slate-500 dark:text-slate-400">{message}</p>
<Link
to="/login"
state={{ from: location.pathname + location.search }}
className="btn-primary mx-auto w-fit"
>
Sign in
</Link>
</div>
)
}

View File

@ -0,0 +1,178 @@
import { useEffect, useRef, useState } from 'react'
import type { PointerEvent as ReactPointerEvent } from 'react'
import type { SignatureType } from '../lib/types'
export interface SignatureDraft {
type: SignatureType
dataUrl: string
}
interface SignaturePadProps {
value: SignatureDraft | null
onChange: (value: SignatureDraft | null) => void
}
const CANVAS_WIDTH = 640
const CANVAS_HEIGHT = 200
const MAX_IMAGE_BYTES = 2 * 1024 * 1024
export function SignaturePad({ value, onChange }: SignaturePadProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const drawing = useRef(false)
const [mode, setMode] = useState<SignatureType>(value?.type ?? 'drawn')
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas || mode !== 'drawn') return
canvas.width = CANVAS_WIDTH
canvas.height = CANVAS_HEIGHT
const context = canvas.getContext('2d')
if (!context) return
context.lineWidth = 2.5
context.lineCap = 'round'
context.lineJoin = 'round'
context.strokeStyle = '#0f172a'
}, [mode])
function position(event: ReactPointerEvent<HTMLCanvasElement>) {
const canvas = canvasRef.current
if (!canvas) return { x: 0, y: 0 }
const rect = canvas.getBoundingClientRect()
return {
x: ((event.clientX - rect.left) / rect.width) * canvas.width,
y: ((event.clientY - rect.top) / rect.height) * canvas.height,
}
}
function startDrawing(event: ReactPointerEvent<HTMLCanvasElement>) {
if (mode !== 'drawn') return
const canvas = canvasRef.current
const context = canvas?.getContext('2d')
if (!canvas || !context) return
event.preventDefault()
canvas.setPointerCapture(event.pointerId)
drawing.current = true
const point = position(event)
context.beginPath()
context.moveTo(point.x, point.y)
}
function draw(event: ReactPointerEvent<HTMLCanvasElement>) {
if (!drawing.current || mode !== 'drawn') return
const canvas = canvasRef.current
const context = canvas?.getContext('2d')
if (!canvas || !context) return
event.preventDefault()
const point = position(event)
context.lineTo(point.x, point.y)
context.stroke()
onChange({ type: 'drawn', dataUrl: canvas.toDataURL('image/png') })
}
function stopDrawing(event: ReactPointerEvent<HTMLCanvasElement>) {
if (!drawing.current) return
drawing.current = false
if (canvasRef.current?.hasPointerCapture(event.pointerId)) {
canvasRef.current.releasePointerCapture(event.pointerId)
}
}
function clear() {
const canvas = canvasRef.current
const context = canvas?.getContext('2d')
if (canvas && context) {
context.clearRect(0, 0, canvas.width, canvas.height)
}
setError(null)
onChange(null)
}
function switchMode(next: SignatureType) {
setMode(next)
setError(null)
if (next === 'drawn') {
onChange(null)
}
}
function uploadImage(file: File | undefined) {
if (!file) return
setError(null)
if (file.type !== 'image/png' && file.type !== 'image/jpeg') {
setError('Choose a PNG or JPEG image.')
return
}
if (file.size > MAX_IMAGE_BYTES) {
setError('The signature image must be 2 MB or smaller.')
return
}
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result === 'string') {
setMode('image')
onChange({ type: 'image', dataUrl: reader.result })
}
}
reader.onerror = () => setError('Could not read that image.')
reader.readAsDataURL(file)
}
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<button
type="button"
className={mode === 'drawn' ? 'btn-primary py-1 text-xs' : 'btn-secondary py-1 text-xs'}
onClick={() => switchMode('drawn')}
>
Draw signature
</button>
<label className={mode === 'image' ? 'btn-primary py-1 text-xs' : 'btn-secondary py-1 text-xs'}>
Upload image
<input
type="file"
accept="image/png,image/jpeg"
className="sr-only"
onChange={(event) => uploadImage(event.target.files?.[0])}
/>
</label>
{value && (
<button type="button" className="btn-secondary py-1 text-xs" onClick={clear}>
Clear
</button>
)}
</div>
{mode === 'drawn' ? (
<canvas
ref={canvasRef}
width={CANVAS_WIDTH}
height={CANVAS_HEIGHT}
className="h-40 w-full touch-none rounded-lg border border-dashed border-slate-300 bg-white dark:border-slate-700"
aria-label="Draw your signature"
onPointerDown={startDrawing}
onPointerMove={draw}
onPointerUp={stopDrawing}
onPointerCancel={stopDrawing}
onPointerLeave={stopDrawing}
/>
) : (
<div className="flex h-40 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-white p-2 dark:border-slate-700">
{value?.type === 'image' ? (
<img src={value.dataUrl} alt="Uploaded signature" className="max-h-full max-w-full object-contain" />
) : (
<p className="text-sm text-slate-500 dark:text-slate-400">Upload an image containing your signature.</p>
)}
</div>
)}
{mode === 'drawn' && !value && (
<p className="text-xs text-slate-500 dark:text-slate-400">
Sign inside the box using a mouse, finger or stylus.
</p>
)}
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
</div>
)
}

View File

@ -0,0 +1,17 @@
interface StatusBadgeProps {
active: boolean
}
export function StatusBadge({ active }: StatusBadgeProps) {
return (
<span
className={`badge ${
active
? 'bg-brand-100 text-brand-800 dark:bg-brand-900/60 dark:text-brand-200'
: 'bg-slate-200 text-slate-600 dark:bg-slate-800 dark:text-slate-400'
}`}
>
{active ? 'Active' : 'Inactive'}
</span>
)
}

View File

@ -0,0 +1,42 @@
import { useTheme, type ThemePreference } from '../hooks/useTheme'
const options: Array<{ value: ThemePreference; label: string; icon: string }> = [
{ value: 'light', label: 'Light', icon: '☀' },
{ value: 'dark', label: 'Dark', icon: '☾' },
{ value: 'system', label: 'System', icon: '⌥' },
]
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<div
role="group"
aria-label="Colour theme"
className="inline-flex rounded-lg border border-slate-200 bg-slate-100 p-1 dark:border-slate-700 dark:bg-slate-800"
>
{options.map((option) => {
const active = theme === option.value
return (
<button
key={option.value}
type="button"
onClick={() => setTheme(option.value)}
aria-pressed={active}
title={`${option.label} theme`}
className={`rounded-md px-2.5 py-1 text-xs font-semibold transition ${
active
? 'bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-white'
: 'text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100'
}`}
>
<span aria-hidden="true" className="mr-1">
{option.icon}
</span>
{option.label}
</button>
)
})}
</div>
)
}

View File

@ -0,0 +1,69 @@
import { useCallback, useEffect, useState } from 'react'
export type ThemePreference = 'light' | 'dark' | 'system'
export type ResolvedTheme = 'light' | 'dark'
export const THEME_STORAGE_KEY = 'kereru-theme'
function readStoredTheme(): ThemePreference {
try {
const stored = localStorage.getItem(THEME_STORAGE_KEY)
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored
} catch {
// localStorage is unavailable; fall through to the default.
}
return 'system'
}
function prefersDark(): boolean {
return typeof window !== 'undefined' && !!window.matchMedia
? window.matchMedia('(prefers-color-scheme: dark)').matches
: false
}
function applyTheme(preference: ThemePreference): ResolvedTheme {
const dark = preference === 'dark' || (preference === 'system' && prefersDark())
document.documentElement.classList.toggle('dark', dark)
return dark ? 'dark' : 'light'
}
export function useTheme() {
const [theme, setThemeState] = useState<ThemePreference>(readStoredTheme)
const [resolved, setResolved] = useState<ResolvedTheme>(() =>
theme === 'dark' || (theme === 'system' && prefersDark()) ? 'dark' : 'light',
)
useEffect(() => {
const next = applyTheme(theme)
setResolved(next)
try {
localStorage.setItem(THEME_STORAGE_KEY, theme)
} catch {
// Persisting the preference is best-effort.
}
}, [theme])
useEffect(() => {
if (theme !== 'system' || !window.matchMedia) return
const query = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => setResolved(applyTheme('system'))
if (query.addEventListener) {
query.addEventListener('change', handler)
return () => query.removeEventListener('change', handler)
}
query.addListener(handler)
return () => query.removeListener(handler)
}, [theme])
const setTheme = useCallback((next: ThemePreference) => setThemeState(next), [])
const toggle = useCallback(() => {
setThemeState((current) => {
const isDark =
current === 'dark' || (current === 'system' && prefersDark())
return isDark ? 'light' : 'dark'
})
}, [])
return { theme, resolved, setTheme, toggle }
}

View File

@ -0,0 +1,60 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
-webkit-text-size-adjust: 100%;
}
body {
@apply bg-slate-50 text-slate-900 antialiased dark:bg-slate-950 dark:text-slate-100;
}
:focus-visible {
@apply outline-none ring-2 ring-brand-500 ring-offset-2 ring-offset-white dark:ring-offset-slate-950;
}
}
@layer components {
.card {
@apply rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900;
}
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-60;
}
.btn-primary {
@apply btn bg-brand-600 text-white hover:bg-brand-700;
}
.btn-secondary {
@apply btn border border-slate-300 bg-white text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800;
}
.btn-danger {
@apply btn bg-red-600 text-white hover:bg-red-700;
}
.label {
@apply mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300;
}
.input {
@apply w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:placeholder:text-slate-500;
}
.badge {
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium;
}
.table-wrap {
@apply overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-800;
}
.table {
@apply min-w-full divide-y divide-slate-200 text-left text-sm dark:divide-slate-800;
}
.table thead {
@apply bg-slate-100 text-xs uppercase tracking-wide text-slate-600 dark:bg-slate-800/60 dark:text-slate-400;
}
.table th {
@apply px-4 py-3 font-semibold;
}
.table td {
@apply px-4 py-3 text-slate-700 dark:text-slate-300;
}
.table tbody tr {
@apply border-b border-slate-100 last:border-0 dark:border-slate-800/60;
}
}

View File

@ -0,0 +1,180 @@
import type {
AccessCode,
ApiErrorBody,
AuthConfig,
AuthMeResponse,
CreateAccessCodesRequest,
CreateFormRequest,
Form,
FormSummary,
Health,
LoginResponse,
Submission,
SubmissionSummary,
SubmitFormRequest,
UpdateFormRequest,
} from './types'
import { clearAuthToken, getAuthToken, notifyAuthChanged } from './session'
export const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? '/api'
export class ApiError extends Error {
readonly status: number
readonly code?: string
constructor(status: number, message: string, code?: string) {
super(message)
this.name = 'ApiError'
this.status = status
this.code = code
}
}
function authHeaders(): Record<string, string> {
const token = getAuthToken()
return token ? { Authorization: `Bearer ${token}` } : {}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = getAuthToken()
const response = await fetch(`${BASE_URL}${path}`, {
...init,
headers: {
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
...authHeaders(),
...init?.headers,
},
})
if (!response.ok) {
let message = `Request failed with status ${response.status}`
let code: string | undefined
try {
const body = (await response.json()) as ApiErrorBody
if (body?.error) message = body.error
code = body?.code
} catch {
// Response was not JSON; keep the generic message.
}
if (response.status === 401 && token) {
clearAuthToken()
notifyAuthChanged()
}
throw new ApiError(response.status, message, code)
}
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
}
export const api = {
health: () => request<Health>('/health'),
getAuthConfig: () => request<AuthConfig>('/auth/config'),
login: (password: string) =>
request<LoginResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify({ password }),
}),
me: () => request<AuthMeResponse>('/auth/me'),
logout: () => request<void>('/auth/logout', { method: 'POST' }),
listForms: (includeInactive = false) =>
request<FormSummary[]>(`/forms${includeInactive ? '?includeInactive=true' : ''}`),
getForm: (id: string) => request<Form>(`/forms/${encodeURIComponent(id)}`),
createForm: (body: CreateFormRequest) =>
request<Form>('/forms', { method: 'POST', body: JSON.stringify(body) }),
updateForm: (id: string, body: UpdateFormRequest) =>
request<Form>(`/forms/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
deleteForm: (id: string) =>
request<void>(`/forms/${encodeURIComponent(id)}`, { method: 'DELETE' }),
activateForm: (id: string) =>
request<Form>(`/forms/${encodeURIComponent(id)}/activate`, { method: 'POST' }),
inactivateForm: (id: string) =>
request<Form>(`/forms/${encodeURIComponent(id)}/inactivate`, { method: 'POST' }),
createVersion: (id: string) =>
request<Form>(`/forms/${encodeURIComponent(id)}/versions`, { method: 'POST' }),
formPdfUrl: (id: string) => `${BASE_URL}/forms/${encodeURIComponent(id)}/pdf`,
downloadPdf: async (id: string, filename?: string, linkedUrl?: string) => {
if (linkedUrl) {
const link = document.createElement('a')
link.href = linkedUrl
link.target = '_blank'
link.rel = 'noopener noreferrer'
document.body.appendChild(link)
link.click()
link.remove()
return
}
const response = await fetch(api.formPdfUrl(id), { headers: authHeaders() })
if (!response.ok) {
throw new ApiError(response.status, 'Could not download the PDF')
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename ?? `form-${id}.pdf`
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
},
downloadSubmissionOfflinePdf: async (id: string, filename?: string) => {
const response = await fetch(
`${BASE_URL}/submissions/${encodeURIComponent(id)}/offline-pdf`,
{ headers: authHeaders() },
)
if (!response.ok) {
throw new ApiError(response.status, 'Could not download the offline PDF')
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename ?? `submission-${id}.pdf`
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
},
listSubmissions: (formId: string) =>
request<SubmissionSummary[]>(`/forms/${encodeURIComponent(formId)}/submissions`),
getSubmission: (id: string) =>
request<Submission>(`/submissions/${encodeURIComponent(id)}`),
submitForm: (formId: string, body: SubmitFormRequest) =>
request<Submission>(`/forms/${encodeURIComponent(formId)}/submissions`, {
method: 'POST',
body: JSON.stringify(body),
}),
listAccessCodes: (formId: string) =>
request<AccessCode[]>(`/forms/${encodeURIComponent(formId)}/access-codes`),
createAccessCodes: (formId: string, body: CreateAccessCodesRequest) =>
request<AccessCode[]>(`/forms/${encodeURIComponent(formId)}/access-codes`, {
method: 'POST',
body: JSON.stringify(body),
}),
}

View File

@ -0,0 +1,164 @@
import type { OIDCClientConfig } from './types'
const PENDING_KEY = 'kereru.oidc.pending'
const PENDING_MAX_AGE_MS = 10 * 60 * 1000
interface PendingLogin {
state: string
nonce: string
codeVerifier: string
returnTo: string
createdAt: number
}
interface TokenResponse {
id_token?: string
error?: string
error_description?: string
}
export function safeReturnTo(value: string | null | undefined, fallback = '/manage'): string {
if (!value || !value.startsWith('/') || value.startsWith('//')) return fallback
return value
}
export async function beginOidcLogin(
config: OIDCClientConfig,
returnTo: string,
): Promise<void> {
const state = randomBase64Url(32)
const nonce = randomBase64Url(32)
const codeVerifier = randomBase64Url(64)
const codeChallenge = base64Url(await sha256(codeVerifier))
const pending: PendingLogin = {
state,
nonce,
codeVerifier,
returnTo: safeReturnTo(returnTo),
createdAt: Date.now(),
}
sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending))
const url = new URL(config.authorizationEndpoint)
url.searchParams.set('client_id', config.clientId)
url.searchParams.set('redirect_uri', oidcRedirectUri())
url.searchParams.set('response_type', 'code')
url.searchParams.set('scope', config.scopes.join(' '))
url.searchParams.set('state', state)
url.searchParams.set('nonce', nonce)
url.searchParams.set('code_challenge', codeChallenge)
url.searchParams.set('code_challenge_method', 'S256')
window.location.assign(url.toString())
}
export async function completeOidcLogin(
config: OIDCClientConfig,
params: URLSearchParams,
): Promise<{ token: string; returnTo: string }> {
const pending = readPending()
if (params.get('error')) {
throw new Error(
params.get('error_description') ?? params.get('error') ?? 'The identity provider rejected sign-in',
)
}
const state = params.get('state')
if (!state || state !== pending.state) {
throw new Error('The sign-in response could not be verified. Please try again.')
}
const code = params.get('code')
if (!code) {
throw new Error('The identity provider did not return an authorization code.')
}
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: oidcRedirectUri(),
client_id: config.clientId,
code_verifier: pending.codeVerifier,
})
const response = await fetch(config.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
const text = await response.text()
let tokens: TokenResponse = {}
try {
tokens = JSON.parse(text) as TokenResponse
} catch {
// Keep the generic error below.
}
if (!response.ok) {
throw new Error(
tokens.error_description ?? tokens.error ?? `Token exchange failed (${response.status})`,
)
}
if (!tokens.id_token) {
throw new Error('The identity provider did not return an ID token.')
}
const claims = decodeJwtPayload(tokens.id_token)
if (claims.nonce !== pending.nonce) {
throw new Error('The sign-in response nonce did not match. Please try again.')
}
sessionStorage.removeItem(PENDING_KEY)
return { token: tokens.id_token, returnTo: pending.returnTo }
}
export function oidcRedirectUri(): string {
return `${window.location.origin}/auth/callback`
}
function readPending(): PendingLogin {
const raw = sessionStorage.getItem(PENDING_KEY)
if (!raw) {
throw new Error('This sign-in session has expired. Please start again.')
}
let pending: PendingLogin
try {
pending = JSON.parse(raw) as PendingLogin
} catch {
sessionStorage.removeItem(PENDING_KEY)
throw new Error('This sign-in session is invalid. Please start again.')
}
if (
typeof pending.state !== 'string' ||
typeof pending.nonce !== 'string' ||
typeof pending.codeVerifier !== 'string' ||
typeof pending.returnTo !== 'string' ||
Date.now() - pending.createdAt > PENDING_MAX_AGE_MS
) {
sessionStorage.removeItem(PENDING_KEY)
throw new Error('This sign-in session has expired. Please start again.')
}
return pending
}
function randomBase64Url(byteLength: number): string {
const bytes = new Uint8Array(byteLength)
crypto.getRandomValues(bytes)
return base64Url(bytes)
}
function base64Url(value: ArrayBuffer | Uint8Array): string {
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function sha256(value: string): Promise<ArrayBuffer> {
return crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))
}
function decodeJwtPayload(token: string): Record<string, unknown> {
const part = token.split('.')[1]
if (!part) throw new Error('The identity provider returned an invalid ID token.')
const normalized = part.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
const binary = atob(padded)
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0))
return JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>
}

View File

@ -0,0 +1,27 @@
const TOKEN_KEY = 'kereru.auth.token'
export const AUTH_CHANGED_EVENT = 'kereru:auth-changed'
export function getAuthToken(): string | null {
try {
return sessionStorage.getItem(TOKEN_KEY)
} catch {
return null
}
}
export function setAuthToken(token: string): void {
sessionStorage.setItem(TOKEN_KEY, token)
}
export function clearAuthToken(): void {
try {
sessionStorage.removeItem(TOKEN_KEY)
} catch {
// Storage can be unavailable in privacy modes; there is nothing else to clear.
}
}
export function notifyAuthChanged(): void {
window.dispatchEvent(new Event(AUTH_CHANGED_EVENT))
}

View File

@ -0,0 +1,233 @@
export type QuestionType = 'boolean' | 'date' | 'number' | 'select' | 'text'
export type UserIdType = 'email' | 'username' | 'phone' | 'foreignkey' | 'guid'
export type SignatureType = 'drawn' | 'image'
export interface SignatureInput {
type: SignatureType
dataUrl: string
name: string
title?: string
}
export interface Signature extends SignatureInput {
signedAt: string
}
export interface QuestionOption {
value: string
label: string
}
export interface Question {
id: string
key: string
label: string
description?: string
type: QuestionType
required: boolean
options?: QuestionOption[]
minValue?: number
maxValue?: number
placeholder?: string
multiline?: boolean
}
export interface QuestionInput {
key: string
label: string
description?: string
type: QuestionType
required: boolean
options?: QuestionOption[]
minValue?: number
maxValue?: number
placeholder?: string
multiline?: boolean
}
export interface FormSummary {
id: string
formGroupId: string
version: number
title: string
description?: string
logoUrl?: string
pdfUrl?: string
externalReferenceRequired?: boolean
externalReferenceLabel?: string
isActive: boolean
questionCount: number
createdAt: string
updatedAt: string
}
export interface Form {
id: string
formGroupId: string
version: number
title: string
description?: string
introduction?: string
isActive: boolean
requiresAccessCode: boolean
signatureRequired?: boolean
externalReferenceRequired?: boolean
externalReferenceLabel?: string
logoUrl?: string
pdfUrl?: string
companyName?: string
contactEmail?: string
contactPhone?: string
contactUrl?: string
createdAt: string
updatedAt: string
questions: Question[]
}
export interface CreateFormRequest {
title: string
description?: string
introduction?: string
requiresAccessCode?: boolean
signatureRequired?: boolean
externalReferenceRequired?: boolean
externalReferenceLabel?: string
logoUrl?: string
pdfUrl?: string
companyName?: string
contactEmail?: string
contactPhone?: string
contactUrl?: string
questions: QuestionInput[]
}
export type UpdateFormRequest = CreateFormRequest
export interface Answer {
questionId: string
value: unknown
}
export interface ClientTracking {
locale?: string
timezone?: string
place?: string
referer?: string
}
export interface SubmitFormRequest {
userId: string
userIdType: UserIdType
accessCode?: string
tracking?: ClientTracking
externalReference?: string
answers: Answer[]
signature?: SignatureInput
offlinePdf?: string
offlinePdfName?: string
}
export interface AnswerRecord {
questionId: string
questionKey: string
value: unknown
}
export interface Tracking {
ipAddress?: string
userAgent?: string
locale?: string
timezone?: string
place?: string
referer?: string
}
export interface Submission {
id: string
formId: string
formVersion: number
userId: string
userIdType: UserIdType
submittedAt: string
externalReference?: string
tracking?: Tracking
answers: AnswerRecord[]
signature?: Signature
offlinePdfName?: string
}
export interface SubmissionSummary {
id: string
formId: string
formVersion: number
userId: string
userIdType: UserIdType
externalReference?: string
submittedAt: string
}
export interface AccessCode {
id: string
formId: string
code: string
used: boolean
usedAt?: string
createdAt: string
}
export interface CreateAccessCodesRequest {
count: number
prefix?: string
}
export type AuthMode = 'password' | 'oidc'
export interface OIDCClientConfig {
issuerUrl: string
authorizationEndpoint: string
tokenEndpoint: string
clientId: string
scopes: string[]
}
export interface AuthConfig {
mode: AuthMode
requireSubmissionAuth: boolean
oidc?: OIDCClientConfig
}
export interface AuthUser {
id: string
name?: string
email?: string
roles: string[]
canAdmin: boolean
canView: boolean
}
export interface AuthMeResponse {
authenticated: boolean
user?: AuthUser
}
export interface LoginRequest {
password: string
}
export interface LoginResponse {
token: string
user: AuthUser
}
export interface Health {
status: string
version: string
uptimeSeconds?: number
}
export interface ApiErrorBody {
error: string
code?: string
}

View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,20 @@
import SwaggerUI from 'swagger-ui-react'
import 'swagger-ui-react/swagger-ui.css'
export function DocsPage() {
const specUrl = `${import.meta.env.BASE_URL}openapi.yaml`
return (
<div className="space-y-4">
<header className="space-y-1">
<h1 className="text-2xl font-bold">API documentation</h1>
<p className="text-slate-500 dark:text-slate-400">
Interactive reference for the Kereru Forms API, served from the local OpenAPI specification.
</p>
</header>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white dark:border-slate-800">
<SwaggerUI url={specUrl} docExpansion="list" tryItOutEnabled />
</div>
</div>
)
}

View File

@ -0,0 +1,609 @@
import { useEffect, useMemo, useState } from 'react'
import type { FormEvent } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { SignInPrompt } from '../components/SignInPrompt'
import { api, ApiError } from '../lib/api'
import type { Answer, Form, Submission, UserIdType } from '../lib/types'
import { QuestionField } from '../components/QuestionField'
import { SignaturePad } from '../components/SignaturePad'
import type { SignatureDraft } from '../components/SignaturePad'
type Step = 'intro' | 'questions' | 'done'
type CompletionMode = 'online' | 'offline'
const MAX_OFFLINE_PDF_BYTES = 10 * 1024 * 1024
function readFileAsDataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result === 'string') resolve(reader.result)
else reject(new Error('Could not read the file'))
}
reader.onerror = () => reject(new Error('Could not read the file'))
reader.readAsDataURL(file)
})
}
const userIdTypes: Array<{ value: UserIdType; label: string }> = [
{ value: 'email', label: 'Email address' },
{ value: 'username', label: 'Username' },
{ value: 'phone', label: 'Phone number' },
{ value: 'foreignkey', label: 'Provided key' },
{ value: 'guid', label: 'GUID' },
]
function isBlank(value: unknown): boolean {
if (value === undefined || value === null) return true
if (typeof value === 'string') return value.trim() === ''
return false
}
export function FillFormPage() {
const { formId = '' } = useParams<{ formId: string }>()
const { user, config, loading: authLoading } = useAuth()
const submissionLocked = Boolean(config?.requireSubmissionAuth && !user)
const [form, setForm] = useState<Form | null>(null)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [step, setStep] = useState<Step>('intro')
const [completionMode, setCompletionMode] = useState<CompletionMode>('online')
const [offlineFile, setOfflineFile] = useState<{ name: string; dataUrl: string } | null>(null)
const [offlineError, setOfflineError] = useState<string | null>(null)
const [signature, setSignature] = useState<SignatureDraft | null>(null)
const [signatureName, setSignatureName] = useState('')
const [signatureTitle, setSignatureTitle] = useState('')
const [userIdType, setUserIdType] = useState<UserIdType>('email')
const [userId, setUserId] = useState('')
const [accessCode, setAccessCode] = useState('')
const [externalReference, setExternalReference] = useState('')
const [answers, setAnswers] = useState<Record<string, unknown>>({})
const [formError, setFormError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [submission, setSubmission] = useState<Submission | null>(null)
const [downloadingPdf, setDownloadingPdf] = useState(false)
const [pdfError, setPdfError] = useState<string | null>(null)
// Map option values to preferred placeholder texts
const placeholders = {
email: "Enter your email (e.g., name@example.com)",
phone: "Enter your phone number (e.g., +61403555012)",
foreignkey: "Use your supplied key",
username: "Enter your handle (e.g., @username)",
guid: "Enter your identifier"
};
useEffect(() => {
if (authLoading) return
if (submissionLocked) {
setForm(null)
setLoading(false)
return
}
let cancelled = false
setLoading(true)
setLoadError(null)
api
.getForm(formId)
.then((result) => {
if (!cancelled) setForm(result)
})
.catch((err: unknown) => {
if (!cancelled) {
setLoadError(err instanceof ApiError ? err.message : 'Could not load this form')
}
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [formId, authLoading, submissionLocked])
const answeredCount = useMemo(
() => Object.values(answers).filter((value) => !isBlank(value)).length,
[answers],
)
async function downloadPrintablePdf() {
if (!form) return
setPdfError(null)
setDownloadingPdf(true)
try {
const filename = form.title.replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase()
await api.downloadPdf(form.id, `${filename || 'form'}.pdf`, form.pdfUrl)
} catch (err) {
setPdfError(err instanceof ApiError ? err.message : 'Could not download the PDF')
} finally {
setDownloadingPdf(false)
}
}
if (authLoading) {
return <p className="text-slate-500 dark:text-slate-400">Checking sign-in…</p>
}
if (submissionLocked) {
return (
<SignInPrompt message="Sign in with your organisation account to open and complete this form." />
)
}
if (loading) {
return <p className="text-slate-500 dark:text-slate-400">Loading form…</p>
}
if (loadError || !form) {
return (
<div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">
{loadError ?? 'Form not found'}
</div>
)
}
if (!form.isActive && step !== 'done') {
return (
<div className="card space-y-2">
<h1 className="text-xl font-bold">This form is closed</h1>
<p className="text-slate-500 dark:text-slate-400">
“{form.title}” is no longer accepting responses.
</p>
<Link to="/" className="btn-secondary mt-2 w-fit">
Back to forms
</Link>
</div>
)
}
function validateIdentity(): boolean {
if (userId.trim() === '') {
setFormError('Please enter your identifier before continuing.')
return false
}
if (form!.requiresAccessCode && accessCode.trim() === '') {
setFormError('An access code is required for this form.')
return false
}
if (form!.externalReferenceRequired && externalReference.trim() === '') {
setFormError(`${form!.externalReferenceLabel ?? 'External reference'} is required.`)
return false
}
return true
}
function completionTracking() {
return {
locale: navigator.language,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
referer: document.referrer || undefined,
}
}
async function completeSubmission(extra: {
answers: Answer[]
signature?: { type: 'drawn' | 'image'; dataUrl: string; name: string; title?: string }
offlinePdf?: string
offlinePdfName?: string
}) {
if (!form) return
setFormError(null)
setSubmitting(true)
try {
const result = await api.submitForm(form.id, {
userId: userId.trim(),
userIdType,
accessCode: form.requiresAccessCode ? accessCode.trim() : undefined,
externalReference: externalReference.trim() || undefined,
tracking: completionTracking(),
...extra,
})
setSubmission(result)
setAnswers({})
setAccessCode('')
setUserId('')
setExternalReference('')
setSignature(null)
setSignatureName('')
setSignatureTitle('')
setOfflineFile(null)
setStep('done')
} catch (err) {
setFormError(err instanceof ApiError ? err.message : 'Could not submit the form')
} finally {
setSubmitting(false)
}
}
async function chooseOfflineFile(file: File | undefined) {
setOfflineError(null)
setFormError(null)
if (!file) {
setOfflineFile(null)
return
}
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
setOfflineError('Choose a PDF file.')
return
}
if (file.size > MAX_OFFLINE_PDF_BYTES) {
setOfflineError('The PDF must be 10 MB or smaller.')
return
}
try {
const dataUrl = await readFileAsDataURL(file)
setOfflineFile({ name: file.name, dataUrl })
} catch {
setOfflineError('Could not read that PDF.')
}
}
async function startQuestions(event: FormEvent) {
event.preventDefault()
setFormError(null)
if (!validateIdentity()) return
if (completionMode === 'offline') {
if (!offlineFile) {
setOfflineError('Upload the completed PDF before submitting.')
return
}
await completeSubmission({
answers: [],
offlinePdf: offlineFile.dataUrl,
offlinePdfName: offlineFile.name,
})
return
}
setStep('questions')
}
async function submit(event: FormEvent) {
event.preventDefault()
setFormError(null)
if (!form) return
const missing = form.questions.filter((q) => q.required && isBlank(answers[q.id]))
if (missing.length > 0) {
setFormError(`Please answer: ${missing.map((q) => q.label).join(', ')}`)
return
}
const payloadAnswers: Answer[] = form.questions
.filter((q) => !isBlank(answers[q.id]))
.map((q) => ({ questionId: q.id, value: answers[q.id] }))
let signaturePayload:
| { type: 'drawn' | 'image'; dataUrl: string; name: string; title?: string }
| undefined
if (form.signatureRequired) {
if (!signature) {
setFormError('Please provide your signature before submitting.')
return
}
if (signatureName.trim() === '') {
setFormError('Please enter the name of the person signing.')
return
}
signaturePayload = {
type: signature.type,
dataUrl: signature.dataUrl,
name: signatureName.trim(),
title: signatureTitle.trim() || undefined,
}
}
await completeSubmission({ answers: payloadAnswers, signature: signaturePayload })
}
if (step === 'done') {
return (
<div className="card mx-auto max-w-xl space-y-3 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-100 text-2xl text-brand-700 dark:bg-brand-900 dark:text-brand-200">
✓
</div>
<h1 className="text-2xl font-bold">Thank you</h1>
<p className="text-slate-500 dark:text-slate-400">
Your response to “{form.title}” has been recorded.
</p>
{submission && (
<p className="text-xs text-slate-400 dark:text-slate-500">
Reference {submission.id}
</p>
)}
{pdfError && <p className="text-sm text-red-600 dark:text-red-400">{pdfError}</p>}
<div className="flex flex-wrap justify-center gap-3 pt-2">
<button
type="button"
className="btn-secondary"
onClick={downloadPrintablePdf}
disabled={downloadingPdf}
>
{downloadingPdf ? 'Preparing PDF…' : 'Download printable PDF'}
</button>
<button
type="button"
className="btn-secondary"
onClick={() => {
setSubmission(null)
setStep('intro')
}}
>
Submit another response
</button>
<Link to="/" className="btn-primary">
Back to forms
</Link>
</div>
</div>
)
}
return (
<div className="mx-auto max-w-3xl space-y-6">
<header className="space-y-2">
{form.logoUrl && (
<img
src={form.logoUrl}
alt={form.companyName ?? form.title}
className="h-14 w-auto"
referrerPolicy="no-referrer"
/>
)}
{form.companyName && (
<p className="text-sm font-semibold uppercase tracking-wide text-brand-600 dark:text-brand-400">
{form.companyName}
</p>
)}
<h1 className="text-3xl font-bold">{form.title}</h1>
<p className="text-xs text-slate-500 dark:text-slate-400">Version {form.version}</p>
{form.description && (
<p className="text-slate-600 dark:text-slate-300">{form.description}</p>
)}
{form.introduction && (
<div className="card bg-slate-100/70 dark:bg-slate-900/60">
<p className="whitespace-pre-line text-sm text-slate-700 dark:text-slate-300">
{form.introduction}
</p>
</div>
)}
{(form.contactEmail || form.contactPhone || form.contactUrl) && (
<p className="text-xs text-slate-500 dark:text-slate-400">
Questions?{' '}
{[form.contactEmail, form.contactPhone, form.contactUrl].filter(Boolean).join(' · ')}
</p>
)}
<div className="pt-1">
<button
type="button"
className="btn-secondary py-1 text-xs"
onClick={downloadPrintablePdf}
disabled={downloadingPdf}
>
{downloadingPdf ? 'Preparing PDF…' : 'Download printable PDF'}
</button>
</div>
{pdfError && <p className="text-sm text-red-600 dark:text-red-400">{pdfError}</p>}
</header>
{step === 'intro' && (
<form onSubmit={startQuestions} className="card space-y-4">
<h2 className="text-lg font-semibold">Before you begin</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
We use your identifier to link this response to you. It is not shown again after you
submit.
</p>
<fieldset className="space-y-2">
<legend className="label">How would you like to respond?</legend>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
name="completionMode"
checked={completionMode === 'online'}
onChange={() => setCompletionMode('online')}
className="mt-0.5 h-4 w-4 accent-brand-600"
/>
<span>
<span className="font-medium">Complete the form online</span>
<span className="block text-xs text-slate-500 dark:text-slate-400">
Answer the questions here and submit your response.
</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
name="completionMode"
checked={completionMode === 'offline'}
onChange={() => setCompletionMode('offline')}
className="mt-0.5 h-4 w-4 accent-brand-600"
/>
<span>
<span className="font-medium">Upload a completed PDF</span>
<span className="block text-xs text-slate-500 dark:text-slate-400">
Download the PDF, complete it offline, then upload it with your identity details.
</span>
</span>
</label>
</fieldset>
{completionMode === 'offline' && (
<div>
<label htmlFor="offlinePdf" className="label">
Completed PDF *
</label>
<input
id="offlinePdf"
type="file"
accept="application/pdf,.pdf"
className="input"
onChange={(event) => void chooseOfflineFile(event.target.files?.[0])}
/>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
Maximum 10 MB. {offlineFile ? `Selected: ${offlineFile.name}` : 'No file selected.'}
</p>
{offlineError && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{offlineError}</p>}
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="userIdType" className="label">
Identifier type
</label>
<select
id="userIdType"
className="input"
value={userIdType}
onChange={(event) => setUserIdType(event.target.value as UserIdType)}
>
{userIdTypes.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor="userId" className="label">
Your identifier
</label>
<input
id="userId"
className="input"
value={userId}
onChange={(event) => setUserId(event.target.value)}
placeholder={placeholders[userIdType] || 'Enter your identifier'} //placeholder={userIdType === 'email' ? 'you@example.com' : 'Enter your identifier'}
required
/>
</div>
</div>
{form.requiresAccessCode && (
<div>
<label htmlFor="accessCode" className="label">
Access code
</label>
<input
id="accessCode"
className="input uppercase"
value={accessCode}
onChange={(event) => setAccessCode(event.target.value.toUpperCase())}
placeholder="XXXX-XXXX"
required
/>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
Each access code can be used once.
</p>
</div>
)}
{form.externalReferenceRequired && (
<div>
<label htmlFor="externalReference" className="label">
{form.externalReferenceLabel ?? 'External reference'}
</label>
<input
id="externalReference"
className="input"
value={externalReference}
onChange={(event) => setExternalReference(event.target.value)}
maxLength={200}
required
/>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
Links this response to a record in another system.
</p>
</div>
)}
{formError && <p className="text-sm text-red-600 dark:text-red-400">{formError}</p>}
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting
? 'Submitting…'
: completionMode === 'offline'
? 'Upload and submit response'
: 'Start form'}
</button>
</form>
)}
{step === 'questions' && (
<form onSubmit={submit} className="space-y-4">
<div className="flex items-center justify-between text-sm text-slate-500 dark:text-slate-400">
<span>
{answeredCount} of {form.questions.length} answered
</span>
<button
type="button"
className="btn-secondary py-1 text-xs"
onClick={() => setStep('intro')}
>
Back
</button>
</div>
{form.questions.map((question) => (
<div key={question.id} className="card">
<QuestionField
question={question}
value={answers[question.id]}
onChange={(value) =>
setAnswers((current) => ({ ...current, [question.id]: value }))
}
/>
</div>
))}
{form.signatureRequired && (
<section className="card space-y-4">
<div>
<h2 className="text-lg font-semibold">Signature</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
Draw your signature or upload an image of it. Your name is required; a title is
optional.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="signatureName" className="label">
Name of person signing *
</label>
<input
id="signatureName"
className="input"
value={signatureName}
onChange={(event) => setSignatureName(event.target.value)}
required
/>
</div>
<div>
<label htmlFor="signatureTitle" className="label">
Title (optional)
</label>
<input
id="signatureTitle"
className="input"
value={signatureTitle}
onChange={(event) => setSignatureTitle(event.target.value)}
/>
</div>
</div>
<SignaturePad value={signature} onChange={setSignature} />
</section>
)}
{formError && <p className="text-sm text-red-600 dark:text-red-400">{formError}</p>}
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Submitting…' : 'Submit response'}
</button>
</form>
)}
</div>
)
}

View File

@ -0,0 +1,574 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api, ApiError } from '../lib/api'
import type { Form, QuestionInput, QuestionOption, QuestionType } from '../lib/types'
const questionTypes: Array<{ value: QuestionType; label: string; hint: string }> = [
{ value: 'text', label: 'Text', hint: 'Free-form text (single or multiline)' },
{ value: 'boolean', label: 'True / false', hint: 'Yes/no style answer' },
{ value: 'number', label: 'Number', hint: 'Numeric input with optional bounds' },
{ value: 'select', label: 'Select', hint: 'Choose one from a list' },
{ value: 'date', label: 'Date', hint: 'Calendar date in YYYY-MM-DD format' },
]
function emptyQuestion(): QuestionInput {
return {
key: `q${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
label: '',
type: 'text',
required: false,
multiline: false,
}
}
function emptyOption(): QuestionOption {
return { value: '', label: '' }
}
function slugKey(value: string): string {
return (
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64) || 'question'
)
}
export function FormBuilderPage() {
const { formId } = useParams<{ formId?: string }>()
const navigate = useNavigate()
const isEdit = Boolean(formId)
const [loading, setLoading] = useState(isEdit)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [introduction, setIntroduction] = useState('')
const [requiresAccessCode, setRequiresAccessCode] = useState(false)
const [signatureRequired, setSignatureRequired] = useState(false)
const [externalReferenceRequired, setExternalReferenceRequired] = useState(false)
const [externalReferenceLabel, setExternalReferenceLabel] = useState('')
const [logoUrl, setLogoUrl] = useState('')
const [pdfUrl, setPdfUrl] = useState('')
const [companyName, setCompanyName] = useState('')
const [contactEmail, setContactEmail] = useState('')
const [contactPhone, setContactPhone] = useState('')
const [contactUrl, setContactUrl] = useState('')
const [questions, setQuestions] = useState<QuestionInput[]>([emptyQuestion()])
useEffect(() => {
if (!formId) return
let cancelled = false
api
.getForm(formId)
.then((form: Form) => {
if (cancelled) return
setTitle(form.title)
setDescription(form.description ?? '')
setIntroduction(form.introduction ?? '')
setRequiresAccessCode(form.requiresAccessCode)
setSignatureRequired(Boolean(form.signatureRequired))
setExternalReferenceRequired(Boolean(form.externalReferenceRequired))
setExternalReferenceLabel(form.externalReferenceLabel ?? '')
setLogoUrl(form.logoUrl ?? '')
setPdfUrl(form.pdfUrl ?? '')
setCompanyName(form.companyName ?? '')
setContactEmail(form.contactEmail ?? '')
setContactPhone(form.contactPhone ?? '')
setContactUrl(form.contactUrl ?? '')
setQuestions(
form.questions.map((q) => ({
key: q.key,
label: q.label,
description: q.description,
type: q.type,
required: q.required,
options: q.options ? q.options.map((o) => ({ ...o })) : undefined,
minValue: q.minValue,
maxValue: q.maxValue,
placeholder: q.placeholder,
multiline: q.multiline,
})),
)
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof ApiError ? err.message : 'Could not load the form')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [formId])
function addQuestion() {
setQuestions((current) => [...current, emptyQuestion()])
}
function updateQuestion(index: number, patch: Partial<QuestionInput>) {
setQuestions((current) => {
const next = [...current]
next[index] = { ...next[index], ...patch }
return next
})
}
function suggestKey(index: number) {
setQuestions((current) => {
const next = [...current]
next[index] = { ...next[index], key: slugKey(next[index].label) }
return next
})
}
function removeQuestion(index: number) {
setQuestions((current) => current.filter((_, i) => i !== index))
}
function addOption(index: number) {
setQuestions((current) => {
const next = [...current]
const options = next[index].options ? [...next[index].options] : []
options.push(emptyOption())
next[index] = { ...next[index], options }
return next
})
}
function updateOption(qIndex: number, oIndex: number, patch: Partial<QuestionOption>) {
setQuestions((current) => {
const next = [...current]
const options = next[qIndex].options ? [...next[qIndex].options] : []
options[oIndex] = { ...options[oIndex], ...patch }
next[qIndex] = { ...next[qIndex], options }
return next
})
}
function removeOption(qIndex: number, oIndex: number) {
setQuestions((current) => {
const next = [...current]
const options = (next[qIndex].options ?? []).filter((_, i) => i !== oIndex)
next[qIndex] = { ...next[qIndex], options }
return next
})
}
async function save(event: React.FormEvent) {
event.preventDefault()
setError(null)
if (title.trim() === '') {
setError('Title is required.')
return
}
if (questions.length === 0) {
setError('Add at least one question.')
return
}
const payload = {
title: title.trim(),
description: description.trim() || undefined,
introduction: introduction.trim() || undefined,
requiresAccessCode,
signatureRequired,
externalReferenceRequired,
externalReferenceLabel: externalReferenceLabel.trim() || undefined,
logoUrl: logoUrl.trim() || undefined,
pdfUrl: pdfUrl.trim() || undefined,
companyName: companyName.trim() || undefined,
contactEmail: contactEmail.trim() || undefined,
contactPhone: contactPhone.trim() || undefined,
contactUrl: contactUrl.trim() || undefined,
questions: questions.map((q) => {
const out: QuestionInput = {
key: q.key.trim(),
label: q.label.trim(),
type: q.type,
required: q.required,
description: q.description?.trim() || undefined,
placeholder: q.placeholder?.trim() || undefined,
}
if (q.type === 'number') {
out.minValue = q.minValue
out.maxValue = q.maxValue
}
if (q.type === 'text') {
out.multiline = q.multiline
}
if (q.type === 'select') {
out.options = (q.options ?? [])
.filter((o) => o.value.trim() && o.label.trim())
.map((o) => ({ value: o.value.trim(), label: o.label.trim() }))
}
return out
}),
}
setSaving(true)
try {
if (isEdit && formId) {
const updated = await api.updateForm(formId, payload)
navigate(`/manage/forms/${updated.id}`)
} else {
const created = await api.createForm(payload)
navigate(`/manage/forms/${created.id}`)
}
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not save the form')
} finally {
setSaving(false)
}
}
if (loading) {
return <p className="text-slate-500 dark:text-slate-400">Loading form…</p>
}
return (
<form onSubmit={save} className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
<Link to="/manage" className="hover:text-brand-600 dark:hover:text-brand-400">
Management
</Link>
<span>/</span>
<span>{isEdit ? 'Edit form' : 'Create form'}</span>
</div>
<h1 className="mt-1 text-2xl font-bold">{isEdit ? 'Edit form' : 'Create form'}</h1>
<p className="text-slate-500 dark:text-slate-400">
{isEdit
? 'Saving creates a new version; earlier submissions keep their original definition.'
: 'Build a new versioned form.'}
</p>
</div>
<div className="flex gap-2">
<Link to="/manage" className="btn-secondary">
Cancel
</Link>
<button type="submit" className="btn-primary" disabled={saving}>
{saving ? 'Saving…' : 'Save form'}
</button>
</div>
</header>
{error && (
<div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">{error}</div>
)}
<section className="card space-y-4">
<h2 className="text-lg font-semibold">Form details</h2>
<div className="grid gap-4 md:grid-cols-2">
<div className="md:col-span-2">
<label htmlFor="title" className="label">
Title *
</label>
<input id="title" className="input" value={title} onChange={(e) => setTitle(e.target.value)} required />
</div>
<div className="md:col-span-2">
<label htmlFor="description" className="label">
Description
</label>
<textarea
id="description"
className="input min-h-[4rem]"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="md:col-span-2">
<label htmlFor="introduction" className="label">
Introduction (optional section)
</label>
<textarea
id="introduction"
className="input min-h-[6rem]"
value={introduction}
onChange={(e) => setIntroduction(e.target.value)}
placeholder="Explain purpose, background and support details."
/>
</div>
<div>
<label htmlFor="company" className="label">
Company name
</label>
<input id="company" className="input" value={companyName} onChange={(e) => setCompanyName(e.target.value)} />
</div>
<div>
<label htmlFor="logo" className="label">
Logo URL
</label>
<input
id="logo"
className="input"
type="url"
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
placeholder="https://example.com/logo.png"
/>
</div>
<div>
<label htmlFor="pdf" className="label">
Linked PDF URL
</label>
<input
id="pdf"
className="input"
type="url"
value={pdfUrl}
onChange={(e) => setPdfUrl(e.target.value)}
placeholder="https://example.com/prebuilt-form.pdf"
/>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
Optional prebuilt PDF used for printable and offline copies.
</p>
</div>
<div>
<label htmlFor="email" className="label">
Contact email
</label>
<input id="email" className="input" type="email" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} />
</div>
<div>
<label htmlFor="phone" className="label">
Contact phone
</label>
<input id="phone" className="input" value={contactPhone} onChange={(e) => setContactPhone(e.target.value)} />
</div>
<div>
<label htmlFor="url" className="label">
Contact URL
</label>
<input id="url" className="input" value={contactUrl} onChange={(e) => setContactUrl(e.target.value)} />
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={requiresAccessCode}
onChange={(e) => setRequiresAccessCode(e.target.checked)}
className="h-4 w-4 accent-brand-600"
/>
Require single-use access codes
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={signatureRequired}
onChange={(e) => setSignatureRequired(e.target.checked)}
className="h-4 w-4 accent-brand-600"
/>
Require a digital signature at the end of the form
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={externalReferenceRequired}
onChange={(e) => setExternalReferenceRequired(e.target.checked)}
className="h-4 w-4 accent-brand-600"
/>
Require an external reference from respondents
</label>
{externalReferenceRequired && (
<div>
<label htmlFor="externalReferenceLabel" className="label">
External reference label
</label>
<input
id="externalReferenceLabel"
className="input"
value={externalReferenceLabel}
onChange={(e) => setExternalReferenceLabel(e.target.value)}
placeholder="External reference"
maxLength={100}
/>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
Shown to respondents and stored with each response, for example “Invoice number”.
</p>
</div>
)}
</section>
<section className="card space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Questions</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">Questions may not have formatting directives.</p>
</div>
<button type="button" className="btn-secondary" onClick={addQuestion}>
Add question
</button>
</div>
<div className="space-y-4">
{questions.map((question, index) => (
<article key={index} className="rounded-xl border border-slate-200 p-4 dark:border-slate-700">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold">Question {index + 1}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Key used to store answers</p>
</div>
<div className="flex gap-2">
<button type="button" className="btn-secondary py-1 text-xs" onClick={() => suggestKey(index)}>
Suggest key
</button>
{questions.length > 1 && (
<button type="button" className="btn-danger py-1 text-xs" onClick={() => removeQuestion(index)}>
Remove
</button>
)}
</div>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div>
<label className="label">Label *</label>
<input
className="input"
value={question.label}
onChange={(e) => updateQuestion(index, { label: e.target.value })}
required
/>
</div>
<div>
<label className="label">Key *</label>
<input
className="input font-mono text-sm"
value={question.key}
onChange={(e) => updateQuestion(index, { key: e.target.value })}
required
/>
</div>
<div className="md:col-span-2">
<label className="label">Description</label>
<input
className="input"
value={question.description ?? ''}
onChange={(e) => updateQuestion(index, { description: e.target.value })}
/>
</div>
<div>
<label className="label">Type</label>
<select
className="input"
value={question.type}
onChange={(e) => updateQuestion(index, { type: e.target.value as QuestionType, options: undefined })}
>
{questionTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
{questionTypes.find((t) => t.value === question.type)?.hint}
</p>
</div>
<div>
<label className="label">Placeholder</label>
<input
className="input"
value={question.placeholder ?? ''}
onChange={(e) => updateQuestion(index, { placeholder: e.target.value })}
/>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-4 text-sm">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={question.required}
onChange={(e) => updateQuestion(index, { required: e.target.checked })}
className="h-4 w-4 accent-brand-600"
/>
Required
</label>
{question.type === 'text' && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(question.multiline)}
onChange={(e) => updateQuestion(index, { multiline: e.target.checked })}
className="h-4 w-4 accent-brand-600"
/>
Multiline
</label>
)}
</div>
{question.type === 'number' && (
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label className="label">Min value</label>
<input
type="number"
className="input"
value={question.minValue ?? ''}
onChange={(e) =>
updateQuestion(index, { minValue: e.target.value === '' ? undefined : Number(e.target.value) })
}
/>
</div>
<div>
<label className="label">Max value</label>
<input
type="number"
className="input"
value={question.maxValue ?? ''}
onChange={(e) =>
updateQuestion(index, { maxValue: e.target.value === '' ? undefined : Number(e.target.value) })
}
/>
</div>
</div>
)}
{question.type === 'select' && (
<div className="mt-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">Options</p>
<button type="button" className="btn-secondary py-1 text-xs" onClick={() => addOption(index)}>
Add option
</button>
</div>
{(question.options ?? []).map((option, oIndex) => (
<div key={oIndex} className="grid gap-2 sm:grid-cols-[1fr,1fr,auto]">
<input
className="input"
placeholder="Label"
value={option.label}
onChange={(e) => updateOption(index, oIndex, { label: e.target.value })}
/>
<input
className="input font-mono text-sm"
placeholder="Value"
value={option.value}
onChange={(e) => updateOption(index, oIndex, { value: e.target.value })}
/>
<button
type="button"
className="btn-danger py-1 text-xs"
onClick={() => removeOption(index, oIndex)}
>
Remove
</button>
</div>
))}
{(question.options ?? []).length === 0 && (
<p className="text-sm text-slate-500 dark:text-slate-400">Add at least one option.</p>
)}
</div>
)}
</article>
))}
</div>
</section>
</form>
)
}

View File

@ -0,0 +1,368 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { api, ApiError } from '../lib/api'
import type { AccessCode, Form, SubmissionSummary } from '../lib/types'
import { StatusBadge } from '../components/StatusBadge'
export function FormDetailPage() {
const { formId = '' } = useParams<{ formId: string }>()
const navigate = useNavigate()
const { user } = useAuth()
const canAdmin = Boolean(user?.canAdmin)
const canView = Boolean(user?.canView)
const [form, setForm] = useState<Form | null>(null)
const [submissions, setSubmissions] = useState<SubmissionSummary[]>([])
const [codes, setCodes] = useState<AccessCode[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [codeCount, setCodeCount] = useState(5)
const [codePrefix, setCodePrefix] = useState('')
async function load() {
setLoading(true)
setError(null)
try {
const [f, subs, accessCodes] = await Promise.all([
api.getForm(formId),
canView ? api.listSubmissions(formId) : Promise.resolve([] as SubmissionSummary[]),
canAdmin ? api.listAccessCodes(formId) : Promise.resolve([] as AccessCode[]),
])
setForm(f)
setSubmissions(subs)
setCodes(accessCodes)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not load form details')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [formId, canAdmin, canView])
async function toggleActive() {
if (!form) return
setBusy(true)
setError(null)
try {
if (form.isActive) {
await api.inactivateForm(form.id)
} else {
await api.activateForm(form.id)
}
await load()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not update the form')
} finally {
setBusy(false)
}
}
async function createVersion() {
if (!form) return
setBusy(true)
setError(null)
try {
const created = await api.createVersion(form.id)
navigate(`/manage/forms/${created.id}`)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not create a new version')
} finally {
setBusy(false)
}
}
async function generateCodes(event: React.FormEvent) {
event.preventDefault()
if (!form) return
setBusy(true)
setError(null)
try {
await api.createAccessCodes(form.id, {
count: codeCount,
prefix: codePrefix.trim() || undefined,
})
setCodePrefix('')
await load()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not generate access codes')
} finally {
setBusy(false)
}
}
async function downloadPdf() {
if (!form) return
try {
await api.downloadPdf(
form.id,
`kereru-form-${form.title.replace(/[^a-z0-9]+/gi, '-')}-v${form.version}.pdf`,
form.pdfUrl,
)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not download the PDF')
}
}
if (loading) {
return <p className="text-slate-500 dark:text-slate-400">Loading form details…</p>
}
if (error && !form) {
return <div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">{error}</div>
}
if (!form) return null
const externalReferenceLabel = form.externalReferenceLabel ?? 'External reference'
const showExternalReference =
form.externalReferenceRequired || submissions.some((submission) => Boolean(submission.externalReference))
return (
<div className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
<Link to="/manage" className="hover:text-brand-600 dark:hover:text-brand-400">
Management
</Link>
<span>/</span>
<span>{form.title}</span>
</div>
<div className="flex items-center gap-3">
{form.logoUrl && (
<img
src={form.logoUrl}
alt={form.companyName ?? form.title}
className="h-10 w-auto"
referrerPolicy="no-referrer"
/>
)}
<h1 className="text-2xl font-bold">{form.title}</h1>
</div>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge active={form.isActive} />
<span className="text-sm text-slate-500 dark:text-slate-400">Version {form.version}</span>
<span className="text-sm text-slate-500 dark:text-slate-400">
{form.requiresAccessCode ? 'Access code required' : 'Open to anyone'}
</span>
{form.signatureRequired && (
<span className="text-sm text-slate-500 dark:text-slate-400">Signature required</span>
)}
{form.externalReferenceRequired && (
<span className="text-sm text-slate-500 dark:text-slate-400">
{form.externalReferenceLabel ?? 'External reference'} required
</span>
)}
{form.pdfUrl && (
<span className="text-sm text-slate-500 dark:text-slate-400">Linked PDF</span>
)}
</div>
{form.description && <p className="text-slate-600 dark:text-slate-300">{form.description}</p>}
</div>
<div className="flex flex-wrap gap-2">
{canAdmin && (
<>
<button type="button" className="btn-secondary" onClick={() => navigate(`/manage/forms/${form.id}/edit`)}>
Edit
</button>
<button type="button" className="btn-secondary" disabled={busy} onClick={createVersion}>
New version
</button>
<button type="button" className="btn-secondary" disabled={busy} onClick={toggleActive}>
{form.isActive ? 'Inactivate' : 'Activate'}
</button>
</>
)}
<button type="button" className="btn-secondary" onClick={downloadPdf}>
Download PDF
</button>
<Link to={`/forms/${form.id}`} className="btn-primary">
Preview form
</Link>
</div>
</header>
{error && (
<div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">{error}</div>
)}
<div className="grid gap-6 lg:grid-cols-2">
<section className="card space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Form details</h2>
<span className="text-xs text-slate-500 dark:text-slate-400">{form.questions.length} questions</span>
</div>
{form.introduction && (
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">Introduction</p>
<p className="mt-1 whitespace-pre-line text-sm text-slate-600 dark:text-slate-400">{form.introduction}</p>
</div>
)}
<div className="space-y-2">
{form.questions.map((question, index) => (
<div key={question.id} className="rounded-lg border border-slate-200 p-3 dark:border-slate-700">
<div className="flex items-start justify-between gap-2">
<p className="font-medium">
{index + 1}. {question.label}
{question.required && <span className="ml-1 text-red-500">*</span>}
</p>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-400">
{question.type}
</span>
</div>
{question.description && <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{question.description}</p>}
{question.type === 'select' && question.options && (
<ul className="mt-2 flex flex-wrap gap-2 text-xs text-slate-500 dark:text-slate-400">
{question.options.map((option) => (
<li key={option.value} className="rounded-full border border-slate-200 px-2 py-0.5 dark:border-slate-700">
{option.label} ({option.value})
</li>
))}
</ul>
)}
</div>
))}
</div>
</section>
<div className="space-y-6">
{form.requiresAccessCode && canAdmin && (
<section className="card space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Access codes</h2>
<span className="text-sm text-slate-500 dark:text-slate-400">{codes.length} total</span>
</div>
<form onSubmit={generateCodes} className="flex flex-wrap items-end gap-2">
<div>
<label htmlFor="code-count" className="label">
Count
</label>
<input
id="code-count"
type="number"
min={1}
max={1000}
className="input w-24"
value={codeCount}
onChange={(event) => setCodeCount(Number(event.target.value))}
/>
</div>
<div className="flex-1 min-w-[160px]">
<label htmlFor="code-prefix" className="label">
Prefix (optional)
</label>
<input
id="code-prefix"
className="input uppercase"
value={codePrefix}
onChange={(event) => setCodePrefix(event.target.value.toUpperCase())}
placeholder="DEMO"
/>
</div>
<button type="submit" className="btn-primary" disabled={busy}>
Generate
</button>
</form>
{codes.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">No access codes yet.</p>
) : (
<div className="max-h-64 overflow-auto rounded-lg border border-slate-200 dark:border-slate-700">
<table className="table">
<thead>
<tr>
<th>Code</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{codes.map((code) => (
<tr key={code.id}>
<td className="font-mono text-xs">{code.code}</td>
<td>
<span
className={`badge ${
code.used
? 'bg-slate-200 text-slate-600 dark:bg-slate-800 dark:text-slate-400'
: 'bg-brand-100 text-brand-700 dark:bg-brand-900/60 dark:text-brand-200'
}`}
>
{code.used ? 'Used' : 'Unused'}
</span>
</td>
<td className="text-xs text-slate-500 dark:text-slate-400">
{new Date(code.createdAt).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)}
<section className="card space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Submissions</h2>
{canView && (
<span className="text-sm text-slate-500 dark:text-slate-400">
{submissions.length} total
</span>
)}
</div>
{!canView ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
A view role is required to see submitted responses.
</p>
) : submissions.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">No submissions yet.</p>
) : (
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>Identifier</th>
<th>Type</th>
{showExternalReference && (
<th>{externalReferenceLabel}</th>
)}
<th>Submitted</th>
<th className="text-right">Details</th>
</tr>
</thead>
<tbody>
{submissions.map((submission) => (
<tr key={submission.id}>
<td className="font-medium">{submission.userId}</td>
<td className="uppercase text-xs text-slate-500 dark:text-slate-400">{submission.userIdType}</td>
{showExternalReference && (
<td className="text-xs text-slate-500 dark:text-slate-400">
{submission.externalReference ?? '—'}
</td>
)}
<td className="text-xs text-slate-500 dark:text-slate-400">
{new Date(submission.submittedAt).toLocaleString()}
</td>
<td className="text-right">
<Link to={`/submissions/${submission.id}`} className="btn-secondary py-1 text-xs">
View
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,136 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { SignInPrompt } from '../components/SignInPrompt'
import { api, ApiError } from '../lib/api'
import type { FormSummary } from '../lib/types'
import { StatusBadge } from '../components/StatusBadge'
export function HomePage() {
const { user, config, loading: authLoading } = useAuth()
const [forms, setForms] = useState<FormSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const submissionLocked = Boolean(config?.requireSubmissionAuth && !user)
useEffect(() => {
if (authLoading) return
if (submissionLocked) {
setForms([])
setLoading(false)
return
}
let cancelled = false
setLoading(true)
api
.listForms(false)
.then((result) => {
if (!cancelled) setForms(result)
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof ApiError ? err.message : 'Could not load forms')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [authLoading, submissionLocked])
return (
<div className="space-y-8">
<section className="card bg-gradient-to-br from-brand-600 to-brand-800 text-white dark:from-brand-800 dark:to-brand-950">
<p className="text-sm font-semibold uppercase tracking-widest text-brand-100">Kererū Forms</p>
<h1 className="mt-2 text-3xl font-bold sm:text-4xl">Simple, versioned forms</h1>
<p className="mt-2 max-w-2xl text-brand-50">
Pick a form below to complete. Forms can also be thought of as surveys.
</p>
{(user?.canAdmin || user?.canView) && (
<div>
<p className="mt-2 max-w-2xl text-brand-50">
Use the buttons below to manage forms or view submitted forms.
</p>
<div className="mt-5 flex flex-wrap gap-3">
<Link to="/manage" className="btn bg-white text-brand-800 hover:bg-brand-50">
Manage forms
</Link>
{user?.canAdmin && (
<Link
to="/manage/new"
className="btn border border-white/40 text-white hover:bg-white/10"
>
Create a form
</Link>
)}
</div>
</div>
)}
</section>
{submissionLocked ? (
<SignInPrompt message="Sign in with your organisation account to view and complete forms." />
) : (
<section>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-bold">Available forms</h2>
<span className="text-sm text-slate-500 dark:text-slate-400">
{forms.length} active {forms.length === 1 ? 'form' : 'forms'}
</span>
</div>
{loading && <p className="text-slate-500 dark:text-slate-400">Loading forms…</p>}
{error && (
<div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">
{error}
</div>
)}
{!loading && !error && forms.length === 0 && (
<div className="card text-center">
<p className="text-lg font-semibold">No active forms yet</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Create one from the management section to get started.
</p>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{forms.map((form) => (
<article key={form.id} className="card flex flex-col justify-between">
<div>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
{form.logoUrl && (
<img
src={form.logoUrl}
alt=""
className="h-8 w-auto"
referrerPolicy="no-referrer"
/>
)}
<h3 className="text-lg font-semibold">{form.title}</h3>
</div>
<StatusBadge active={form.isActive} />
</div>
{form.description && (
<p className="mt-2 line-clamp-3 text-sm text-slate-500 dark:text-slate-400">
{form.description}
</p>
)}
<p className="mt-3 text-xs text-slate-500 dark:text-slate-400">
Version {form.version} · {form.questionCount} questions
</p>
</div>
<Link to={`/forms/${form.id}`} className="btn-primary mt-5 w-full">
Open form
</Link>
</article>
))}
</div>
</section>
)}
</div>
)
}

View File

@ -0,0 +1,127 @@
import { useState } from 'react'
import type { FormEvent } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { ApiError } from '../lib/api'
import { safeReturnTo } from '../lib/oidc'
export function LoginPage() {
const { config, user, error: configError, loginWithPassword, loginWithOidc } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const state = location.state as { from?: string } | null
const query = new URLSearchParams(location.search)
const returnTo = safeReturnTo(state?.from ?? query.get('returnTo'))
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function signInWithPassword(event: FormEvent) {
event.preventDefault()
setBusy(true)
setError(null)
try {
await loginWithPassword(password)
navigate(returnTo, { replace: true })
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not sign in')
} finally {
setBusy(false)
}
}
async function signInWithOidc() {
setBusy(true)
setError(null)
try {
await loginWithOidc(returnTo)
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not start sign-in')
setBusy(false)
}
}
if (user) {
return (
<div className="card mx-auto max-w-lg space-y-3 text-center">
<h1 className="text-2xl font-bold">You are signed in</h1>
<p className="text-slate-500 dark:text-slate-400">
Signed in as {user.name ?? user.email ?? user.id}.
</p>
<div className="flex flex-wrap justify-center gap-3">
{(user.canAdmin || user.canView) && (
<Link to="/manage" className="btn-primary">
Go to management
</Link>
)}
<Link to="/" className="btn-secondary">
Back to forms
</Link>
</div>
</div>
)
}
return (
<div className="card mx-auto max-w-lg space-y-5">
<div>
<h1 className="text-2xl font-bold">Sign in</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Management access requires an admin or view role.
</p>
</div>
{(error || configError) && (
<div className="rounded-lg border border-red-300 p-3 text-sm text-red-700 dark:border-red-900 dark:text-red-300">
{error ?? configError}
</div>
)}
{!config && <p className="text-slate-500 dark:text-slate-400">Loading sign-in options…</p>}
{config?.mode === 'oidc' && config.oidc && (
<div className="space-y-3">
<p className="text-sm text-slate-600 dark:text-slate-300">
Continue with your organisation account.
</p>
<button type="button" className="btn-primary w-full" onClick={signInWithOidc} disabled={busy}>
{busy ? 'Redirecting…' : 'Sign in with OAuth'}
</button>
</div>
)}
{config?.mode === 'password' && (
<form onSubmit={signInWithPassword} className="space-y-3">
<div>
<label htmlFor="password" className="label">
Local password
</label>
<input
id="password"
type="password"
className="input"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
required
/>
</div>
<button type="submit" className="btn-primary w-full" disabled={busy || !password}>
{busy ? 'Signing in…' : 'Sign in'}
</button>
<p className="text-xs text-slate-500 dark:text-slate-400">
Local sign-in grants both admin and view roles and is intended for development or
testing when no OIDC provider is configured.
</p>
</form>
)}
{config?.mode === 'oidc' && !config.oidc && (
<p className="text-sm text-red-600 dark:text-red-400">
OIDC is enabled but the provider configuration is incomplete.
</p>
)}
</div>
)
}

View File

@ -0,0 +1,223 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { api, ApiError } from '../lib/api'
import type { FormSummary } from '../lib/types'
import { StatusBadge } from '../components/StatusBadge'
export function ManagePage() {
const navigate = useNavigate()
const { user } = useAuth()
const canAdmin = Boolean(user?.canAdmin)
const canView = Boolean(user?.canView)
const [forms, setForms] = useState<FormSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState<string | null>(null)
async function load() {
setLoading(true)
setError(null)
try {
const result = await api.listForms(true)
setForms(result)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not load forms')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
async function toggleActive(form: FormSummary) {
setBusy(form.id)
setError(null)
try {
if (form.isActive) {
await api.inactivateForm(form.id)
} else {
await api.activateForm(form.id)
}
await load()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not update the form')
} finally {
setBusy(null)
}
}
async function createVersion(form: FormSummary) {
setBusy(form.id)
setError(null)
try {
const created = await api.createVersion(form.id)
navigate(`/manage/forms/${created.id}`)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not create a new version')
} finally {
setBusy(null)
}
}
async function remove(form: FormSummary) {
if (!window.confirm(`Delete "${form.title}" (version ${form.version})? This also removes its submissions and access codes.`)) return
setBusy(form.id)
setError(null)
try {
await api.deleteForm(form.id)
await load()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not delete the form')
} finally {
setBusy(null)
}
}
async function downloadPdf(form: FormSummary) {
try {
await api.downloadPdf(
form.id,
`kereru-form-${form.title.replace(/[^a-z0-9]+/gi, '-')}-v${form.version}.pdf`,
form.pdfUrl,
)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not download the PDF')
}
}
return (
<div className="space-y-6">
<header className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold">Management</h1>
<p className="text-slate-500 dark:text-slate-400">
{canAdmin
? 'Create, version, activate and review forms and submissions.'
: 'View forms and review submissions.'}
</p>
</div>
{canAdmin && (
<Link to="/manage/new" className="btn-primary">
Create form
</Link>
)}
</header>
{error && (
<div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">
{error}
</div>
)}
{loading ? (
<p className="text-slate-500 dark:text-slate-400">Loading forms…</p>
) : forms.length === 0 ? (
<div className="card text-center">
<p className="text-lg font-semibold">No forms yet</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{canAdmin
? 'Create your first form to start collecting responses.'
: 'No forms have been created yet.'}
</p>
{canAdmin && (
<Link to="/manage/new" className="btn-primary mt-4">
Create form
</Link>
)}
</div>
) : (
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>Form</th>
<th>Version</th>
<th>Status</th>
<th>Questions</th>
<th>Updated</th>
<th className="text-right">Actions</th>
</tr>
</thead>
<tbody>
{forms.map((form) => (
<tr key={form.id}>
<td>
<div>
<p className="font-semibold text-slate-900 dark:text-white">{form.title}</p>
{form.description && (
<p className="text-xs text-slate-500 dark:text-slate-400">
{form.description}
</p>
)}
{form.externalReferenceRequired && (
<p className="text-xs text-slate-500 dark:text-slate-400">
{form.externalReferenceLabel ?? 'External reference'} required
</p>
)}
</div>
</td>
<td>v{form.version}</td>
<td>
<StatusBadge active={form.isActive} />
</td>
<td>{form.questionCount}</td>
<td className="text-xs text-slate-500 dark:text-slate-400">
{new Date(form.updatedAt).toLocaleString()}
</td>
<td>
<div className="flex flex-wrap justify-end gap-2">
<Link to={`/forms/${form.id}`} className="btn-secondary py-1 text-xs">
Preview
</Link>
<Link to={`/manage/forms/${form.id}`} className="btn-secondary py-1 text-xs">
{canView ? 'View / submissions' : 'Manage'}
</Link>
<button
type="button"
className="btn-secondary py-1 text-xs"
onClick={() => downloadPdf(form)}
>
PDF
</button>
{canAdmin && (
<>
<button
type="button"
className="btn-secondary py-1 text-xs"
disabled={busy === form.id}
onClick={() => createVersion(form)}
>
New version
</button>
<button
type="button"
className="btn-secondary py-1 text-xs"
disabled={busy === form.id}
onClick={() => toggleActive(form)}
>
{form.isActive ? 'Inactivate' : 'Activate'}
</button>
<button
type="button"
className="btn-danger py-1 text-xs"
disabled={busy === form.id}
onClick={() => remove(form)}
>
Delete
</button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,50 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { completeOidcLogin } from '../lib/oidc'
import { setAuthToken } from '../lib/session'
export function OidcCallbackPage() {
const { loading, config, refresh } = useAuth()
const navigate = useNavigate()
const [error, setError] = useState<string | null>(null)
const started = useRef(false)
useEffect(() => {
if (loading || started.current) return
started.current = true
if (!config?.oidc) {
setError('OIDC is not configured.')
return
}
void completeOidcLogin(config.oidc, new URLSearchParams(window.location.search))
.then(async ({ token, returnTo }) => {
setAuthToken(token)
await refresh()
navigate(returnTo, { replace: true })
})
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : 'Could not complete sign-in')
})
}, [loading, config, refresh, navigate])
if (error) {
return (
<div className="card mx-auto max-w-lg space-y-3 text-center">
<h1 className="text-xl font-bold">Sign-in failed</h1>
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
<Link to="/login" className="btn-primary mx-auto w-fit">
Try again
</Link>
</div>
)
}
return (
<div className="card mx-auto max-w-lg text-center">
<p className="text-slate-500 dark:text-slate-400">Completing sign-in…</p>
</div>
)
}

View File

@ -0,0 +1,201 @@
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { api, ApiError } from '../lib/api'
import type { Form, Submission } from '../lib/types'
export function SubmissionDetailPage() {
const { submissionId = '' } = useParams<{ submissionId: string }>()
const [submission, setSubmission] = useState<Submission | null>(null)
const [form, setForm] = useState<Form | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [downloadingOffline, setDownloadingOffline] = useState(false)
useEffect(() => {
let cancelled = false
api
.getSubmission(submissionId)
.then(async (result) => {
if (cancelled) return
setSubmission(result)
try {
const resultForm = await api.getForm(result.formId)
if (!cancelled) setForm(resultForm)
} catch {
if (!cancelled) setForm(null)
}
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof ApiError ? err.message : 'Could not load submission')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [submissionId])
if (loading) {
return <p className="text-slate-500 dark:text-slate-400">Loading submission…</p>
}
if (error || !submission) {
return <div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">{error ?? 'Submission not found'}</div>
}
function formatValue(value: unknown): string {
if (value === true) return 'True'
if (value === false) return 'False'
if (Array.isArray(value)) return value.join(', ')
if (typeof value === 'object' && value !== null) return JSON.stringify(value)
return String(value)
}
async function downloadOfflinePdf() {
if (!submission) return
setDownloadingOffline(true)
setError(null)
try {
await api.downloadSubmissionOfflinePdf(submission.id, submission.offlinePdfName)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not download the offline PDF')
} finally {
setDownloadingOffline(false)
}
}
const questionLabels = new Map(form?.questions.map((question) => [question.id, question.label]) ?? [])
const externalReferenceLabel = form?.externalReferenceLabel ?? 'External reference'
return (
<div className="mx-auto max-w-3xl space-y-6">
<header className="space-y-2">
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
<Link to="/manage" className="hover:text-brand-600 dark:hover:text-brand-400">
Management
</Link>
<span>/</span>
<Link to={`/manage/forms/${submission.formId}`} className="hover:text-brand-600 dark:hover:text-brand-400">
Form
</Link>
<span>/</span>
<span>Submission</span>
</div>
<h1 className="text-2xl font-bold">Submission details</h1>
<div className="flex flex-wrap gap-3 text-sm text-slate-500 dark:text-slate-400">
<span>
Identifier: <span className="text-slate-900 dark:text-white">{submission.userId}</span> ({submission.userIdType})
</span>
<span>Form version {submission.formVersion}</span>
<span>{new Date(submission.submittedAt).toLocaleString()}</span>
</div>
</header>
{error && (
<div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">{error}</div>
)}
{submission.externalReference && (
<section className="card space-y-2">
<h2 className="text-lg font-semibold">{externalReferenceLabel}</h2>
<p className="text-slate-700 dark:text-slate-300">{submission.externalReference}</p>
</section>
)}
{(submission.signature || submission.offlinePdfName) && (
<section className="card space-y-4">
<h2 className="text-lg font-semibold">Signature and attachments</h2>
{submission.signature && (
<div className="space-y-2">
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
{submission.signature.name}
{submission.signature.title ? `, ${submission.signature.title}` : ''}
</p>
<img
src={submission.signature.dataUrl}
alt={`Signature of ${submission.signature.name}`}
className="max-h-40 max-w-full rounded-lg border border-slate-200 bg-white p-2 dark:border-slate-700"
/>
<p className="text-xs text-slate-500 dark:text-slate-400">
{submission.signature.type === 'drawn' ? 'Drawn signature' : 'Uploaded signature'} ·{' '}
{new Date(submission.signature.signedAt).toLocaleString()}
</p>
</div>
)}
{submission.offlinePdfName && (
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm">
Offline PDF: <span className="font-medium">{submission.offlinePdfName}</span>
</p>
<button
type="button"
className="btn-secondary py-1 text-xs"
onClick={downloadOfflinePdf}
disabled={downloadingOffline}
>
{downloadingOffline ? 'Preparing…' : 'Download PDF'}
</button>
</div>
)}
</section>
)}
<section className="card space-y-4">
<h2 className="text-lg font-semibold">Answers</h2>
<div className="divide-y divide-slate-200 dark:divide-slate-800">
{submission.answers.map((answer) => (
<div key={answer.questionId} className="flex flex-col gap-1 py-3 first:pt-0 last:pb-0">
<p className="font-medium">{questionLabels.get(answer.questionId) ?? answer.questionKey}</p>
<p className="text-slate-700 dark:text-slate-300">{formatValue(answer.value)}</p>
</div>
))}
</div>
</section>
{submission.tracking && (
<section className="card space-y-3">
<h2 className="text-lg font-semibold">Tracking (server captured)</h2>
<dl className="grid gap-2 text-sm sm:grid-cols-2">
{submission.tracking.ipAddress && (
<>
<dt className="text-slate-500 dark:text-slate-400">IP address</dt>
<dd>{submission.tracking.ipAddress}</dd>
</>
)}
{submission.tracking.userAgent && (
<>
<dt className="text-slate-500 dark:text-slate-400">User agent</dt>
<dd className="break-all">{submission.tracking.userAgent}</dd>
</>
)}
{submission.tracking.locale && (
<>
<dt className="text-slate-500 dark:text-slate-400">Locale</dt>
<dd>{submission.tracking.locale}</dd>
</>
)}
{submission.tracking.timezone && (
<>
<dt className="text-slate-500 dark:text-slate-400">Timezone</dt>
<dd>{submission.tracking.timezone}</dd>
</>
)}
{submission.tracking.place && (
<>
<dt className="text-slate-500 dark:text-slate-400">Place</dt>
<dd>{submission.tracking.place}</dd>
</>
)}
{submission.tracking.referer && (
<>
<dt className="text-slate-500 dark:text-slate-400">Referer</dt>
<dd className="break-all">{submission.tracking.referer}</dd>
</>
)}
</dl>
</section>
)}
</div>
)
}

View File

@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { safeReturnTo } from '../lib/oidc'
describe('safeReturnTo', () => {
it('accepts local application paths', () => {
expect(safeReturnTo('/manage')).toBe('/manage')
expect(safeReturnTo('/manage/forms/123?tab=submissions')).toBe(
'/manage/forms/123?tab=submissions',
)
})
it('rejects external and protocol-relative redirects', () => {
expect(safeReturnTo('https://evil.example')).toBe('/manage')
expect(safeReturnTo('//evil.example/path')).toBe('/manage')
expect(safeReturnTo(undefined)).toBe('/manage')
})
})

View File

@ -0,0 +1,123 @@
import { render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { MemoryRouter } from 'react-router-dom'
import { ThemeToggle } from '../components/ThemeToggle'
import { StatusBadge } from '../components/StatusBadge'
import { QuestionField } from '../components/QuestionField'
import { SignaturePad } from '../components/SignaturePad'
describe('ThemeToggle', () => {
it('renders all theme options', () => {
const { getByText } = render(
<MemoryRouter>
<ThemeToggle />
</MemoryRouter>,
)
expect(getByText('Light')).toBeInTheDocument()
expect(getByText('Dark')).toBeInTheDocument()
expect(getByText('System')).toBeInTheDocument()
})
})
describe('StatusBadge', () => {
it('shows active and inactive states', () => {
const { getByText, rerender } = render(<StatusBadge active />)
expect(getByText('Active')).toBeInTheDocument()
rerender(<StatusBadge active={false} />)
expect(getByText('Inactive')).toBeInTheDocument()
})
})
describe('QuestionField', () => {
it('renders boolean checkboxes', () => {
const onChange = vi.fn()
const { getByLabelText } = render(
<QuestionField
question={{
id: 'q1',
key: 'happy',
label: 'Are you happy?',
type: 'boolean',
required: true,
}}
value={undefined}
onChange={onChange}
/>,
)
expect(getByLabelText(/Are you happy\?/)).toBeInTheDocument()
expect(getByLabelText('True')).toBeInTheDocument()
expect(getByLabelText('False')).toBeInTheDocument()
})
it('renders select options', () => {
const onChange = vi.fn()
const { getByRole } = render(
<QuestionField
question={{
id: 'q2',
key: 'colour',
label: 'Colour',
type: 'select',
required: false,
options: [
{ value: 'red', label: 'Red' },
{ value: 'blue', label: 'Blue' },
],
}}
value=""
onChange={onChange}
/>,
)
expect(getByRole('combobox')).toBeInTheDocument()
})
it('renders multiline text area', () => {
const onChange = vi.fn()
const { getByRole } = render(
<QuestionField
question={{
id: 'q3',
key: 'notes',
label: 'Notes',
type: 'text',
required: false,
multiline: true,
}}
value=""
onChange={onChange}
/>,
)
expect(getByRole('textbox')).toBeInTheDocument()
})
it('renders a date input', () => {
const onChange = vi.fn()
const { getByLabelText } = render(
<QuestionField
question={{
id: 'q4',
key: 'when',
label: 'When',
type: 'date',
required: true,
}}
value="2026-10-23"
onChange={onChange}
/>,
)
expect(getByLabelText(/When/)).toHaveValue('2026-10-23')
})
})
describe('SignaturePad', () => {
it('shows upload controls and previews an uploaded signature', () => {
const onChange = vi.fn()
const dataUrl = 'data:image/png;base64,aGVsbG8='
const { getByText, getByAltText } = render(
<SignaturePad value={{ type: 'image', dataUrl }} onChange={onChange} />,
)
expect(getByText('Draw signature')).toBeInTheDocument()
expect(getByText('Upload image')).toBeInTheDocument()
expect(getByAltText('Uploaded signature')).toHaveAttribute('src', dataUrl)
})
})

View File

@ -0,0 +1,21 @@
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { Layout } from '../components/Layout'
vi.mock('../auth/AuthContext', () => ({
useAuth: () => ({ user: null, logout: vi.fn() }),
}))
describe('Layout', () => {
it('renders navigation and theme toggle', () => {
render(
<MemoryRouter>
<Layout />
</MemoryRouter>,
)
expect(screen.getAllByText(/Kererū/).length).toBeGreaterThan(0)
expect(screen.getByRole('navigation', { name: /main/i })).toBeInTheDocument()
expect(screen.getByRole('group', { name: /colour theme/i })).toBeInTheDocument()
})
})

View File

@ -0,0 +1 @@
import '@testing-library/jest-dom'

View File

@ -0,0 +1,28 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
brand: {
50: '#effdf6',
100: '#d9fbe8',
200: '#b5f5d3',
300: '#82ebb5',
400: '#47d990',
500: '#1fbf72',
600: '#129c5b',
700: '#117b4b',
800: '#12613e',
900: '#115036',
950: '#052d1d',
},
},
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
}

View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src", "vite.config.ts"]
}

View File

@ -0,0 +1,49 @@
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitest/config'
import type { Plugin } from 'vite'
import react from '@vitejs/plugin-react'
const openapiSpecPath = fileURLToPath(new URL('../backend/openapi/openapi.yaml', import.meta.url))
function openapiSpec(): Plugin {
return {
name: 'kereru-openapi-spec',
configureServer(server) {
server.middlewares.use('/openapi.yaml', (_req, res, next) => {
readFile(openapiSpecPath)
.then((data) => {
res.setHeader('Content-Type', 'application/yaml')
res.end(data)
})
.catch(next)
})
},
async generateBundle() {
this.emitFile({
type: 'asset',
fileName: 'openapi.yaml',
source: await readFile(openapiSpecPath, 'utf8'),
})
},
}
}
export default defineConfig({
plugins: [react(), openapiSpec()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
css: false,
},
})