Compare commits
4 Commits
c2bbb485bc
...
ca81ec7524
| Author | SHA1 | Date |
|---|---|---|
|
|
ca81ec7524 | |
|
|
56602612c8 | |
|
|
be2cfc8bfd | |
|
|
5c9a7a0f64 |
|
|
@ -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
|
||||
|
|
@ -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
150
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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",
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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:
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
}),
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1 @@
|
|||
import '@testing-library/jest-dom'
|
||||
|
|
@ -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: [],
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue