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)
+