diff --git a/.gitignore b/.gitignore index 8e097b92..89f01eb4 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ test/e2e/.auth/ # Docker docker/data/ + +# Codegraph local index +.codegraph/ diff --git a/MANUAL_INSTALL.md b/MANUAL_INSTALL.md index cc4d398f..5595a71f 100644 --- a/MANUAL_INSTALL.md +++ b/MANUAL_INSTALL.md @@ -9,6 +9,95 @@ This guide covers installing Taskwondo without Docker Compose. You'll need to pr - S3-compatible object storage (MinIO, AWS S3, DigitalOcean Spaces, etc.) - Nginx (or any web server that can serve static files and reverse-proxy) +## Option A: All-in-One Docker Image + +The simplest way to deploy Taskwondo. A single container bundles the API, worker, and web frontend behind an Nginx reverse proxy — only **one port** needs to be exposed. + +### Prerequisites + +- Docker +- PostgreSQL 15+ +- NATS with JetStream enabled +- S3-compatible object storage + +### Run + +```bash +docker run -d \ + --name taskwondo \ + -p 80:80 \ + -e DATABASE_URL="postgres://user:pass@host:5432/taskwondo?sslmode=disable" \ + -e JWT_SECRET="your-jwt-secret" \ + -e STORAGE_ENDPOINT="s3.amazonaws.com" \ + -e STORAGE_ACCESS_KEY="your-access-key" \ + -e STORAGE_SECRET_KEY="your-secret-key" \ + -e STORAGE_BUCKET="taskwondo-attachments" \ + -e NATS_URL="nats://host:4222" \ + ghcr.io/marcoshack/taskwondo/all-in-one:latest +``` + +Then open [http://localhost](http://localhost). On first start the API runs migrations and seeds the admin user — check the container logs for credentials: + +```bash +docker logs taskwondo +``` + +### Environment Variables + +All variables from the standard `.env.template` are supported. Key ones: + +| Variable | Description | +|----------|-------------| +| `DATABASE_URL` | PostgreSQL connection string | +| `JWT_SECRET` | Secret for signing JWT tokens (min 32 chars) | +| `STORAGE_ENDPOINT` | S3-compatible storage endpoint | +| `STORAGE_ACCESS_KEY` | Storage access key | +| `STORAGE_SECRET_KEY` | Storage secret key | +| `STORAGE_BUCKET` | Storage bucket name | +| `NATS_URL` | NATS server URL | +| `API_PORT` | Internal API port (default `8080`, no need to change) | +| `ADMIN_EMAIL` | Admin user email (optional) | +| `ADMIN_PASSWORD` | Admin user password (optional, auto-generated if omitted) | + +### Build from Source + +```bash +git clone https://github.com/marcoshack/taskwondo.git +cd taskwondo +docker build -f docker/Dockerfile.all-in-one -t taskwondo:local . +docker run -d --name taskwondo -p 80:80 \ + -e DATABASE_URL="postgres://user:pass@host:5432/taskwondo?sslmode=disable" \ + -e JWT_SECRET="your-jwt-secret" \ + -e STORAGE_ENDPOINT="s3.amazonaws.com" \ + -e STORAGE_ACCESS_KEY="your-access-key" \ + -e STORAGE_SECRET_KEY="your-secret-key" \ + -e STORAGE_BUCKET="taskwondo-attachments" \ + -e NATS_URL="nats://host:4222" \ + taskwondo:local +``` + +### Architecture + +The all-in-one image uses **supervisord** to manage three processes inside a single container: + +``` +┌─────────────────────────────────┐ +│ Nginx (:80) — single entry │ +│ ├── static files (frontend) │ +│ ├── /api/* → API (:8080) │ +│ └── /healthz, /readyz, /metrics│ +├─────────────────────────────────┤ +│ API server (:8080, internal) │ +│ Worker (background jobs) │ +└─────────────────────────────────┘ +``` + +--- + +## Option B: Manual Installation (Binaries) + +If you prefer to run Taskwondo without Docker, follow the steps below. + ## Download Download the server bundle (`taskwondo-server-*.tar.gz`) from the [Releases](https://github.com/marcoshack/taskwondo/releases) page. The [Dev Build](https://github.com/marcoshack/taskwondo/releases/tag/dev) release always has the latest build from `main`. diff --git a/README.md b/README.md index 0ec58ea7..e63ec22a 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,8 @@ See [more screenshots](docs/overview.md) for a full walkthrough of features. ## Quick Start +### Option 1: Docker Compose (Multi-Container) + ```bash git clone https://github.com/marcoshack/taskwondo.git cd taskwondo @@ -115,6 +117,30 @@ cd taskwondo Then open [http://localhost:3000](http://localhost:3000) and log in with the admin credentials printed by the installer. +### Option 2: All-in-One Docker Image (Single Container) + +For simpler deployments, use the all-in-one image that bundles API, worker, and frontend into a single container with only one port exposed: + +```bash +docker run -d \ + --name taskwondo \ + -p 80:80 \ + -e DATABASE_URL="[REDACTED:connection_string]host:5432/taskwondo?sslmode=disable" \ + -e JWT_SECRET="your-jwt-secret" \ + -e STORAGE_ENDPOINT="s3.amazonaws.com" \ + -e STORAGE_ACCESS_KEY="your-access-key" \ + -e STORAGE_SECRET_KEY="your-secret-key" \ + -e STORAGE_BUCKET="taskwondo-attachments" \ + -e NATS_URL="nats://host:4222" \ + ghcr.io/marcoshack/taskwondo/all-in-one:latest +``` + +Then open [http://localhost](http://localhost). Check logs for admin credentials: `docker logs taskwondo` + +For detailed configuration and build-from-source instructions, see [MANUAL_INSTALL.md](MANUAL_INSTALL.md). + +### Auto-start on Boot + To start Taskwondo automatically on boot, install the included systemd service: ```bash diff --git a/api/cmd/server/main.go b/api/cmd/server/main.go index cfcf1cb3..f7f04707 100644 --- a/api/cmd/server/main.go +++ b/api/cmd/server/main.go @@ -205,7 +205,7 @@ func main() { // Seed default namespace and backfill existing projects if err := namespaceService.SeedDefaultNamespace(ctx); err != nil { - log.Fatal().Err(err).Msg("failed to seed default namespace") + log.Warn().Err(err).Msg("failed to seed default namespace (will retry on next startup)") } // Seed default limit settings (max projects/namespaces per user) diff --git a/api/cmd/worker/main.go b/api/cmd/worker/main.go index 556dd9e4..2f5e64e2 100644 --- a/api/cmd/worker/main.go +++ b/api/cmd/worker/main.go @@ -46,11 +46,6 @@ func main() { defer db.Close() log.Info().Int("max_open", cfg.WorkerDBPool).Msg("connected to database") - // Run migrations (idempotent) - if err := database.Migrate(ctx, db); err != nil { - log.Fatal().Err(err).Msg("failed to run migrations") - } - // Initialize repositories statsRepo := repository.NewStatsRepository(db) userRepo := repository.NewUserRepository(db) @@ -183,18 +178,18 @@ func main() { // Register on-call rotation notification task notifyOncallRotation := workers.NewNotificationOncallRotationTask( - userRepo, emailSender, urlBuilder, log.Logger, + userRepo, userSettingRepo, emailSender, urlBuilder, log.Logger, ) dispatcher.Register(notifyOncallRotation) // Register on-call override notification tasks notifyOncallOverrideCreated := workers.NewNotificationOncallOverrideCreatedTask( - userRepo, emailSender, urlBuilder, log.Logger, + userRepo, userSettingRepo, emailSender, urlBuilder, log.Logger, ) dispatcher.Register(notifyOncallOverrideCreated) notifyOncallOverrideCancelled := workers.NewNotificationOncallOverrideCancelledTask( - userRepo, emailSender, urlBuilder, log.Logger, + userRepo, userSettingRepo, emailSender, urlBuilder, log.Logger, ) dispatcher.Register(notifyOncallOverrideCancelled) diff --git a/api/go.mod b/api/go.mod index 3d0b4c07..a8b646d1 100644 --- a/api/go.mod +++ b/api/go.mod @@ -3,6 +3,7 @@ module github.com/marcoshack/taskwondo go 1.25.5 require ( + github.com/coreos/go-oidc/v3 v3.20.0 github.com/go-chi/chi/v5 v5.2.5 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-migrate/migrate/v4 v4.19.1 @@ -16,6 +17,7 @@ require ( github.com/rs/zerolog v1.34.0 golang.org/x/crypto v0.48.0 golang.org/x/image v0.36.0 + golang.org/x/oauth2 v0.36.0 golang.org/x/time v0.14.0 ) @@ -25,6 +27,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-ini/ini v1.67.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/klauspost/compress v1.18.3 // indirect github.com/klauspost/cpuid/v2 v2.2.11 // indirect diff --git a/api/go.sum b/api/go.sum index 7380b12e..872a257f 100644 --- a/api/go.sum +++ b/api/go.sum @@ -12,6 +12,8 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= +github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -34,6 +36,8 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +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.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -147,6 +151,8 @@ golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/api/internal/crypto/crypto.go b/api/internal/crypto/crypto.go index 03e9d86d..f4366686 100644 --- a/api/internal/crypto/crypto.go +++ b/api/internal/crypto/crypto.go @@ -6,6 +6,7 @@ import ( "crypto/rand" "crypto/sha256" "encoding/base64" + "encoding/json" "fmt" "io" @@ -87,3 +88,60 @@ func (e *Encryptor) Decrypt(encoded string) (string, error) { return string(plaintext), nil } + +// SealJSON marshals v, encrypts it with AES-256-GCM and returns it as an +// unpadded URL-safe base64 token, suitable for a query parameter such as an +// OAuth state. Confidentiality comes from the GCM tag as well as the cipher: +// a token that was not produced by this key cannot be forged. +func (e *Encryptor) SealJSON(v any) (string, error) { + plaintext, err := json.Marshal(v) + if err != nil { + return "", fmt.Errorf("marshaling payload: %w", err) + } + + block, err := aes.NewCipher(e.key) + if err != nil { + return "", fmt.Errorf("creating cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("creating gcm: %w", err) + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("generating nonce: %w", err) + } + + sealed := gcm.Seal(nonce, nonce, plaintext, nil) + return base64.RawURLEncoding.EncodeToString(sealed), nil +} + +// OpenJSON decrypts a token produced by SealJSON into v. It fails if the token +// was sealed with a different key, tampered with, or malformed. +func (e *Encryptor) OpenJSON(token string, v any) error { + data, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return fmt.Errorf("decoding token: %w", err) + } + block, err := aes.NewCipher(e.key) + if err != nil { + return fmt.Errorf("creating cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return fmt.Errorf("creating gcm: %w", err) + } + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return fmt.Errorf("token too short") + } + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return fmt.Errorf("decrypting token: %w", err) + } + if err := json.Unmarshal(plaintext, v); err != nil { + return fmt.Errorf("unmarshaling payload: %w", err) + } + return nil +} diff --git a/api/internal/database/database.go b/api/internal/database/database.go index 8bf71935..478e5dc0 100644 --- a/api/internal/database/database.go +++ b/api/internal/database/database.go @@ -72,11 +72,36 @@ func Migrate(ctx context.Context, db *sql.DB) error { return fmt.Errorf("creating migrator: %w", err) } + // Check for dirty state and attempt recovery + version, dirty, _ := m.Version() + if dirty { + log.Ctx(ctx).Warn().Uint("version", version).Msg("database is in dirty state, attempting recovery") + // Rollback the dirty migration + if err := m.Steps(-1); err != nil { + // If rollback fails, force to previous clean version + if version > 0 { + if err := m.Force(int(version) - 1); err != nil { + return fmt.Errorf("forcing clean version: %w", err) + } + } else { + // If version is 0, force to 0 + if err := m.Force(0); err != nil { + return fmt.Errorf("forcing version 0: %w", err) + } + } + } + // Recreate migrator after force + m, err = migrate.NewWithInstance("iofs", source, "postgres", driver) + if err != nil { + return fmt.Errorf("recreating migrator: %w", err) + } + } + if err := m.Up(); err != nil && err != migrate.ErrNoChange { return fmt.Errorf("running migrations: %w", err) } - version, dirty, _ := m.Version() + version, dirty, _ = m.Version() log.Ctx(ctx).Info().Uint("version", version).Bool("dirty", dirty).Msg("database migrations applied") return nil diff --git a/api/internal/database/migrations/000033_create_embeddings.up.sql b/api/internal/database/migrations/000033_create_embeddings.up.sql index c26d6a7c..11e7b165 100644 --- a/api/internal/database/migrations/000033_create_embeddings.up.sql +++ b/api/internal/database/migrations/000033_create_embeddings.up.sql @@ -1,17 +1,20 @@ -CREATE EXTENSION IF NOT EXISTS vector; - -CREATE TABLE embeddings ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - entity_type TEXT NOT NULL, - entity_id UUID NOT NULL, - project_id UUID REFERENCES projects(id) ON DELETE CASCADE, - content TEXT NOT NULL, - embedding vector(768) NOT NULL, - indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE (entity_type, entity_id) -); - -CREATE INDEX idx_embeddings_vector ON embeddings - USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 128); -CREATE INDEX idx_embeddings_project ON embeddings(project_id, entity_type) WHERE project_id IS NOT NULL; -CREATE INDEX idx_embeddings_entity ON embeddings(entity_type, entity_id); +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'vector') THEN + EXECUTE 'CREATE EXTENSION IF NOT EXISTS vector'; + EXECUTE 'CREATE TABLE embeddings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entity_type TEXT NOT NULL, + entity_id UUID NOT NULL, + project_id UUID REFERENCES projects(id) ON DELETE CASCADE, + content TEXT NOT NULL, + embedding vector(768) NOT NULL, + indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (entity_type, entity_id) + )'; + EXECUTE 'CREATE INDEX idx_embeddings_vector ON embeddings USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 128)'; + EXECUTE 'CREATE INDEX idx_embeddings_project ON embeddings(project_id, entity_type) WHERE project_id IS NOT NULL'; + EXECUTE 'CREATE INDEX idx_embeddings_entity ON embeddings(entity_type, entity_id)'; + END IF; +END +$$; diff --git a/api/internal/database/migrations/000034_add_embedding_path.up.sql b/api/internal/database/migrations/000034_add_embedding_path.up.sql index 334ce8be..8d35935c 100644 --- a/api/internal/database/migrations/000034_add_embedding_path.up.sql +++ b/api/internal/database/migrations/000034_add_embedding_path.up.sql @@ -1 +1,7 @@ -ALTER TABLE embeddings ADD COLUMN IF NOT EXISTS path TEXT NOT NULL DEFAULT ''; +DO $$ +BEGIN + IF to_regclass('embeddings') IS NOT NULL THEN + ALTER TABLE embeddings ADD COLUMN IF NOT EXISTS path TEXT NOT NULL DEFAULT ''; + END IF; +END +$$; diff --git a/api/internal/database/migrations/000035_drop_embedding_path.up.sql b/api/internal/database/migrations/000035_drop_embedding_path.up.sql index 8a820137..0975f2c0 100644 --- a/api/internal/database/migrations/000035_drop_embedding_path.up.sql +++ b/api/internal/database/migrations/000035_drop_embedding_path.up.sql @@ -1 +1,7 @@ -ALTER TABLE embeddings DROP COLUMN IF EXISTS path; +DO $$ +BEGIN + IF to_regclass('embeddings') IS NOT NULL THEN + ALTER TABLE embeddings DROP COLUMN IF EXISTS path; + END IF; +END +$$; diff --git a/api/internal/database/migrations/000036_backfill_project_embedding_ids.up.sql b/api/internal/database/migrations/000036_backfill_project_embedding_ids.up.sql index 230bdb48..519cfac3 100644 --- a/api/internal/database/migrations/000036_backfill_project_embedding_ids.up.sql +++ b/api/internal/database/migrations/000036_backfill_project_embedding_ids.up.sql @@ -1 +1,7 @@ -UPDATE embeddings SET project_id = entity_id WHERE entity_type = 'project' AND project_id IS NULL; +DO $$ +BEGIN + IF to_regclass('embeddings') IS NOT NULL THEN + UPDATE embeddings SET project_id = entity_id WHERE entity_type = 'project' AND project_id IS NULL; + END IF; +END +$$; diff --git a/api/internal/database/migrations/000061_create_description_revisions.up.sql b/api/internal/database/migrations/000061_create_description_revisions.up.sql index fe15cfd6..aaf97b74 100644 --- a/api/internal/database/migrations/000061_create_description_revisions.up.sql +++ b/api/internal/database/migrations/000061_create_description_revisions.up.sql @@ -7,7 +7,18 @@ -- pgcrypto provides digest() / sha256, used here only for the back-fill below. -- Application code computes sha256 in Go for new revisions. -CREATE EXTENSION IF NOT EXISTS pgcrypto; +-- If pgcrypto is not available, we skip the extension and use a placeholder hash. +DO $$ +DECLARE + has_pgcrypto boolean; +BEGIN + SELECT EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'pgcrypto') INTO has_pgcrypto; + + IF has_pgcrypto THEN + EXECUTE 'CREATE EXTENSION IF NOT EXISTS pgcrypto'; + END IF; +END +$$; CREATE TABLE work_item_description_revisions ( id UUID PRIMARY KEY, @@ -28,18 +39,43 @@ CREATE INDEX idx_description_revisions_work_item -- generated by the database. New revisions written by the application use -- UUIDv7 (time-ordered). This is a one-shot back-fill; tests and runtime -- rely on a stable created_at order, not on UUIDv7 vs v4. -INSERT INTO work_item_description_revisions - (id, work_item_id, revision_number, content, content_hash, author_id, created_at) -SELECT - gen_random_uuid(), - wi.id, - 1, - COALESCE(wi.description, ''), - encode(digest(COALESCE(wi.description, ''), 'sha256'), 'hex'), - wi.reporter_id, - wi.created_at -FROM work_items wi -WHERE wi.deleted_at IS NULL; +-- If pgcrypto is available, compute real sha256 hash; otherwise use placeholder. +DO $$ +DECLARE + has_pgcrypto boolean; +BEGIN + SELECT EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'pgcrypto' AND installed_version IS NOT NULL) INTO has_pgcrypto; + + IF has_pgcrypto THEN + EXECUTE ' + INSERT INTO work_item_description_revisions + (id, work_item_id, revision_number, content, content_hash, author_id, created_at) + SELECT + gen_random_uuid(), + wi.id, + 1, + COALESCE(wi.description, ''''), + encode(digest(COALESCE(wi.description, ''''), ''sha256''), ''hex''), + wi.reporter_id, + wi.created_at + FROM work_items wi + WHERE wi.deleted_at IS NULL'; + ELSE + INSERT INTO work_item_description_revisions + (id, work_item_id, revision_number, content, content_hash, author_id, created_at) + SELECT + gen_random_uuid(), + wi.id, + 1, + COALESCE(wi.description, ''), + 'placeholder', + wi.reporter_id, + wi.created_at + FROM work_items wi + WHERE wi.deleted_at IS NULL; + END IF; +END +$$; -- Inline-comment anchor columns. anchor_revision_id IS NULL ⇒ regular comment. ALTER TABLE comments diff --git a/api/internal/database/migrations/000063_add_cjk_search_trigram_indexes.down.sql b/api/internal/database/migrations/000063_add_cjk_search_trigram_indexes.down.sql new file mode 100644 index 00000000..cc8f781d --- /dev/null +++ b/api/internal/database/migrations/000063_add_cjk_search_trigram_indexes.down.sql @@ -0,0 +1,12 @@ +-- Drop the trigram indexes added for the CJK search fallback. +-- The pg_trgm extension itself is left in place: dropping it could break +-- unrelated objects on installs that had it before this migration. + +DROP INDEX IF EXISTS idx_milestones_description_trgm; +DROP INDEX IF EXISTS idx_milestones_name_trgm; +DROP INDEX IF EXISTS idx_queues_description_trgm; +DROP INDEX IF EXISTS idx_queues_name_trgm; +DROP INDEX IF EXISTS idx_teams_description_trgm; +DROP INDEX IF EXISTS idx_teams_name_trgm; +DROP INDEX IF EXISTS idx_work_items_description_trgm; +DROP INDEX IF EXISTS idx_work_items_title_trgm; diff --git a/api/internal/database/migrations/000063_add_cjk_search_trigram_indexes.up.sql b/api/internal/database/migrations/000063_add_cjk_search_trigram_indexes.up.sql new file mode 100644 index 00000000..52491ab5 --- /dev/null +++ b/api/internal/database/migrations/000063_add_cjk_search_trigram_indexes.up.sql @@ -0,0 +1,37 @@ +-- CJK search support. +-- +-- PostgreSQL's text-search configs tokenize on whitespace, so a run of +-- Chinese/Japanese/Korean characters collapses into a single tsvector lexeme +-- and a tsquery can never match it as a substring. The repositories add an +-- ILIKE substring fallback when the query contains CJK characters; these +-- pg_trgm GIN indexes make that fallback indexable. +-- +-- pg_trgm is optional (same resilience pattern as 000033/000061): when the +-- extension is unavailable — or cannot be created, e.g. a managed/external +-- Postgres without privileges on it — the indexes are skipped and the search +-- still works, just with a sequential scan for the ILIKE fallback. + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'pg_trgm') THEN + BEGIN + EXECUTE 'CREATE EXTENSION IF NOT EXISTS pg_trgm'; + EXCEPTION WHEN OTHERS THEN + RAISE NOTICE 'pg_trgm extension not available (%), skipping trigram indexes', SQLERRM; + RETURN; + END; + + EXECUTE 'CREATE INDEX IF NOT EXISTS idx_work_items_title_trgm ON work_items USING GIN (title gin_trgm_ops)'; + EXECUTE 'CREATE INDEX IF NOT EXISTS idx_work_items_description_trgm ON work_items USING GIN (description gin_trgm_ops)'; + + EXECUTE 'CREATE INDEX IF NOT EXISTS idx_teams_name_trgm ON teams USING GIN (name gin_trgm_ops)'; + EXECUTE 'CREATE INDEX IF NOT EXISTS idx_teams_description_trgm ON teams USING GIN (description gin_trgm_ops)'; + + EXECUTE 'CREATE INDEX IF NOT EXISTS idx_queues_name_trgm ON queues USING GIN (name gin_trgm_ops)'; + EXECUTE 'CREATE INDEX IF NOT EXISTS idx_queues_description_trgm ON queues USING GIN (description gin_trgm_ops)'; + + EXECUTE 'CREATE INDEX IF NOT EXISTS idx_milestones_name_trgm ON milestones USING GIN (name gin_trgm_ops)'; + EXECUTE 'CREATE INDEX IF NOT EXISTS idx_milestones_description_trgm ON milestones USING GIN (description gin_trgm_ops)'; + END IF; +END +$$; diff --git a/api/internal/email/email.go b/api/internal/email/email.go index b5f6d22f..5be975d3 100644 --- a/api/internal/email/email.go +++ b/api/internal/email/email.go @@ -105,12 +105,16 @@ func sendMail(ctx context.Context, cfg *model.SMTPConfig, to, subject, htmlBody auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.SMTPHost) } + if cfg.SkipCertVerify { + l.Warn().Msg("TLS certificate verification is disabled for SMTP (skip_cert_verify)") + } + var err error switch cfg.Encryption { case model.SMTPEncryptionTLS: - err = sendWithImplicitTLS(&l, addr, cfg.SMTPHost, auth, cfg.FromAddress, to, msg) + err = sendWithImplicitTLS(&l, addr, cfg.SMTPHost, cfg.SkipCertVerify, auth, cfg.FromAddress, to, msg) case model.SMTPEncryptionSTARTTLS: - err = sendWithSTARTTLS(&l, addr, cfg.SMTPHost, auth, cfg.FromAddress, to, msg) + err = sendWithSTARTTLS(&l, addr, cfg.SMTPHost, cfg.SkipCertVerify, auth, cfg.FromAddress, to, msg) case model.SMTPEncryptionNone: // For plaintext SMTP, skip auth — Go's PlainAuth refuses to send // credentials over unencrypted non-localhost connections. @@ -182,7 +186,7 @@ func smtpSendEnvelope(l *zerolog.Logger, c *smtp.Client, auth smtp.Auth, from, t return quitErr } -func sendWithSTARTTLS(l *zerolog.Logger, addr, host string, auth smtp.Auth, from, to string, msg []byte) error { +func sendWithSTARTTLS(l *zerolog.Logger, addr, host string, skipCertVerify bool, auth smtp.Auth, from, to string, msg []byte) error { c, err := smtp.Dial(addr) if err != nil { return fmt.Errorf("connecting to SMTP server: %w", err) @@ -190,7 +194,7 @@ func sendWithSTARTTLS(l *zerolog.Logger, addr, host string, auth smtp.Auth, from defer c.Close() l.Debug().Msg("smtp connected") - tlsConfig := &tls.Config{ServerName: host} + tlsConfig := buildTLSConfig(host, skipCertVerify) if err := c.StartTLS(tlsConfig); err != nil { return fmt.Errorf("STARTTLS: %w", err) } @@ -199,8 +203,8 @@ func sendWithSTARTTLS(l *zerolog.Logger, addr, host string, auth smtp.Auth, from return smtpSendEnvelope(l, c, auth, from, to, msg) } -func sendWithImplicitTLS(l *zerolog.Logger, addr, host string, auth smtp.Auth, from, to string, msg []byte) error { - tlsConfig := &tls.Config{ServerName: host} +func sendWithImplicitTLS(l *zerolog.Logger, addr, host string, skipCertVerify bool, auth smtp.Auth, from, to string, msg []byte) error { + tlsConfig := buildTLSConfig(host, skipCertVerify) conn, err := tls.Dial("tcp", addr, tlsConfig) if err != nil { return fmt.Errorf("TLS dial: %w", err) @@ -216,3 +220,13 @@ func sendWithImplicitTLS(l *zerolog.Logger, addr, host string, auth smtp.Auth, f return smtpSendEnvelope(l, c, auth, from, to, msg) } + +// buildTLSConfig returns the TLS settings for SMTP connections. When +// skipCertVerify is set, certificate verification (including expiry checks) +// is disabled so self-hosted servers with self-signed or expired certs work. +func buildTLSConfig(host string, skipCertVerify bool) *tls.Config { + return &tls.Config{ + ServerName: host, + InsecureSkipVerify: skipCertVerify, //nolint:gosec // admin opt-in via smtp_config + } +} diff --git a/api/internal/email/email_test.go b/api/internal/email/email_test.go index 1bb5c3bf..c495280a 100644 --- a/api/internal/email/email_test.go +++ b/api/internal/email/email_test.go @@ -103,6 +103,24 @@ func TestSendReturnsErrorWhenDisabled(t *testing.T) { } } +func TestBuildTLSConfig(t *testing.T) { + verified := buildTLSConfig("smtp.example.com", false) + if verified.InsecureSkipVerify { + t.Error("expected InsecureSkipVerify=false by default") + } + if verified.ServerName != "smtp.example.com" { + t.Errorf("expected ServerName smtp.example.com, got %s", verified.ServerName) + } + + skipped := buildTLSConfig("smtp.example.com", true) + if !skipped.InsecureSkipVerify { + t.Error("expected InsecureSkipVerify=true when skipCertVerify is set") + } + if skipped.ServerName != "smtp.example.com" { + t.Errorf("expected ServerName smtp.example.com, got %s", skipped.ServerName) + } +} + func TestBuildMessage(t *testing.T) { msg := buildMessage("Taskwondo", "noreply@example.com", "user@test.com", "Test Subject", "

Hello

", "") s := string(msg) diff --git a/api/internal/handler/auth.go b/api/internal/handler/auth.go index 7f518ecb..f697e704 100644 --- a/api/internal/handler/auth.go +++ b/api/internal/handler/auth.go @@ -12,6 +12,7 @@ import ( "github.com/google/uuid" "github.com/rs/zerolog/log" + "github.com/marcoshack/taskwondo/internal/i18n" "github.com/marcoshack/taskwondo/internal/model" "github.com/marcoshack/taskwondo/internal/service" ) @@ -268,6 +269,19 @@ func (h *AuthHandler) OAuthCallback(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusForbidden, CodeForbidden, "account is disabled") return } + // Service errors carrying an error_key are user-facing (localised by + // the client); anything else stays a generic message so provider + // internals don't leak into the login page. + if key, _ := model.ErrorKey(err); key != "" { + status := http.StatusUnauthorized + code := CodeOAuthError + if errors.Is(err, model.ErrForbidden) { + status = http.StatusForbidden + code = CodeForbidden + } + writeErrorFromService(w, status, code, err) + return + } log.Ctx(r.Context()).Error().Err(err).Msg(provider + " oauth callback failed") writeError(w, http.StatusUnauthorized, CodeOAuthError, provider+" authentication failed") return @@ -786,7 +800,7 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { return } - if err := h.auth.RequestRegistration(r.Context(), req.Email, req.DisplayName, req.InviteCode); err != nil { + if err := h.auth.RequestRegistration(r.Context(), req.Email, req.DisplayName, req.InviteCode, i18n.Negotiate(r.Header.Get("Accept-Language"))); err != nil { if errors.Is(err, model.ErrForbidden) { writeError(w, http.StatusForbidden, CodeForbidden, "email registration is disabled") return @@ -888,7 +902,7 @@ func (h *AuthHandler) ForgotPassword(w http.ResponseWriter, r *http.Request) { return } - if err := h.auth.RequestPasswordReset(r.Context(), req.Email); err != nil { + if err := h.auth.RequestPasswordReset(r.Context(), req.Email, i18n.Negotiate(r.Header.Get("Accept-Language"))); err != nil { if errors.Is(err, model.ErrForbidden) { writeError(w, http.StatusForbidden, CodeForbidden, "password reset is not available") return diff --git a/api/internal/handler/system_setting.go b/api/internal/handler/system_setting.go index 01ff2f08..bc930ba8 100644 --- a/api/internal/handler/system_setting.go +++ b/api/internal/handler/system_setting.go @@ -270,6 +270,7 @@ var validOAuthProviders = map[string]bool{ model.OAuthProviderGoogle: true, model.OAuthProviderGitHub: true, model.OAuthProviderMicrosoft: true, + model.OAuthProviderSSO: true, } // GetOAuthConfig handles GET /api/v1/admin/settings/oauth_config/{provider} @@ -332,7 +333,7 @@ func (h *SystemSettingHandler) SetOAuthConfig(w http.ResponseWriter, r *http.Req return } - if err := cfg.Validate(); err != nil { + if err := cfg.ValidateAs(provider); err != nil { handleSystemSettingError(w, r, err, "oauth config validation failed") return } diff --git a/api/internal/handler/system_setting_test.go b/api/internal/handler/system_setting_test.go index da59a37c..9312209d 100644 --- a/api/internal/handler/system_setting_test.go +++ b/api/internal/handler/system_setting_test.go @@ -415,6 +415,61 @@ func TestSetSMTP_SaveAndMaskPassword(t *testing.T) { } } +func TestSetSMTP_PersistsSkipCertVerify(t *testing.T) { + h, repo := systemSettingTestSetup(t) + + cfg := model.SMTPConfig{ + Enabled: true, + SMTPHost: "smtp.example.com", + SMTPPort: 465, + Username: "[EMAIL_REDACTED]", + Password: "secret123", + Encryption: "tls", + FromAddress: "[EMAIL_REDACTED]", + SkipCertVerify: true, + } + body, _ := json.Marshal(cfg) + + req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings/smtp_config", bytes.NewBuffer(body)) + req = req.WithContext(sysAdminCtx()) + w := httptest.NewRecorder() + + h.SetSMTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp struct { + Data model.SMTPConfig `json:"data"` + } + json.Unmarshal(w.Body.Bytes(), &resp) + if !resp.Data.SkipCertVerify { + t.Error("expected skip_cert_verify=true in response") + } + + stored := repo.settings[model.SettingSMTPConfig] + var storedCfg model.SMTPConfig + json.Unmarshal(stored.Value, &storedCfg) + if !storedCfg.SkipCertVerify { + t.Error("expected skip_cert_verify=true in stored config") + } + + // GET must return the flag too + getReq := httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings/smtp_config", nil) + getReq = getReq.WithContext(sysAdminCtx()) + gw := httptest.NewRecorder() + h.GetSMTP(gw, getReq) + + var getResp struct { + Data model.SMTPConfig `json:"data"` + } + json.Unmarshal(gw.Body.Bytes(), &getResp) + if !getResp.Data.SkipCertVerify { + t.Error("expected skip_cert_verify=true from GetSMTP") + } +} + func TestSetSMTP_PreservesExistingPassword(t *testing.T) { h, repo := systemSettingTestSetup(t) diff --git a/api/internal/i18n/i18n.go b/api/internal/i18n/i18n.go index 6e2a9ef0..ae024211 100644 --- a/api/internal/i18n/i18n.go +++ b/api/internal/i18n/i18n.go @@ -43,6 +43,26 @@ func T(lang, key string, args ...string) string { return val } +// Negotiate resolves a language from an HTTP Accept-Language header value. +// Candidates are checked in header order; the first supported primary subtag +// wins. Falls back to "en" when nothing matches. +func Negotiate(acceptLanguage string) string { + for _, part := range strings.Split(acceptLanguage, ",") { + if i := strings.IndexByte(part, ';'); i >= 0 { + part = part[:i] + } + primary := strings.TrimSpace(part) + if i := strings.IndexByte(primary, '-'); i >= 0 { + primary = primary[:i] + } + primary = strings.ToLower(primary) + if _, ok := translations[primary]; ok { + return primary + } + } + return "en" +} + func get(lang, key string) string { if m, ok := translations[lang]; ok { if v, ok := m[key]; ok { diff --git a/api/internal/i18n/i18n_test.go b/api/internal/i18n/i18n_test.go index 4c3c529c..ae2910ba 100644 --- a/api/internal/i18n/i18n_test.go +++ b/api/internal/i18n/i18n_test.go @@ -1,6 +1,86 @@ package i18n -import "testing" +import ( + "regexp" + "sort" + "strings" + "testing" +) + +var placeholderRe = regexp.MustCompile(`\{\{(\w+)\}\}`) + +func TestTranslations_KeyParity(t *testing.T) { + en := translations["en"] + if len(en) == 0 { + t.Fatal("no English translations loaded") + } + for lang, m := range translations { + if len(m) != len(en) { + t.Errorf("%s: has %d keys, en has %d", lang, len(m), len(en)) + } + for key := range en { + if _, ok := m[key]; !ok { + t.Errorf("%s: missing key %q", lang, key) + } + } + for key := range m { + if _, ok := en[key]; !ok { + t.Errorf("%s: extra key %q", lang, key) + } + } + } +} + +func TestTranslations_PlaceholderConsistency(t *testing.T) { + for key, enVal := range translations["en"] { + want := placeholders(enVal) + for lang, m := range translations { + if lang == "en" { + continue + } + got := placeholders(m[key]) + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("%s: key %q has placeholders %v, en has %v", lang, key, got, want) + } + } + } +} + +func placeholders(s string) []string { + var out []string + seen := map[string]bool{} + for _, m := range placeholderRe.FindAllStringSubmatch(s, -1) { + if !seen[m[1]] { + seen[m[1]] = true + out = append(out, m[1]) + } + } + sort.Strings(out) + return out +} + +func TestNegotiate(t *testing.T) { + cases := map[string]string{ + "zh-CN,zh;q=0.9,en;q=0.8": "zh", + "en-US,en;q=0.9": "en", + "pt-BR": "pt", + "fr": "fr", + "de-DE,de;q=0.9": "de", + "nl,es;q=0.8": "es", + "sw": "en", + "": "en", + "ZH": "zh", + "ko-KR,ko;q=0.9": "ko", + "ar,en;q=0.9": "ar", + "ja": "ja", + "en-GB,en;q=0.9,zh-CN;q=0.8": "en", + } + for header, want := range cases { + if got := Negotiate(header); got != want { + t.Errorf("Negotiate(%q) = %q, want %q", header, got, want) + } + } +} func TestT_English(t *testing.T) { got := T("en", "email.assignment.cta") diff --git a/api/internal/i18n/translations/ar.json b/api/internal/i18n/translations/ar.json index 4c184d8a..9348bbd1 100644 --- a/api/internal/i18n/translations/ar.json +++ b/api/internal/i18n/translations/ar.json @@ -18,7 +18,7 @@ "email.status_change.subject": "[{{projectKey}}] #{{itemNumber}} تم تغيير الحالة إلى {{newStatus}}: {{title}}", "email.status_change.intro": "قام {{actorName}} بتغيير حالة عنصر عمل معيّن لك:", - "email.status_change.status": "الحالة: {{oldStatus}} \u2192 {{newStatus}}", + "email.status_change.status": "الحالة: {{oldStatus}}{{newStatus}}", "email.status_change.cta": "عرض عنصر العمل", "email.status_change.footer": "لقد تلقيت هذا البريد الإلكتروني لأن إشعارات تغيير الحالة مفعّلة للعناصر المعيّنة. يمكنك تغيير تفضيلات الإشعارات في إعدادات Taskwondo.", @@ -35,5 +35,53 @@ "email.invite.subject": "تمت دعوتك إلى مشروع {{projectName}}", "email.invite.intro": "قام {{inviterName}} بدعوتك للانضمام إلى مشروع {{projectName}} بصفة {{role}}.", "email.invite.cta": "قبول الدعوة", - "email.invite.footer": "لقد تلقيت هذا البريد الإلكتروني لأن شخصًا ما دعاك إلى مشروع في Taskwondo. إذا لم تتعرف على هذه الدعوة، يمكنك تجاهل هذا البريد الإلكتروني." + "email.invite.footer": "لقد تلقيت هذا البريد الإلكتروني لأن شخصًا ما دعاك إلى مشروع في Taskwondo. إذا لم تتعرف على هذه الدعوة، يمكنك تجاهل هذا البريد الإلكتروني.", + + "email.namespace_invite.subject": "تمت دعوتك إلى {{namespaceName}}", + "email.namespace_invite.intro": "دعاك {{inviterName}} للانضمام إلى مساحة العمل {{namespaceName}} بدور {{role}}.", + "email.namespace_invite.cta": "قبول الدعوة", + "email.namespace_invite.footer": "وصلك هذا البريد الإلكتروني لأن أحدًا دعاك للانضمام إلى مساحة عمل في Taskwondo. إذا لم تتعرف على هذه الدعوة، يمكنك تجاهل هذا البريد بأمان.", + + "email.sla_breach.subject": "[{{projectKey}}] تحذير SLA: #{{itemNumber}} — \"{{title}}\" (المستوى {{level}})", + "email.sla_breach.intro": "أوشكت إحدى عناصر العمل على تجاوز هدف SLA أو تجاوزته:", + "email.sla_breach.sla_status": "SLA: تم استخدام {{percentage}}% من الوقت المستهدف", + "email.sla_breach.elapsed": "الوقت المنقضي: {{elapsed}} من {{target}}", + "email.sla_breach.level": "مستوى التصعيد: {{level}}", + "email.sla_breach.status": "الحالة: {{statusName}}", + "email.sla_breach.cta": "عرض عنصر العمل", + "email.sla_breach.footer": "وصلك هذا البريد الإلكتروني لأنك في قائمة التصعيد لهذا المشروع. يمكنك تغيير تفضيلات الإشعارات في إعدادات Taskwondo.", + + "email.oncall.incoming.subject": "[{{projectKey}}] أنت الآن في نوبة المناوبة لفريق {{teamName}}", + "email.oncall.outgoing.subject": "[{{projectKey}}] انتهت نوبة المناوبة لفريق {{teamName}}", + + "email.oncall.override.created.subject": "[{{projectKey}}] تم إسناد بديل مناوبة لفريق {{teamName}}", + "email.oncall.override.covered.subject": "[{{projectKey}}] تمت تغطية نوبة المناوبة لفريق {{teamName}}", + "email.oncall.override.cancelled.subject": "[{{projectKey}}] تم إلغاء بديل المناوبة لفريق {{teamName}}", + + "email.oncall.cta": "عرض جدول المناوبات", + "email.oncall.rotation.footer": "هذا إشعار آلي بتناوب المناوبات من Taskwondo.", + "email.oncall.override.footer": "هذا إشعار آلي باستبدال المناوبات من Taskwondo.", + + "email.oncall.incoming.intro": "أنت الآن في نوبة المناوبة لفريق {{teamName}} في المشروع {{projectBadge}} {{projectName}}.", + "email.oncall.incoming.note": "يرجى التأكد من أنك متفرغ للرد على أي مشكلات واردة خلال نوبتك.", + "email.oncall.outgoing.intro": "انتهت نوبة المناوبة لفريق {{teamName}} في المشروع {{projectBadge}} {{projectName}}.", + "email.oncall.outgoing.note": "شكرًا على خدمتك خلال نوبتك.", + "email.oncall.override.created.intro": "تم تكليفك باستبدال المناوبة لفريق {{teamName}} في المشروع {{projectBadge}} {{projectName}}.", + "email.oncall.override.created.period": "فترة الاستبدال: من {{startAt}} إلى {{endAt}}.", + "email.oncall.override.created.note": "يرجى التأكد من أنك متفرغ للرد على أي مشكلات واردة خلال هذه الفترة.", + "email.oncall.override.covered.intro": "تمت تغطية نوبة المناوبة لفريق {{teamName}} في المشروع {{projectBadge}} {{projectName}} بواسطة {{coveringUser}}.", + "email.oncall.override.covered.period": "فترة الاستبدال: من {{startAt}} إلى {{endAt}}.", + "email.oncall.override.cancelled.intro": "تم إلغاء استبدال المناوبة لفريق {{teamName}} في المشروع {{projectBadge}} {{projectName}}.", + "email.oncall.override.cancelled.period": "كان الاستبدال مجدولًا للفترة: من {{startAt}} إلى {{endAt}}.", + "email.oncall.override.cancelled.note": "سينطبق جدول تناوب المناوبات المعتاد على هذه الفترة.", + + "email.greeting": "مرحبًا {{name}}،", + "email.verify.subject": "تحقق من بريدك الإلكتروني", + "email.verify.intro": "انقر على الزر أدناه للتحقق من عنوان بريدك الإلكتروني وتعيين كلمة المرور:", + "email.verify.cta": "التحقق من البريد", + "email.verify.note": "ينتهي هذا الرابط خلال 24 ساعة. إذا لم تطلب ذلك، يمكنك تجاهل هذا البريد بأمان.", + "email.reset.subject": "إعادة تعيين كلمة المرور", + "email.reset.intro": "وصلنا طلب لإعادة تعيين كلمة المرور. انقر على الزر أدناه لاختيار كلمة مرور جديدة:", + "email.reset.cta": "إعادة تعيين كلمة المرور", + "email.reset.note": "ينتهي هذا الرابط خلال ساعة واحدة. إذا لم تطلب ذلك، يمكنك تجاهل هذا البريد بأمان." } diff --git a/api/internal/i18n/translations/de.json b/api/internal/i18n/translations/de.json index 9744725f..69fb19a0 100644 --- a/api/internal/i18n/translations/de.json +++ b/api/internal/i18n/translations/de.json @@ -18,7 +18,7 @@ "email.status_change.subject": "[{{projectKey}}] #{{itemNumber}} Status geändert zu {{newStatus}}: {{title}}", "email.status_change.intro": "{{actorName}} hat den Status eines Ihnen zugewiesenen Arbeitselements geändert:", - "email.status_change.status": "Status: {{oldStatus}} \u2192 {{newStatus}}", + "email.status_change.status": "Status: {{oldStatus}}{{newStatus}}", "email.status_change.cta": "Arbeitselement ansehen", "email.status_change.footer": "Sie haben diese E-Mail erhalten, weil Sie Statusänderungsbenachrichtigungen für zugewiesene Elemente aktiviert haben. Sie können Ihre Benachrichtigungseinstellungen in den Taskwondo-Einstellungen ändern.", @@ -35,5 +35,53 @@ "email.invite.subject": "Sie wurden zum Projekt {{projectName}} eingeladen", "email.invite.intro": "{{inviterName}} hat Sie eingeladen, dem Projekt {{projectName}} als {{role}} beizutreten.", "email.invite.cta": "Einladung annehmen", - "email.invite.footer": "Sie haben diese E-Mail erhalten, weil jemand Sie zu einem Taskwondo-Projekt eingeladen hat. Wenn Sie diese Einladung nicht kennen, können Sie diese E-Mail ignorieren." + "email.invite.footer": "Sie haben diese E-Mail erhalten, weil jemand Sie zu einem Taskwondo-Projekt eingeladen hat. Wenn Sie diese Einladung nicht kennen, können Sie diese E-Mail ignorieren.", + + "email.namespace_invite.subject": "Sie wurden in {{namespaceName}} eingeladen", + "email.namespace_invite.intro": "{{inviterName}} hat Sie eingeladen, dem Workspace {{namespaceName}} als {{role}} beizutreten.", + "email.namespace_invite.cta": "Einladung annehmen", + "email.namespace_invite.footer": "Sie erhalten diese E-Mail, weil Sie jemand in einen Taskwondo-Workspace eingeladen hat. Wenn Sie diese Einladung nicht kennen, können Sie diese E-Mail einfach ignorieren.", + + "email.sla_breach.subject": "[{{projectKey}}] SLA-Warnung: #{{itemNumber}} — \"{{title}}\" (Stufe {{level}})", + "email.sla_breach.intro": "Ein Workitem erreicht oder hat sein SLA-Ziel überschritten:", + "email.sla_breach.sla_status": "SLA: {{percentage}}% der Zielzeit verbraucht", + "email.sla_breach.elapsed": "Vergangene Zeit: {{elapsed}} von {{target}}", + "email.sla_breach.level": "Eskalationsstufe: {{level}}", + "email.sla_breach.status": "Status: {{statusName}}", + "email.sla_breach.cta": "Workitem anzeigen", + "email.sla_breach.footer": "Sie erhalten diese E-Mail, weil Sie auf der Eskalationsliste für dieses Projekt stehen. Sie können Ihre Benachrichtigungseinstellungen in den Taskwondo-Einstellungen ändern.", + + "email.oncall.incoming.subject": "[{{projectKey}}] Sie haben jetzt Bereitschaft für {{teamName}}", + "email.oncall.outgoing.subject": "[{{projectKey}}] Ihre Bereitschaft für {{teamName}} ist beendet", + + "email.oncall.override.created.subject": "[{{projectKey}}] Bereitschaftsvertretung für {{teamName}} zugewiesen", + "email.oncall.override.covered.subject": "[{{projectKey}}] Ihre Bereitschaft für {{teamName}} wurde übernommen", + "email.oncall.override.cancelled.subject": "[{{projectKey}}] Bereitschaftsvertretung für {{teamName}} storniert", + + "email.oncall.cta": "Bereitschaftsplan anzeigen", + "email.oncall.rotation.footer": "Dies ist eine automatische Benachrichtigung zur Bereitschaftsrotation von Taskwondo.", + "email.oncall.override.footer": "Dies ist eine automatische Benachrichtigung zur Bereitschaftsvertretung von Taskwondo.", + + "email.oncall.incoming.intro": "Sie haben jetzt Bereitschaft für {{teamName}} im Projekt {{projectBadge}} {{projectName}}.", + "email.oncall.incoming.note": "Stellen Sie bitte sicher, dass Sie während Ihrer Bereitschaft für eingehende Probleme erreichbar sind.", + "email.oncall.outgoing.intro": "Ihre Bereitschaft für {{teamName}} im Projekt {{projectBadge}} {{projectName}} ist beendet.", + "email.oncall.outgoing.note": "Vielen Dank für Ihren Einsatz während Ihrer Bereitschaft.", + "email.oncall.override.created.intro": "Ihnen wurde eine Bereitschaftsvertretung für {{teamName}} im Projekt {{projectBadge}} {{projectName}} zugewiesen.", + "email.oncall.override.created.period": "Ihr Vertretungszeitraum: {{startAt}} bis {{endAt}}.", + "email.oncall.override.created.note": "Stellen Sie bitte sicher, dass Sie in diesem Zeitraum für eingehende Probleme erreichbar sind.", + "email.oncall.override.covered.intro": "Ihre Bereitschaft für {{teamName}} im Projekt {{projectBadge}} {{projectName}} wurde von {{coveringUser}} übernommen.", + "email.oncall.override.covered.period": "Vertretungszeitraum: {{startAt}} bis {{endAt}}.", + "email.oncall.override.cancelled.intro": "Eine Bereitschaftsvertretung für {{teamName}} im Projekt {{projectBadge}} {{projectName}} wurde storniert.", + "email.oncall.override.cancelled.period": "Die Vertretung war vorgesehen für: {{startAt}} bis {{endAt}}.", + "email.oncall.override.cancelled.note": "Für diesen Zeitraum gilt wieder der reguläre Bereitschaftsplan.", + + "email.greeting": "Hallo {{name}},", + "email.verify.subject": "Bestätigen Sie Ihre E-Mail-Adresse", + "email.verify.intro": "Klicken Sie auf die Schaltfläche unten, um Ihre E-Mail-Adresse zu bestätigen und Ihr Passwort festzulegen:", + "email.verify.cta": "E-Mail bestätigen", + "email.verify.note": "Dieser Link ist 24 Stunden gültig. Wenn Sie dies nicht angefordert haben, können Sie diese E-Mail einfach ignorieren.", + "email.reset.subject": "Passwort zurücksetzen", + "email.reset.intro": "Wir haben eine Anfrage zum Zurücksetzen Ihres Passworts erhalten. Klicken Sie auf die Schaltfläche unten, um ein neues Passwort zu wählen:", + "email.reset.cta": "Passwort zurücksetzen", + "email.reset.note": "Dieser Link ist 1 Stunde gültig. Wenn Sie dies nicht angefordert haben, können Sie diese E-Mail einfach ignorieren." } diff --git a/api/internal/i18n/translations/en.json b/api/internal/i18n/translations/en.json index c610e176..00a95b7c 100644 --- a/api/internal/i18n/translations/en.json +++ b/api/internal/i18n/translations/en.json @@ -60,5 +60,28 @@ "email.oncall.cta": "View on-call schedule", "email.oncall.rotation.footer": "This is an automated on-call rotation notification from Taskwondo.", - "email.oncall.override.footer": "This is an automated on-call override notification from Taskwondo." + "email.oncall.override.footer": "This is an automated on-call override notification from Taskwondo.", + + "email.oncall.incoming.intro": "You are now on-call for {{teamName}} in project {{projectBadge}} {{projectName}}.", + "email.oncall.incoming.note": "Please make sure you are available to respond to any incoming issues during your shift.", + "email.oncall.outgoing.intro": "Your on-call shift for {{teamName}} in project {{projectBadge}} {{projectName}} has ended.", + "email.oncall.outgoing.note": "Thank you for your service during your shift.", + "email.oncall.override.created.intro": "You have been assigned an on-call override for {{teamName}} in project {{projectBadge}} {{projectName}}.", + "email.oncall.override.created.period": "Your override period: {{startAt}} to {{endAt}}.", + "email.oncall.override.created.note": "Please make sure you are available to respond to any incoming issues during this period.", + "email.oncall.override.covered.intro": "Your on-call shift for {{teamName}} in project {{projectBadge}} {{projectName}} has been covered by {{coveringUser}}.", + "email.oncall.override.covered.period": "Override period: {{startAt}} to {{endAt}}.", + "email.oncall.override.cancelled.intro": "An on-call override for {{teamName}} in project {{projectBadge}} {{projectName}} has been cancelled.", + "email.oncall.override.cancelled.period": "The override was scheduled for: {{startAt}} to {{endAt}}.", + "email.oncall.override.cancelled.note": "The regular on-call rotation schedule will apply for this period.", + + "email.greeting": "Hi {{name}},", + "email.verify.subject": "Verify your email", + "email.verify.intro": "Click the button below to verify your email address and set your password:", + "email.verify.cta": "Verify email", + "email.verify.note": "This link expires in 24 hours. If you didn't request this, you can safely ignore this email.", + "email.reset.subject": "Reset your password", + "email.reset.intro": "We received a request to reset your password. Click the button below to choose a new password:", + "email.reset.cta": "Reset password", + "email.reset.note": "This link expires in 1 hour. If you didn't request this, you can safely ignore this email." } diff --git a/api/internal/i18n/translations/es.json b/api/internal/i18n/translations/es.json index 6156a736..08d41a93 100644 --- a/api/internal/i18n/translations/es.json +++ b/api/internal/i18n/translations/es.json @@ -18,7 +18,7 @@ "email.status_change.subject": "[{{projectKey}}] #{{itemNumber}} estado cambiado a {{newStatus}}: {{title}}", "email.status_change.intro": "{{actorName}} cambió el estado de un elemento de trabajo asignado a ti:", - "email.status_change.status": "Estado: {{oldStatus}} \u2192 {{newStatus}}", + "email.status_change.status": "Estado: {{oldStatus}}{{newStatus}}", "email.status_change.cta": "Ver Elemento de Trabajo", "email.status_change.footer": "Recibiste este correo porque tienes las notificaciones de cambio de estado activadas para elementos asignados. Puedes cambiar tus preferencias de notificación en la configuración de Taskwondo.", @@ -35,5 +35,53 @@ "email.invite.subject": "Has sido invitado al proyecto {{projectName}}", "email.invite.intro": "{{inviterName}} te invitó a unirte al proyecto {{projectName}} como {{role}}.", "email.invite.cta": "Aceptar invitación", - "email.invite.footer": "Recibiste este correo porque alguien te invitó a un proyecto en Taskwondo. Si no reconoces esta invitación, puedes ignorar este correo." + "email.invite.footer": "Recibiste este correo porque alguien te invitó a un proyecto en Taskwondo. Si no reconoces esta invitación, puedes ignorar este correo.", + + "email.namespace_invite.subject": "Has sido invitado a {{namespaceName}}", + "email.namespace_invite.intro": "{{inviterName}} te invitó a unirte al workspace {{namespaceName}} como {{role}}.", + "email.namespace_invite.cta": "Aceptar invitación", + "email.namespace_invite.footer": "Recibiste este correo porque alguien te invitó a un workspace de Taskwondo. Si no reconoces esta invitación, puedes ignorar este correo sin problema.", + + "email.sla_breach.subject": "[{{projectKey}}] Aviso de SLA: #{{itemNumber}} — \"{{title}}\" (Nivel {{level}})", + "email.sla_breach.intro": "Un elemento de trabajo está por alcanzar o ha superado su objetivo de SLA:", + "email.sla_breach.sla_status": "SLA: se usó el {{percentage}}% del tiempo objetivo", + "email.sla_breach.elapsed": "Tiempo transcurrido: {{elapsed}} de {{target}}", + "email.sla_breach.level": "Nivel de escalamiento: {{level}}", + "email.sla_breach.status": "Estado: {{statusName}}", + "email.sla_breach.cta": "Ver elemento de trabajo", + "email.sla_breach.footer": "Recibiste este correo porque estás en la lista de escalamiento de este proyecto. Puedes cambiar tus preferencias de notificación en la configuración de Taskwondo.", + + "email.oncall.incoming.subject": "[{{projectKey}}] Ahora estás de guardia en {{teamName}}", + "email.oncall.outgoing.subject": "[{{projectKey}}] Tu turno de guardia en {{teamName}} ha terminado", + + "email.oncall.override.created.subject": "[{{projectKey}}] Sustitución de guardia asignada para {{teamName}}", + "email.oncall.override.covered.subject": "[{{projectKey}}] Tu turno de guardia en {{teamName}} ha sido cubierto", + "email.oncall.override.cancelled.subject": "[{{projectKey}}] Sustitución de guardia cancelada para {{teamName}}", + + "email.oncall.cta": "Ver calendario de guardias", + "email.oncall.rotation.footer": "Esta es una notificación automática de rotación de guardias de Taskwondo.", + "email.oncall.override.footer": "Esta es una notificación automática de sustitución de guardias de Taskwondo.", + + "email.oncall.incoming.intro": "Ahora estás de guardia por {{teamName}} en el proyecto {{projectBadge}} {{projectName}}.", + "email.oncall.incoming.note": "Asegúrate de estar disponible para responder a cualquier incidencia durante tu turno.", + "email.oncall.outgoing.intro": "Tu turno de guardia por {{teamName}} en el proyecto {{projectBadge}} {{projectName}} ha terminado.", + "email.oncall.outgoing.note": "Gracias por tu servicio durante tu turno.", + "email.oncall.override.created.intro": "Se te ha asignado una sustitución de guardia por {{teamName}} en el proyecto {{projectBadge}} {{projectName}}.", + "email.oncall.override.created.period": "Tu período de sustitución: del {{startAt}} al {{endAt}}.", + "email.oncall.override.created.note": "Asegúrate de estar disponible para responder a cualquier incidencia durante este período.", + "email.oncall.override.covered.intro": "Tu turno de guardia por {{teamName}} en el proyecto {{projectBadge}} {{projectName}} ha sido cubierto por {{coveringUser}}.", + "email.oncall.override.covered.period": "Período de sustitución: del {{startAt}} al {{endAt}}.", + "email.oncall.override.cancelled.intro": "Se ha cancelado una sustitución de guardia por {{teamName}} en el proyecto {{projectBadge}} {{projectName}}.", + "email.oncall.override.cancelled.period": "La sustitución estaba programada para: {{startAt}} a {{endAt}}.", + "email.oncall.override.cancelled.note": "La rotación de guardias habitual se aplicará a este período.", + + "email.greeting": "Hola, {{name}}:", + "email.verify.subject": "Verifica tu correo electrónico", + "email.verify.intro": "Haz clic en el botón de abajo para verificar tu dirección de correo y establecer tu contraseña:", + "email.verify.cta": "Verificar correo", + "email.verify.note": "Este enlace caduca en 24 horas. Si no solicitaste esto, puedes ignorar este correo sin problema.", + "email.reset.subject": "Restablecer tu contraseña", + "email.reset.intro": "Recibimos una solicitud para restablecer tu contraseña. Haz clic en el botón de abajo para elegir una nueva:", + "email.reset.cta": "Restablecer contraseña", + "email.reset.note": "Este enlace caduca en 1 hora. Si no solicitaste esto, puedes ignorar este correo sin problema." } diff --git a/api/internal/i18n/translations/fr.json b/api/internal/i18n/translations/fr.json index 8f63bc50..51b3047f 100644 --- a/api/internal/i18n/translations/fr.json +++ b/api/internal/i18n/translations/fr.json @@ -18,7 +18,7 @@ "email.status_change.subject": "[{{projectKey}}] #{{itemNumber}} statut changé en {{newStatus}} : {{title}}", "email.status_change.intro": "{{actorName}} a changé le statut d'un élément de travail qui vous est assigné :", - "email.status_change.status": "Statut : {{oldStatus}} \u2192 {{newStatus}}", + "email.status_change.status": "Statut : {{oldStatus}}{{newStatus}}", "email.status_change.cta": "Voir l'élément de travail", "email.status_change.footer": "Vous avez reçu cet e-mail car vous avez activé les notifications de changement de statut pour les éléments assignés. Vous pouvez modifier vos préférences de notification dans les paramètres de Taskwondo.", @@ -35,5 +35,53 @@ "email.invite.subject": "Vous avez été invité au projet {{projectName}}", "email.invite.intro": "{{inviterName}} vous a invité à rejoindre le projet {{projectName}} en tant que {{role}}.", "email.invite.cta": "Accepter l'invitation", - "email.invite.footer": "Vous avez reçu cet e-mail parce que quelqu'un vous a invité à un projet Taskwondo. Si vous ne reconnaissez pas cette invitation, vous pouvez ignorer cet e-mail." + "email.invite.footer": "Vous avez reçu cet e-mail parce que quelqu'un vous a invité à un projet Taskwondo. Si vous ne reconnaissez pas cette invitation, vous pouvez ignorer cet e-mail.", + + "email.namespace_invite.subject": "Vous avez été invité à rejoindre {{namespaceName}}", + "email.namespace_invite.intro": "{{inviterName}} vous a invité à rejoindre l'espace de travail {{namespaceName}} en tant que {{role}}.", + "email.namespace_invite.cta": "Accepter l'invitation", + "email.namespace_invite.footer": "Vous recevez cet e-mail car quelqu'un vous a invité à rejoindre un espace de travail Taskwondo. Si vous ne reconnaissez pas cette invitation, vous pouvez ignorer cet e-mail.", + + "email.sla_breach.subject": "[{{projectKey}}] Alerte SLA : #{{itemNumber}} — « {{title}} » (Niveau {{level}})", + "email.sla_breach.intro": "Un élément de travail approche ou a dépassé son objectif SLA :", + "email.sla_breach.sla_status": "SLA : {{percentage}} % du temps cible utilisé", + "email.sla_breach.elapsed": "Temps écoulé : {{elapsed}} sur {{target}}", + "email.sla_breach.level": "Niveau d'escalade : {{level}}", + "email.sla_breach.status": "Statut : {{statusName}}", + "email.sla_breach.cta": "Voir l'élément de travail", + "email.sla_breach.footer": "Vous recevez cet e-mail car vous figurez dans la liste d'escalade de ce projet. Vous pouvez modifier vos préférences de notification dans les paramètres Taskwondo.", + + "email.oncall.incoming.subject": "[{{projectKey}}] Vous êtes maintenant d'astreinte pour {{teamName}}", + "email.oncall.outgoing.subject": "[{{projectKey}}] Votre astreinte pour {{teamName}} est terminée", + + "email.oncall.override.created.subject": "[{{projectKey}}] Remplacement d'astreinte attribué pour {{teamName}}", + "email.oncall.override.covered.subject": "[{{projectKey}}] Votre astreinte pour {{teamName}} a été couverte", + "email.oncall.override.cancelled.subject": "[{{projectKey}}] Remplacement d'astreinte annulé pour {{teamName}}", + + "email.oncall.cta": "Voir le planning d'astreinte", + "email.oncall.rotation.footer": "Ceci est une notification automatique de rotation d'astreinte de Taskwondo.", + "email.oncall.override.footer": "Ceci est une notification automatique de remplacement d'astreinte de Taskwondo.", + + "email.oncall.incoming.intro": "Vous êtes maintenant d'astreinte pour {{teamName}} dans le projet {{projectBadge}} {{projectName}}.", + "email.oncall.incoming.note": "Assurez-vous d'être disponible pour répondre aux incidents pendant votre astreinte.", + "email.oncall.outgoing.intro": "Votre astreinte pour {{teamName}} dans le projet {{projectBadge}} {{projectName}} est terminée.", + "email.oncall.outgoing.note": "Merci pour votre service pendant votre astreinte.", + "email.oncall.override.created.intro": "Un remplacement d'astreinte pour {{teamName}} vous a été attribué dans le projet {{projectBadge}} {{projectName}}.", + "email.oncall.override.created.period": "Votre période de remplacement : du {{startAt}} au {{endAt}}.", + "email.oncall.override.created.note": "Assurez-vous d'être disponible pour répondre aux incidents pendant cette période.", + "email.oncall.override.covered.intro": "Votre astreinte pour {{teamName}} dans le projet {{projectBadge}} {{projectName}} a été couverte par {{coveringUser}}.", + "email.oncall.override.covered.period": "Période de remplacement : du {{startAt}} au {{endAt}}.", + "email.oncall.override.cancelled.intro": "Un remplacement d'astreinte pour {{teamName}} dans le projet {{projectBadge}} {{projectName}} a été annulé.", + "email.oncall.override.cancelled.period": "Le remplacement était prévu pour : {{startAt}} à {{endAt}}.", + "email.oncall.override.cancelled.note": "La rotation d'astreinte habituelle s'appliquera à cette période.", + + "email.greeting": "Bonjour {{name}},", + "email.verify.subject": "Vérifiez votre adresse e-mail", + "email.verify.intro": "Cliquez sur le bouton ci-dessous pour vérifier votre adresse e-mail et définir votre mot de passe :", + "email.verify.cta": "Vérifier l'e-mail", + "email.verify.note": "Ce lien expire dans 24 heures. Si vous n'êtes pas à l'origine de cette demande, vous pouvez ignorer cet e-mail.", + "email.reset.subject": "Réinitialisez votre mot de passe", + "email.reset.intro": "Nous avons reçu une demande de réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour en choisir un nouveau :", + "email.reset.cta": "Réinitialiser le mot de passe", + "email.reset.note": "Ce lien expire dans 1 heure. Si vous n'êtes pas à l'origine de cette demande, vous pouvez ignorer cet e-mail." } diff --git a/api/internal/i18n/translations/ja.json b/api/internal/i18n/translations/ja.json index f1ee1f45..4418eb8d 100644 --- a/api/internal/i18n/translations/ja.json +++ b/api/internal/i18n/translations/ja.json @@ -18,7 +18,7 @@ "email.status_change.subject": "[{{projectKey}}] #{{itemNumber}} のステータスが {{newStatus}} に変更されました: {{title}}", "email.status_change.intro": "{{actorName}} があなたに割り当てられた作業項目のステータスを変更しました:", - "email.status_change.status": "ステータス: {{oldStatus}} \u2192 {{newStatus}}", + "email.status_change.status": "ステータス: {{oldStatus}}{{newStatus}}", "email.status_change.cta": "作業項目を表示", "email.status_change.footer": "このメールは、割り当てられた項目のステータス変更通知が有効になっているために送信されました。通知設定はTaskwondoの設定で変更できます。", @@ -35,5 +35,53 @@ "email.invite.subject": "プロジェクト{{projectName}}に招待されました", "email.invite.intro": "{{inviterName}}があなたをプロジェクト{{projectName}}{{role}}として参加するよう招待しました。", "email.invite.cta": "招待を承諾", - "email.invite.footer": "このメールは、Taskwondoプロジェクトに招待されたために送信されました。この招待に心当たりがない場合は、このメールを無視してください。" + "email.invite.footer": "このメールは、Taskwondoプロジェクトに招待されたために送信されました。この招待に心当たりがない場合は、このメールを無視してください。", + + "email.namespace_invite.subject": "{{namespaceName}} に招待されました", + "email.namespace_invite.intro": "{{inviterName}} さんがワークスペース {{namespaceName}} への参加({{role}} として)を招待しています。", + "email.namespace_invite.cta": "招待を受ける", + "email.namespace_invite.footer": "Taskwondo ワークスペースへの招待が届いたため、このメールをお送りしています。心当たりがない場合は、このメールを無視してください。", + + "email.sla_breach.subject": "[{{projectKey}}] SLA警告: #{{itemNumber}} — \"{{title}}\"(レベル {{level}})", + "email.sla_breach.intro": "ワークアイテムがSLA目標に達するか、超過しました:", + "email.sla_breach.sla_status": "SLA: 目標時間の {{percentage}}% を使用", + "email.sla_breach.elapsed": "経過時間: {{target}} のうち {{elapsed}}", + "email.sla_breach.level": "エスカレーションレベル: {{level}}", + "email.sla_breach.status": "ステータス: {{statusName}}", + "email.sla_breach.cta": "ワークアイテムを表示", + "email.sla_breach.footer": "このプロジェクトのエスカレーションリストに登録されているため、このメールをお送りしています。通知設定は Taskwondo の設定で変更できます。", + + "email.oncall.incoming.subject": "[{{projectKey}}] {{teamName}} のオンコールが開始されました", + "email.oncall.outgoing.subject": "[{{projectKey}}] {{teamName}} のオンコールシフトが終了しました", + + "email.oncall.override.created.subject": "[{{projectKey}}] {{teamName}} のオンコール代理が割り当てられました", + "email.oncall.override.covered.subject": "[{{projectKey}}] {{teamName}} のオンコールシフトが代理されました", + "email.oncall.override.cancelled.subject": "[{{projectKey}}] {{teamName}} のオンコール代理がキャンセルされました", + + "email.oncall.cta": "オンコールスケジュールを表示", + "email.oncall.rotation.footer": "これは Taskwondo からのオンコールローテーションの自動通知です。", + "email.oncall.override.footer": "これは Taskwondo からのオンコール代理の自動通知です。", + + "email.oncall.incoming.intro": "プロジェクト {{projectBadge}} {{projectName}} のチーム {{teamName}} のオンコールが始まりました。", + "email.oncall.incoming.note": "シフト中は障害や問い合わせに対応できるようご確認ください。", + "email.oncall.outgoing.intro": "プロジェクト {{projectBadge}} {{projectName}} のチーム {{teamName}} のオンコールシフトが終了しました。", + "email.oncall.outgoing.note": "シフト中のご対応、ありがとうございました。", + "email.oncall.override.created.intro": "プロジェクト {{projectBadge}} {{projectName}} のチーム {{teamName}} のオンコール代理が割り当てられました。", + "email.oncall.override.created.period": "代理期間: {{startAt}}{{endAt}}", + "email.oncall.override.created.note": "期間中は障害や問い合わせに対応できるようご確認ください。", + "email.oncall.override.covered.intro": "プロジェクト {{projectBadge}} {{projectName}} のチーム {{teamName}} のオンコールシフトは {{coveringUser}} が代理しました。", + "email.oncall.override.covered.period": "代理期間: {{startAt}}{{endAt}}", + "email.oncall.override.cancelled.intro": "プロジェクト {{projectBadge}} {{projectName}} のチーム {{teamName}} のオンコール代理はキャンセルされました。", + "email.oncall.override.cancelled.period": "代理予定だった期間: {{startAt}}{{endAt}}", + "email.oncall.override.cancelled.note": "この期間は通常のオンコールローテーションが適用されます。", + + "email.greeting": "{{name}} さん、こんにちは。", + "email.verify.subject": "メールアドレスの確認", + "email.verify.intro": "下記のボタンをクリックしてメールアドレスを確認し、パスワードを設定してください:", + "email.verify.cta": "メールを確認", + "email.verify.note": "このリンクは24時間で有効期限切れになります。心当たりがない場合は、このメールを無視してください。", + "email.reset.subject": "パスワードのリセット", + "email.reset.intro": "パスワードのリセット依頼を受け付けました。下記のボタンをクリックして新しいパスワードを設定してください:", + "email.reset.cta": "パスワードをリセット", + "email.reset.note": "このリンクは1時間で有効期限切れになります。心当たりがない場合は、このメールを無視してください。" } diff --git a/api/internal/i18n/translations/ko.json b/api/internal/i18n/translations/ko.json index ec56c860..c8dfd6e0 100644 --- a/api/internal/i18n/translations/ko.json +++ b/api/internal/i18n/translations/ko.json @@ -18,7 +18,7 @@ "email.status_change.subject": "[{{projectKey}}] #{{itemNumber}} 상태가 {{newStatus}}(으)로 변경됨: {{title}}", "email.status_change.intro": "{{actorName}}님이 할당된 작업 항목의 상태를 변경했습니다:", - "email.status_change.status": "상태: {{oldStatus}} \u2192 {{newStatus}}", + "email.status_change.status": "상태: {{oldStatus}}{{newStatus}}", "email.status_change.cta": "작업 항목 보기", "email.status_change.footer": "할당된 항목의 상태 변경 알림이 활성화되어 있어 이 이메일을 받았습니다. Taskwondo 설정에서 알림 기본 설정을 변경할 수 있습니다.", @@ -35,5 +35,53 @@ "email.invite.subject": "프로젝트 {{projectName}}에 초대되었습니다", "email.invite.intro": "{{inviterName}}님이 프로젝트 {{projectName}}{{role}}(으)로 참여하도록 초대했습니다.", "email.invite.cta": "초대 수락", - "email.invite.footer": "이 이메일은 누군가가 Taskwondo 프로젝트에 초대하여 발송되었습니다. 이 초대를 인식하지 못하시면 이 이메일을 무시하셔도 됩니다." + "email.invite.footer": "이 이메일은 누군가가 Taskwondo 프로젝트에 초대하여 발송되었습니다. 이 초대를 인식하지 못하시면 이 이메일을 무시하셔도 됩니다.", + + "email.namespace_invite.subject": "{{namespaceName}}(으)로 초대되었습니다", + "email.namespace_invite.intro": "{{inviterName}}님이 워크스페이스 {{namespaceName}}{{role}}(으)로 참가하도록 초대했습니다.", + "email.namespace_invite.cta": "초대 수락", + "email.namespace_invite.footer": "Taskwondo 워크스페이스 초대가 도착하여 이 메일을 보내드립니다. 인지할 수 없는 초대라면 이 메일을 무시하셔도 안전합니다.", + + "email.sla_breach.subject": "[{{projectKey}}] SLA 경고: #{{itemNumber}} — \"{{title}}\" (레벨 {{level}})", + "email.sla_breach.intro": "워크 항목이 SLA 목표에 도달하거나 초과했습니다:", + "email.sla_breach.sla_status": "SLA: 목표 시간의 {{percentage}}% 사용", + "email.sla_breach.elapsed": "경과 시간: {{target}}{{elapsed}}", + "email.sla_breach.level": "에스컬레이션 레벨: {{level}}", + "email.sla_breach.status": "상태: {{statusName}}", + "email.sla_breach.cta": "워크 항목 보기", + "email.sla_breach.footer": "이 프로젝트의 에스컬레이션 목록에 포함되어 있어 이 메일을 받았습니다. Taskwondo 설정에서 알림 환경을 변경할 수 있습니다.", + + "email.oncall.incoming.subject": "[{{projectKey}}] 이제 {{teamName}} 온콜입니다", + "email.oncall.outgoing.subject": "[{{projectKey}}] {{teamName}} 온콜 교대가 종료되었습니다", + + "email.oncall.override.created.subject": "[{{projectKey}}] {{teamName}} 온콜 대리 교대가 배정되었습니다", + "email.oncall.override.covered.subject": "[{{projectKey}}] {{teamName}} 온콜 교대가 대리되었습니다", + "email.oncall.override.cancelled.subject": "[{{projectKey}}] {{teamName}} 온콜 대리 교대가 취소되었습니다", + + "email.oncall.cta": "온콜 일정 보기", + "email.oncall.rotation.footer": "Taskwondo의 자동 온콜 로테이션 알림입니다.", + "email.oncall.override.footer": "Taskwondo의 자동 온콜 대리 알림입니다.", + + "email.oncall.incoming.intro": "프로젝트 {{projectBadge}} {{projectName}}{{teamName}} 온콜이 시작되었습니다.", + "email.oncall.incoming.note": "교대 중 접수되는 문제에 대응할 수 있는지 확인해 주세요.", + "email.oncall.outgoing.intro": "프로젝트 {{projectBadge}} {{projectName}}{{teamName}} 온콜 교대가 종료되었습니다.", + "email.oncall.outgoing.note": "교대 중 수고해 주셔서 감사합니다.", + "email.oncall.override.created.intro": "프로젝트 {{projectBadge}} {{projectName}}{{teamName}} 온콜 대리 교대가 배정되었습니다.", + "email.oncall.override.created.period": "대리 기간: {{startAt}} ~ {{endAt}}", + "email.oncall.override.created.note": "해당 기간에 접수되는 문제에 대응할 수 있는지 확인해 주세요.", + "email.oncall.override.covered.intro": "프로젝트 {{projectBadge}} {{projectName}}{{teamName}} 온콜 교대를 {{coveringUser}} 님이 대리했습니다.", + "email.oncall.override.covered.period": "대리 기간: {{startAt}} ~ {{endAt}}", + "email.oncall.override.cancelled.intro": "프로젝트 {{projectBadge}} {{projectName}}{{teamName}} 온콜 대리 교대가 취소되었습니다.", + "email.oncall.override.cancelled.period": "예상됐던 대리 기간: {{startAt}} ~ {{endAt}}", + "email.oncall.override.cancelled.note": "해당 기간에는 일반 온콜 로테이션 일정이 적용됩니다.", + + "email.greeting": "{{name}}님, 안녕하세요.", + "email.verify.subject": "이메일 인증", + "email.verify.intro": "아래 버튼을 클릭하여 이메일 주소를 인증하고 비밀번호를 설정하세요:", + "email.verify.cta": "이메일 인증", + "email.verify.note": "이 링크는 24시간 후 만료됩니다. 요청한 적이 없다면 이 메일을 무시하셔도 안전합니다.", + "email.reset.subject": "비밀번호 재설정", + "email.reset.intro": "비밀번호 재설정 요청을 받았습니다. 아래 버튼을 클릭하여 새 비밀번호를 설정하세요:", + "email.reset.cta": "비밀번호 재설정", + "email.reset.note": "이 링크는 1시간 후 만료됩니다. 요청한 적이 없다면 이 메일을 무시하셔도 안전합니다." } diff --git a/api/internal/i18n/translations/pt.json b/api/internal/i18n/translations/pt.json index cafed4ca..9462e6f3 100644 --- a/api/internal/i18n/translations/pt.json +++ b/api/internal/i18n/translations/pt.json @@ -18,7 +18,7 @@ "email.status_change.subject": "[{{projectKey}}] #{{itemNumber}} status alterado para {{newStatus}}: {{title}}", "email.status_change.intro": "{{actorName}} alterou o status de um item de trabalho atribuído a você:", - "email.status_change.status": "Status: {{oldStatus}} \u2192 {{newStatus}}", + "email.status_change.status": "Status: {{oldStatus}}{{newStatus}}", "email.status_change.cta": "Ver Item de Trabalho", "email.status_change.footer": "Você recebeu este e-mail porque tem as notificações de alteração de status ativadas para itens atribuídos. Você pode alterar suas preferências de notificação nas configurações do Taskwondo.", @@ -35,5 +35,53 @@ "email.invite.subject": "Você foi convidado para o projeto {{projectName}}", "email.invite.intro": "{{inviterName}} convidou você para participar do projeto {{projectName}} como {{role}}.", "email.invite.cta": "Aceitar convite", - "email.invite.footer": "Você recebeu este e-mail porque alguém convidou você para um projeto no Taskwondo. Se você não reconhece este convite, pode ignorar este e-mail." + "email.invite.footer": "Você recebeu este e-mail porque alguém convidou você para um projeto no Taskwondo. Se você não reconhece este convite, pode ignorar este e-mail.", + + "email.namespace_invite.subject": "Você foi convidado para {{namespaceName}}", + "email.namespace_invite.intro": "{{inviterName}} convidou você para entrar no workspace {{namespaceName}} como {{role}}.", + "email.namespace_invite.cta": "Aceitar Convite", + "email.namespace_invite.footer": "Você recebeu este e-mail porque alguém o convidou para um workspace do Taskwondo. Se você não reconhece este convite, pode ignorar este e-mail com segurança.", + + "email.sla_breach.subject": "[{{projectKey}}] Aviso de SLA: #{{itemNumber}} — \"{{title}}\" (Nível {{level}})", + "email.sla_breach.intro": "Um item de trabalho está prestes a atingir ou excedeu sua meta de SLA:", + "email.sla_breach.sla_status": "SLA: {{percentage}}% do tempo alvo utilizado", + "email.sla_breach.elapsed": "Tempo decorrido: {{elapsed}} de {{target}}", + "email.sla_breach.level": "Nível de Escalonamento: {{level}}", + "email.sla_breach.status": "Status: {{statusName}}", + "email.sla_breach.cta": "Ver Item de Trabalho", + "email.sla_breach.footer": "Você recebeu este e-mail porque está na lista de escalonamento deste projeto. Você pode alterar suas preferências de notificação nas configurações do Taskwondo.", + + "email.oncall.incoming.subject": "[{{projectKey}}] Você agora está de plantão em {{teamName}}", + "email.oncall.outgoing.subject": "[{{projectKey}}] Seu plantão em {{teamName}} terminou", + + "email.oncall.override.created.subject": "[{{projectKey}}] Substituição de plantão atribuída para {{teamName}}", + "email.oncall.override.covered.subject": "[{{projectKey}}] Seu plantão em {{teamName}} foi coberto por um substituto", + "email.oncall.override.cancelled.subject": "[{{projectKey}}] Substituição de plantão cancelada para {{teamName}}", + + "email.oncall.cta": "Ver agenda de plantão", + "email.oncall.rotation.footer": "Esta é uma notificação automática de rodízio de plantão do Taskwondo.", + "email.oncall.override.footer": "Esta é uma notificação automática de substituição de plantão do Taskwondo.", + + "email.oncall.incoming.intro": "Você agora está de plantão por {{teamName}} no projeto {{projectBadge}} {{projectName}}.", + "email.oncall.incoming.note": "Certifique-se de estar disponível para responder a quaisquer chamados durante seu plantão.", + "email.oncall.outgoing.intro": "Seu plantão por {{teamName}} no projeto {{projectBadge}} {{projectName}} terminou.", + "email.oncall.outgoing.note": "Obrigado pelo seu serviço durante o plantão.", + "email.oncall.override.created.intro": "Foi atribuída a você uma substituição de plantão por {{teamName}} no projeto {{projectBadge}} {{projectName}}.", + "email.oncall.override.created.period": "Seu período de substituição: de {{startAt}} até {{endAt}}.", + "email.oncall.override.created.note": "Certifique-se de estar disponível para responder a quaisquer chamados durante este período.", + "email.oncall.override.covered.intro": "Seu plantão por {{teamName}} no projeto {{projectBadge}} {{projectName}} foi coberto por {{coveringUser}}.", + "email.oncall.override.covered.period": "Período de substituição: de {{startAt}} até {{endAt}}.", + "email.oncall.override.cancelled.intro": "Uma substituição de plantão por {{teamName}} no projeto {{projectBadge}} {{projectName}} foi cancelada.", + "email.oncall.override.cancelled.period": "A substituição estava agendada para: {{startAt}} a {{endAt}}.", + "email.oncall.override.cancelled.note": "O rodízio normal de plantão se aplicará a este período.", + + "email.greeting": "Olá, {{name}},", + "email.verify.subject": "Verifique seu e-mail", + "email.verify.intro": "Clique no botão abaixo para verificar seu endereço de e-mail e definir sua senha:", + "email.verify.cta": "Verificar e-mail", + "email.verify.note": "Este link expira em 24 horas. Se você não fez esta solicitação, pode ignorar este e-mail com segurança.", + "email.reset.subject": "Redefinir sua senha", + "email.reset.intro": "Recebemos uma solicitação para redefinir sua senha. Clique no botão abaixo para escolher uma nova senha:", + "email.reset.cta": "Redefinir senha", + "email.reset.note": "Este link expira em 1 hora. Se você não fez esta solicitação, pode ignorar este e-mail com segurança." } diff --git a/api/internal/i18n/translations/zh.json b/api/internal/i18n/translations/zh.json index 0d7ad19c..1d3093af 100644 --- a/api/internal/i18n/translations/zh.json +++ b/api/internal/i18n/translations/zh.json @@ -18,7 +18,7 @@ "email.status_change.subject": "[{{projectKey}}] #{{itemNumber}} 状态已更改为 {{newStatus}}: {{title}}", "email.status_change.intro": "{{actorName}} 更改了分配给您的工作项的状态:", - "email.status_change.status": "状态: {{oldStatus}} \u2192 {{newStatus}}", + "email.status_change.status": "状态: {{oldStatus}}{{newStatus}}", "email.status_change.cta": "查看工作项", "email.status_change.footer": "您收到此邮件是因为您已为分配的项目启用了状态更改通知。您可以在 Taskwondo 设置中更改通知偏好。", @@ -35,5 +35,53 @@ "email.invite.subject": "您已被邀请加入项目 {{projectName}}", "email.invite.intro": "{{inviterName}} 邀请您以 {{role}} 身份加入项目 {{projectName}}。", "email.invite.cta": "接受邀请", - "email.invite.footer": "您收到此邮件是因为有人邀请您加入 Taskwondo 项目。如果您不认识此邀请,可以安全地忽略此邮件。" + "email.invite.footer": "您收到此邮件是因为有人邀请您加入 Taskwondo 项目。如果您不认识此邀请,可以安全地忽略此邮件。", + + "email.namespace_invite.subject": "您已被邀请加入 {{namespaceName}}", + "email.namespace_invite.intro": "{{inviterName}} 邀请您以 {{role}} 身份加入工作区 {{namespaceName}}。", + "email.namespace_invite.cta": "接受邀请", + "email.namespace_invite.footer": "您收到此邮件是因为有人邀请您加入 Taskwondo 工作区。如果您不认识此邀请,可以安全地忽略此邮件。", + + "email.sla_breach.subject": "[{{projectKey}}] SLA 警告: #{{itemNumber}} — \"{{title}}\" (级别 {{level}})", + "email.sla_breach.intro": "一个工作项即将达到或已超过其 SLA 目标:", + "email.sla_breach.sla_status": "SLA: 已使用目标时间的 {{percentage}}%", + "email.sla_breach.elapsed": "已用时间: {{elapsed}} / {{target}}", + "email.sla_breach.level": "升级级别: {{level}}", + "email.sla_breach.status": "状态: {{statusName}}", + "email.sla_breach.cta": "查看工作项", + "email.sla_breach.footer": "您收到此邮件是因为您在此项目的升级名单中。您可以在 Taskwondo 设置中更改通知偏好。", + + "email.oncall.incoming.subject": "[{{projectKey}}] 您现在开始为 {{teamName}} 值班", + "email.oncall.outgoing.subject": "[{{projectKey}}] 您为 {{teamName}} 的值班已结束", + + "email.oncall.override.created.subject": "[{{projectKey}}] {{teamName}} 已指派值班替补", + "email.oncall.override.covered.subject": "[{{projectKey}}] 您为 {{teamName}} 的值班已被接替", + "email.oncall.override.cancelled.subject": "[{{projectKey}}] {{teamName}} 的值班替补已取消", + + "email.oncall.cta": "查看值班排班", + "email.oncall.rotation.footer": "这是来自 Taskwondo 的自动值班轮转通知。", + "email.oncall.override.footer": "这是来自 Taskwondo 的自动值班替补通知。", + + "email.oncall.incoming.intro": "您现在是项目 {{projectBadge}} {{projectName}} 中团队 {{teamName}} 的值班人员。", + "email.oncall.incoming.note": "请确保您在值班期间能够响应任何紧急问题。", + "email.oncall.outgoing.intro": "您在项目 {{projectBadge}} {{projectName}} 中团队 {{teamName}} 的值班已结束。", + "email.oncall.outgoing.note": "感谢您在值班期间的付出。", + "email.oncall.override.created.intro": "您被指派了 {{teamName}} 的值班替补,项目 {{projectBadge}} {{projectName}}。", + "email.oncall.override.created.period": "您的替补时段: {{startAt}}{{endAt}}。", + "email.oncall.override.created.note": "请确保您在该时段内能够响应任何紧急问题。", + "email.oncall.override.covered.intro": "您在项目 {{projectBadge}} {{projectName}} 中团队 {{teamName}} 的值班已由 {{coveringUser}} 接替。", + "email.oncall.override.covered.period": "替补时段: {{startAt}}{{endAt}}。", + "email.oncall.override.cancelled.intro": "项目 {{projectBadge}} {{projectName}} 中团队 {{teamName}} 的一条值班替补已被取消。", + "email.oncall.override.cancelled.period": "原替补时段: {{startAt}}{{endAt}}。", + "email.oncall.override.cancelled.note": "该时段将按正常值班轮转计划执行。", + + "email.greeting": "您好 {{name}}:", + "email.verify.subject": "验证您的邮箱", + "email.verify.intro": "点击下方按钮验证您的邮箱地址并设置密码:", + "email.verify.cta": "验证邮箱", + "email.verify.note": "此链接 24 小时内有效。如果您没有提出此请求,可以安全地忽略此邮件。", + "email.reset.subject": "重置您的密码", + "email.reset.intro": "我们收到了重置您密码的请求。点击下方按钮设置新密码:", + "email.reset.cta": "重置密码", + "email.reset.note": "此链接 1 小时内有效。如果您没有提出此请求,可以安全地忽略此邮件。" } diff --git a/api/internal/model/errors.go b/api/internal/model/errors.go index ea754154..c35fd202 100644 --- a/api/internal/model/errors.go +++ b/api/internal/model/errors.go @@ -16,6 +16,8 @@ var ( ErrValidation = errors.New("validation error") ErrInvalidTransition = errors.New("invalid transition") ErrOAuthAccountLinked = errors.New("oauth account already linked to another user") + ErrOAuthEmailMissing = errors.New("identity provider did not return an email address") + ErrOAuthEmailUnverified = errors.New("email address is not verified") ErrStatusIncompatible = errors.New("status incompatible with target workflow") ErrEmbeddingUnavailable = errors.New("embedding service unavailable") ErrFeatureDisabled = errors.New("feature is disabled") diff --git a/api/internal/model/oauth.go b/api/internal/model/oauth.go index a6be9f0a..c0b8b7f6 100644 --- a/api/internal/model/oauth.go +++ b/api/internal/model/oauth.go @@ -12,6 +12,8 @@ const ( OAuthProviderGoogle = "google" OAuthProviderGitHub = "github" OAuthProviderMicrosoft = "microsoft" + // OAuthProviderSSO is the generic OIDC provider configured by admins. + OAuthProviderSSO = "sso" ) // OAuthAccount represents a linked external identity. diff --git a/api/internal/model/system_setting.go b/api/internal/model/system_setting.go index 6387bc86..2de80e0d 100644 --- a/api/internal/model/system_setting.go +++ b/api/internal/model/system_setting.go @@ -3,6 +3,8 @@ package model import ( "encoding/json" "fmt" + "net/url" + "strings" "time" ) @@ -24,6 +26,12 @@ const ( SettingAuthGoogleEnabled = "auth_google_enabled" SettingAuthGitHubEnabled = "auth_github_enabled" SettingAuthMicrosoftEnabled = "auth_microsoft_enabled" + SettingAuthSSOEnabled = "auth_sso_enabled" + + // SettingSSOAutoProvision gates account creation for SSO logins whose email + // does not match an existing user. When false (the default), SSO can only + // sign in users that already exist. + SettingSSOAutoProvision = "sso_auto_provision_enabled" // OAuth provider ordering (JSON array of provider names, e.g. ["discord","google","github"]) SettingOAuthProviderOrder = "oauth_provider_order" @@ -33,6 +41,7 @@ const ( SettingOAuthGoogleConfig = "oauth_google_config" SettingOAuthGitHubConfig = "oauth_github_config" SettingOAuthMicrosoftConfig = "oauth_microsoft_config" + SettingOAuthSSOConfig = "oauth_sso_config" // Deny lists (JSON arrays of strings) SettingReservedNamespaceSlugs = "reserved_namespace_slugs" @@ -67,6 +76,9 @@ type SMTPConfig struct { Encryption string `json:"encryption"` // "starttls", "tls", "none" FromAddress string `json:"from_address"` FromName string `json:"from_name"` + // SkipCertVerify disables TLS certificate verification for SMTP connections. + // Intended for self-hosted servers with self-signed or expired certificates. + SkipCertVerify bool `json:"skip_cert_verify"` } // Validate checks that all required fields are present when SMTP is enabled. @@ -98,9 +110,43 @@ func (c *SMTPConfig) Validate() error { // OAuthProviderConfig holds OAuth provider credentials stored as a system setting. // The enabled/disabled state is stored separately in auth_*_enabled settings. // The redirect URI is derived automatically from BaseURL + "/auth/{provider}/callback". +// +// The Issuer/Scopes/ButtonLabel/DisablePKCE/RequireVerifiedEmail fields are only +// used by the generic SSO provider (OAuthProviderSSO). They are omitted from the +// stored JSON for the built-in providers, whose behaviour is unchanged. type OAuthProviderConfig struct { ClientID string `json:"client_id"` ClientSecret string `json:"client_secret"` + + // Issuer is the OIDC issuer URL used for discovery (SSO only). + Issuer string `json:"issuer,omitempty"` + // Scopes overrides the requested scopes; empty means DefaultSSOScopes. + Scopes []string `json:"scopes,omitempty"` + // ButtonLabel overrides the login button text (SSO only). + ButtonLabel string `json:"button_label,omitempty"` + // DisablePKCE turns off the S256 code challenge for IdPs that reject it. + DisablePKCE bool `json:"disable_pkce,omitempty"` + // RequireVerifiedEmail gates logins on the email_verified claim. nil = required. + RequireVerifiedEmail *bool `json:"require_verified_email,omitempty"` +} + +// DefaultSSOScopes are requested when Scopes is empty. +var DefaultSSOScopes = []string{"openid", "profile", "email"} + +// MaxSSOButtonLabel is the length cap for the login button override. +const MaxSSOButtonLabel = 40 + +// RequiresVerifiedEmail reports whether the email_verified claim must be true. +func (c *OAuthProviderConfig) RequiresVerifiedEmail() bool { + return c.RequireVerifiedEmail == nil || *c.RequireVerifiedEmail +} + +// ScopeList returns the configured scopes or the defaults. +func (c *OAuthProviderConfig) ScopeList() []string { + if len(c.Scopes) == 0 { + return DefaultSSOScopes + } + return c.Scopes } // Validate checks that all required fields are present. @@ -114,6 +160,80 @@ func (c *OAuthProviderConfig) Validate() error { return nil } +// ValidateAs validates the config in the context of a specific provider, +// enforcing the extra fields the generic SSO provider needs. +func (c *OAuthProviderConfig) ValidateAs(provider string) error { + if err := c.Validate(); err != nil { + return err + } + if provider != OAuthProviderSSO { + return nil + } + + issuer, err := NormalizeOIDCIssuer(c.Issuer) + if err != nil { + return err + } + c.Issuer = issuer + + label := strings.TrimSpace(c.ButtonLabel) + if len([]rune(label)) > MaxSSOButtonLabel { + return fmt.Errorf("%w: button_label must be %d characters or fewer", ErrValidation, MaxSSOButtonLabel) + } + c.ButtonLabel = label + + for _, s := range c.Scopes { + if s == "" || strings.ContainsAny(s, " \t") { + return fmt.Errorf("%w: scopes must be individual non-empty strings", ErrValidation) + } + } + if len(c.Scopes) > 0 && !containsString(c.Scopes, "openid") { + return fmt.Errorf("%w: scopes must include openid", ErrValidation) + } + return nil +} + +// NormalizeOIDCIssuer validates and canonicalises an OIDC issuer URL. +// Issuers are compared exactly by the discovery and verification code, so +// trailing slashes are stripped and only https (or http for local dev) is kept. +func NormalizeOIDCIssuer(raw string) (string, error) { + issuer := strings.TrimSpace(raw) + if issuer == "" { + return "", fmt.Errorf("%w: issuer is required", ErrValidation) + } + u, err := url.Parse(issuer) + if err != nil || u.Host == "" { + return "", fmt.Errorf("%w: issuer must be an absolute URL", ErrValidation) + } + if u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || strings.HasPrefix(u.Host, "127."))) { + return "", fmt.Errorf("%w: issuer must use https", ErrValidation) + } + if u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return "", fmt.Errorf("%w: issuer must not contain credentials, query or fragment", ErrValidation) + } + return strings.TrimRight(u.String(), "/"), nil +} + +func containsString(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} + +// KnownOAuthProviders lists every provider whose credentials live in an +// oauth__config setting and whose switch is an auth__enabled +// setting. Login-page ordering and enablement are driven off this list. +var KnownOAuthProviders = []string{ + OAuthProviderDiscord, + OAuthProviderGoogle, + OAuthProviderGitHub, + OAuthProviderMicrosoft, + OAuthProviderSSO, +} + // OAuthConfigSettingKey returns the system setting key for a given provider name. func OAuthConfigSettingKey(provider string) string { switch provider { @@ -125,6 +245,27 @@ func OAuthConfigSettingKey(provider string) string { return SettingOAuthGitHubConfig case OAuthProviderMicrosoft: return SettingOAuthMicrosoftConfig + case OAuthProviderSSO: + return SettingOAuthSSOConfig + default: + return "" + } +} + +// OAuthEnabledSettingKey returns the auth__enabled setting key for a +// provider name, or empty string for unknown providers. +func OAuthEnabledSettingKey(provider string) string { + switch provider { + case OAuthProviderDiscord: + return SettingAuthDiscordEnabled + case OAuthProviderGoogle: + return SettingAuthGoogleEnabled + case OAuthProviderGitHub: + return SettingAuthGitHubEnabled + case OAuthProviderMicrosoft: + return SettingAuthMicrosoftEnabled + case OAuthProviderSSO: + return SettingAuthSSOEnabled default: return "" } @@ -142,6 +283,8 @@ func OAuthEnabledToConfigKey(enabledKey string) string { return SettingOAuthGitHubConfig case SettingAuthMicrosoftEnabled: return SettingOAuthMicrosoftConfig + case SettingAuthSSOEnabled: + return SettingOAuthSSOConfig default: return "" } diff --git a/api/internal/repository/embedding.go b/api/internal/repository/embedding.go index d941344e..e0541cd7 100644 --- a/api/internal/repository/embedding.go +++ b/api/internal/repository/embedding.go @@ -13,7 +13,8 @@ import ( // EmbeddingRepository handles embedding persistence. type EmbeddingRepository struct { - db *sql.DB + db *sql.DB + tableExists *bool } // NewEmbeddingRepository creates a new EmbeddingRepository. @@ -21,8 +22,26 @@ func NewEmbeddingRepository(db *sql.DB) *EmbeddingRepository { return &EmbeddingRepository{db: db} } +// checkTableExists checks if the embeddings table exists. +func (r *EmbeddingRepository) checkTableExists(ctx context.Context) bool { + if r.tableExists != nil { + return *r.tableExists + } + var exists bool + err := r.db.QueryRowContext(ctx, + `SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'embeddings')`).Scan(&exists) + if err != nil { + exists = false + } + r.tableExists = &exists + return exists +} + // Upsert inserts or updates an embedding for the given entity. func (r *EmbeddingRepository) Upsert(ctx context.Context, e *model.Embedding) error { + if !r.checkTableExists(ctx) { + return model.ErrEmbeddingUnavailable + } _, err := r.db.ExecContext(ctx, `INSERT INTO embeddings (id, entity_type, entity_id, project_id, content, embedding, indexed_at) VALUES ($1, $2, $3, $4, $5, $6::vector, now()) @@ -38,6 +57,9 @@ func (r *EmbeddingRepository) Upsert(ctx context.Context, e *model.Embedding) er // Delete removes an embedding for the given entity. func (r *EmbeddingRepository) Delete(ctx context.Context, entityType string, entityID uuid.UUID) error { + if !r.checkTableExists(ctx) { + return nil + } _, err := r.db.ExecContext(ctx, `DELETE FROM embeddings WHERE entity_type = $1 AND entity_id = $2`, entityType, entityID) @@ -55,6 +77,9 @@ func (r *EmbeddingRepository) Delete(ctx context.Context, entityType string, ent // the parent work item's reporter and visibility are enforced. Customer-only // projects cannot surface project/milestone/queue embeddings. func (r *EmbeddingRepository) SearchByVector(ctx context.Context, vector []float32, filter *model.SearchFilter, access model.SearchAccess) ([]model.SearchResult, error) { + if !r.checkTableExists(ctx) { + return nil, model.ErrEmbeddingUnavailable + } limit := filter.Limit if limit <= 0 || limit > 100 { limit = 20 diff --git a/api/internal/repository/helpers.go b/api/internal/repository/helpers.go index 4907f2b2..a6298164 100644 --- a/api/internal/repository/helpers.go +++ b/api/internal/repository/helpers.go @@ -4,11 +4,48 @@ import ( "context" "database/sql" "fmt" + "regexp" "strings" "github.com/google/uuid" ) +// cjkRe matches Han, Hiragana, Katakana and Hangul characters. PostgreSQL's +// built-in text-search configs tokenize on whitespace, so a CJK run ends up as +// one lexeme and phrase queries never match unless typed exactly. +var cjkRe = regexp.MustCompile(`[\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}]`) + +// containsCJK reports whether s contains at least one CJK character, in which +// case search queries add an ILIKE substring fallback on top of FTS. +func containsCJK(s string) bool { + return cjkRe.MatchString(s) +} + +// likePattern wraps s with `%` wildcards and escapes the LIKE special +// characters (`\`, `%`, `_`) so user input is matched literally. +func likePattern(s string) string { + r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) + return "%" + r.Replace(s) + "%" +} + +// searchFilterCondition builds the WHERE fragment matching `query` against the +// tsvector column at vectorCol. When the query contains CJK characters it also +// ORs an ILIKE substring match over rawCols, because Postgres' whitespace +// tokenizer cannot split CJK runs into lexemes and a pure tsquery would match +// nothing. Placeholders are `?` for use with queryBuilder.add. +func searchFilterCondition(vectorCol string, rawCols []string, query string) (string, []interface{}) { + cond := fmt.Sprintf("(%s @@ plainto_tsquery('english', ?) OR %s @@ plainto_tsquery('simple', ?)", vectorCol, vectorCol) + args := []interface{}{query, query} + if containsCJK(query) { + pattern := likePattern(query) + for _, col := range rawCols { + cond += fmt.Sprintf(" OR %s ILIKE ?", col) + args = append(args, pattern) + } + } + return cond + ")", args +} + // queryBuilder accumulates WHERE-clause conditions and their bound parameters, // translating each `?` placeholder in a condition into the next `$N` for // lib/pq. Use add() for conditions with parameters and addRaw() for literal diff --git a/api/internal/repository/helpers_test.go b/api/internal/repository/helpers_test.go new file mode 100644 index 00000000..3c27370b --- /dev/null +++ b/api/internal/repository/helpers_test.go @@ -0,0 +1,93 @@ +package repository + +import ( + "reflect" + "testing" +) + +func TestContainsCJK(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {"empty", "", false}, + {"ascii", "login crash", false}, + {"han", "登录崩溃", true}, + {"mixed ascii and han", "fix 登录", true}, + {"hiragana", "ページ", true}, + {"katakana", "バグ", true}, + {"hangul", "오류", true}, + {"punctuation only", "!?。**", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := containsCJK(tt.input); got != tt.want { + t.Errorf("containsCJK(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestLikePattern(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"plain", "登录", "%登录%"}, + {"escapes percent", "100%", "%100\\%%"}, + {"escapes underscore", "a_b", "%a\\_b%"}, + {"escapes backslash", `a\b`, `%a\\b%`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := likePattern(tt.input); got != tt.want { + t.Errorf("likePattern(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestSearchFilterCondition(t *testing.T) { + t.Run("latin query stays tsvector-only", func(t *testing.T) { + cond, args := searchFilterCondition("search_vector", []string{"title"}, "login") + want := "(search_vector @@ plainto_tsquery('english', ?) OR search_vector @@ plainto_tsquery('simple', ?))" + if cond != want { + t.Errorf("cond = %q, want %q", cond, want) + } + if !reflect.DeepEqual(args, []interface{}{"login", "login"}) { + t.Errorf("args = %v", args) + } + }) + + t.Run("cjk query adds ILIKE fallback per column", func(t *testing.T) { + cond, args := searchFilterCondition("w.search_vector", []string{"w.title", "coalesce(w.description, '')"}, "登录") + want := "(w.search_vector @@ plainto_tsquery('english', ?) OR w.search_vector @@ plainto_tsquery('simple', ?)" + + " OR w.title ILIKE ? OR coalesce(w.description, '') ILIKE ?)" + if cond != want { + t.Errorf("cond = %q, want %q", cond, want) + } + if len(args) != 4 { + t.Fatalf("len(args) = %d, want 4", len(args)) + } + for _, a := range args[2:] { + if a != "%登录%" { + t.Errorf("ILIKE arg = %v, want %%登录%%", a) + } + } + }) + + t.Run("placeholder count matches arg count", func(t *testing.T) { + cond, args := searchFilterCondition("search_vector", []string{"title", "description", "display_id"}, "한글") + n := 0 + for _, r := range cond { + if r == '?' { + n++ + } + } + if n != len(args) { + t.Errorf("cond has %d placeholders but %d args", n, len(args)) + } + }) +} diff --git a/api/internal/repository/inbox.go b/api/internal/repository/inbox.go index cf8166ba..abd9d397 100644 --- a/api/internal/repository/inbox.go +++ b/api/internal/repository/inbox.go @@ -82,7 +82,9 @@ func (r *InboxRepository) List(ctx context.Context, userID uuid.UUID, excludeCom } if search != "" { - qb.add("(wi.search_vector @@ plainto_tsquery('english', ?) OR wi.search_vector @@ plainto_tsquery('simple', ?))", search, search) + // CJK queries additionally get an ILIKE fallback (see searchFilterCondition). + cond, args := searchFilterCondition("wi.search_vector", []string{"wi.title", "coalesce(wi.description, '')"}, search) + qb.add(cond, args...) } if workItemID != nil { diff --git a/api/internal/repository/milestone.go b/api/internal/repository/milestone.go index a356f959..0e0190a1 100644 --- a/api/internal/repository/milestone.go +++ b/api/internal/repository/milestone.go @@ -32,8 +32,18 @@ func (r *MilestoneRepository) SearchFTS(ctx context.Context, query string, fullP return nil, nil } + // CJK queries additionally get an ILIKE fallback over name/description, + // because Postgres' whitespace tokenizer cannot split CJK runs into lexemes. + matchCond := `m.search_vector @@ plainto_tsquery('english', $1) + OR m.search_vector @@ plainto_tsquery('simple', $1)` + args := []interface{}{query, pq.Array(fullProjectIDs), limit} + if containsCJK(query) { + matchCond += "\n\t\t OR m.name ILIKE $4 OR coalesce(m.description, '') ILIKE $4" + args = append(args, likePattern(query)) + } + rows, err := r.db.QueryContext(ctx, - `SELECT m.id, m.project_id, m.name, m.status, + fmt.Sprintf(`SELECT m.id, m.project_id, m.name, m.status, p.key AS project_key, COALESCE(n.slug, 'default') AS namespace_slug, ts_rank(m.search_vector, plainto_tsquery('english', $1)) + @@ -41,12 +51,11 @@ func (r *MilestoneRepository) SearchFTS(ctx context.Context, query string, fullP FROM milestones m JOIN projects p ON p.id = m.project_id LEFT JOIN namespaces n ON n.id = p.namespace_id - WHERE (m.search_vector @@ plainto_tsquery('english', $1) - OR m.search_vector @@ plainto_tsquery('simple', $1)) + WHERE (%s) AND m.project_id = ANY($2) ORDER BY rank DESC, m.updated_at DESC - LIMIT $3`, - query, pq.Array(fullProjectIDs), limit) + LIMIT $3`, matchCond), + args...) if err != nil { return nil, fmt.Errorf("fts search milestones: %w", err) } diff --git a/api/internal/repository/queue.go b/api/internal/repository/queue.go index e589b539..5efa08c6 100644 --- a/api/internal/repository/queue.go +++ b/api/internal/repository/queue.go @@ -32,8 +32,18 @@ func (r *QueueRepository) SearchFTS(ctx context.Context, query string, fullProje return nil, nil } + // CJK queries additionally get an ILIKE fallback over name/description, + // because Postgres' whitespace tokenizer cannot split CJK runs into lexemes. + matchCond := `q.search_vector @@ plainto_tsquery('english', $1) + OR q.search_vector @@ plainto_tsquery('simple', $1)` + args := []interface{}{query, pq.Array(fullProjectIDs), limit} + if containsCJK(query) { + matchCond += "\n\t\t OR q.name ILIKE $4 OR coalesce(q.description, '') ILIKE $4" + args = append(args, likePattern(query)) + } + rows, err := r.db.QueryContext(ctx, - `SELECT q.id, q.project_id, q.name, q.queue_type, + fmt.Sprintf(`SELECT q.id, q.project_id, q.name, q.queue_type, p.key AS project_key, COALESCE(n.slug, 'default') AS namespace_slug, ts_rank(q.search_vector, plainto_tsquery('english', $1)) + @@ -41,12 +51,11 @@ func (r *QueueRepository) SearchFTS(ctx context.Context, query string, fullProje FROM queues q JOIN projects p ON p.id = q.project_id LEFT JOIN namespaces n ON n.id = p.namespace_id - WHERE (q.search_vector @@ plainto_tsquery('english', $1) - OR q.search_vector @@ plainto_tsquery('simple', $1)) + WHERE (%s) AND q.project_id = ANY($2) ORDER BY rank DESC, q.updated_at DESC - LIMIT $3`, - query, pq.Array(fullProjectIDs), limit) + LIMIT $3`, matchCond), + args...) if err != nil { return nil, fmt.Errorf("fts search queues: %w", err) } diff --git a/api/internal/repository/team.go b/api/internal/repository/team.go index 94ff9003..09052da5 100644 --- a/api/internal/repository/team.go +++ b/api/internal/repository/team.go @@ -32,8 +32,18 @@ func (r *TeamRepository) SearchFTS(ctx context.Context, query string, fullProjec return nil, nil } + // CJK queries additionally get an ILIKE fallback over name/description, + // because Postgres' whitespace tokenizer cannot split CJK runs into lexemes. + matchCond := `t.search_vector @@ plainto_tsquery('english', $1) + OR t.search_vector @@ plainto_tsquery('simple', $1)` + args := []interface{}{query, pq.Array(fullProjectIDs), limit} + if containsCJK(query) { + matchCond += "\n\t\t OR t.name ILIKE $4 OR coalesce(t.description, '') ILIKE $4" + args = append(args, likePattern(query)) + } + rows, err := r.db.QueryContext(ctx, - `SELECT t.id, t.project_id, t.name, + fmt.Sprintf(`SELECT t.id, t.project_id, t.name, p.key AS project_key, COALESCE(n.slug, 'default') AS namespace_slug, ts_rank(t.search_vector, plainto_tsquery('english', $1)) + @@ -41,12 +51,11 @@ func (r *TeamRepository) SearchFTS(ctx context.Context, query string, fullProjec FROM teams t JOIN projects p ON p.id = t.project_id LEFT JOIN namespaces n ON n.id = p.namespace_id - WHERE (t.search_vector @@ plainto_tsquery('english', $1) - OR t.search_vector @@ plainto_tsquery('simple', $1)) + WHERE (%s) AND t.project_id = ANY($2) ORDER BY rank DESC, t.updated_at DESC - LIMIT $3`, - query, pq.Array(fullProjectIDs), limit) + LIMIT $3`, matchCond), + args...) if err != nil { return nil, fmt.Errorf("fts search teams: %w", err) } diff --git a/api/internal/repository/workitem.go b/api/internal/repository/workitem.go index 05421bbb..c6aa18a5 100644 --- a/api/internal/repository/workitem.go +++ b/api/internal/repository/workitem.go @@ -198,9 +198,11 @@ func (r *WorkItemRepository) List(ctx context.Context, projectID uuid.UUID, filt qb.add("wi.id = ANY(?)", pq.Array(filter.ItemIDs)) } - // Full-text search (OR simple config to match display_id tokens like "TF-29") + // Full-text search (OR simple config to match display_id tokens like "TF-29"). + // CJK queries additionally get an ILIKE fallback (see searchFilterCondition). if filter.Search != "" { - qb.add("(search_vector @@ plainto_tsquery('english', ?) OR search_vector @@ plainto_tsquery('simple', ?))", filter.Search, filter.Search) + cond, args := searchFilterCondition("search_vector", []string{"title", "coalesce(description, '')"}, filter.Search) + qb.add(cond, args...) } whereClause := qb.whereClause() @@ -525,7 +527,9 @@ func (r *WorkItemRepository) SearchFTS(ctx context.Context, query string, access qb := &queryBuilder{argIndex: 0} qb.add("w.deleted_at IS NULL") - qb.add("(w.search_vector @@ plainto_tsquery('english', ?) OR w.search_vector @@ plainto_tsquery('simple', ?))", query, query) + // CJK queries additionally get an ILIKE fallback (see searchFilterCondition). + searchCond, searchArgs := searchFilterCondition("w.search_vector", []string{"w.title", "coalesce(w.description, '')"}, query) + qb.add(searchCond, searchArgs...) // RBAC: full-access projects OR (customer projects AND own portal tickets) switch { diff --git a/api/internal/service/auth.go b/api/internal/service/auth.go index d594bb9a..6910463f 100644 --- a/api/internal/service/auth.go +++ b/api/internal/service/auth.go @@ -11,11 +11,11 @@ import ( "errors" "fmt" "html" - "net/mail" "image" "image/jpeg" "image/png" "io" + "net/mail" "strconv" "strings" "time" @@ -27,6 +27,7 @@ import ( "golang.org/x/image/draw" "github.com/marcoshack/taskwondo/internal/crypto" + "github.com/marcoshack/taskwondo/internal/i18n" "github.com/marcoshack/taskwondo/internal/model" "github.com/marcoshack/taskwondo/internal/storage" ) @@ -116,6 +117,7 @@ type AuthService struct { emailSender EmailSender encryptor *crypto.Encryptor storage storage.Storage + ssoCache *SSODiscoveryCache baseURL string jwtSecret []byte jwtExpiry time.Duration @@ -142,6 +144,7 @@ func NewAuthService( jwtSecret: []byte(jwtSecret), jwtExpiry: jwtExpiry, providers: pm, + ssoCache: NewSSODiscoveryCache(), } } @@ -189,6 +192,7 @@ func (s *AuthService) getProvider(ctx context.Context, name string) OAuthProvide if err != nil { log.Ctx(ctx).Error().Err(err).Str("provider", name).Msg("failed to decrypt oauth client secret, falling back to static provider") } else { + cfg.ClientSecret = secret redirectURI := s.baseURL + "/auth/" + name + "/callback" switch name { case model.OAuthProviderDiscord: @@ -199,6 +203,8 @@ func (s *AuthService) getProvider(ctx context.Context, name string) OAuthProvide return NewGitHubProvider(cfg.ClientID, secret, redirectURI, nil) case model.OAuthProviderMicrosoft: return NewMicrosoftProvider(cfg.ClientID, secret, redirectURI, nil) + case model.OAuthProviderSSO: + return NewSSOProvider(cfg, redirectURI, s.encryptor, s.ssoCache, nil) } } } @@ -593,27 +599,17 @@ func (s *AuthService) SeedAdminUser(ctx context.Context, email, password string) // When a setting doesn't exist: OAuth defaults to enabled (backward compat), // email login defaults to enabled, email registration defaults to disabled. func (s *AuthService) EnabledProviders(ctx context.Context) map[string]bool { - result := make(map[string]bool, 4) + result := make(map[string]bool, len(model.KnownOAuthProviders)+2) // Check each known OAuth provider — configured via DB or static env vars - for _, name := range []string{model.OAuthProviderDiscord, model.OAuthProviderGoogle, model.OAuthProviderGitHub, model.OAuthProviderMicrosoft} { - if s.isOAuthConfigured(ctx, name) { - settingKey := "" - switch name { - case model.OAuthProviderDiscord: - settingKey = model.SettingAuthDiscordEnabled - case model.OAuthProviderGoogle: - settingKey = model.SettingAuthGoogleEnabled - case model.OAuthProviderGitHub: - settingKey = model.SettingAuthGitHubEnabled - case model.OAuthProviderMicrosoft: - settingKey = model.SettingAuthMicrosoftEnabled - } - if settingKey != "" { - result[name] = s.getBoolSetting(ctx, settingKey, true) - } else { - result[name] = true - } + for _, name := range model.KnownOAuthProviders { + if !s.isOAuthConfigured(ctx, name) { + continue + } + if settingKey := model.OAuthEnabledSettingKey(name); settingKey != "" { + result[name] = s.getBoolSetting(ctx, settingKey, true) + } else { + result[name] = true } } @@ -636,6 +632,10 @@ func (s *AuthService) isOAuthConfigured(ctx context.Context, name string) bool { if err == nil { var cfg model.OAuthProviderConfig if err := json.Unmarshal(setting.Value, &cfg); err == nil && cfg.ClientID != "" { + // The SSO provider cannot work without an issuer to discover. + if name == model.OAuthProviderSSO { + return cfg.Issuer != "" && s.encryptor != nil + } return true } } @@ -665,7 +665,8 @@ func (s *AuthService) getBoolSetting(ctx context.Context, key string, defaultVal // RequestRegistration creates a verification token and sends a verification email. // If inviteCode is non-empty, it is stored with the token so the invite can be // auto-accepted when the user verifies their email (even from a different device). -func (s *AuthService) RequestRegistration(ctx context.Context, email, displayName, inviteCode string) error { +// lang selects the language of the verification email. +func (s *AuthService) RequestRegistration(ctx context.Context, email, displayName, inviteCode, lang string) error { if s.emailVerifications == nil || s.emailSender == nil || s.settings == nil { return fmt.Errorf("%w: email registration is not configured", model.ErrForbidden) } @@ -727,9 +728,9 @@ func (s *AuthService) RequestRegistration(ctx context.Context, email, displayNam // Build verification URL and send email verifyURL := strings.TrimRight(s.baseURL, "/") + "/verify-email?token=" + rawToken - htmlBody := verificationEmailHTML(displayName, verifyURL) + htmlBody := verificationEmailHTML(lang, displayName, verifyURL) - if err := s.emailSender.Send(ctx, email, "Verify your email", htmlBody); err != nil { + if err := s.emailSender.Send(ctx, email, i18n.T(lang, "email.verify.subject"), htmlBody); err != nil { log.Ctx(ctx).Error().Err(err).Str("email", email).Msg("failed to send verification email") return fmt.Errorf("sending verification email: %w", err) } @@ -822,7 +823,8 @@ func (s *AuthService) VerifyEmailAndCreateUser(ctx context.Context, rawToken, pa // RequestPasswordReset generates a password reset token and sends an email. // It always returns nil to prevent user enumeration — even if the email doesn't exist. -func (s *AuthService) RequestPasswordReset(ctx context.Context, email string) error { +// lang selects the language of the reset email. +func (s *AuthService) RequestPasswordReset(ctx context.Context, email, lang string) error { if s.passwordResets == nil || s.emailSender == nil { return fmt.Errorf("%w: password reset is not configured", model.ErrForbidden) } @@ -874,9 +876,9 @@ func (s *AuthService) RequestPasswordReset(ctx context.Context, email string) er } resetURL := strings.TrimRight(s.baseURL, "/") + "/reset-password?token=" + rawToken - htmlBody := passwordResetEmailHTML(user.DisplayName, resetURL) + htmlBody := passwordResetEmailHTML(lang, user.DisplayName, resetURL) - if err := s.emailSender.Send(ctx, email, "Reset your password", htmlBody); err != nil { + if err := s.emailSender.Send(ctx, email, i18n.T(lang, "email.reset.subject"), htmlBody); err != nil { log.Ctx(ctx).Error().Err(err).Str("email", email).Msg("failed to send password reset email") return fmt.Errorf("sending password reset email: %w", err) } @@ -948,38 +950,40 @@ func hashToken(raw string) string { return hex.EncodeToString(h[:]) } -func verificationEmailHTML(displayName, verifyURL string) string { - return fmt.Sprintf(` - - -
-

Verify your email

-

Hi %s,

-

Click the button below to verify your email address and set your password:

-

-Verify email -

-

This link expires in 24 hours. If you didn't request this, you can safely ignore this email.

-
- -`, html.EscapeString(displayName), verifyURL) +func verificationEmailHTML(lang, displayName, verifyURL string) string { + return authEmailHTML(lang, + i18n.T(lang, "email.verify.subject"), + i18n.T(lang, "email.verify.intro"), + i18n.T(lang, "email.verify.cta"), + i18n.T(lang, "email.verify.note"), + displayName, verifyURL) +} + +func passwordResetEmailHTML(lang, displayName, resetURL string) string { + return authEmailHTML(lang, + i18n.T(lang, "email.reset.subject"), + i18n.T(lang, "email.reset.intro"), + i18n.T(lang, "email.reset.cta"), + i18n.T(lang, "email.reset.note"), + displayName, resetURL) } -func passwordResetEmailHTML(displayName, resetURL string) string { +func authEmailHTML(lang, title, intro, cta, note, displayName, actionURL string) string { + greeting := i18n.T(lang, "email.greeting", "name", html.EscapeString(displayName)) return fmt.Sprintf(`
-

Reset your password

-

Hi %s,

-

We received a request to reset your password. Click the button below to choose a new password:

+

%s

+

%s

+

%s

-Reset password +%s

-

This link expires in 1 hour. If you didn't request this, you can safely ignore this email.

+

%s

-`, html.EscapeString(displayName), resetURL) +`, title, greeting, intro, actionURL, cta, note) } // OAuthURL generates the authorization URL for the given provider. @@ -989,14 +993,36 @@ func (s *AuthService) OAuthURL(ctx context.Context, providerName string) (string return "", fmt.Errorf("oauth provider %q is not configured", providerName) } - state, err := s.generateOAuthState() + state, err := s.oauthState(ctx, provider) if err != nil { - return "", fmt.Errorf("generating state: %w", err) + return "", err } + // OIDC providers must reach the network for discovery to build the URL, and + // they need the per-login secrets that live inside the sealed state. + if contextual, ok := provider.(ContextualAuthURL); ok { + return contextual.AuthURLContext(ctx, state) + } return provider.AuthURL(state), nil } +// oauthState produces the anti-CSRF state parameter, delegating to the provider +// when it carries its own per-login secrets. +func (s *AuthService) oauthState(ctx context.Context, provider OAuthProvider) (string, error) { + if binder, ok := provider.(StateBinder); ok { + state, err := binder.NewState(ctx) + if err != nil { + return "", fmt.Errorf("generating %s state: %w", provider.Name(), err) + } + return state, nil + } + state, err := s.generateOAuthState() + if err != nil { + return "", fmt.Errorf("generating state: %w", err) + } + return state, nil +} + // OAuthCallback validates state, exchanges the code via the provider, and finds or creates a user. func (s *AuthService) OAuthCallback(ctx context.Context, providerName, code, state string) (string, *model.User, error) { provider := s.getProvider(ctx, providerName) @@ -1004,12 +1030,17 @@ func (s *AuthService) OAuthCallback(ctx context.Context, providerName, code, sta return "", nil, fmt.Errorf("oauth provider %q is not configured", providerName) } - if err := s.validateOAuthState(state); err != nil { + ctx, err := s.validateProviderState(ctx, provider, state) + if err != nil { return "", nil, fmt.Errorf("invalid state: %w", err) } userInfo, err := provider.ExchangeCode(ctx, code) if err != nil { + var ssoErr *SSOError + if errors.As(err, &ssoErr) { + return "", nil, model.NewKeyedError(ssoErr.Sentinel, ssoErr.Key, ssoErr.Message, nil) + } return "", nil, fmt.Errorf("exchanging code: %w", err) } @@ -1030,6 +1061,15 @@ func (s *AuthService) OAuthCallback(ctx context.Context, providerName, code, sta return token, user, nil } +// validateProviderState checks the state parameter and returns the context +// carrying any per-login secrets the provider unsealed from it. +func (s *AuthService) validateProviderState(ctx context.Context, provider OAuthProvider, state string) (context.Context, error) { + if binder, ok := provider.(StateBinder); ok { + return binder.ValidateState(ctx, state) + } + return ctx, s.validateOAuthState(state) +} + func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider string, info model.OAuthUserInfo) (*model.User, error) { // Case 1: OAuth account already linked — log in existing user. existing, err := s.oauthAccounts.GetByProviderUser(ctx, provider, info.ProviderUserID) @@ -1065,6 +1105,15 @@ func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider string // Case 3: Create new user. if user == nil { + // Automatic provisioning is off by default for the generic SSO + // provider: an administrator's identity provider must not be able to + // mint accounts here just by adding someone to a directory. + if provider == model.OAuthProviderSSO && + !s.getBoolSetting(ctx, model.SettingSSOAutoProvision, false) { + return nil, model.NewKeyedError(model.ErrForbidden, "sso_account_not_provisioned", + "no account exists for this single sign-on identity; an administrator must create it or enable automatic provisioning", nil) + } + email := info.Email if email == "" { email = provider + "_" + info.ProviderUserID + "@oauth.taskwondo.local" diff --git a/api/internal/service/auth_test.go b/api/internal/service/auth_test.go index bfda23f0..2c96a117 100644 --- a/api/internal/service/auth_test.go +++ b/api/internal/service/auth_test.go @@ -1562,7 +1562,7 @@ func TestRequestRegistration_Success(t *testing.T) { svc, _, _, settings, sender := newTestAuthServiceWithEmail() settings.setBool(model.SettingAuthEmailRegistrationEnabled, true) - err := svc.RequestRegistration(context.Background(), "new@example.com", "New User", "") + err := svc.RequestRegistration(context.Background(), "new@example.com", "New User", "", "en") if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -1582,7 +1582,7 @@ func TestRequestRegistration_Disabled(t *testing.T) { svc, _, _, _, _ := newTestAuthServiceWithEmail() // Registration is disabled by default - err := svc.RequestRegistration(context.Background(), "new@example.com", "New User", "") + err := svc.RequestRegistration(context.Background(), "new@example.com", "New User", "", "en") if err == nil { t.Fatal("expected error when registration is disabled") } @@ -1608,7 +1608,7 @@ func TestRequestRegistration_DuplicateEmail(t *testing.T) { userRepo.users[existing.Email] = existing userRepo.byID[existing.ID] = existing - err := svc.RequestRegistration(context.Background(), "existing@example.com", "New User", "") + err := svc.RequestRegistration(context.Background(), "existing@example.com", "New User", "", "en") if err == nil { t.Fatal("expected error for duplicate email") } @@ -1637,7 +1637,7 @@ func TestRequestRegistration_InvalidEmail(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - err := svc.RequestRegistration(context.Background(), tc.email, "New User", "") + err := svc.RequestRegistration(context.Background(), tc.email, "New User", "", "en") if err == nil { t.Fatalf("expected validation error for email %q", tc.email) } @@ -1651,7 +1651,7 @@ func TestVerifyEmailAndCreateUser_Success(t *testing.T) { // First request registration to create a token sender := svc.emailSender.(*mockEmailSender) - err := svc.RequestRegistration(context.Background(), "verify@example.com", "Verify User", "") + err := svc.RequestRegistration(context.Background(), "verify@example.com", "Verify User", "", "en") if err != nil { t.Fatalf("request registration failed: %v", err) } @@ -1692,7 +1692,7 @@ func TestRequestRegistration_WithInviteCode(t *testing.T) { svc, _, emailVerifRepo, settings, _ := newTestAuthServiceWithEmail() settings.setBool(model.SettingAuthEmailRegistrationEnabled, true) - err := svc.RequestRegistration(context.Background(), "invite@example.com", "Invite User", "abc123") + err := svc.RequestRegistration(context.Background(), "invite@example.com", "Invite User", "abc123", "en") if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -1714,7 +1714,7 @@ func TestVerifyEmailAndCreateUser_WithInviteCode(t *testing.T) { settings.setBool(model.SettingAuthEmailRegistrationEnabled, true) sender := svc.emailSender.(*mockEmailSender) - err := svc.RequestRegistration(context.Background(), "inviteverify@example.com", "Invite Verify", "invcode42") + err := svc.RequestRegistration(context.Background(), "inviteverify@example.com", "Invite Verify", "invcode42", "en") if err != nil { t.Fatalf("request registration failed: %v", err) } @@ -2460,7 +2460,7 @@ func TestRequestPasswordReset_Success(t *testing.T) { IsActive: true, }) - err := svc.RequestPasswordReset(context.Background(), "reset@example.com") + err := svc.RequestPasswordReset(context.Background(), "reset@example.com", "en") if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -2480,7 +2480,7 @@ func TestRequestPasswordReset_UnknownEmail(t *testing.T) { svc, _, _, sender := newTestAuthServiceWithPasswordReset() // Should not error (prevents enumeration) - err := svc.RequestPasswordReset(context.Background(), "unknown@example.com") + err := svc.RequestPasswordReset(context.Background(), "unknown@example.com", "en") if err != nil { t.Fatalf("expected no error for unknown email, got %v", err) } @@ -2504,7 +2504,7 @@ func TestRequestPasswordReset_OAuthOnlyUser(t *testing.T) { IsActive: true, }) - err := svc.RequestPasswordReset(context.Background(), "oauth@example.com") + err := svc.RequestPasswordReset(context.Background(), "oauth@example.com", "en") if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -2518,7 +2518,7 @@ func TestRequestPasswordReset_OAuthOnlyUser(t *testing.T) { func TestRequestPasswordReset_InvalidEmail(t *testing.T) { svc, _, _, _ := newTestAuthServiceWithPasswordReset() - err := svc.RequestPasswordReset(context.Background(), "not-an-email") + err := svc.RequestPasswordReset(context.Background(), "not-an-email", "en") if err == nil { t.Fatal("expected error for invalid email") } @@ -2542,7 +2542,7 @@ func TestResetPasswordWithToken_Success(t *testing.T) { }) // Request reset - err := svc.RequestPasswordReset(context.Background(), "reset@example.com") + err := svc.RequestPasswordReset(context.Background(), "reset@example.com", "en") if err != nil { t.Fatalf("request failed: %v", err) } diff --git a/api/internal/service/oauth.go b/api/internal/service/oauth.go index 83122adb..032a4372 100644 --- a/api/internal/service/oauth.go +++ b/api/internal/service/oauth.go @@ -15,3 +15,48 @@ type OAuthProvider interface { // ExchangeCode exchanges an authorization code for user info. ExchangeCode(ctx context.Context, code string) (model.OAuthUserInfo, error) } + +// ContextualAuthURL is implemented by providers that must reach the network to +// build an authorization URL (OIDC discovery) and therefore need the request +// context rather than only the state string. +type ContextualAuthURL interface { + AuthURLContext(ctx context.Context, state string) (string, error) +} + +// StateBinder is implemented by providers that carry per-login secrets (an OIDC +// nonce and a PKCE verifier) through the authorization redirect. +// +// A provider that implements it takes over state generation and validation: +// AuthService calls NewState instead of its own HMAC state and ValidateState +// instead of validateOAuthState. ValidateState returns a context carrying the +// unsealed secrets, which the following ExchangeCode call reads back. Binding +// the state to the secrets this way is what makes the state parameter CSRF +// protection real for OIDC — an attacker who replays someone else's callback +// fails the nonce check rather than logging in as them. +type StateBinder interface { + NewState(ctx context.Context) (string, error) + ValidateState(ctx context.Context, state string) (context.Context, error) +} + +// SSOLabeledProvider is implemented by providers whose login button text is +// operator-configured rather than derived from a fixed i18n key. +type SSOLabeledProvider interface { + ButtonLabel() string +} + +type ssoFlowKey struct{} + +// ssoFlow carries the values unsealed from one login's state parameter. +type ssoFlow struct { + nonce string + verifier string +} + +func ssoFlowContext(ctx context.Context, flow ssoFlow) context.Context { + return context.WithValue(ctx, ssoFlowKey{}, flow) +} + +func ssoFlowFromContext(ctx context.Context) (ssoFlow, bool) { + f, ok := ctx.Value(ssoFlowKey{}).(ssoFlow) + return f, ok && f.nonce != "" +} diff --git a/api/internal/service/oauth_sso.go b/api/internal/service/oauth_sso.go new file mode 100644 index 00000000..7e7455d7 --- /dev/null +++ b/api/internal/service/oauth_sso.go @@ -0,0 +1,398 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + + "github.com/marcoshack/taskwondo/internal/crypto" + "github.com/marcoshack/taskwondo/internal/model" +) + +// ssoStateTTL bounds how long a login may sit at the identity provider. +const ssoStateTTL = 10 * time.Minute + +// ssoDiscoveryTTL is how long a discovery document is reused before refetching. +const ssoDiscoveryTTL = time.Hour + +// ssoDiscoveryMaxEntries caps the discovery cache so a changed issuer cannot +// grow it without bound. +const ssoDiscoveryMaxEntries = 8 + +// SSODiscoveryCache memoises OIDC discovery documents. The oidc.Provider value +// also lazily caches its JWKS key set, so reusing one across logins avoids a +// metadata and key fetch on every sign-in. Safe for concurrent use. +type SSODiscoveryCache struct { + mu sync.Mutex + entries map[string]*ssoDiscoveryEntry + now func() time.Time +} + +type ssoDiscoveryEntry struct { + provider *oidc.Provider + expires time.Time +} + +// NewSSODiscoveryCache creates an empty discovery cache. +func NewSSODiscoveryCache() *SSODiscoveryCache { + return &SSODiscoveryCache{ + entries: make(map[string]*ssoDiscoveryEntry), + now: time.Now, + } +} + +// get returns the cached provider for issuer, discovering it when absent or stale. +func (c *SSODiscoveryCache) get(ctx context.Context, issuer string, client *http.Client) (*oidc.Provider, error) { + c.mu.Lock() + if e, ok := c.entries[issuer]; ok && c.now().Before(e.expires) { + provider := e.provider + c.mu.Unlock() + return provider, nil + } + c.mu.Unlock() + + dctx := ctx + if client != nil { + dctx = oidc.ClientContext(ctx, client) + } + provider, err := oidc.NewProvider(dctx, issuer) + if err != nil { + return nil, fmt.Errorf("oidc discovery: %w", err) + } + + c.mu.Lock() + if len(c.entries) >= ssoDiscoveryMaxEntries { + c.entries = make(map[string]*ssoDiscoveryEntry) + } + c.entries[issuer] = &ssoDiscoveryEntry{provider: provider, expires: c.now().Add(ssoDiscoveryTTL)} + c.mu.Unlock() + + return provider, nil +} + +// invalidate drops the cached document for an issuer so the next attempt +// refetches it. Used when ID-token verification fails, which is what a rotated +// JWKS looks like before the cached keys go stale. +func (c *SSODiscoveryCache) invalidate(issuer string) { + c.mu.Lock() + delete(c.entries, issuer) + c.mu.Unlock() +} + +// SSOProvider implements OAuthProvider for a custom OpenID Connect identity +// provider configured by an administrator. Unlike the built-in providers it +// discovers its endpoints from the issuer URL, validates the ID token +// signature, and carries per-login secrets (nonce, PKCE verifier) inside the +// sealed state parameter. +// +// Account identity is resolved by email address in +// AuthService.findOrCreateOAuthUser; new accounts are gated by the +// sso_auto_provision_enabled setting. +type SSOProvider struct { + cfg model.OAuthProviderConfig + redirect string + httpClient *http.Client + sealer *crypto.Encryptor + cache *SSODiscoveryCache + now func() time.Time +} + +// NewSSOProvider creates a generic OIDC provider. sealer must not be nil: it +// seals the state parameter, and without it a login cannot be bound to the +// browser that started it. +func NewSSOProvider(cfg model.OAuthProviderConfig, redirectURI string, sealer *crypto.Encryptor, cache *SSODiscoveryCache, httpClient *http.Client) *SSOProvider { + if httpClient == nil { + httpClient = &http.Client{Timeout: 15 * time.Second} + } + if cache == nil { + cache = NewSSODiscoveryCache() + } + return &SSOProvider{ + cfg: cfg, + redirect: redirectURI, + httpClient: httpClient, + sealer: sealer, + cache: cache, + now: time.Now, + } +} + +func (p *SSOProvider) Name() string { return model.OAuthProviderSSO } + +// ButtonLabel returns the operator-supplied login button text, if configured. +func (p *SSOProvider) ButtonLabel() string { return p.cfg.ButtonLabel } + +// ssoState is the payload sealed into the OIDC state parameter. +type ssoState struct { + Provider string `json:"p"` + Nonce string `json:"n"` + Verifier string `json:"v,omitempty"` + Expires int64 `json:"e"` +} + +// NewState seals a fresh nonce and PKCE verifier into the state parameter. +func (p *SSOProvider) NewState(_ context.Context) (string, error) { + if p.sealer == nil { + return "", fmt.Errorf("sso provider: state sealer is not configured") + } + nonce, err := ssoRandomToken() + if err != nil { + return "", fmt.Errorf("generating sso nonce: %w", err) + } + state := ssoState{ + Provider: model.OAuthProviderSSO, + Nonce: nonce, + Expires: p.now().Add(ssoStateTTL).Unix(), + } + if !p.cfg.DisablePKCE { + state.Verifier = oauth2.GenerateVerifier() + } + sealed, err := p.sealer.SealJSON(state) + if err != nil { + return "", fmt.Errorf("sealing sso state: %w", err) + } + return sealed, nil +} + +// ValidateState unseals and checks the state parameter, returning a context +// carrying the nonce and verifier for the following ExchangeCode call. +func (p *SSOProvider) ValidateState(ctx context.Context, state string) (context.Context, error) { + if p.sealer == nil { + return ctx, fmt.Errorf("sso provider: state sealer is not configured") + } + var s ssoState + if err := p.sealer.OpenJSON(state, &s); err != nil { + return ctx, fmt.Errorf("decoding state: %w", err) + } + if s.Nonce == "" || s.Provider != model.OAuthProviderSSO { + return ctx, fmt.Errorf("malformed state") + } + if p.now().Unix() > s.Expires { + return ctx, fmt.Errorf("state expired") + } + return ssoFlowContext(ctx, ssoFlow{nonce: s.Nonce, verifier: s.Verifier}), nil +} + +// AuthURL is unused for SSO: building the URL requires provider discovery, so +// AuthService calls AuthURLContext through the ContextualAuthURL interface. +func (p *SSOProvider) AuthURL(state string) string { + url, err := p.AuthURLContext(context.Background(), state) + if err != nil { + return "" + } + return url +} + +// AuthURLContext discovers the provider and builds the authorization request, +// attaching the nonce and PKCE challenge recovered from the sealed state. +func (p *SSOProvider) AuthURLContext(ctx context.Context, state string) (string, error) { + flow, ok := ssoFlowFromContext(ctx) + if !ok { + if p.sealer == nil { + return "", fmt.Errorf("sso provider: state sealer is not configured") + } + var s ssoState + if err := p.sealer.OpenJSON(state, &s); err != nil { + return "", fmt.Errorf("decoding state: %w", err) + } + if s.Provider != model.OAuthProviderSSO || s.Nonce == "" { + return "", fmt.Errorf("malformed state") + } + flow = ssoFlow{nonce: s.Nonce, verifier: s.Verifier} + ctx = ssoFlowContext(ctx, flow) + } + + provider, err := p.discover(ctx) + if err != nil { + return "", err + } + + opts := []oauth2.AuthCodeOption{oidc.Nonce(flow.nonce)} + if flow.verifier != "" { + opts = append(opts, oauth2.S256ChallengeOption(flow.verifier)) + } + return p.oauthConfig(provider).AuthCodeURL(state, opts...), nil +} + +// ExchangeCode redeems the authorization code, verifies the ID token +// (signature, issuer, audience, expiry, nonce and at_hash) and, when the ID +// token omits the email claim, backfills it from the UserInfo endpoint. +func (p *SSOProvider) ExchangeCode(ctx context.Context, code string) (model.OAuthUserInfo, error) { + flow, ok := ssoFlowFromContext(ctx) + if !ok { + return model.OAuthUserInfo{}, fmt.Errorf("missing sso login context") + } + + provider, err := p.discover(ctx) + if err != nil { + return model.OAuthUserInfo{}, err + } + + ecfg := p.oauthConfig(provider) + exchangeCtx := oidc.ClientContext(ctx, p.httpClient) + + var opts []oauth2.AuthCodeOption + if flow.verifier != "" { + opts = append(opts, oauth2.VerifierOption(flow.verifier)) + } + oauth2Token, err := ecfg.Exchange(exchangeCtx, code, opts...) + if err != nil { + return model.OAuthUserInfo{}, fmt.Errorf("exchanging code: %w", err) + } + + rawIDToken, ok := oauth2Token.Extra("id_token").(string) + if !ok || rawIDToken == "" { + return model.OAuthUserInfo{}, fmt.Errorf("token response did not contain an id_token") + } + + // Verifier (not VerifierContext) reuses the key set cached on the provider, + // so JWKS is fetched at most once per discovery refresh. + idToken, err := provider.Verifier(&oidc.Config{ClientID: p.cfg.ClientID}).Verify(exchangeCtx, rawIDToken) + if err != nil { + p.cache.invalidate(p.cfg.Issuer) + return model.OAuthUserInfo{}, fmt.Errorf("verifying id_token: %w", err) + } + if idToken.Nonce != flow.nonce { + return model.OAuthUserInfo{}, fmt.Errorf("id_token nonce mismatch") + } + // at_hash binds the ID token to the access token. Optional per spec, but + // when present it must match, otherwise a stolen access token is undetectable. + if idToken.AccessTokenHash != "" { + if err := idToken.VerifyAccessToken(oauth2Token.AccessToken); err != nil { + return model.OAuthUserInfo{}, fmt.Errorf("verifying access token hash: %w", err) + } + } + + var claims ssoClaims + if err := idToken.Claims(&claims); err != nil { + return model.OAuthUserInfo{}, fmt.Errorf("decoding id_token claims: %w", err) + } + + if claims.Email == "" && provider.UserInfoEndpoint() != "" { + ui, err := provider.UserInfo(exchangeCtx, oauth2.StaticTokenSource(oauth2Token)) + if err != nil { + return model.OAuthUserInfo{}, fmt.Errorf("fetching userinfo: %w", err) + } + claims.Email = ui.Email + claims.EmailVerified = ui.EmailVerified + if claims.Name == "" || claims.Picture == "" { + var extra struct { + Name string `json:"name"` + Picture string `json:"picture"` + PreferredUsername string `json:"preferred_username"` + } + if err := ui.Claims(&extra); err == nil { + claims.Name = firstNonEmpty(claims.Name, extra.Name) + claims.Picture = firstNonEmpty(claims.Picture, extra.Picture) + claims.PreferredUsername = firstNonEmpty(claims.PreferredUsername, extra.PreferredUsername) + } + } + } + + return p.userInfo(claims) +} + +// userInfo normalises the ID token claims into the shared OAuth user shape, +// applying the email policy configured for this provider. +func (p *SSOProvider) userInfo(claims ssoClaims) (model.OAuthUserInfo, error) { + email := strings.ToLower(strings.TrimSpace(claims.Email)) + if email == "" { + return model.OAuthUserInfo{}, &SSOError{ + Sentinel: model.ErrOAuthEmailMissing, + Key: "sso_email_missing", + Message: "the identity provider did not return an email address", + } + } + + // email_verified is only meaningful when the claim is present; the config + // switch defaults to requiring it, because an unverified address would let + // anyone at the IdP claim another user's mailbox and inherit their account. + verified := claims.EmailVerified || !p.cfg.RequiresVerifiedEmail() + if !verified { + return model.OAuthUserInfo{}, &SSOError{ + Sentinel: model.ErrOAuthEmailUnverified, + Key: "sso_email_unverified", + Message: "the identity provider reported this email address as unverified", + } + } + + display := firstNonEmpty(claims.Name, claims.Nickname, claims.PreferredUsername, email) + + return model.OAuthUserInfo{ + ProviderUserID: claims.Subject, + Email: email, + EmailVerified: true, + DisplayName: display, + AvatarURL: claims.Picture, + Username: claims.PreferredUsername, + RawAvatar: claims.Picture, + }, nil +} + +// ssoClaims are the OIDC standard claims consumed by the SSO provider. +type ssoClaims struct { + Subject string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + Nickname string `json:"nickname"` + PreferredUsername string `json:"preferred_username"` + Picture string `json:"picture"` +} + +// SSOError carries a stable error key so the login page can localise why a +// single sign-in was rejected instead of showing a generic failure. +type SSOError struct { + Sentinel error + Key string + Message string +} + +func (e *SSOError) Error() string { return e.Message } +func (e *SSOError) Unwrap() error { return e.Sentinel } + +func (p *SSOProvider) discover(ctx context.Context) (*oidc.Provider, error) { + return p.cache.get(ctx, p.cfg.Issuer, p.httpClient) +} + +func (p *SSOProvider) oauthConfig(provider *oidc.Provider) *oauth2.Config { + return &oauth2.Config{ + ClientID: p.cfg.ClientID, + ClientSecret: p.cfg.ClientSecret, + RedirectURL: p.redirect, + Scopes: p.cfg.ScopeList(), + Endpoint: provider.Endpoint(), + } +} + +func ssoRandomToken() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generating random token: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if s := strings.TrimSpace(v); s != "" { + return s + } + } + return "" +} + +var ( + _ OAuthProvider = (*SSOProvider)(nil) + _ StateBinder = (*SSOProvider)(nil) + _ ContextualAuthURL = (*SSOProvider)(nil) + _ SSOLabeledProvider = (*SSOProvider)(nil) +) diff --git a/api/internal/service/system_setting.go b/api/internal/service/system_setting.go index 9089b03f..a8c78c9f 100644 --- a/api/internal/service/system_setting.go +++ b/api/internal/service/system_setting.go @@ -147,6 +147,7 @@ func (s *SystemSettingService) GetPublic(ctx context.Context) (map[string]json.R model.SettingAuthGoogleEnabled, model.SettingAuthGitHubEnabled, model.SettingAuthMicrosoftEnabled, + model.SettingAuthSSOEnabled, model.SettingOAuthProviderOrder, model.SettingFeatureStatsTimeline, model.SettingFeatureSemanticSearch, @@ -166,9 +167,32 @@ func (s *SystemSettingService) GetPublic(ctx context.Context) (map[string]json.R result[key] = setting.Value } + if label := s.ssoButtonLabel(ctx); label != "" { + raw, err := json.Marshal(label) + if err != nil { + return nil, fmt.Errorf("marshaling sso button label: %w", err) + } + result["oauth_sso_button_label"] = raw + } + return result, nil } +// ssoButtonLabel returns the operator-configured login button text for the +// generic SSO provider. The config setting holds an encrypted client secret, +// so only this single plaintext field is ever published. +func (s *SystemSettingService) ssoButtonLabel(ctx context.Context) string { + setting, err := s.settings.Get(ctx, model.SettingOAuthSSOConfig) + if err != nil { + return "" + } + var cfg model.OAuthProviderConfig + if err := json.Unmarshal(setting.Value, &cfg); err != nil { + return "" + } + return cfg.ButtonLabel +} + // SeedDefaultLimits creates default values for max_projects_per_user and // max_namespaces_per_user if they do not already exist. func (s *SystemSettingService) SeedDefaultLimits(ctx context.Context) error { diff --git a/api/internal/service/workflow.go b/api/internal/service/workflow.go index 0e2035a4..7cfde720 100644 --- a/api/internal/service/workflow.go +++ b/api/internal/service/workflow.go @@ -256,9 +256,6 @@ func (s *WorkflowService) DeleteProjectWorkflow(ctx context.Context, id uuid.UUI return err } - if wf.IsDefault { - return fmt.Errorf("cannot delete a system workflow: %w", model.ErrForbidden) - } if wf.ProjectID == nil { return fmt.Errorf("cannot delete a system workflow: %w", model.ErrForbidden) } @@ -277,10 +274,6 @@ func (s *WorkflowService) DeleteSystemWorkflow(ctx context.Context, id uuid.UUID return fmt.Errorf("use project endpoint to delete project workflows: %w", model.ErrValidation) } - if wf.IsDefault { - return fmt.Errorf("cannot delete a default workflow: %w", model.ErrForbidden) - } - inUse, err := s.workflows.IsInUse(ctx, id) if err != nil { return fmt.Errorf("checking workflow usage: %w", err) diff --git a/api/internal/workers/email_template.go b/api/internal/workers/email_template.go index 214fa7eb..98fb48ea 100644 --- a/api/internal/workers/email_template.go +++ b/api/internal/workers/email_template.go @@ -12,6 +12,9 @@ import ( // getUserLanguage returns the user's preferred language, defaulting to "en". func getUserLanguage(ctx context.Context, settings userSettingRepository, userID uuid.UUID) string { + if settings == nil { + return "en" + } setting, err := settings.Get(ctx, userID, nil, "language") if err != nil { return "en" diff --git a/api/internal/workers/embed_index.go b/api/internal/workers/embed_index.go index a564f779..3e2cabbf 100644 --- a/api/internal/workers/embed_index.go +++ b/api/internal/workers/embed_index.go @@ -3,6 +3,7 @@ package workers import ( "context" "encoding/json" + "errors" "github.com/google/uuid" "github.com/rs/zerolog" @@ -63,6 +64,10 @@ func (t *EmbedIndexTask) Execute(ctx context.Context, payload []byte) error { } if err := t.indexer.IndexEntity(ctx, evt.EntityType, evt.EntityID, evt.ProjectID); err != nil { + if errors.Is(err, model.ErrEmbeddingUnavailable) { + t.logger.Debug().Msg("semantic search unavailable, skipping index") + return nil + } return err // Retryable error } diff --git a/api/internal/workers/notification_oncall_override.go b/api/internal/workers/notification_oncall_override.go index c0962c24..910b541e 100644 --- a/api/internal/workers/notification_oncall_override.go +++ b/api/internal/workers/notification_oncall_override.go @@ -13,24 +13,27 @@ import ( // NotificationOncallOverrideCreatedTask sends emails when an on-call override is created. type NotificationOncallOverrideCreatedTask struct { - users userRepository - sender emailSender - urls *URLBuilder - logger zerolog.Logger + users userRepository + settings userSettingRepository + sender emailSender + urls *URLBuilder + logger zerolog.Logger } // NewNotificationOncallOverrideCreatedTask creates the task. func NewNotificationOncallOverrideCreatedTask( users userRepository, + settings userSettingRepository, sender emailSender, urls *URLBuilder, logger zerolog.Logger, ) *NotificationOncallOverrideCreatedTask { return &NotificationOncallOverrideCreatedTask{ - users: users, - sender: sender, - urls: urls, - logger: logger, + users: users, + settings: settings, + sender: sender, + urls: urls, + logger: logger, } } @@ -47,7 +50,6 @@ func (t *NotificationOncallOverrideCreatedTask) Execute(ctx context.Context, pay return nil } - lang := "en" oncallURL := t.urls.OncallTab(ctx, evt.ProjectID, evt.ProjectKey, evt.TeamID) // Notify override user (the person taking over) @@ -56,6 +58,7 @@ func (t *NotificationOncallOverrideCreatedTask) Execute(ctx context.Context, pay return fmt.Errorf("loading override user: %w", err) } + lang := getUserLanguage(ctx, t.settings, evt.OverrideUserID) subject := i18n.T(lang, "email.oncall.override.created.subject", "projectKey", evt.ProjectKey, "teamName", evt.TeamName) body := oncallOverrideCreatedEmailHTML(lang, evt.TeamName, evt.ProjectKey, evt.ProjectName, evt.StartAt.Format("2006-01-02 15:04 MST"), evt.EndAt.Format("2006-01-02 15:04 MST"), oncallURL) @@ -71,8 +74,9 @@ func (t *NotificationOncallOverrideCreatedTask) Execute(ctx context.Context, pay return fmt.Errorf("loading scheduled user: %w", err) } - coveredSubject := i18n.T(lang, "email.oncall.override.covered.subject", "projectKey", evt.ProjectKey, "teamName", evt.TeamName) - coveredBody := oncallOverrideCoveredEmailHTML(lang, evt.TeamName, evt.ProjectKey, evt.ProjectName, overrideUser.DisplayName, evt.StartAt.Format("2006-01-02 15:04 MST"), evt.EndAt.Format("2006-01-02 15:04 MST"), oncallURL) + coveredLang := getUserLanguage(ctx, t.settings, evt.ScheduledUser) + coveredSubject := i18n.T(coveredLang, "email.oncall.override.covered.subject", "projectKey", evt.ProjectKey, "teamName", evt.TeamName) + coveredBody := oncallOverrideCoveredEmailHTML(coveredLang, evt.TeamName, evt.ProjectKey, evt.ProjectName, overrideUser.DisplayName, evt.StartAt.Format("2006-01-02 15:04 MST"), evt.EndAt.Format("2006-01-02 15:04 MST"), oncallURL) if err := t.sender.Send(ctx, scheduledUser.Email, coveredSubject, coveredBody); err != nil { return fmt.Errorf("sending override covered email: %w", err) @@ -85,24 +89,27 @@ func (t *NotificationOncallOverrideCreatedTask) Execute(ctx context.Context, pay // NotificationOncallOverrideCancelledTask sends emails when an on-call override is cancelled. type NotificationOncallOverrideCancelledTask struct { - users userRepository - sender emailSender - urls *URLBuilder - logger zerolog.Logger + users userRepository + settings userSettingRepository + sender emailSender + urls *URLBuilder + logger zerolog.Logger } // NewNotificationOncallOverrideCancelledTask creates the task. func NewNotificationOncallOverrideCancelledTask( users userRepository, + settings userSettingRepository, sender emailSender, urls *URLBuilder, logger zerolog.Logger, ) *NotificationOncallOverrideCancelledTask { return &NotificationOncallOverrideCancelledTask{ - users: users, - sender: sender, - urls: urls, - logger: logger, + users: users, + settings: settings, + sender: sender, + urls: urls, + logger: logger, } } @@ -119,8 +126,6 @@ func (t *NotificationOncallOverrideCancelledTask) Execute(ctx context.Context, p return nil } - lang := "en" - // Notify override user that their override was cancelled overrideUser, err := t.users.GetByID(ctx, evt.OverrideUserID) if err != nil { @@ -128,6 +133,7 @@ func (t *NotificationOncallOverrideCancelledTask) Execute(ctx context.Context, p } oncallURL := t.urls.OncallTab(ctx, evt.ProjectID, evt.ProjectKey, evt.TeamID) + lang := getUserLanguage(ctx, t.settings, evt.OverrideUserID) subject := i18n.T(lang, "email.oncall.override.cancelled.subject", "projectKey", evt.ProjectKey, "teamName", evt.TeamName) body := oncallOverrideCancelledEmailHTML(lang, evt.TeamName, evt.ProjectKey, evt.ProjectName, evt.StartAt.Format("2006-01-02 15:04 MST"), evt.EndAt.Format("2006-01-02 15:04 MST"), oncallURL) @@ -140,22 +146,35 @@ func (t *NotificationOncallOverrideCancelledTask) Execute(ctx context.Context, p } func oncallOverrideCreatedEmailHTML(lang, teamName, projectKey, projectName, startAt, endAt, oncallURL string) string { - content := fmt.Sprintf(`

You have been assigned an on-call override for %s in project %s %s.

-

Your override period: %s to %s.

-

Please make sure you are available to respond to any incoming issues during this period.

`, teamName, projectKeyBadge(projectKey), projectName, startAt, endAt) + intro := i18n.T(lang, "email.oncall.override.created.intro", + "teamName", teamName, + "projectBadge", projectKeyBadge(projectKey), + "projectName", projectName) + period := i18n.T(lang, "email.oncall.override.created.period", "startAt", startAt, "endAt", endAt) + note := i18n.T(lang, "email.oncall.override.created.note") + content := fmt.Sprintf("

%s

\n

%s

\n

%s

", intro, period, note) return emailHTML(lang, "email.oncall.cta", oncallURL, "email.oncall.override.footer", content) } func oncallOverrideCoveredEmailHTML(lang, teamName, projectKey, projectName, coveringUser, startAt, endAt, oncallURL string) string { - content := fmt.Sprintf(`

Your on-call shift for %s in project %s %s has been covered by %s.

-

Override period: %s to %s.

`, teamName, projectKeyBadge(projectKey), projectName, coveringUser, startAt, endAt) + intro := i18n.T(lang, "email.oncall.override.covered.intro", + "teamName", teamName, + "projectBadge", projectKeyBadge(projectKey), + "projectName", projectName, + "coveringUser", coveringUser) + period := i18n.T(lang, "email.oncall.override.covered.period", "startAt", startAt, "endAt", endAt) + content := fmt.Sprintf("

%s

\n

%s

", intro, period) return emailHTML(lang, "email.oncall.cta", oncallURL, "email.oncall.override.footer", content) } func oncallOverrideCancelledEmailHTML(lang, teamName, projectKey, projectName, startAt, endAt, oncallURL string) string { - content := fmt.Sprintf(`

An on-call override for %s in project %s %s has been cancelled.

-

The override was scheduled for: %s to %s.

-

The regular on-call rotation schedule will apply for this period.

`, teamName, projectKeyBadge(projectKey), projectName, startAt, endAt) + intro := i18n.T(lang, "email.oncall.override.cancelled.intro", + "teamName", teamName, + "projectBadge", projectKeyBadge(projectKey), + "projectName", projectName) + period := i18n.T(lang, "email.oncall.override.cancelled.period", "startAt", startAt, "endAt", endAt) + note := i18n.T(lang, "email.oncall.override.cancelled.note") + content := fmt.Sprintf("

%s

\n

%s

\n

%s

", intro, period, note) return emailHTML(lang, "email.oncall.cta", oncallURL, "email.oncall.override.footer", content) } diff --git a/api/internal/workers/notification_oncall_override_test.go b/api/internal/workers/notification_oncall_override_test.go index a2455a2f..485bc331 100644 --- a/api/internal/workers/notification_oncall_override_test.go +++ b/api/internal/workers/notification_oncall_override_test.go @@ -24,10 +24,11 @@ func TestNotificationOncallOverrideCreated_Execute(t *testing.T) { sender := &mockEmailSender{} task := &NotificationOncallOverrideCreatedTask{ - users: users, - sender: sender, - urls: newTestURLBuilder(), - logger: zerolog.Nop(), + users: users, + settings: &mockUserSettingRepo{settings: map[string]*model.UserSetting{}}, + sender: sender, + urls: newTestURLBuilder(), + logger: zerolog.Nop(), } teamID := uuid.New() @@ -95,10 +96,11 @@ func TestNotificationOncallOverrideCancelled_Execute(t *testing.T) { sender := &mockEmailSender{} task := &NotificationOncallOverrideCancelledTask{ - users: users, - sender: sender, - urls: newTestURLBuilder(), - logger: zerolog.Nop(), + users: users, + settings: &mockUserSettingRepo{settings: map[string]*model.UserSetting{}}, + sender: sender, + urls: newTestURLBuilder(), + logger: zerolog.Nop(), } teamID := uuid.New() diff --git a/api/internal/workers/notification_oncall_rotation.go b/api/internal/workers/notification_oncall_rotation.go index d9a3fe8b..35c14f45 100644 --- a/api/internal/workers/notification_oncall_rotation.go +++ b/api/internal/workers/notification_oncall_rotation.go @@ -13,24 +13,27 @@ import ( // NotificationOncallRotationTask sends emails when an on-call rotation advances. type NotificationOncallRotationTask struct { - users userRepository - sender emailSender - urls *URLBuilder - logger zerolog.Logger + users userRepository + settings userSettingRepository + sender emailSender + urls *URLBuilder + logger zerolog.Logger } // NewNotificationOncallRotationTask creates the task. func NewNotificationOncallRotationTask( users userRepository, + settings userSettingRepository, sender emailSender, urls *URLBuilder, logger zerolog.Logger, ) *NotificationOncallRotationTask { return &NotificationOncallRotationTask{ - users: users, - sender: sender, - urls: urls, - logger: logger, + users: users, + settings: settings, + sender: sender, + urls: urls, + logger: logger, } } @@ -59,9 +62,9 @@ func (t *NotificationOncallRotationTask) Execute(ctx context.Context, payload [] return fmt.Errorf("loading new on-call user: %w", err) } - lang := "en" oncallURL := t.urls.OncallTab(ctx, evt.ProjectID, evt.ProjectKey, evt.TeamID) + lang := getUserLanguage(ctx, t.settings, evt.NewUserID) incomingSubject := i18n.T(lang, "email.oncall.incoming.subject", "projectKey", evt.ProjectKey, "teamName", evt.TeamName) incomingBody := oncallIncomingEmailHTML(lang, evt.TeamName, evt.ProjectKey, evt.ProjectName, oncallURL) @@ -77,8 +80,9 @@ func (t *NotificationOncallRotationTask) Execute(ctx context.Context, payload [] return fmt.Errorf("loading old on-call user: %w", err) } - outgoingSubject := i18n.T(lang, "email.oncall.outgoing.subject", "projectKey", evt.ProjectKey, "teamName", evt.TeamName) - outgoingBody := oncallOutgoingEmailHTML(lang, evt.TeamName, evt.ProjectKey, evt.ProjectName, oncallURL) + outLang := getUserLanguage(ctx, t.settings, evt.OldUserID) + outgoingSubject := i18n.T(outLang, "email.oncall.outgoing.subject", "projectKey", evt.ProjectKey, "teamName", evt.TeamName) + outgoingBody := oncallOutgoingEmailHTML(outLang, evt.TeamName, evt.ProjectKey, evt.ProjectName, oncallURL) if err := t.sender.Send(ctx, oldUser.Email, outgoingSubject, outgoingBody); err != nil { return fmt.Errorf("sending outgoing oncall email: %w", err) @@ -90,13 +94,21 @@ func (t *NotificationOncallRotationTask) Execute(ctx context.Context, payload [] } func oncallIncomingEmailHTML(lang, teamName, projectKey, projectName, oncallURL string) string { - content := fmt.Sprintf(`

You are now on-call for %s in project %s %s.

-

Please make sure you are available to respond to any incoming issues during your shift.

`, teamName, projectKeyBadge(projectKey), projectName) + intro := i18n.T(lang, "email.oncall.incoming.intro", + "teamName", teamName, + "projectBadge", projectKeyBadge(projectKey), + "projectName", projectName) + note := i18n.T(lang, "email.oncall.incoming.note") + content := fmt.Sprintf("

%s

\n

%s

", intro, note) return emailHTML(lang, "email.oncall.cta", oncallURL, "email.oncall.rotation.footer", content) } func oncallOutgoingEmailHTML(lang, teamName, projectKey, projectName, oncallURL string) string { - content := fmt.Sprintf(`

Your on-call shift for %s in project %s %s has ended.

-

Thank you for your service during your shift.

`, teamName, projectKeyBadge(projectKey), projectName) + intro := i18n.T(lang, "email.oncall.outgoing.intro", + "teamName", teamName, + "projectBadge", projectKeyBadge(projectKey), + "projectName", projectName) + note := i18n.T(lang, "email.oncall.outgoing.note") + content := fmt.Sprintf("

%s

\n

%s

", intro, note) return emailHTML(lang, "email.oncall.cta", oncallURL, "email.oncall.rotation.footer", content) } diff --git a/api/internal/workers/notification_oncall_rotation_test.go b/api/internal/workers/notification_oncall_rotation_test.go index d1a5420e..5175f39c 100644 --- a/api/internal/workers/notification_oncall_rotation_test.go +++ b/api/internal/workers/notification_oncall_rotation_test.go @@ -30,10 +30,11 @@ func TestNotificationOncallRotation_Execute(t *testing.T) { sender := &mockEmailSender{} task := &NotificationOncallRotationTask{ - users: users, - sender: sender, - urls: newTestURLBuilder(), - logger: zerolog.Nop(), + users: users, + settings: &mockUserSettingRepo{settings: map[string]*model.UserSetting{}}, + sender: sender, + urls: newTestURLBuilder(), + logger: zerolog.Nop(), } teamID := uuid.New() @@ -105,10 +106,11 @@ func TestNotificationOncallRotation_SameUser(t *testing.T) { sender := &mockEmailSender{} task := &NotificationOncallRotationTask{ - users: users, - sender: sender, - urls: newTestURLBuilder(), - logger: zerolog.Nop(), + users: users, + settings: &mockUserSettingRepo{settings: map[string]*model.UserSetting{}}, + sender: sender, + urls: newTestURLBuilder(), + logger: zerolog.Nop(), } evt := model.OncallRotationAdvancedEvent{ @@ -136,6 +138,57 @@ func TestNotificationOncallRotation_SameUser(t *testing.T) { } } +func TestNotificationOncallRotation_LanguagePreference(t *testing.T) { + oldUserID := uuid.New() + newUserID := uuid.New() + + users := &mockUserRepo{users: map[uuid.UUID]*model.User{ + oldUserID: {ID: oldUserID, Email: "[EMAIL_REDACTED]", DisplayName: "Alice"}, + newUserID: {ID: newUserID, Email: "[EMAIL_REDACTED]", DisplayName: "Bob"}, + }} + sender := &mockEmailSender{} + + // Incoming user prefers Chinese; outgoing user has no preference (defaults to en). + langJSON, _ := json.Marshal("zh") + settings := &mockUserSettingRepo{settings: map[string]*model.UserSetting{ + languageKey(newUserID): {UserID: newUserID, Key: "language", Value: langJSON}, + }} + + task := &NotificationOncallRotationTask{ + users: users, + settings: settings, + sender: sender, + urls: newTestURLBuilder(), + logger: zerolog.Nop(), + } + + evt := model.OncallRotationAdvancedEvent{ + RotationID: uuid.New(), + TeamID: uuid.New(), + ProjectID: uuid.New(), + ProjectKey: "ENG", + ProjectName: "Engineering Platform", + TeamName: "Engineering", + OldUserID: oldUserID, + NewUserID: newUserID, + } + payload, _ := json.Marshal(evt) + + if err := task.Execute(context.Background(), payload); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(sender.sent) != 2 { + t.Fatalf("expected 2 emails, got %d", len(sender.sent)) + } + + if !strings.Contains(sender.sent[0].subject, "值班") || !strings.Contains(sender.sent[0].body, "查看值班排班") { + t.Errorf("expected Chinese incoming email, got subject=%q", sender.sent[0].subject) + } + if !strings.Contains(sender.sent[1].subject, "Your on-call shift") || !strings.Contains(sender.sent[1].body, "View on-call schedule") { + t.Errorf("expected English outgoing email, got subject=%q", sender.sent[1].subject) + } +} + func TestNotificationOncallRotation_InvalidPayload(t *testing.T) { task := &NotificationOncallRotationTask{ users: &mockUserRepo{users: map[uuid.UUID]*model.User{}}, diff --git a/docker-compose.all-in-one.yml b/docker-compose.all-in-one.yml new file mode 100644 index 00000000..dd50af86 --- /dev/null +++ b/docker-compose.all-in-one.yml @@ -0,0 +1,47 @@ +services: + taskwondo: + image: ghcr.io/marcoshack/taskwondo/all-in-one:${IMAGE_TAG:-latest} + build: + context: . + dockerfile: docker/Dockerfile.all-in-one + args: + - GOPROXY + ports: + - "${WEB_PORT:-3000}:80" + depends_on: + nats: + condition: service_healthy + env_file: .env + environment: + - DATABASE_URL=${DATABASE_URL:?required} + - JWT_SECRET=${JWT_SECRET:?required — generate a random string of at least 32 chars} + - STORAGE_ENDPOINT=${STORAGE_ENDPOINT:?required} + - STORAGE_ACCESS_KEY=${STORAGE_ACCESS_KEY:?required} + - STORAGE_SECRET_KEY=${STORAGE_SECRET_KEY:?required} + - STORAGE_BUCKET=${STORAGE_BUCKET:-taskwondo-attachments} + - NATS_URL=nats://nats:4222 + - OLLAMA_URL=${OLLAMA_URL:-} + restart: unless-stopped + deploy: + resources: + limits: + memory: 1G + + nats: + image: nats:2-alpine + command: ["--jetstream", "--store_dir=/data", "--http_port=8222"] + volumes: + - natsdata:/data + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8222/healthz"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + deploy: + resources: + limits: + memory: 512M + +volumes: + natsdata: diff --git a/docker-compose.minimal.yml b/docker-compose.minimal.yml new file mode 100644 index 00000000..b962c3f0 --- /dev/null +++ b/docker-compose.minimal.yml @@ -0,0 +1,55 @@ +# 最小化 Docker Compose 配置 +# 使用外部 PostgreSQL 数据库,内置 NATS 消息队列 + +services: + nats: + image: nats:2-alpine + command: ["--jetstream", "--store_dir=/data", "--http_port=8222"] + volumes: + - natsdata:/data + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8222/healthz"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + deploy: + resources: + limits: + memory: 256M + + api: + image: ghcr.io/marcoshack/taskwondo/api:${IMAGE_TAG:-latest} + depends_on: + nats: + condition: service_healthy + environment: + - API_PORT=8080 + - DATABASE_URL=${DATABASE_URL:?required} + - JWT_SECRET=${JWT_SECRET:?required} + - STORAGE_ENDPOINT=${STORAGE_ENDPOINT:?required} + - STORAGE_ACCESS_KEY=${STORAGE_ACCESS_KEY:?required} + - STORAGE_SECRET_KEY=${STORAGE_SECRET_KEY:?required} + - STORAGE_BUCKET=${STORAGE_BUCKET:-taskwondo-attachments} + - STORAGE_USE_SSL=${STORAGE_USE_SSL:-true} + - NATS_URL=nats://nats:4222 + restart: unless-stopped + deploy: + resources: + limits: + memory: 512M + + web: + image: ghcr.io/marcoshack/taskwondo/web:${IMAGE_TAG:-latest} + ports: + - "${WEB_PORT:-3000}:80" + depends_on: + - api + restart: unless-stopped + deploy: + resources: + limits: + memory: 512M + +volumes: + natsdata: diff --git a/docker/Dockerfile.all-in-one b/docker/Dockerfile.all-in-one new file mode 100644 index 00000000..2b2ed3a4 --- /dev/null +++ b/docker/Dockerfile.all-in-one @@ -0,0 +1,47 @@ +# ---- Build Go binaries ---- +FROM docker.1ms.run/golang:1.25-alpine AS go-builder + +RUN apk add --no-cache git +WORKDIR /src + +ARG GOPROXY +ENV GOPROXY=${GOPROXY} + +COPY api/go.mod api/go.sum ./ +RUN go mod download + +COPY api/ . + +ARG COMMIT_SHA=dev +RUN CGO_ENABLED=0 go build -ldflags "-X main.commitSHA=${COMMIT_SHA}" -o /bin/taskwondo ./cmd/server +RUN CGO_ENABLED=0 go build -o /bin/taskwondo-worker ./cmd/worker + +# ---- Build frontend ---- +FROM docker.1ms.run/node:22-alpine AS web-builder + +WORKDIR /src + +ARG NPM_VERSION=11.15.0 +RUN npm install -g npm@${NPM_VERSION} + +COPY web/package.json web/package-lock.json ./ +RUN npm ci + +COPY web/ . +RUN npm run build + +# ---- Final image ---- +FROM docker.1ms.run/alpine:3.20 + +RUN apk add --no-cache ca-certificates tzdata nginx supervisor + +COPY --from=go-builder /bin/taskwondo /bin/taskwondo +COPY --from=go-builder /bin/taskwondo-worker /bin/taskwondo-worker +COPY --from=web-builder /src/dist /usr/share/nginx/html + +COPY docker/nginx-all-in-one.conf /etc/nginx/http.d/default.conf +COPY docker/supervisord.conf /etc/supervisord.conf + +EXPOSE 80 + +ENTRYPOINT ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"] diff --git a/docker/Dockerfile.api.cn b/docker/Dockerfile.api.cn new file mode 100644 index 00000000..56eca431 --- /dev/null +++ b/docker/Dockerfile.api.cn @@ -0,0 +1,26 @@ +FROM docker.1ms.run/golang:1.25-alpine AS builder + +RUN apk add --no-cache git + +WORKDIR /src + +ARG GOPROXY +ENV GOPROXY=${GOPROXY} + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG COMMIT_SHA=dev +RUN CGO_ENABLED=0 go build -ldflags "-X main.commitSHA=${COMMIT_SHA}" -o /bin/taskwondo ./cmd/server + +FROM docker.1ms.run/alpine:3.20 + +RUN apk add --no-cache ca-certificates tzdata + +COPY --from=builder /bin/taskwondo /bin/taskwondo + +EXPOSE 8080 + +ENTRYPOINT ["/bin/taskwondo"] diff --git a/docker/Dockerfile.web.cn b/docker/Dockerfile.web.cn new file mode 100644 index 00000000..f128abc9 --- /dev/null +++ b/docker/Dockerfile.web.cn @@ -0,0 +1,21 @@ +FROM docker.1ms.run/node:22-alpine AS builder + +WORKDIR /src + +ARG NPM_VERSION=11.15.0 +RUN npm install -g npm@${NPM_VERSION} + +COPY web/package.json web/package-lock.json ./ +RUN npm ci + +COPY web/ . +RUN npm run build + +FROM docker.1ms.run/nginx:alpine + +COPY --from=builder /src/dist /usr/share/nginx/html +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker/Dockerfile.worker.cn b/docker/Dockerfile.worker.cn new file mode 100644 index 00000000..47657ef3 --- /dev/null +++ b/docker/Dockerfile.worker.cn @@ -0,0 +1,23 @@ +FROM docker.1ms.run/golang:1.25-alpine AS builder + +RUN apk add --no-cache git + +WORKDIR /src + +ARG GOPROXY +ENV GOPROXY=${GOPROXY} + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 go build -o /bin/taskwondo-worker ./cmd/worker + +FROM docker.1ms.run/alpine:3.20 + +RUN apk add --no-cache ca-certificates tzdata + +COPY --from=builder /bin/taskwondo-worker /bin/taskwondo-worker + +ENTRYPOINT ["/bin/taskwondo-worker"] diff --git a/docker/nginx-all-in-one.conf b/docker/nginx-all-in-one.conf new file mode 100644 index 00000000..5a1f14d1 --- /dev/null +++ b/docker/nginx-all-in-one.conf @@ -0,0 +1,38 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + server_tokens off; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + location /api/ { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + client_max_body_size 55m; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + } + + location ~ ^/(healthz|readyz|metrics)$ { + proxy_pass http://127.0.0.1:8080; + } + + location / { + try_files $uri $uri/ /index.html; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/docker/supervisord.conf b/docker/supervisord.conf new file mode 100644 index 00000000..9ca8a0df --- /dev/null +++ b/docker/supervisord.conf @@ -0,0 +1,32 @@ +[supervisord] +nodaemon=true +logfile=/var/log/supervisord.log +pidfile=/var/run/supervisord.pid + +[program:nginx] +command=nginx -g "daemon off;" +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:api] +command=/bin/taskwondo +environment=API_PORT="8080" +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:worker] +command=/bin/taskwondo-worker +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/test/e2e/lib/api.ts b/test/e2e/lib/api.ts index 324056f9..27694c9e 100644 --- a/test/e2e/lib/api.ts +++ b/test/e2e/lib/api.ts @@ -715,6 +715,7 @@ export interface SMTPConfig { encryption: 'starttls' | 'tls' | 'none'; from_address: string; from_name: string; + skip_cert_verify?: boolean; } export async function setSMTPConfig( @@ -772,6 +773,7 @@ export async function resetSMTPConfig( encryption: 'starttls', from_address: '', from_name: '', + skip_cert_verify: false, }); } diff --git a/test/e2e/tests/admin/brand-title.spec.ts b/test/e2e/tests/admin/brand-title.spec.ts new file mode 100644 index 00000000..6cfa53ce --- /dev/null +++ b/test/e2e/tests/admin/brand-title.spec.ts @@ -0,0 +1,67 @@ +import { test as base, expect } from '../../lib/fixtures'; +import { getAdminToken } from '../../lib/fixtures'; +import * as api from '../../lib/api'; + +// Admin context — brand_name is a system setting +const test = base.extend({ + storageState: async ({}, use) => { + const adminToken = getAdminToken(); + const state = { + cookies: [], + origins: [ + { + origin: process.env.BASE_URL || 'http://localhost:5173', + localStorage: [{ name: 'taskwondo_token', value: adminToken }], + }, + ], + }; + await use(state as any); + }, +}); + +test.describe.configure({ mode: 'serial' }); + +const BRAND = 'Acme Tab Title'; + +test.describe('Brand tab title', () => { + test.afterEach(async ({ request }) => { + const adminToken = getAdminToken(); + // Re-sync the default namespace display name back, then drop the override + await api.setSystemSetting(request, adminToken, 'brand_name', 'Taskwondo'); + await api.deleteSystemSetting(request, adminToken, 'brand_name'); + }); + + test('tab title uses the configured brand name', async ({ page, request }) => { + const adminToken = getAdminToken(); + await api.setSystemSetting(request, adminToken, 'brand_name', BRAND); + + // Base title on a plain page + await page.goto('/'); + await page.waitForLoadState('networkidle'); + await expect(page).toHaveTitle(BRAND); + + // Work item detail page composes display ID + title + brand + const project = await api.createProject(request, adminToken, 'BT' + Date.now().toString(36).slice(-3).toUpperCase(), 'Brand Title Project'); + const item = await api.createWorkItem(request, adminToken, project.key, { + title: 'Check the tab', + type: 'task', + }); + + await page.goto(`/d/projects/${project.key}/items/${item.item_number}`); + await expect(page.getByText('Check the tab').first()).toBeVisible(); + await expect(page).toHaveTitle(`${project.key}-${item.item_number} Check the tab - ${BRAND}`); + + // Leaving the detail page restores the base brand title + await page.goto('/'); + await expect(page).toHaveTitle(BRAND); + }); + + test('tab title falls back to Taskwondo when no brand is set', async ({ page, request }) => { + const adminToken = getAdminToken(); + await api.deleteSystemSetting(request, adminToken, 'brand_name'); + + await page.goto('/'); + await page.waitForLoadState('networkidle'); + await expect(page).toHaveTitle('Taskwondo'); + }); +}); diff --git a/test/e2e/tests/admin/smtp-config.spec.ts b/test/e2e/tests/admin/smtp-config.spec.ts index ea5a15b5..671616df 100644 --- a/test/e2e/tests/admin/smtp-config.spec.ts +++ b/test/e2e/tests/admin/smtp-config.spec.ts @@ -247,6 +247,35 @@ test.describe('SMTP Configuration', () => { await attach(page, testInfo, '01-encryption-persisted'); }); + test('persists skip certificate verification option', async ({ page }, testInfo) => { + await gotoIntegrations(page); + + // Enable SMTP and fill required fields + await page.getByRole('switch').click(); + await page.getByLabel('SMTP Host').fill('smtp.example.com'); + await page.getByLabel('SMTP Port').fill('465'); + await page.getByLabel('Username').fill('[EMAIL_REDACTED]'); + await page.getByLabel('Password').fill('pass'); + await page.getByLabel('From Address').fill('[EMAIL_REDACTED]'); + await page.locator('select').selectOption('tls'); + + // The cert-verify checkbox should be visible for TLS + const skipCheckbox = page.getByLabel('Skip certificate verification'); + await expect(skipCheckbox).toBeVisible(); + await expect(skipCheckbox).not.toBeChecked(); + await skipCheckbox.check(); + + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText('SMTP settings saved.')).toBeVisible(); + + // Reload and verify the option persisted + await page.reload(); + await page.waitForLoadState('networkidle'); + await page.getByRole('heading', { name: 'SMTP' }).click(); + await expect(page.getByLabel('Skip certificate verification')).toBeChecked(); + await attach(page, testInfo, '01-skip-cert-verify-persisted'); + }); + test('non-admin user cannot access integrations page', async ({ page, testUser }, testInfo) => { await page.goto('/'); await dismissWelcomeModal(page); diff --git a/test/e2e/tests/navigation/search-cjk.spec.ts b/test/e2e/tests/navigation/search-cjk.spec.ts new file mode 100644 index 00000000..e2c30706 --- /dev/null +++ b/test/e2e/tests/navigation/search-cjk.spec.ts @@ -0,0 +1,124 @@ +import { test, expect } from '../../lib/fixtures'; +import * as api from '../../lib/api'; +import { openPalette, paletteInput } from '../../lib/palette'; + +const BASE_URL = process.env.BASE_URL || 'http://localhost:5173'; + +interface SearchData { + fts: { results: Array<{ entity_type: string; entity_id: string; snippet: string }>; total: number }; + semantic: { available: boolean }; +} + +async function searchAPI( + request: import('@playwright/test').APIRequestContext, + token: string, + query: string, + entityType?: string, +): Promise { + let url = `${BASE_URL}/api/v1/search?q=${encodeURIComponent(query)}`; + if (entityType) url += `&entity_type=${entityType}`; + const res = await request.get(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok()) throw new Error(`Search failed (${res.status()}): ${await res.text()}`); + const body = await res.json(); + return body.data; +} + +test.describe('CJK search', () => { + test('API: Chinese substring matches a work item title', async ({ request, testUser, testProject }) => { + const suffix = Date.now(); + const item = await api.createWorkItem(request, testUser.token, testProject.key, { + title: `登录页面崩溃${suffix}`, + type: 'task', + }); + + const data = await searchAPI(request, testUser.token, `登录页面${suffix}`); + const hit = data.fts.results.find(r => r.entity_id === item.id); + expect(hit).toBeDefined(); + }); + + test('API: Chinese substring matches inside a work item description', async ({ request, testUser, testProject }) => { + const suffix = Date.now(); + const item = await api.createWorkItem(request, testUser.token, testProject.key, { + title: `Issue ${suffix}`, + type: 'task', + description: `用户反馈支付流程在弱网环境下会超时${suffix}`, + }); + + const data = await searchAPI(request, testUser.token, `弱网环境${suffix}`); + const hit = data.fts.results.find(r => r.entity_id === item.id); + expect(hit).toBeDefined(); + }); + + test('API: Chinese query matches milestones and teams', async ({ request, testUser, testProject }) => { + const suffix = Date.now(); + const milestone = await api.createMilestone(request, testUser.token, testProject.key, { + name: `发布里程碑${suffix}`, + }); + const team = await api.createTeam(request, testUser.token, testProject.key, { + name: `平台组${suffix}`, + }); + + const msData = await searchAPI(request, testUser.token, `里程碑${suffix}`, 'milestone'); + expect(msData.fts.results.some(r => r.entity_id === milestone.id)).toBe(true); + + const teamData = await searchAPI(request, testUser.token, `平台组${suffix}`, 'team'); + expect(teamData.fts.results.some(r => r.entity_id === team.id)).toBe(true); + }); + + test('API: mixed CJK + latin query matches when terms sit in different fields', async ({ request, testUser, testProject }) => { + const suffix = Date.now(); + const item = await api.createWorkItem(request, testUser.token, testProject.key, { + title: `修复登录超时 bugfix-${suffix}`, + type: 'task', + }); + + const data = await searchAPI(request, testUser.token, `登录超时 bugfix-${suffix}`); + const hit = data.fts.results.find(r => r.entity_id === item.id); + expect(hit).toBeDefined(); + }); + + test('API: project item list search accepts a Chinese query', async ({ request, testUser, testProject }) => { + const suffix = Date.now(); + const item = await api.createWorkItem(request, testUser.token, testProject.key, { + title: `数据库连接池耗尽${suffix}`, + type: 'task', + }); + + const body = await api.listWorkItems(request, testUser.token, testProject.key, { + q: `连接池${suffix}`, + }); + expect(body.data.some((i) => i.id === item.id)).toBe(true); + }); + + test('UI: a single Chinese character finds work items in the palette', async ({ + page, + request, + testUser, + testProject, + }) => { + const suffix = `搜${Date.now()}`; + await api.createWorkItem(request, testUser.token, testProject.key, { + title: `${suffix}页面白屏`, + type: 'task', + }); + + await page.goto(`/d/projects/${testProject.key}/items`); + const welcomeHeading = page.getByRole('heading', { name: 'Welcome' }); + if (await welcomeHeading.isVisible({ timeout: 2000 }).catch(() => false)) { + await page.keyboard.press('Escape'); + await expect(welcomeHeading).not.toBeVisible({ timeout: 3000 }); + } + await expect(page.getByRole('heading', { name: /items/i })).toBeVisible({ timeout: 5000 }); + + await openPalette(page); + const searchInput = paletteInput(page); + await expect(searchInput).toBeVisible({ timeout: 3000 }); + // One CJK character is already a meaningful word — it must clear the floor. + await searchInput.fill(suffix); + + const resultItem = page.locator('[data-search-item]').filter({ hasText: suffix }).first(); + await expect(resultItem).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/test/e2e/tests/preferences/cjk-typography.spec.ts b/test/e2e/tests/preferences/cjk-typography.spec.ts new file mode 100644 index 00000000..99c9d527 --- /dev/null +++ b/test/e2e/tests/preferences/cjk-typography.spec.ts @@ -0,0 +1,60 @@ +import { test, expect } from '../../lib/fixtures'; + +async function attach(page: any, testInfo: any, name: string) { + const screenshot = await page.screenshot(); + await testInfo.attach(name, { body: screenshot, contentType: 'image/png' }); +} + +async function rootFontSize(page: any): Promise { + return page.evaluate(() => parseFloat(getComputedStyle(document.documentElement).fontSize)); +} + +async function selectLanguage(page: any, nativeLabel: string, lang: string) { + await page.goto('/preferences/appearance'); + await page.waitForLoadState('networkidle'); + await page.getByRole('button', { name: new RegExp(`^${nativeLabel}\\s`) }).click(); + await expect.poll(() => page.evaluate(() => document.documentElement.lang)).toBe(lang); +} + +async function selectFontSize(page: any, label: string, value: string) { + await page.getByRole('button', { name: new RegExp(`^${label}`) }).click(); + await expect + .poll(() => page.evaluate(() => localStorage.getItem('taskwondo_font_size'))) + .toBe(value); +} + +test.describe('CJK typography', () => { + test('chinese ui renders at an optically smaller size and uses a CJK font stack', async ({ page }, testInfo) => { + await selectLanguage(page, 'English', 'en'); + const latinSize = await rootFontSize(page); + expect(latinSize).toBeCloseTo(17.6, 1); + expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).not.toContain('PingFang SC'); + + await selectLanguage(page, '中文', 'zh'); + const zhSize = await rootFontSize(page); + // CJK glyphs fill the em box, so the root size is nudged down ~9% for zh + expect(zhSize).toBeLessThan(latinSize - 1); + expect(zhSize).toBeCloseTo(latinSize * 0.909, 0); + expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('PingFang SC'); + await attach(page, testInfo, '01-chinese-appearance'); + + await selectLanguage(page, 'English', 'en'); + expect(await rootFontSize(page)).toBeCloseTo(latinSize, 1); + }); + + test('font size preference still scales the whole ui for chinese', async ({ page }, testInfo) => { + await selectLanguage(page, '中文', 'zh'); + const normal = await rootFontSize(page); + + await selectFontSize(page, '大', 'large'); + const larger = await rootFontSize(page); + expect(larger).toBeGreaterThan(normal); + await attach(page, testInfo, '02-chinese-larger'); + + await selectFontSize(page, '小', 'small'); + const smaller = await rootFontSize(page); + expect(smaller).toBeLessThan(normal); + expect(smaller).toBeCloseTo(16 * 0.909, 0); + await attach(page, testInfo, '03-chinese-smaller'); + }); +}); diff --git a/web/eslint.config.js b/web/eslint.config.js index 5e6b472f..8438870a 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -15,6 +15,9 @@ export default defineConfig([ reactHooks.configs.flat.recommended, reactRefresh.configs.vite, ], + rules: { + 'react-hooks/set-state-in-effect': 'off', + }, languageOptions: { ecmaVersion: 2020, globals: globals.browser, diff --git a/web/package-lock.json b/web/package-lock.json index 8e0a65e1..73eb451d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,38 +8,38 @@ "name": "web", "version": "0.0.0", "dependencies": { - "@tailwindcss/typography": "^0.5.19", - "@tanstack/react-query": "^5.90.21", - "axios": "^1.13.5", - "i18next": "^25.8.11", - "i18next-browser-languagedetector": "^8.2.1", - "lucide-react": "^0.574.0", - "mermaid": "^11.12.3", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "react-easy-crop": "^5.5.6", - "react-i18next": "^16.5.4", - "react-markdown": "^10.1.0", - "react-router-dom": "^7.13.0", - "recharts": "^3.7.0", - "remark-gfm": "^4.0.1" + "@tailwindcss/typography": "0.5.19", + "@tanstack/react-query": "5.90.21", + "axios": "1.13.5", + "i18next": "25.8.11", + "i18next-browser-languagedetector": "8.2.1", + "lucide-react": "0.574.0", + "mermaid": "11.12.3", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-easy-crop": "5.5.6", + "react-i18next": "16.5.4", + "react-markdown": "10.1.0", + "react-router-dom": "7.13.0", + "recharts": "3.7.0", + "remark-gfm": "4.0.1" }, "devDependencies": { - "@eslint/js": "^9.39.1", - "@tailwindcss/vite": "^4.1.18", - "@types/node": "^24.10.1", - "@types/react": "^19.2.7", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.1", - "eslint": "^9.39.1", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.24", - "globals": "^16.5.0", - "tailwindcss": "^4.1.18", - "typescript": "~5.9.3", - "typescript-eslint": "^8.48.0", - "vite": "^7.3.1", - "vitest": "^4.0.18" + "@eslint/js": "9.39.2", + "@tailwindcss/vite": "4.1.18", + "@types/node": "24.10.13", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "5.1.4", + "eslint": "9.39.2", + "eslint-plugin-react-hooks": "7.0.1", + "eslint-plugin-react-refresh": "0.4.26", + "globals": "16.5.0", + "tailwindcss": "4.1.18", + "typescript": "5.9.3", + "typescript-eslint": "8.56.0", + "vite": "7.3.1", + "vitest": "4.0.18" } }, "node_modules/@antfu/install-pkg": { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1f84523b..2f97084b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,4 +1,5 @@ import axios from 'axios' +import i18n from '@/i18n' const TOKEN_KEY = 'taskwondo_token' @@ -38,6 +39,8 @@ api.interceptors.request.use((config) => { if (token) { config.headers.Authorization = `Bearer ${token}` } + // Lets the API localize server-side artifacts (e.g. verification / password reset emails) + config.headers['Accept-Language'] = i18n.language return config }) diff --git a/web/src/api/systemSettings.ts b/web/src/api/systemSettings.ts index 326ad8a5..ea7c0e41 100644 --- a/web/src/api/systemSettings.ts +++ b/web/src/api/systemSettings.ts @@ -47,6 +47,7 @@ export interface SMTPConfig { encryption: 'starttls' | 'tls' | 'none' from_address: string from_name: string + skip_cert_verify: boolean } export async function getSMTPConfig(): Promise { @@ -69,6 +70,12 @@ export async function testSMTPConfig(): Promise<{ message: string }> { export interface OAuthProviderConfig { client_id: string client_secret: string + // Generic OIDC / custom SSO only. Absent for the built-in providers. + issuer?: string + scopes?: string[] + button_label?: string + disable_pkce?: boolean + require_verified_email?: boolean } export async function getOAuthConfig(provider: string): Promise { diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index 3d77b14f..6e5dc17e 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -156,13 +156,13 @@ export function AppShell() { } return ( -
-