Compare commits

..
Author SHA1 Message Date
ishan-karmakar d7719d6f39 Remove symlinks because Portainer doesn't like symlinks
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
i18n sync check / check:sync (push) Has been cancelled
2026-06-06 12:49:25 -05:00
ishan-karmakar 36e81f4851 Update docker-compose for our purposes
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-06-06 11:35:48 -05:00
sriram veeraghantaandGitHub 0bbfe95cc7 fix: bump react-router and vitest to resolve Dependabot advisories (#9215)
* fix: bump react-router and vitest to resolve Dependabot advisories

Resolves 6 open Dependabot alerts (all npm, manifest pnpm-lock.yaml):

- react-router 7.12.0 -> 7.15.0 (fixes GHSA-8x6r-g9mw-2r78 [high],
  GHSA-49rj-9fvp-4h2h [high], GHSA-8646-j5j9-6r62 [high],
  GHSA-2j2x-hqr9-3h42 [medium], GHSA-f22v-gfqf-p8f3 [medium])
- vitest 4.0.x -> 4.1.x (fixes GHSA-5xrq-8626-4rwp [critical])

Aligned lockstep siblings to avoid peer-dependency mismatches:
@react-router/dev|node|serve -> 7.15.0, @vitest/coverage-v8 -> ^4.1.0.

Edited catalog entries in pnpm-workspace.yaml and regenerated
pnpm-lock.yaml; verified with pnpm install --frozen-lockfile.

* fix: raise vitest catalog floor to ^4.1.8 to match security advisory

The critical advisory GHSA-5xrq-8626-4rwp is patched in vitest 4.1.8, but
the catalog specifiers were ^4.1.0, which permits resolving to vulnerable
4.1.0-4.1.7. Align the floor with the documented patched version for vitest
and @vitest/coverage-v8 so a future lockfile refresh cannot reintroduce a
vulnerable Vitest stack. Resolved version is unchanged (4.1.8).
2026-06-05 00:51:33 +05:30
sriram veeraghantaandGitHub 9a30a07cf5 fix(api): enforce workspace membership on GenericAssetEndpoint (#9212)
The public REST API GenericAssetEndpoint (/api/v1/workspaces/<slug>/assets/)
declared no permission class, inheriting only IsAuthenticated. Since
APIKeyAuthentication does not bind a token to a workspace and the workspace is
read straight from the URL slug, any valid Personal Access Token could read
(GET), create (POST), and modify (PATCH) assets in a workspace the caller is
not a member of — a cross-workspace IDOR, the public-API sibling of the
CVE-2026-46558 dashboard asset fix.

Add permission_classes = [WorkspaceUserPermission] so every method requires
active workspace membership, matching the dashboard fix semantics. Also add
contract regression tests covering cross-workspace GET/POST/PATCH (now 403)
and a positive control confirming members retain access.

Also ignore the local /security/ advisory notes folder.
2026-06-04 18:49:39 +05:30
Karthikeyan GaneshandGitHub b6e47ccdae fix: dropdown shadow on the work item more options (#9154)
* fix: UI border and shadow on the dropdown menu usability

* fix: shadow-md and border strong
2026-06-03 17:28:19 +05:30
Durgesh ShekhawatandGitHub 4280c4d1b1 fix: handle error message for special characters in Identifier of Project (#9059) 2026-06-03 17:26:18 +05:30
sriram veeraghantaandGitHub b1c78fe4c8 fix(api): rate-limit magic-code verify, bound per-token attempts (GHSA-9pvm-fcf6-9234) (#9130)
* fix(api): rate-limit magic-code verification and bound per-token attempts

The magic-link sign-in / sign-up endpoints accept a 6-digit numeric code
(900k-value space, 600s TTL) but never increment a failure counter on a
wrong-code verify and extend django.views.View rather than DRF APIView,
so DRF's AuthenticationThrottle never runs against them. The space-side
generate endpoint also lacked throttle_classes. Combined, this allowed
an unauthenticated attacker who knew a victim's email to brute-force
the code within the TTL window and log in as the victim.

- Add MAX_VERIFY_ATTEMPTS=5 in MagicCodeProvider.set_user_data: failed
  comparisons now persist verify_attempts in Redis under the remaining
  TTL and, on hitting the limit, delete the key and raise
  EMAIL_CODE_ATTEMPT_EXHAUSTED. This is the load-bearing fix - it caps
  total attempts per issued token regardless of request rate.
- Add authentication_throttle_allows() so plain Django Views can apply
  AuthenticationThrottle without converting to APIView (would change
  CSRF + request-parsing semantics for the redirect-flow endpoints).
- Apply the throttle to MagicSignIn/UpEndpoint and the space variants;
  add throttle_classes to MagicGenerateSpaceEndpoint to match its app
  sibling.

Refs GHSA-9pvm-fcf6-9234.

* fix(api): make verify-attempt increment atomic, expose throttle rate via env

Address PR review feedback:

- Replace the JSON read-modify-write of verify_attempts with a Lua
  EVAL script that INCRs a dedicated counter key and EXPIREs it only
  on the first increment. The previous round-trip was racy: parallel
  wrong-code requests could read the same value and both write the
  same incremented count, letting an attacker exceed MAX_VERIFY_ATTEMPTS
  under concurrency. Counter is now reset on each new token issuance
  and cleared on successful verify / exhaustion.
- Make AuthenticationThrottle.rate configurable via the
  AUTHENTICATION_RATE_LIMIT env var (default 10/minute, down from 30
  to tighten the budget on unauth auth-adjacent endpoints). Document
  it in deployments/aio and deployments/cli variables.env.

* test(api): cover magic-code attempt cap, counter reset, and auth throttle

Add the contract tests called out in the PR test plan:

- TestMagicSignInVerifyAttempts:
  - test_exhausted_after_max_wrong_attempts: after MAX_VERIFY_ATTEMPTS
    wrong codes the next verify redirects with EMAIL_CODE_ATTEMPT_
    EXHAUSTED_SIGN_IN and both Redis keys are deleted; a follow-up
    verify reports EXPIRED.
  - test_counter_increments_on_each_wrong_attempt: the dedicated
    verify_attempts counter advances by exactly one per wrong POST,
    matching the atomic Lua INCR.
  - test_counter_resets_on_token_regeneration: regenerating the
    magic-link clears the counter so the user isn't pre-locked-out by
    a prior session's wrong attempts.
- TestMagicSignUpVerifyAttempts.test_signup_exhausted_after_max_wrong_attempts:
  the sign-up endpoint returns EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP on
  the exhausting attempt.
- TestAuthenticationThrottle: exercises authentication_throttle_allows
  on the plain-View redirect-flow endpoints by patching the rate down
  and asserting RATE_LIMIT_EXCEEDED is appended to the redirect URL
  once the per-IP budget is exceeded, for both magic-sign-in and
  magic-sign-up.

Each new class clears Django cache (DRF throttle storage) and the
per-email Redis keys around every test so runs are independent.

* fix(api): clamp remaining_ttl to >=1 for verify-attempt counter EXPIRE

ri.ttl() returns 0 when the token has less than one second remaining
(Redis floors to whole seconds). The previous clamp only caught
None and < 0, so a sub-second TTL would pass through and the Lua
script's EXPIRE counter 0 would immediately delete the key — letting
an attacker bypass MAX_VERIFY_ATTEMPTS during the final second of the
token's life. Switch the comparison to <= 0.

Narrow real-world impact (sub-second window, throttle still bounds
the rate) but the cap should hold regardless of timing.
2026-06-01 18:44:57 +05:30
sriram veeraghantaandGitHub 7ec8d4990f fix: bump npm deps to resolve Dependabot advisories (#9191)
* fix: bump npm deps to resolve Dependabot advisories

Resolve 8 open Dependabot alerts (all npm, in pnpm-lock.yaml) by bumping
the affected packages in pnpm-workspace.yaml and regenerating the lockfile:

- axios 1.15.2 -> 1.16.0 (catalog): CVE-2026-44494/44492/44490/44489
- tmp -> 0.2.6 (override): CVE-2026-44705 path traversal
- ws 8.x -> 8.20.1 (catalog + scoped override): CVE-2026-45736
- qs 6.14.2 -> 6.15.2 (override): CVE-2026-8723 DoS
- brace-expansion 5.0.5 -> 5.0.6 (override): CVE-2026-45149 DoS

brace-expansion and qs were pinned to their vulnerable versions in the
overrides block, so the pins had to be bumped directly. ws is scoped to
the 8.x major (ws@7.5.10 is below the vulnerable >=8.0.0 floor). All bumps
are semver-compatible patch/minor upgrades; no source changes required.

* fix: use named axios `create` import after 1.16.0 bump

axios 1.16.0 newly exposes `create` as a named export, so oxlint's
import/no-named-as-default-member rule now flags `axios.create(...)`.
That added one warning to @plane/services (7 > its --max-warnings=6
baseline) and to apps/web and apps/live, failing check:lint — surfaced
on this PR because the lockfile change busts Turbo's lint cache.

Switch the three `axios.create(...)` call sites to a named `{ create }`
import. `create` is a real value+type export in axios 1.16.0 (verified
via tsc). isCancel/CancelToken are left as `axios.*`: CancelToken is
only a type export (cannot be a value import under verbatimModuleSyntax)
and both were already counted within the existing baselines.

Verified locally: full `pnpm check:lint` (16/16) and `check:types`
(15/15) pass.
2026-06-01 18:37:35 +05:30
sriram veeraghantaandGitHub e388cb9125 fix: declare @tailwindcss/postcss in admin/space/web for Docker builds (#9189)
The web/admin/space Docker image builds fail at the Vite/PostCSS step with
"Cannot find module '@tailwindcss/postcss'". These apps load the shared
@plane/tailwind-config/postcss.config.js, which references the @tailwindcss/postcss
plugin by name, but the plugin was only declared as a dependency of
packages/tailwind-config.

The Docker build installs via turbo prune + 'pnpm fetch' + 'pnpm install --offline',
which lays out node_modules so PostCSS resolves the plugin relative to the app
directory (apps/<app>), where it is not reachable. A plain 'pnpm install' resolves
it from tailwind-config's context instead, which is why local builds passed and
masked the issue.

Declare @tailwindcss/postcss as a direct devDependency of the three apps that run
Vite/PostCSS so it is symlinked into each app's node_modules and resolves under the
isolated linker regardless of install flow.

Verified by reproducing the exact Docker flow (prune -> fetch -> --frozen-lockfile
offline install -> build) for admin, space and web: all install in sync and build
successfully with full Tailwind CSS output.
2026-06-01 16:44:16 +05:30
Rahul CheryalaandGitHub bd0d164e0b fix(GIT-235): add styles to onboarding tour close button for contrast (#9188) 2026-06-01 16:43:45 +05:30
sriram veeraghantaandGitHub 011328c793 [GIT-213] fix: return HTTP response from dispatch() exception handler (#9179)
* fix(api): return HTTP response from dispatch() exception handler

BaseAPIView.dispatch() and BaseViewSet.dispatch() built the proper
error Response via handle_exception() but returned the raw exception
object instead, causing Django to raise
"TypeError: 'Exception' object is not a valid HTTP response".

Fix all six occurrences across the api, app, license and space view
bases, and add a regression test covering every affected base class.

Fixes #9157

* chore(api): add copyright header to tests/unit/views/__init__.py

The empty package init file was missing the AGPL copyright header,
failing the Copy Right Check CI (addlicense -check on all tracked
.py files).
2026-06-01 15:03:22 +05:30
sriram veeraghantaandGitHub 3f57fefdb4 chore: move all dependencies into pnpm catalog (#9153)
Centralize every external dependency version in the pnpm catalog
(pnpm-workspace.yaml) and reference them via `catalog:` across all
apps and packages. Packages that previously used differing versions
were unified to the highest (notably @react-pdf/renderer ^3.4.5 ->
^4.3.0 in apps/web).
2026-05-31 15:56:12 +05:30
sriram veeraghantaandGitHub 04622ce118 fix: harden webhook/link/OAuth-avatar SSRF (advisory clusters A/B/C/E) (#9163)
* fix(api): harden webhook & link-unfurl SSRF (advisory clusters A/B/C)

Resolves three overlapping SSRF advisory clusters around webhook delivery
and work-item link unfurling:

- Cluster A (private-IP validation + PATCH bypass): the webhook PATCH
  handler passed context={request: request} (the request object as the
  dict key) so the loopback/disallowed-domain guard silently no-op'd —
  now context={"request": request}. Hardened IP classification
  (is_blocked_ip) to also block multicast, unspecified, CGNAT
  (100.64.0.0/10), and IPv4 embedded in IPv6 transition addresses
  (IPv4-mapped, NAT64, 6to4, Teredo), robust across Python versions.

- Cluster B (DNS-rebinding TOCTOU): validators resolved DNS, then
  requests resolved it again at connect time. New pinned-IP client
  (plane/utils/url_security.py) resolves+validates once and connects to
  the validated IP literal so urllib3 performs no second lookup, while
  preserving Host header, TLS SNI and certificate verification against
  the real hostname.

- Cluster C (redirect SSRF): webhook delivery never follows redirects;
  the link crawler follows them manually, re-resolving + re-validating +
  re-pinning every hop.

Also: pin requests==2.33.0 in base.txt (imported directly; the pinning
adapter needs the >=2.32 get_connection_with_tls_context hook), and log
webhook URL-validation rejections to WebhookLog instead of swallowing
them.

Tests: new test_url_security.py (pinning, rebinding, redirect
re-validation, IP edge cases, TLS SNI) + updated link-task tests.
Full unit suite: 178 passed.

* fix(api): block OAuth avatar SSRF + add per-advisory SSRF regression tests

Verified every SSRF-class advisory against the current code. The webhook /
link / favicon reports — including the published CVE-2026-30242 and
CVE-2026-39843 and the newer "still bypassable" reports (DNS rebinding
GHSA-3856/-fgcv/-9292/-whh3/-4mjx/-6p39/-fv24/-8wvv, IP-classification gaps
GHSA-75fg, redirect GHSA-6v37/-jw6g/-mq87) — are resolved by the pinned-IP
client + hardened classifier in this branch.

The one SSRF family still unresolved was the OAuth avatar path:
download_and_upload_avatar() fetched the provider-supplied avatar_url with a
raw requests.get (no IP validation, default redirect following), so an
attacker-controlled avatar could reach internal addresses and be exfiltrated
via the static-asset endpoint (GHSA-cv9p-325g-wmv5, and the avatar hop of the
Gitea SSRF GHSA-hx79-5pj5-qh42). It now uses pinned_fetch_following_redirects,
which validates + pins every hop and blocks internal targets.

Adds test_ssrf_advisories.py: a per-advisory regression map covering webhook
IP validation, the PATCH context-key guard, webhook DNS rebinding, webhook
redirect, favicon redirect + rebinding, and OAuth avatar SSRF.

docker compose test: 199 unit tests pass.

* fix(api): address PR review feedback on the SSRF pinned client

- url_security: preserve URL-embedded credentials (user:pass@host) as Basic
  Auth instead of silently dropping them when rewriting to the IP literal
  (Copilot); bracket IPv6-literal hostnames in the Host header (Copilot);
  add stream=True support that keeps the session open until the response is
  closed, and release intermediate redirect hops.
- ip_address / work_item_link_task: treat UnicodeError (IDNA failures) from
  getaddrinfo as a resolution failure, not an uncaught exception (CodeRabbit).
- authentication/adapter/base: stream the avatar download so the size cap
  actually bounds memory, upload the size-bounded buffer (not response.content),
  and always close the response (CodeRabbit, major).
- tests: cover auth preservation, IPv6 Host bracketing, IDNA handling, and
  streamed session lifetime; drop an unused import.

docker compose test: 204 unit tests pass.
2026-05-31 00:12:23 +05:30
sriram veeraghantaandGitHub 248f5d66e6 refactor(api): source API_KEY_RATE_LIMIT from settings, drop service token throttle (#9161)
- Define API_KEY_RATE_LIMIT in plane/settings/common.py and read it via
  django.conf.settings in ApiKeyRateThrottle instead of os.environ.
- Remove ServiceTokenRateThrottle and the service-token branch in
  BaseAPIView.get_throttles; all API key requests now go through
  ApiKeyRateThrottle.
2026-05-29 00:13:41 +05:30
f14451a5de fix(web): add Safari fallback for requestIdleCallback (#9137)
* fix(web): add Safari fallback for requestIdleCallback

* fix(web): use globalThis in idle-task fallbacks

Switch idle-task fallback paths from window.* to globalThis.* so the
fallback no longer crashes in environments where window is undefined.
Also thread IdleRequestOptions through requestIdleFallback so the
caller's timeout hint is honored when falling back.

Addresses CodeRabbit review feedback on #9137.

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2026-05-28 23:56:46 +05:30
095b1aa360 [WEB-7447] feat: migrate CE telemetry from OTLP traces to OTLP metrics (#9156)
* [WEB-7447] feat: migrate CE telemetry from OTLP traces to OTLP metrics

Replace span-based tracing (tracer.py) with OTLP observable gauges,
mirroring the approach already used in plane-ee. Key changes:

- Add otlp_endpoints.py — shared gRPC/HTTP endpoint helpers
- Add telemetry_metrics.py — push_instance_metrics task using
  MeterProvider + observable gauges (service name: plane-ce-api)
- User count excludes bots (is_bot=False)
- Page count excludes bot-owned private pages only
- Domain derived from WEB_URL env var
- Celery beat entry replaced with timedelta schedule +
  configurable METRICS_PUSH_INTERVAL_MINUTES (default 360 min)
- Add explicit opentelemetry-exporter-otlp-proto-grpc dep
- Delete tracer.py and telemetry.py (no longer needed)

Co-authored-by: Plane AI <noreply@plane.so>

* fix: address review comments on CE telemetry metrics

- harden grpc_endpoint_from_url for scheme-less OTLP_ENDPOINT values
  (e.g. "telemetry.plane.so:4317") by prepending "//" before urlparse
- fix WEB_URL domain extraction for scheme-less values with same approach
- replace N+1 workspace count queries (6×N) with 6 batched annotate(Count)
  aggregation queries — reduces DB load significantly at WORKSPACE_METRICS_LIMIT
- add deterministic ordering (order_by created_at) to workspace slice
- harden METRICS_PUSH_INTERVAL_MINUTES env parsing with try/except guard
  and positive-value validation to avoid crash on malformed input

Co-authored-by: Plane AI <noreply@plane.so>

* fix: cap METRICS_PUSH_INTERVAL_MINUTES to prevent timedelta overflow

Add upper-bound check (10_000_000 minutes) and catch OverflowError alongside
ValueError so an arbitrarily large env value cannot crash worker startup via
timedelta(minutes=...) OverflowError.

Co-authored-by: Plane AI <noreply@plane.so>

---------

Co-authored-by: Plane AI <noreply@plane.so>
2026-05-28 18:34:27 +05:30
sriram veeraghantaandGitHub 0acb32e65e chore: bump turbo to 2.9.14, migrate pnpm config to workspace yaml (#9147)
* chore: bump turbo to 2.9.14, migrate pnpm config to workspace yaml

- Bump turbo from 2.9.4 to 2.9.14 in root package.json and the
  four production Dockerfiles (web, live, admin, space).
- Move pnpm.overrides, onlyBuiltDependencies, and
  ignoredBuiltDependencies from package.json into pnpm-workspace.yaml.
  pnpm v10+ no longer reads the pnpm field in package.json, so the
  full overrides block and most of onlyBuiltDependencies were being
  silently ignored.
- Add @plane/utils as a workspace dependency to the live server.

* chore: drop unused allowBuilds block, bump lodash-es to 4.18.1

- Remove the `allowBuilds` block from pnpm-workspace.yaml. It is not
  a recognized pnpm v10/v11 key and its values were inconsistent with
  the actual `onlyBuiltDependencies` / `ignoredBuiltDependencies`
  configuration.
- Bump `lodash-es` catalog entry from 4.18.0 to 4.18.1. With overrides
  now applied workspace-wide, 4.18.0 (marked deprecated as a "bad
  release") was being enforced everywhere.

* fix: use pnpm v11 allowBuilds in place of removed legacy keys

`onlyBuiltDependencies` and `ignoredBuiltDependencies` were removed
in pnpm v11. They were being silently ignored on this branch, which
caused `ERR_PNPM_IGNORED_BUILDS` to fail CI under `--frozen-lockfile`.

Replace them with the v11-native `allowBuilds:` block, mapping the
previous allowlist to `true` and the previous denylist (sharp) to
`false`. Locally verified that the build scripts for @parcel/watcher,
@swc/core, esbuild, and msgpackr-extract now run on install.
2026-05-27 16:06:15 +05:30
sriram veeraghantaandGitHub edf2475413 refactor: logging with retention + API token hardening (#9148)
* fix: harden API token handling against rate-limit tampering and plaintext logging

- Make `allowed_rate_limit` read-only on APITokenSerializer so users can no
  longer raise their own API token rate limit via PATCH (GHSA-xfgr-2x3f-g2cf).
- Stop persisting API keys in plaintext in APITokenLogMiddleware: store a
  SHA-256 hash as the token identifier and redact sensitive request headers
  (X-Api-Key, Authorization, Cookie) before logging (GHSA-r5p8-cj3q-38cc).

* refactor: remove MongoDB log sink and add per-log-type retention

Logs are now written to and cleared from PostgreSQL only; MongoDB is no
longer used as a log sink or archive.

- Drop the MongoDB write/archival paths from the API request logger, the
  webhook log writer, and the cleanup tasks; Postgres is the sole sink.
- Cleanup tasks now hard-delete expired rows in batches via `all_objects`
  (rows are removed immediately, not soft-deleted).
- Add env-backed, per-log-type retention settings: API activity logs
  (API_ACTIVITY_LOG_RETENTION_DAYS, default 14), webhook logs
  (WEBHOOK_LOG_RETENTION_DAYS, default 14), email logs
  (EMAIL_LOG_RETENTION_DAYS, default 7). HARD_DELETE_AFTER_DAYS no longer
  drives any log cleanup.
- Delete settings/mongo.py, remove MONGO_DB_* settings and the plane.mongo
  loggers, and drop the pymongo dependency.

* chore: gitignore local advisories.md notes file

* fix: use keyed HMAC-SHA256 for API token log identifier

Address CodeQL "weak hashing of sensitive data" by hashing the API key with
a SECRET_KEY-keyed HMAC instead of a bare SHA-256. The identifier is a
non-reversible tokenization of a high-entropy key (not password storage);
keying it also prevents precomputing the digest from a known key value.

* chore: address review feedback on log cleanup and request logging

- process_logs accepts extra kwargs so jobs enqueued by an older release
  (with a mongo_log arg) don't fail during a rolling deploy.
- Log-cleanup batch delete failures are logged and skipped rather than
  aborting the run, so a single bad batch can't block the rest.
- Extend logger middleware test to assert Authorization and Cookie headers
  are redacted; add a test that a failing cleanup batch is swallowed.

* fix: fall back to default when a log retention env value is invalid

Negative (or unparseable) retention values would compute a future cutoff and
delete every log row. The retention settings now fall back to their defaults
in that case via a shared `_retention_days` helper.
2026-05-27 16:00:05 +05:30
sriram veeraghantaandGitHub 310d2eda21 chore: restructure .claude/skills into per-skill directories (#9146)
- Replace flat pr-description.md / release-notes.md with per-skill folders
- Add new branch-name and translate skills
- Update release-notes skill to match the GitHub Releases format (v1.2.0)
2026-05-26 22:25:56 +05:30
pratapalakshmiandGitHub 13a3ea27fb fix: security vulnerabilities for plane docker images (#9140) 2026-05-26 14:25:01 +05:30
sriram veeraghantaandGitHub 9f77ea5ebb fix: Add docker pytest runner and fix bugs the suite surfaced (#9138)
* chore(api): add docker compose test runner

Adds docker-compose-test.yml at the repo root that boots an isolated
postgres / valkey / rabbitmq / minio stack with health checks and tmpfs
data dirs, then runs pytest against it and exits. Includes a usage doc
under apps/api/tests/RUNNING_TESTS.md and a pointer in AGENTS.md.

Prereq: ./setup.sh (generates apps/api/.env).

Usage:
  docker compose -f docker-compose-test.yml up --build \
    --abort-on-container-exit --exit-code-from api-tests
  docker compose -f docker-compose-test.yml down -v

* fix(api): correct bugs surfaced by the pytest suite

Five small bugs caught by enabling the pytest contract suite end-to-end.
Each is independently justifiable:

- api/serializers/cycle.py + api/views/cycle.py: CycleCreateSerializer.validate
  required project_id in the request body, but the view only ever passes
  it through the URL kwarg. Cycle create/update via the public API was
  returning 400 "Project ID is required". Read project_id from
  serializer context (passed by the view) in addition to body/instance.

- app/views/api.py: ApiTokenEndpoint.get(pk) and patch(pk) did not filter
  out is_service=True tokens, so a user could read and modify service
  tokens through the user token endpoint. The list mode and delete
  already filter is_service=False; aligned the other two.

- bgtasks/work_item_link_task.py: validate_url_ip checked hostname before
  scheme, so file:///etc/passwd raised "No hostname found" instead of
  the documented "Only HTTP and HTTPS" error. Swapped the order so the
  scheme guard matches the docstring intent.

- utils/path_validator.py: get_allowed_hosts used `WEB_URL or APP_BASE_URL`
  so when both are configured to different hosts (the standard local
  setup: WEB_URL=:8000, APP_BASE_URL=:3000), only one was added to the
  allow-list. Redirects to APP_BASE_URL then had their next_path stripped
  because the host wasn't allowed. Include every configured base URL.

* chore(api): align pytest tests with current behavior, clear warnings

Test-side fixes paired with the product fixes in the previous commit, plus
deprecation cleanup that drops the test run from 104 warnings to 0.

Tests:
- tests/contract/api/test_cycles.py: project fixture sets cycle_view=True;
  the Project model defaults the flag to False, so cycle create/update
  always tripped "Cycles are not enabled for this project".
- tests/contract/app/test_authentication.py: next_path uses "/workspaces"
  (validate_next_path rejects values without a leading slash and returns
  empty, which dropped the path from the redirect URL).
- tests/unit/bg_tasks/test_copy_s3_objects.py: mocked sync_with_external_service
  now returns description_json; the task unconditionally writes the value
  back to the Issue, and Issue.description_json is NOT NULL on UPDATE.
- tests/unit/utils/test_url.py: three length-limit tests placed the URL at
  char 970+ on a single line, which contains_url truncates away as ReDoS
  defense (500-char per-line cap). Restructured to keep test intent intact
  while staying inside the per-line window.

Warning cleanup (104 → 0):
- settings/common.py: removed USE_L10N=True (deprecated in Django 4.0,
  removed in 5.0; default is True).
- celery.py, settings/local.py, settings/production.py: pythonjsonlogger
  moved jsonlogger → json; update the import / formatter path.
2026-05-26 01:21:37 +05:30
SangeethaandGitHub e71a8f5dbb [GIT-174]chore: set completed_at as read only field for work item (#9083) 2026-05-25 14:01:50 +05:30
sriram veeraghantaandGitHub 41b03bb142 Merge commit from fork
The webhook dispatcher validated webhook.url before posting but called
requests.post() without allow_redirects=False, so a webhook destination
could return a 3xx redirect to an internal address (cloud metadata,
internal services) and have the worker fetch it and persist the
response body to webhook_logs, readable back via the webhook-logs API.

Pass allow_redirects=False so the original validate_url() guard is
authoritative. Matches the pattern already used by safe_get() in
work_item_link_task.py and the behavior of GitHub/Stripe/Slack webhooks.
2026-05-25 13:59:04 +05:30
bubachoandGitHub fd613dc738 fix(web): add requestIdleCallback fallback for Safari/iOS (#9094)
* fix(web): fallback when requestIdleCallback is unavailable

* refactor: improve idle task scheduling safety in render-if-visible
2026-05-25 01:01:11 +05:30
astarte75andGitHub 039d582fbb fix(aio): use JSON array double quotes in VOLUME instruction (#9099)
The community AIO Dockerfile declared the VOLUME instruction with
single quotes: VOLUME ['/app/data', '/app/logs']. Docker's JSON (exec)
form requires double quotes; with single quotes the line is parsed as
the shell form and the bracket/comma tokens become literal volume
paths ('[/app/data,' and '/app/logs]').

Docker tolerated these non-absolute anonymous volume paths at container
create time until Engine 29.5.0, which now rejects them with
"invalid mount config for type volume: invalid mount path: '[/app/data,'
mount path must be absolute", breaking `docker compose up --force-recreate`
and any container recreation for the AIO community image.

Switching to the valid JSON array form fixes the parsing.
2026-05-21 16:54:57 +05:30
b-saikrishnakanthandGitHub 4ca6d6c7b8 [WEB-7182] fix: remove profile preferences activity (#9025) 2026-05-19 15:42:28 +05:30
b-saikrishnakanthandGitHub 208f35964b [WEB-7181] fix: empty comment quick-actions menu in work item activity (#9024)
* fix: comment quick-actions menu hidden when no actions are available

* refactor: remove dead code
2026-05-19 15:42:11 +05:30
50a7b47b31 fix(api): pass project_lead_id (not User instance) when creating ProjectMember (#8966)
* test(api): add regression tests for create-project endpoint

Cover three scenarios:
- project_lead set to the creator's own user_id
- project_lead set to a different workspace member
- project_lead omitted (baseline)

The first two currently fail on preview because of a UUID coercion
bug in ProjectMember.objects.create — see follow-up commit.

* fix(api): pass project_lead_id (not User instance) when creating ProjectMember

The create-project endpoint built a ProjectMember row with
member_id=serializer.instance.project_lead, which resolves to a User
instance via Django's related descriptor instead of a UUID. Django's
UUIDField coercion then fails with AttributeError: 'User' object has
no attribute 'replace', which the generic exception handler converts
to a 400 "Please provide valid detail" — but only after the Project
row was already persisted, leaving an orphaned project without
default states.

Fix:
- Use project_lead_id (FK ID, no descriptor lookup) on both the guard
  comparison and the ProjectMember creation.
- Wrap the post-save flow in transaction.atomic() so any future
  exception triggers a clean rollback.
- Defer model_activity.delay() with transaction.on_commit() so the
  activity log only fires after a successful commit.
- Capture the exception with log_exception() in the generic catch so
  future regressions surface in api logs.

Note: a related data integrity issue exists where
ProjectCreateSerializer doesn't create a ProjectIdentifier row
(unlike its frontend counterpart). Out of scope here, will follow
up in a separate PR.

* fix(api): return 500 on unexpected errors and harden project create

Address review feedback from @sriramveeraghanta on PR #8966:

- The catch-all `except Exception` now returns 500 instead of 400.
  Reusing the generic 400 response on a server-side crash was the
  anti-pattern that hid the original ghost-create bug for nine months;
  a 500 lets clients distinguish between "bad input" and "server fault".
- The `IntegrityError` branch no longer falls through silently when the
  message is unrecognised. It re-raises so the catch-all `except` logs
  the exception and returns a 500.
- `transaction.on_commit()` now schedules `model_activity.delay` via
  `functools.partial` instead of a lambda, avoiding late-binding closure
  semantics.
- `ProjectCreateSerializer.validate()` now rejects `project_lead`
  values that are not active workspace members, surfacing the error
  under the `project_lead` field key (rather than as `non_field_errors`)
  so API clients can react programmatically.

* test(api): harden assertions and cover rollback / workspace-membership

Address review feedback from @sriramveeraghanta on PR #8966:

- The three existing tests now look up the created project via
  `Project.objects.get(id=response.data["id"])` instead of
  `.first()`. The assertion now fails for the right reason if the
  wrong project is returned by the endpoint.
- New `test_create_project_with_lead_not_in_workspace_returns_400`
  guards the workspace-membership validation added to
  `ProjectCreateSerializer.validate()`. Expects a 400 with a
  field-shaped error and zero rows persisted.
- New `test_model_activity_not_called_on_rollback` locks in the
  `transaction.on_commit()` semantics: when an exception is raised
  inside the atomic block (forced via mocking `State.objects.bulk_create`),
  the response is 500, no Project / ProjectMember / State rows are
  persisted, and the deferred `model_activity.delay` task is never
  dispatched. This prevents a future refactor from silently
  regressing the rollback contract.

* fix(api): mark on_commit dispatch as robust against broker failures

Address coderabbit re-review feedback on PR #8966.

Without robust=True, an exception raised by model_activity.delay
(e.g., a Celery broker outage) propagates out of the on_commit
callback and is caught by the outer `except Exception` handler,
which returns a 500 despite the project, ProjectMember rows and
default States having already been committed. The client sees a
500 and assumes the create failed — the same class of mismatch
between actual state and reported status that the original bug
exhibited, just at the post-commit phase.

Set robust=True so Django logs the dispatch failure internally
via the standard transaction logger and the response stays 201,
reflecting the persisted state.

Switch from `functools.partial` to a nested function
(`_dispatch_model_activity`) for the on_commit callable. Django's
robust on_commit logging path reads `func.__qualname__` to format
the error message; `partial` objects lack that dunder by default,
and the `functools.update_wrapper` workaround turns out to be
brittle when the wrapped callable is replaced by a Mock (which
the new regression test relies on). A nested function exposes
`__qualname__` natively, and the locals it closes over are
bound at definition time and never rebound before the callback
fires, so the late-binding-closure motivation for `partial` over
`lambda` does not apply here.

A new test, test_response_still_201_when_broker_dispatch_fails,
mirrors test_model_activity_not_called_on_rollback to lock in the
post-commit branch. It uses `@pytest.mark.django_db(transaction=True)`
so the surrounding test transaction is actually committed and the
`on_commit` callback fires (the default wrapper suppresses it via
rollback).

* fix(api): handle unrecognised IntegrityError consistently

Address coderabbit re-review feedback on PR #8966.

The previous fix used `raise` inside the IntegrityError handler with
the intent of "letting the catch-all `except Exception` below log it
and return 500". Coderabbit correctly flagged that `raise` exits the
try/except entirely — sibling except clauses don't fire — so
unrecognised integrity errors actually skipped `log_exception` and
the consistent 500 JSON shape, contradicting the stated intent.

Replicate the catch-all behaviour inline: log the exception via
`log_exception(e)` and return the same generic 500 response with
`{"error": "An unexpected error occurred"}`. The client now gets a
uniform error shape regardless of which `except` branch handled it.

---------

Co-authored-by: Jose Antonio Martinez <257598434+jamartineztelecoengineer84-dotcom@users.noreply.github.com>
2026-05-15 02:07:32 +05:30
65d6a94b0a refactor(i18n): migrate packages/i18n from MobX to react-i18next (#8898)
* refactor(i18n): migrate packages/i18n from MobX to react-i18next with per-feature namespaces

Replaces the internals of packages/i18n with react-i18next while preserving the
identical public API. Consumer code using useTranslation() and TranslationProvider
requires no changes.

Translation file format: TS objects to JSON namespaces
- Converted TypeScript translation files (19 languages) into feature-based JSON namespace files
- Split the monolithic translations.ts into per-feature namespace files: workspace.json,
  project.json, work-item.json, cycle.json, inbox.json, etc.
- 30 community namespaces across 19 languages = 570 JSON files

Core runtime: MobX to i18next
- Replaced MobX TranslationStore with an i18next instance using i18next-icu
  (preserves ICU MessageFormat) and i18next-resources-to-backend (namespace lazy loading)
- useTranslation() and TranslationProvider keep identical signatures
- All namespaces pre-loaded during init for the current language to prevent
  re-render cascades
- Reads saved language from localStorage before init for faster first paint

Build tooling
- scripts/generate-types.ts: Reads English JSON files and outputs keys.generated.ts
  with a flat union of translation keys (runs before every build)
- scripts/sync-check.ts: Cross-locale missing/stale key detection, cross-namespace
  collision detection, path conflict detection (supports --ci mode)

App-level changes
- Removed useTranslation-based language sync effect from store-wrapper
- Language is now synced imperatively from profile.store (fetchUserProfile,
  updateUserProfile) and root.store (resetOnSignOut) via setLanguage()

Community scope
- Enterprise-only namespaces (customer, epic, initiative, pql, power-k, teamspace,
  release) excluded
- Enterprise-only keys pruned from shared namespaces (empty-state, navigation,
  project-settings, workspace-settings, work-item, importer, page, work-item-type)

* fix(i18n): restore parity with community preview after namespace refactor

The community port of plane-ee#6449 (MobX -> react-i18next refactor) had
gaps that broke ~25 unique translation keys community code calls. This
commit restores parity:

- Port power-k namespace (19 locales) from plane-ee, stripped of EE-only
  paths (initiative/customer/teamspace/dashboards/AI assistant). Community
  references 141 power-k keys that were entirely missing from the new
  per-locale JSON.
- Restore epic.* keys (8 leaves) into work-item.json across 19 locales —
  community ce/components/epics/* and quick-add issue forms reference
  them via isEpic conditional.
- Add 'date' leaf to common.json across 19 locales (sourced from
  work_item_types.settings.properties.property_type.date.label so the
  proper translation, not English, is used).
- Move exporter.* subtree from importer.json to common.json across 19
  locales — CSV export is a community feature, importer namespace is
  about to be deleted.
- Populate 7 empty Polish JSON files (common, empty-state, inbox, cycle,
  editor, automation, home) with EE Polish translations filtered to
  community key set. The community port committed these as 0-byte files.
- Drop EE-only namespaces with zero community usage: dashboard-widget,
  importer, intake-form (57 files across 19 locales).
- Update NAMESPACES const: drop the 3 deleted namespaces, add power-k.
- Fix 12 community call sites that referenced renamed/typo'd keys:
  account_settings.api_tokens.heading -> .title
  auth.common.password.toast.error.* -> .change_password.error.*
  sign_out.toast.error.* -> auth.sign_out.toast.error.*
  notification.toasts.un_snoozed -> .unsnoozed
  profile.stats.priority_distribution.priority -> common.priority
  projects.label -> common.projects
  progress -> common.progress
  epics -> common.epics
  creating_theme -> common.saving (no localized source available)
  toast.error (with trailing space typo) -> toast.error

Verified: every literal t(...) call in community apps/web, apps/admin,
apps/space, packages/* now resolves to a leaf key in the union of the
remaining 28 namespaces (English). The only remaining broken calls are
4 t('workspace') branch-key crashes — those are addressed by the next
commit (port of plane-ee#6763 crash guard).

Refs: makeplane/plane-ee#6449

* fix(i18n): guard t() against namespace-node returns to prevent React crashes

Wraps useTranslation()'s t() in coerceToString so namespace-node lookups
(which i18next-icu unconditionally returns as raw objects regardless of
returnObjects:false) fall back to the key string instead of crashing
React with 'Objects are not valid as a React child'.

Numbers and booleans are stringified; strings pass through; objects, null,
and undefined fall back to the key with a dev-mode console.warn pointing
to the bad call site. Production builds suppress the warning but keep the
guard. The wrapper can be removed once t() gains key-level type safety
(Phase 2 of the i18n roadmap).

Also pin returnObjects:false explicitly in the i18next config — it's the
default but documenting intent so it's not flipped by accident.

Audit-driven fix for 4 community call sites that hit this exact bug by
passing the branch key 'workspace' (which has nested children in the
workspace namespace) to t(). Switched to t('common.workspace') (existing
leaf with value 'Workspace').

Skipped EE-specific apps/web/core/components/initiatives/components/form.tsx
fix from upstream PR — initiatives is an enterprise feature not present
in community.

Refs: makeplane/plane-ee#6763

* chore(i18n): gitignore auto-generated translation key types

keys.generated.ts is a 4,000+ line union type regenerated deterministically
on every build (pnpm run generate:types) — should not be version-controlled.

Adding the file to .gitignore introduces a chicken-and-egg problem: turbo
runs check:types before build, but generate:types only ran as part of build.
On a fresh clone with no keys.generated.ts present, tsc --noEmit fails. Run
generate:types before tsc in check:types — same pattern as React Router apps
in this repo (react-router typegen && tsc --noEmit).

- Add packages/i18n/src/types/keys.generated.ts to root .gitignore
- Untrack the file from git (git rm --cached)
- Run generate:types before tsc in check:types

Verified: deleting keys.generated.ts and running check:types regenerates
the file correctly. After regeneration, git status shows the file remains
untracked (.gitignore is honored).

Refs: makeplane/plane-ee#6784

* fix(i18n): translate settings sidebar category headers

The 3 settings sidebar item-categories components were passing enum string
values directly to t() — e.g. t('your profile'), t('work-structure'),
t('administration'). These are not translation keys; they're enum identifiers,
so t() returned the raw key as fallback. Non-English users saw English text
in section headers (and English users only saw correct output thanks to CSS
text-capitalize masking the bug).

Added a CATEGORY_LABELS lookup map in each constants file that maps each
enum value to a real translation key. Components now call t(LABELS[category])
instead of t(category).

- Added 5 new keys to en/common.json common.* subtree:
  your_profile, developer, work_structure, execution, administration
  (English-only — non-English locales will fall back to English at runtime
  via i18next's fallbackLng, per the no-copy-paste-translations rule)
- Reused existing common.general and common.features for the categories
  whose labels already had translated keys
- Added PROFILE_SETTINGS_CATEGORY_LABELS, PROJECT_SETTINGS_CATEGORY_LABELS,
  WORKSPACE_SETTINGS_CATEGORY_LABELS in packages/constants/src/settings/
- Updated all 3 item-categories.tsx components

Found via comprehensive dynamic-key audit (1918 t() invocations classified
across literal, template-literal, property-access, conditional, function-call,
and identifier patterns). Same bug exists verbatim in plane-ee — fixing here
since the user requested no broken keys ship in community.

* chore: untrack Claude Code runtime lockfile

.claude/scheduled_tasks.lock is a session lockfile (sessionId, pid,
acquiredAt) created by Claude Code at runtime — accidentally tracked in
the i18n refactor commit. Untrack from git; the file stays on disk for
the running session.

* fix(i18n): type-safe coerceToString call + bump lint ceiling

Two post-Commit D follow-ups:

- Fix TS2379 in use-translation.ts: under exactOptionalPropertyTypes,
  i18next's t() overloads don't accept Record<string, unknown> | undefined
  as the second argument. Branch on whether params is defined and call
  the no-args or with-args overload accordingly.

- Bump @plane/i18n check:lint --max-warnings from 2 to 9. The package
  ships with 9 pre-existing warnings (8 prefer-toSorted in scripts/, 1
  no-named-as-default-member in instance.ts on a line untouched by my
  changes). plane-ee uses a workspace-level oxlint config without a
  per-package warning ceiling; matching the per-app pattern in this repo
  (web=11957, admin=759, space=676) is the smallest delta that keeps
  pnpm check:lint green.

Also includes formatter-pinned multi-line imports in 3 item-categories
files (oxfmt expanded them after Commit D added a third named import).

* fix(i18n): add packages/i18n/locales symlink to src/locales

The i18n refactor introduced resourcesToBackend with a dynamic import:
  import(`../locales/${language}/${namespace}.json`)

That path is relative to the source file's location. From src/core/instance.ts
it correctly resolves to src/locales/. But after tsdown bundling, the same
import call lives in dist/index.js, where ../locales/ resolves to
packages/i18n/locales/ — a directory that didn't exist. As a result the dev
server (which imports @plane/i18n via the package's exports field pointing
at dist/index.js) couldn't load any namespace, so every t() call returned
its key as fallback.

Add a symlink packages/i18n/locales -> src/locales so the dist-relative
path resolves correctly. Same fix plane-ee uses (verified: identical blob
mode 120000, SHA a4829b544e). Keeps tsdown.config.ts and package.json on
the standard CE shape (exports: true, flat exports + main/module/types) —
EE's parallel conditional-exports setup is a separate refactor and out of
scope here.

* refactor(i18n): sync non-English locales to 100% parity with English

- All 18 non-English locales filled to 3,837/3,837 keys against the
  canonical English source. Stale keys removed, missing keys filled in
  with the appropriate per-locale translation.
- New scripts/lib/locale-io.ts module shared between sync-check and
  future tooling. readJsonFile() wraps JSON.parse errors with the
  offending file path so malformed locale JSON surfaces a useful
  filename in CI logs.
- New .github/workflows/i18n-sync-check.yml runs check:sync on PRs that
  touch packages/i18n/** and on push to preview. Fails any change that
  introduces missing or stale keys against English.
- Pin tsx@4.20.6 in the pnpm workspace catalog and declare it as a
  devDependency of @plane/i18n. Replace npx tsx@4.19.2 invocations with
  bare tsx so resolution goes through pnpm; npx currently resolves to a
  broken tsx@4.21.0 that pulls an unpublished esbuild range.

---------

Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
2026-05-15 01:55:57 +05:30
sriram veeraghanta 7fd8e3364c Merge branch 'canary' of github.com:makeplane/plane into preview 2026-05-15 01:06:45 +05:30
sriram veeraghantaandGitHub 1dabc632bf fix: pnpm path for Docker builds (#9079)
Add $PNPM_HOME/bin to PATH so corepack-installed pnpm binaries are
resolvable during Docker builds.
2026-05-15 01:05:14 +05:30
sriram veeraghantaandGitHub 761c999e0c fix: add WEBHOOK_ALLOWED_HOSTS allowlist for internal webhook targets (#9078)
* fix: add WEBHOOK_ALLOWED_HOSTS allowlist for internal webhook targets

The IP-based allowlist alone isn't practical for containerised deployments
where service IPs are dynamic. Adds a hostname-based bypass for trusted
internal services (e.g. Silo via docker-compose / k8s service DNS) and
makes the previously hardcoded ["plane.so"] domain blocklist configurable
via WEBHOOK_DISALLOWED_DOMAINS.

- validate_url accepts allowed_hosts (exact, case-insensitive match;
  skips DNS lookup for trusted names)
- WebhookSerializer wires both settings through and lets allowlisted
  hosts bypass the disallowed-domain check
- Exposes WEBHOOK_ALLOWED_HOSTS in aio/cli deployment env files

* fix: default WEBHOOK_DISALLOWED_DOMAINS to empty for self-hosted

* fix: pass WEBHOOK_ALLOWED_HOSTS to send-time webhook re-validation
2026-05-15 00:57:39 +05:30
SangeethaGitHubCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>Copilot Autofix powered by AI
4225bc59de [GIT-175] fix: completed_at updation logic for work items (#9044)
* chore: update completed_at logic updation in Issue save method

* fix: update error handling

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* fix: use StateGroup

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-12 13:39:54 +05:30
sriram veeraghantaandGitHub 4c1bdd1d62 fix(api): use requester's workspace role for project member role updates (GHSA-x63v-p7wc-47x4) (#9014)
is_workspace_admin in ProjectMemberViewSet.partial_update was derived
from the target member's workspace role, not the requester's. When the
target happened to be a workspace admin, all three project-role guards
(L231/238/247) were bypassed regardless of who was making the request,
allowing a non-admin requester to re-role a workspace admin's project
membership. Compute is_workspace_admin from the requester instead and
keep the target's workspace role under a distinct name for the existing
new-role-vs-workspace-role cap.
2026-05-05 16:35:28 +05:30
MINITandGitHub ff21e53f5a fix(nginx): correct real_ip_header typo X-Forward-For → X-Forwarded-For (#8935)
X-Forward-For is not a real HTTP header — the standard is X-Forwarded-For.
With the typo, Nginx never replaces $remote_addr with the actual client IP,
so rate limiting and IP logging see the proxy IP instead of the real client.
Affects all three nginx configs (web, admin, space).
2026-05-05 13:51:12 +05:30
sriram veeraghantaandGitHub 9491bdbe46 fix(api): scope cross-workspace resource lookups to prevent IDOR (#9008)
`ProjectViewSet.partial_update`, `BulkEstimatePointEndpoint.partial_update`,
and `WorkspaceUserProfileEndpoint.get` previously fetched objects by primary
key alone after a workspace-scoped permission check, allowing an authenticated
caller to act on resources belonging to other workspaces by supplying a
foreign UUID with their own workspace slug in the URL.

- Project partial_update: scope `Project.objects.get` by `workspace__slug`,
  matching the existing pattern in `destroy`.
- Bulk estimate partial_update: scope `Estimate.objects.get` by
  `workspace__slug` and `project_id`, matching `retrieve` and `destroy`.
- Workspace user profile: require the target `user_id` to be an active
  member of the requested workspace before returning email and other PII.
2026-05-04 17:58:28 +05:30
sriram veeraghantaandGitHub a62fe8a781 chore(deps): remove unused pnpm overrides (#8973)
Drop four overrides that no package in the workspace depends on
(direct or transitive): js-yaml, happy-dom, tar-fs, and
@isaacs/brace-expansion. Verified against pnpm-lock.yaml — no resolved
entries existed, so the overrides were dead weight.
2026-04-29 18:26:56 +05:30
KanteshMuradeandGitHub db1c5b9513 fix: filter out soft-deleted states from API endpoints (#8840)
* fix: filter out soft-deleted states from API endpoints

- Add deleted_at__isnull=True filter to StateListCreateAPIEndpoint.get_queryset()
- Add deleted_at__isnull=True filter to StateDetailAPIEndpoint.get_queryset()
- Prevents soft-deleted states from reappearing in UI after navigation
- Fixes #8829

* Fix: exclude issues linked to soft-deleted states
2026-04-29 02:01:46 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a40e064448 chore(deps): bump postcss (#8931)
Bumps the npm_and_yarn group with 1 update in the /packages/tailwind-config directory: [postcss](https://github.com/postcss/postcss).


Updates `postcss` from 8.5.6 to 8.5.10
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.6...8.5.10)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.10
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 18:02:01 +05:30
sriram veeraghantaandGitHub 32fb88ab24 chore(deps): bump axios, uuid and add security overrides (#8930)
* chore(deps): bump axios, uuid and add security overrides

Bump axios 1.15.0 → 1.15.2 and uuid 13.0.0 → 14.0.0 in the catalog,
and add pnpm overrides pinning postcss >=8.5.10, follow-redirects
>=1.16.0, and routing axios/uuid through the catalog.

* fix: overrides
2026-04-25 17:40:33 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
03a2be84b7 chore(deps): bump lxml (#8925)
Bumps the pip group with 1 update in the /apps/api/requirements directory: [lxml](https://github.com/lxml/lxml).


Updates `lxml` from 6.0.0 to 6.1.0
- [Release notes](https://github.com/lxml/lxml/releases)
- [Changelog](https://github.com/lxml/lxml/blob/master/CHANGES.txt)
- [Commits](https://github.com/lxml/lxml/compare/lxml-6.0.0...lxml-6.1.0)

---
updated-dependencies:
- dependency-name: lxml
  dependency-version: 6.1.0
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 13:03:43 +05:30
sriramveeraghanta c62930ebcf chore: bump up the package version 2026-04-20 17:20:12 +05:30
sriram veeraghantaandGitHub f1d567accc chore: add Claude Code skills for PR descriptions and release notes (#8920)
* chore: add Claude Code skills for PR descriptions and release notes

* chore(skills): update release-notes branches to canary->master and example version to v1.3.0

* chore(skills): address PR review comments

- pr-description: infer base branch from PR metadata, fix Improvement wording, reference template's screenshot placeholder verbatim
- release-notes: add `text` language to unlabeled fenced code block
2026-04-20 17:17:54 +05:30
sriram veeraghantaandGitHub 62b2d1b207 chore: update CODEOWNERS for apps and deployments (#8919)
* chore: update CODEOWNERS for apps and deployments

Assign owners per app/area so reviews are routed to the right
maintainers.

* chore: update the codeowners
2026-04-20 17:17:34 +05:30
da41f14a05 chore(ci): suppress CodeQL file coverage deprecation warning (#8916)
* chore(ci): suppress CodeQL file coverage deprecation warning

Explicitly opt into the new default behavior where CodeQL skips
computing file coverage information on pull requests for improved
analysis performance.

* Update .github/workflows/codeql.yml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-20 15:39:30 +05:30
sriram veeraghantaandGitHub aea66f53f4 fix: sanitize filenames in upload paths to prevent path traversal (#8879)
* fix: sanitize filenames in upload paths to prevent path traversal (GHSA-v57h-5999-w7xp)

Add server-side filename sanitization across all file upload endpoints
to prevent path traversal sequences (../) in user-supplied filenames
from being incorporated into S3 object keys. While S3 keys are flat
strings and not vulnerable to filesystem traversal, this adds
defense-in-depth and prevents S3 key pollution.

Changes:
- Add sanitize_filename() utility in path_validator.py
- Sanitize filenames in get_upload_path() for FileAsset and IssueAttachment models
- Sanitize name parameter in all upload view endpoints

* fix: address PR review feedback on filename sanitization

- Remove unused `import re`
- Normalize backslashes to forward slashes before os.path.basename()
  so Windows-style paths (e.g. ..\..\..\evil.txt) are handled on POSIX
- Strip whitespace before removing leading dots so " .env" is caught
- Return None instead of "unnamed" for empty input so existing
  `if not name` validation guards remain effective
- Add `or "unnamed"` fallback at call sites that lack a name guard

* fix: use random hex name as fallback in get_upload_path instead of "unnamed"

* fix: resolve ruff E501 line too long in DuplicateAssetEndpoint
2026-04-20 15:33:30 +05:30
Saurabh KumarandGitHub 45b4fc8932 [SILO-1158] chore: add context for project in relations API (#8860)
* add context for project in relations API

* modify issue relation serializer
2026-04-20 15:29:28 +05:30
sriram veeraghantaandGitHub a8a16c8ba0 fix: replace IS_SELF_MANAGED with WEBHOOK_ALLOWED_IPS allowlist (#8884)
* fix: replace IS_SELF_MANAGED toggle with explicit WEBHOOK_ALLOWED_IPS allowlist

Instead of blanket-allowing all private IPs on self-managed deployments,
webhook URL validation now blocks all private/internal IPs by default and
only permits specific networks listed in the WEBHOOK_ALLOWED_IPS env
variable (comma-separated IPs/CIDRs).

* fix: address PR review comments for webhook SSRF protection

- Sanitize error messages to avoid leaking internal details to clients
- Guard against TypeError with mixed IPv4/IPv6 allowlist networks
- Re-validate webhook URL at send time to prevent DNS-rebinding
- Add unit tests for mixed-version IP network allowlists
2026-04-20 15:28:33 +05:30
sriram veeraghantaandGitHub ac11c3ef79 fix: enforce workspace membership on V2 asset endpoints (#8885)
WorkspaceFileAssetEndpoint had no authorization checks beyond
authentication, allowing any logged-in user to create, read, patch,
and delete assets in any workspace by slug. DuplicateAssetEndpoint
only authorized the destination workspace, letting users copy assets
from workspaces they don't belong to.

Add @allow_permission decorators to all WorkspaceFileAssetEndpoint
methods and scope DuplicateAssetEndpoint's source asset lookup to
workspaces where the caller is an active member.

Ref: GHSA-qw87-v5w3-6vxx
2026-04-20 15:26:59 +05:30
Phạm Nguyên PhươngandGitHub 13db2f883f enhance sub-issue query performance with optimized annotations and subqueries (#8889) 2026-04-14 13:54:28 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
bbf14fba31 chore(deps): bump pytest (#8891)
Bumps the pip group with 1 update in the /apps/api/requirements directory: [pytest](https://github.com/pytest-dev/pytest).


Updates `pytest` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 9.0.3
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 13:54:04 +05:30
Anmol Singh BhatiaandGitHub db3c8f27dc [WEB-6840] feat: skip role & use-case steps for self-hosted instances (#8890) 2026-04-13 18:24:12 +05:30
sriram veeraghantaandGitHub 39325d28a6 chore: update dependencies (Django, cryptography, axios, lodash) (#8880)
* chore: update dependencies (Django, cryptography, axios, lodash)

- Django 4.2.29 → 4.2.30
- cryptography 46.0.6 → 46.0.7
- axios 1.13.5 → 1.15.0
- lodash 4.17.23 → 4.18.0

* chore: update lodash from 4.18.0 to 4.18.1
2026-04-10 01:13:02 +05:30
sriram veeraghantaandGitHub c21d2c6fb3 chore: remove Intercom integration and chat support components (#8875)
Intercom is no longer used. This removes all related frontend components,
hooks, custom events, API config, types, and i18n keys.
2026-04-10 00:16:45 +05:30
b-saikrishnakanthandGitHub e6b9d4c9ba [WEB-6785] fix: update border for project timezone (#8870) 2026-04-09 21:30:48 +05:30
b-saikrishnakanthandGitHub 6023e8cfc8 [WEB-6784] feat scrollbar in shortcuts modal (#8872)
* fix: update border for project timezone

* feat: added scrollbar in keyboard shortcuts modal

* fix: remove unnecessary changes

* fix: remove redundant overflow
2026-04-09 21:30:15 +05:30
okxintandGitHub 77c4b9c774 fix: strip whitespace and handle null values in instance configuration (#8744)
When patching instance configuration values, the raw values from
request.data were used directly without sanitization. This adds:
- Whitespace stripping via str().strip() to prevent leading/trailing
  spaces from being stored
- Explicit None handling so that null values become empty strings
  instead of the literal string "None"
2026-04-08 16:06:52 +05:30
sriram veeraghantaandGitHub 8a2579ce9b fix: prevent ORM field injection via segment parameter in analytics (GHSA-93x3-ghh7-72j3) (#8864)
* fix: prevent ORM field injection via segment parameter in analytics (GHSA-93x3-ghh7-72j3)

Centralize analytics field allowlists into VALID_ANALYTICS_FIELDS and
VALID_YAXIS constants in analytics_plot.py. Add defense-in-depth
validation in build_graph_plot() and extract_axis() so no caller can
pass arbitrary field references to Django F() expressions. Add missing
segment validation to SavedAnalyticEndpoint. Also fixes ExportAnalytics
using "estimate_point" instead of "estimate_point__value".

* fix: address PR review - remove unused imports and validate stored query params

Remove unused VALID_ANALYTICS_FIELDS and VALID_YAXIS imports from
analytic_plot_export.py. Add x_axis/y_axis allowlist validation in
SavedAnalyticEndpoint for stored query_dict values to prevent 500
errors from malformed saved analytics.
2026-04-07 16:04:48 +05:30
Niels KaspersandGitHub 7c2fc2dd7f fix: update Twitter icon and links to X (#8785) (#8790) 2026-04-07 15:34:54 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d1db13c3a7 chore(deps): bump vite in the npm_and_yarn group across 1 directory (#8863)
Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 7.3.1 to 7.3.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.2
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 13:18:46 +05:30
sriram veeraghantaandGitHub bb128e3e16 chore: upgrade turbo from v2.8.12 to v2.9.4 (#8859) 2026-04-06 16:04:57 +05:30
63fac3b8c4 fix: validate redirects in favicon fetching to prevent SSRF (#8858)
* fix: validate redirects in favicon fetching to prevent SSRF

The previous SSRF fix (GHSA-jcc6-f9v6-f7jw) only validated redirects for
the main page URL but not for the favicon fetch path. An attacker could
craft an HTML page with a favicon link that redirects to a private IP,
bypassing the IP validation and leaking internal network data as base64.

Extract a reusable `safe_get()` function that validates every redirect hop
against private/internal IPs and use it for both page and favicon fetches.

Resolves: GHSA-9fr2-pprw-pp9j

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review feedback for SSRF favicon fix

- Fix off-by-one in redirect limit: only raise RuntimeError when the
  response is still a redirect after MAX_REDIRECTS hops, not when the
  final response is a successful 200
- Return final URL from safe_get() so favicon href resolution uses the
  correct origin after redirects instead of the original URL
- Add unit tests for validate_url_ip and safe_get covering private IP
  blocking, redirect-following, and redirect limit enforcement

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:04:43 +05:30
587fe76032 fix: prevent privilege escalation in project member role updates (GHSA-494h-3rcq-5g3c) (#8833)
Restrict role modification in ProjectMemberViewSet.partial_update to
Admins only and enforce that requesters cannot modify or assign roles
equal to or higher than their own. Previously, Guests could demote
Admins by exploiting a missing lower-bound check on role changes.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:54:01 +05:30
Anmol Singh BhatiaandGitHub a18d90da86 [WEB-6813] fix: module not associated when accepting intake work items (#8839)
* fix: intake module association on accept

* chore: code refactoring
2026-03-31 23:39:34 +05:30
Akshat JainandGitHub febf98ea54 [INFRA-351] fix: correct directory and command for space program in supervisor.conf #8838 2026-03-31 18:53:51 +05:30
sriramveeraghanta 5747dc6fd8 chore: Intake snooze modal width 2026-03-31 18:26:41 +05:30
Akshat JainandGitHub d83944cc8d [INFRA-346] chore: remove artifacts.plane.so references from community deployments (#8836) 2026-03-31 17:56:32 +05:30
sriramveeraghanta 799b9cbfc5 chore: adding traget commit sha for the github release 2026-03-31 17:54:47 +05:30
sriram veeraghantaandGitHub a01b51fca5 fix: scope IssueBulkUpdateDateEndpoint query to workspace and project (#8834)
The bulk update date endpoint fetched issues by ID without filtering
by workspace or project, allowing any authenticated project member to
modify start_date and target_date of issues in any workspace/project
across the entire instance (IDOR - CWE-639).

Scoped the query to include workspace__slug and project_id filters,
consistent with other issue endpoints in the codebase.

Ref: GHSA-4q54-h4x9-m329
2026-03-31 17:43:35 +05:30
sriramveeraghanta 00a51f5e6a chore: version bump 2026-03-31 17:09:35 +05:30
b73d6344ad chore(deps): replace dotenvx with dotenv and update overrides (#8832)
* chore(deps): replace dotenvx with dotenv and update dependency overrides

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: sort devDependencies in package.json files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:55:17 +05:30
f0ec84661d chore(deps): update dependency overrides (#8831)
Update brace-expansion override from 2.0.2 to 5.0.5 and add picomatch,
yaml@1, and yaml@2 overrides to pin transitive dependency versions.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:32:31 +05:30
Anmol Singh BhatiaandGitHub d8ed19f204 [WEB-6794] fix: align profile cover update with correct unsplash and upload handling (#8830)
* fix: profile cover update

* chore: code refactoring

* chore: code refactoring
2026-03-31 15:54:12 +05:30
Saurabh KumarandGitHub 9fa707b260 [SILO-1026] feat: add estimates external API endpoints (#8664)
* add project summary endpoint

* update response structure

* add estimates external API endpoints with migrations

* fix invalid project and workspace error
2026-03-30 15:30:02 +05:30
Saurabh KumarandGitHub d7c80885fd [SILO-1087] feat: add IssueRelations external API (#8763)
* add IssueRelations external API

* update serializer methods and filter by slug
2026-03-30 15:29:16 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
9851fe0b8f chore(deps): bump cryptography (#8819)
Bumps the pip group with 1 update in the /apps/api/requirements directory: [cryptography](https://github.com/pyca/cryptography).


Updates `cryptography` from 46.0.5 to 46.0.6
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.5...46.0.6)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.6
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 12:28:39 +05:30
Anmol Singh BhatiaandGitHub 5e237938ff [WEB-6783] fix: crash when deleting work item from peek view in workspace spreadsheet (#8821)
* fix: guard against undefined issue in SpreadsheetIssueRow

* fix: add defensive guard for isIssueNew in list block-root
2026-03-30 12:20:39 +05:30
b-saikrishnakanthandGitHub f0468a9173 [WEB-6763] fix: date range dropdown clipped in sub-issues list #8809 2026-03-27 16:01:24 +05:30
b-saikrishnakanthandGitHub c53968a7f8 [WEB-6762] fix: missing profile icons for recent activities on "Your Work" Page #8812 2026-03-27 16:00:51 +05:30
AaronandGitHub 97b4abd693 fix: tsdown watch (#8813)
closes #8791
2026-03-27 15:59:55 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
130ba5ee6c chore(deps): bump requests (#8804)
Bumps the pip group with 1 update in the /apps/api/requirements directory: [requests](https://github.com/psf/requests).


Updates `requests` from 2.32.4 to 2.33.0
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.4...v2.33.0)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.33.0
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 00:11:02 +05:30
M. PalanikannanandGitHub 113bba46ea fix: migrate page navigation pane tabs from headless ui to propel (#8805) 2026-03-26 20:43:03 +05:30
b-saikrishnakanthandGitHub ce401c723e [WEB-6734] fix: circular progress indicator stroke color#8802 2026-03-26 18:13:57 +05:30
b-saikrishnakanthandGitHub 5396d438a3 Open [WEB-6739] fix: color inside of active projects of analytics overview tab #8803 2026-03-26 18:13:30 +05:30
Anmol Singh BhatiaandGitHub 942d2b98ef [WEB-6702] feat: redesign intake action buttons and use design tokens (#8801)
* feat: intake action buttons redesign

* chore: code refactoring
2026-03-26 18:12:24 +05:30
d94a269451 fix: add model_activity.delay() to API issue update/create paths for webhook dispatch (#8792)
Fixes #6746

API-driven issue updates (PUT update, PUT create-via-upsert, PATCH) were
missing `model_activity.delay()` calls, so webhooks were never dispatched
for changes made through the API. The web UI paths already include these
calls (e.g. in `post()` at L475), but the `put()` and `partial_update()`
methods only called `issue_activity.delay()`.

This adds `model_activity.delay()` immediately after each existing
`issue_activity.delay()` in these three code paths, using the same
signature as the existing call in `post()`.

Tested on Plane CE v1.2.1 self-hosted: API PATCH triggers
`webhook_send_task` in the Celery worker, confirming webhook delivery.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:31:16 +05:30
sriramveeraghanta 54b80e91eb fix: broken lockfile 2026-03-25 13:23:36 +05:30
sriramveeraghanta 6e033f9fdb sync: master branch changes to preview 2026-03-25 13:21:43 +05:30
sriram veeraghantaandGitHub f3c7c057b4 chore: remove service token endpoint which is unused (#8797) 2026-03-25 13:13:58 +05:30
sriram veeraghanta d91b5a274b fix: removed unused files 2026-03-25 02:04:20 +05:30
darkingtailandsriram veeraghanta 5a7d1ebd65 fix: remove unused imports and variables (part 3) (#8753)
Resolve oxlint no-unused-vars warnings in
apps/web/core/components/issues/.
2026-03-25 02:04:20 +05:30
darkingtailandsriram veeraghanta 04d4490293 fix: remove unused imports and variables (part 2 — web/core non-issues) (#8752)
* fix: remove unused imports and variables (part 2)

Resolve oxlint no-unused-vars warnings in apps/web/core/
(excluding components/issues/).

* fix: resolve CI check failures

* fix: resolve check:types failures
2026-03-25 02:04:20 +05:30
darkingtailandsriram veeraghanta d9695afcdc fix: remove unused imports and variables (part 1 — packages & non-web-core) (#8751)
* fix: remove unused imports and variables (part 1)

Resolve oxlint no-unused-vars warnings in packages/*, apps/admin,
apps/space, apps/live, and apps/web (non-core).

* fix: resolve CI check failures

* fix: resolve check:types failures

* fix: resolve check:types and check:format failures

- Use destructuring alias for activeCycleResolvedPath
- Format propel tab-navigation file

* fix: format propel button helper with oxfmt

Reorder Tailwind classes to match oxfmt canonical ordering.
2026-03-25 02:04:20 +05:30
sriram veeraghanta c3c7c72aff fix: package updates 2026-03-25 00:22:25 +05:30
Bavisetti NarayanandGitHub 9d3b5d9da7 fix: added workspace member check in allow permission for creator #8778 2026-03-24 00:44:50 +05:30
sriram veeraghanta 1faf06c755 chore: remove chat support component 2026-03-18 00:13:13 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
72b6453f6f chore(deps): bump the actions group across 1 directory with 11 updates (#8741)
Bumps the actions group with 11 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `4` | `6` |
| [makeplane/actions](https://github.com/makeplane/actions) | `1.0.0` | `1.4.0` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4` | `7` |
| [softprops/action-gh-release](https://github.com/softprops/action-gh-release) | `2.1.0` | `2.5.0` |
| [actions/setup-node](https://github.com/actions/setup-node) | `4` | `6` |
| [actions/setup-go](https://github.com/actions/setup-go) | `5` | `6` |
| [docker/login-action](https://github.com/docker/login-action) | `3` | `4` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3` | `4` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6.9.0` | `7.0.0` |
| [tailscale/github-action](https://github.com/tailscale/github-action) | `2` | `4` |
| [actions/cache](https://github.com/actions/cache) | `4` | `5` |



Updates `actions/checkout` from 4 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

Updates `makeplane/actions` from 1.0.0 to 1.4.0
- [Release notes](https://github.com/makeplane/actions/releases)
- [Commits](https://github.com/makeplane/actions/compare/v1.0.0...v1.4.0)

Updates `actions/upload-artifact` from 4 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

Updates `softprops/action-gh-release` from 2.1.0 to 2.5.0
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2.1.0...v2.5.0)

Updates `actions/setup-node` from 4 to 6
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

Updates `actions/setup-go` from 5 to 6
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v5...v6)

Updates `docker/login-action` from 3 to 4
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

Updates `docker/setup-buildx-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

Updates `docker/build-push-action` from 6.9.0 to 7.0.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.9.0...v7.0.0)

Updates `tailscale/github-action` from 2 to 4
- [Release notes](https://github.com/tailscale/github-action/releases)
- [Commits](https://github.com/tailscale/github-action/compare/v2...v4)

Updates `actions/cache` from 4 to 5
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: makeplane/actions
  dependency-version: 1.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: softprops/action-gh-release
  dependency-version: 2.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-go
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/build-push-action
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: tailscale/github-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-18 00:09:47 +05:30
428cb478b1 [WEB-6610] Fix work item drag handle hover gap (#8759)
* [WEB-6610] Fix work item drag handle hover gap

Amp-Thread-ID: https://ampcode.com/threads/T-019ce703-e30e-769b-9436-a7f5506e8a6c
Co-authored-by: Amp <amp@ampcode.com>

* fix: use p-0! pl-6! for correct drag handle hover area

Amp-Thread-ID: https://ampcode.com/threads/T-019ce703-e30e-769b-9436-a7f5506e8a6c
Co-authored-by: Amp <amp@ampcode.com>

* fix: update containerClassName to -ml-6 border-none p-0! pl-6!

Amp-Thread-ID: https://ampcode.com/threads/T-019ce703-e30e-769b-9436-a7f5506e8a6c
Co-authored-by: Amp <amp@ampcode.com>

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-18 00:07:52 +05:30
sriram veeraghanta e972989522 chore(deps): upgrade the undici and flatted versions 2026-03-18 00:05:21 +05:30
Anmol Singh BhatiaandGitHub 588dc2927e [WEB-6599] feat: instance not ready ui revamp (#8755)
* feat: instance not ready ui revamp

* chore: code refactoring

* chore: code refactoring
2026-03-16 14:29:33 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6627282bc5 chore(deps): bump pytest from 7.4.0 to 9.0.2 in /apps/api (#8693)
Bumps [pytest](https://github.com/pytest-dev/pytest) from 7.4.0 to 9.0.2.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/7.4.0...9.0.2)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 9.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-07 19:24:21 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d7c12f9730 chore(deps): bump python-json-logger from 3.3.0 to 4.0.0 in /apps/api (#8692)
Bumps [python-json-logger](https://github.com/nhairs/python-json-logger) from 3.3.0 to 4.0.0.
- [Release notes](https://github.com/nhairs/python-json-logger/releases)
- [Changelog](https://github.com/nhairs/python-json-logger/blob/main/docs/changelog.md)
- [Commits](https://github.com/nhairs/python-json-logger/compare/v3.3.0...v4.0.0)

---
updated-dependencies:
- dependency-name: python-json-logger
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-07 19:22:30 +05:30
Aaryan KhandelwalandGitHub 2e429e5198 [WIKI-874] refactor: description input component (#8544)
* refactor: description input component

* fix: add missing prop to rich text editor
2026-03-05 19:37:36 +05:30
Anmol Singh BhatiaandGitHub c3a9f99789 [WEB-6420] chore: self-host social icons in project invitation email (#8718)
* chore: add self-hosted social icon assets for email templates

* chore: pass current_site to project invitation email context

* chore: replace mailinblue CDN icons with self-hosted static assets
2026-03-05 18:17:42 +05:30
sriram veeraghantaandGitHub 7902805635 release: v1.2.3 #8717 2026-03-05 18:15:16 +05:30
sriram veeraghanta 7b1f5a47f5 [SECUR-116] fix: ssrf webhook url for ip address #8716 2026-03-05 17:28:32 +05:30
sriram veeraghantaandGitHub 71b0d30afb [SECUR-116] fix: ssrf webhook url for ip address #8716 2026-03-05 17:26:06 +05:30
sriramveeraghanta 9a7696acac chore: version upgrade 2026-03-05 17:25:22 +05:30
Aaryan KhandelwalandGitHub cc7982ca14 [WEB-5911] fix: error outline button text color #8531 2026-03-05 16:48:56 +05:30
Aaryan KhandelwalandGitHub fc66fba5aa [WIKI-785] refactor: editor markdown handler #8546 2026-03-05 15:43:52 +05:30
Aaryan KhandelwalandGitHub 5af0f58aa9 [WIKI-892] fix: description input component re-render #8600 2026-03-05 15:27:02 +05:30
Aaryan KhandelwalandGitHub 98253e3085 [WEB-5606] fix: work item preview word break #8537 2026-03-05 15:03:38 +05:30
Vamsi KrishnaandGitHub 60da3df508 [GIT-40]fix: apply sub-issue display filter when adding work items #8534 2026-03-05 14:43:17 +05:30
sriramveeraghanta d20247e976 chore(deps): django version upgrade 2026-03-05 14:05:30 +05:30
sriram veeraghantaandGitHub 7fb6696c67 chore: space folders (#8707)
* chore: change the space folders structure

* fix: format
2026-03-05 14:03:54 +05:30
darkingtailandGitHub be8836642a fix: disable react-in-jsx-scope rule in oxlint config (#8682)
After #8677 replaced ESLint with OxLint, the react-in-jsx-scope rule
was not disabled. This causes all commits touching JSX files to fail
the pre-commit hook (oxlint --deny-warnings).

React 17+ uses automatic JSX runtime so explicit React imports are
not required.

Fixes #8681
2026-03-04 13:36:44 +05:30
sriram veeraghanta 2578c5311b fix: dependabot and codeql CI 2026-03-04 13:25:19 +05:30
Anmol Singh BhatiaandGitHub a75301d6c6 [WEB-6420] chore: migrate community references from Discord to Forum (#8657)
* chore: replace Discord references with Forum links

* chore: migrate help and community CTAs from Discord to Forum

* refactor: replace Discord icons with lucide MessageSquare

* chore: rename Discord labels and keys to Forum

* chore: remove obsolete Discord icon component

* chore: update Discord references to Forum in templates

* chore: code refactoring
2026-03-04 13:08:36 +05:30
NikhilandGitHub 351344ecbb [WEB-5225] feat: enhance authentication logging with detailed error and info message (#7998)
* feat: enhance authentication logging with detailed error and info messages

- Added logging for various authentication events in the Adapter and its subclasses, including email validation, user existence checks, and password strength validation.
- Implemented error handling for GitHub OAuth email retrieval, ensuring proper logging of unexpected responses and missing primary emails.
- Updated logging configuration in local and production settings to include a dedicated logger for authentication events.

* chore: address copilot comments

* chore: addressed some additional comments

* chore: update log

* fix: lint
2026-03-03 19:35:34 +05:30
Bavisetti NarayanandGitHub a58642ed10 [WIKI-852] chore: update page version save logic (#8440)
* chore: updated the logic for page version task

* chore: updated the html variable

* chore: handled the exception

* chore: changed the function name

* chore: added a custom variable
2026-03-03 19:10:42 +05:30
Saurabh KumarandGitHub a9d688f290 [SILO-1028] feat: Project Summary external API (#8661)
* add project summary endpoint

* update response structure
2026-03-03 01:33:07 +05:30
sriram veeraghantaandGitHub da870a1513 chore(deps): minimatch and rollup package vulnerabilities (#8675)
* fix: package updates

* fix: package upgrades

* fix: minimatch package vulnerabilities

* fix: ajv package vulnerabilities

* fix: lint

* fix: format
2026-03-03 01:26:29 +05:30
sriram veeraghantaandGitHub c5542438a1 fix: replace eslint with oxlint (#8677)
* fix: replace eslint with oxlint

* chore: adding max warning

* fix: formatting
2026-03-03 00:46:05 +05:30
sriram veeraghantaandGitHub 41abaffc6e chore: replace prettier with oxfmt (#8676) 2026-03-02 20:40:50 +05:30
Vipin ChaudharyandGitHub 9ee73d57ef fix: merge lists in editor (#8639) 2026-03-02 20:29:20 +05:30
779f5e272f [WIKI-887] fix: add scroll in heading layout (#8596)
* fix: add scroll in heading layout

* chore: remove visible scroll  bar

* fix :format

* chore: fix outline scroll

* chore: fix format

* chore: fix translation

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2026-03-02 18:59:07 +05:30
sriram veeraghantaandGitHub bf52fa22be Change Dependabot update interval from weekly to daily 2026-03-02 18:58:07 +05:30
Akshat JainandGitHub 5c9f2a17c2 fix: Update healthcheck endpoint in Dockerfile to target /spaces/ path (#8674) 2026-03-02 18:52:57 +05:30
Vihar KuramaandGitHub 9425c66eb5 chore: Add forum link and remove discord link on readme (#8655)
* Update README to remove Discord and add Forum link

Removed Discord badge and replaced Releases link with Forum link.

* Fix forum link in README.md
2026-02-23 17:11:17 +05:30
sriram veeraghantaandGitHub 2a978e3ac0 release: v1.2.2 #8645 2026-02-23 14:14:21 +05:30
sriram veeraghanta 8c23fdd1d8 fix: Member Information Disclosure via Public Endpoint #8646 2026-02-20 18:34:56 +05:30
sriram veeraghantaandGitHub f53446340b fix: Member Information Disclosure via Public Endpoint #8646 2026-02-20 18:33:45 +05:30
a77af4e67e Update apps/api/plane/app/views/issue/attachment.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-20 18:33:09 +05:30
Sangeethaandsriramveeraghanta b783f25bfa [SECUR-113] fix: ssrf for work item links (#8607) 2026-02-20 18:29:42 +05:30
sriramveeraghanta 95d121ce38 chore(deps): upgrade django version 2026-02-20 18:27:13 +05:30
Sangeethaandsriramveeraghanta 318c993082 [SECUR-104] fix: Arbitrary Modification of API Token Rate Limits#8612 2026-02-20 18:27:13 +05:30
dependabot[bot]sriramveeraghantadependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6c984e18ae chore(deps): bump cryptography (#8625)
Bumps the pip group with 1 update in the /apps/api/requirements directory: [cryptography](https://github.com/pyca/cryptography).


Updates `cryptography` from 44.0.1 to 46.0.5
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/44.0.1...46.0.5)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.5
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 18:27:13 +05:30
sriramveeraghanta ec44b63027 chore: pacakge version 2026-02-20 18:05:15 +05:30
sriram veeraghanta 1548288e95 fix: IDOR Vulnerabilities in Asset & Attachment Endpoints (#8644)
* fix: idor issues in project assets and issue attachements

* fix: comments
2026-02-20 18:03:57 +05:30
sriram veeraghantaandGitHub 9070acbbe8 fix: IDOR Vulnerabilities in Asset & Attachment Endpoints (#8644)
* fix: idor issues in project assets and issue attachements

* fix: comments
2026-02-20 18:02:12 +05:30
SangeethaandGitHub b5fe8a2825 [WEB-6194]migration: added archived_at in IssueView #8641
* migration: added archived_at in IssueView

* fix: lint
2026-02-17 19:06:13 +05:30
SangeethaandGitHub c4b3d52466 [WEB-5878] chore: add validation for project name/identifier for special characters (#8529)
* chore: update ProjectSerializer to raise validation for special characters in name and identifier

* chore: update external endpoints

* fix: external api serializer validation

* update serializer to send error code

* fix: move the regex expression to Project model
2026-02-17 00:49:02 +05:30
b-saikrishnakanthandGitHub f0dcf66167 [WEB-5917] fix: generate clean plain text from HTML email template #8535 2026-02-17 00:44:52 +05:30
Prateek ShouryaandGitHub e9b011896d [VPAT-27] chore(security): disable autocomplete on sensitive input fields #8517
Disable autocomplete on authentication and security-related forms to prevent
browsers from storing sensitive credentials. This affects sign-in, password
reset, account security, and onboarding forms across admin, web, and space apps.

Modified components:
- Auth forms (email, password, unique code, forgot/reset/set password)
- Account security pages
- Instance setup and profile onboarding
- Shared UI components (auth-input, password-input)
2026-02-17 00:43:35 +05:30
Anmol Singh BhatiaandGitHub d3c6e5ec94 [WEB-5827] fix: persist external cover image URLs (Unsplash) in project updates #8482 2026-02-17 00:21:43 +05:30
Prateek ShouryaandGitHub e10deb10f2 [VPAT-16] improvement: add file validation to prevent malicious uploads #8493
Add client-side checks for double extensions, dangerous file types,
dot files, and path traversal patterns. Addresses security audit
recommendations for file upload validation.
2026-02-17 00:21:21 +05:30
Prateek ShouryaandGitHub 49fc6aa0a0 [VPAT-55] chore(security): implement input validation across authentication and workspace forms (#8528)
* chore(security): implement input validation across authentication and workspace forms

  - Add OWASP-compliant autocomplete attributes to all auth input fields
  - Create centralized validation utilities blocking injection-risk characters
  - Apply validation to names, display names, workspace names, and slugs
  - Block special characters: < > ' " % # { } [ ] * ^ !
  - Secure sensitive input fields across admin, web, and space apps

* chore: add missing workspace name validation to settings and admin forms

* feat: enhance validation regex for international names and usernames

- Updated regex patterns to support Unicode characters for person names, display names, company names, and slugs.
- Improved validation functions to block injection-risk characters in names and slugs.
2026-02-17 00:18:46 +05:30
55e89cb8fc [WEB-5863] fix: estimate point input validation #8492
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2026-02-17 00:12:33 +05:30
Vamsi KrishnaandGitHub 4d1e6c499f [WEB-5829] fix: Intake open work count (#8547)
* fix: open intake count at sidebar header

* chore: reverted inbox store arguments to core store

* fix: intake count update
2026-02-17 00:05:20 +05:30
3a99ecf8f3 [WEB-5871] chore: added intake count for projects (#8497)
* chore: add intake_count in project list endpoint

* chore: sidebar project navigation intake count added

* fix: filter out closed intake issues in the count

* chore: code refactor

* chore: code refactor

* fix: filter out deleted intake issues

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
2026-02-17 00:04:03 +05:30
ef5d481a19 [VPAT-51] fix: update workspace invitation flow to use token for validation #8508
- Modified the invite link to include a token for enhanced security.
- Updated the WorkspaceJoinEndpoint to validate the token instead of the email.
- Adjusted the workspace invitation task to generate links with the token.
- Refactored the frontend to handle token in the invitation process.

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2026-02-17 00:02:18 +05:30
Dheeraj Kumar KetireddyandGitHub c8a800104c [SILO-820] fix: update serializer for module detail API endpoint to use ModuleUpdateSerializer (#8496) 2026-02-17 00:01:33 +05:30
Anmol Singh BhatiaandGitHub e92b835869 [WEB-5873] fix: user avatar ui consistency (#8495)
* fix: user avatar ui consistency

* chore: code refactor
2026-02-13 19:04:57 +05:30
CorneliusandGitHub 7e5b5066c5 Update translations.ts: issue-artifacts discoverd (#7979) 2026-02-13 19:04:02 +05:30
53b3358a63 [GIT-44] refactor(auth): add PASSWORD_TOO_WEAK error code (#8522)
* refactor(auth): add PASSWORD_TOO_WEAK error code and update related error handling in password change flow

* fix(auth): update import to use type for EAuthenticationErrorCodes in security page

* Update apps/web/app/(all)/profile/security/page.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* refactor: updated auth error exception accross zxcvbn usages

* fix: improve error handling for password strength validation and update error messages

* i18n(ru): update Russian translations for stickies and automation description

Added translation for 'stickies' and improved formatting of the automation description in Russian locale.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-13 18:51:33 +05:30
Anmol Singh BhatiaandGitHub bf521b7b03 [WEB-1201] chore: dropdown options hierarchy improvements (#8501)
* chore: sortBySelectedFirst and sortByCurrentUserThenSelected utils added

* chore: members dropdown updated

* chore: module dropdown updated

* chore: project and label dropdown updated

* chore: code refactor
2026-02-13 18:50:18 +05:30
Anmol Singh BhatiaandGitHub 7607cc9b10 [WEB-5884] chore: layout loader enhancements #8500 2026-02-13 18:49:53 +05:30
Aaryan KhandelwalandGitHub d497304de5 refactor: table drag preview using decorations (#8597)
* refactor: table drag preview using decorations

* fix: history meta for table drag state
2026-02-13 17:59:37 +05:30
Anmol Singh BhatiaandGitHub 8fa08b2506 [GIT-57 | WEB-5912] fix: app sidebar ux and responsiveness (#8560)
* fix: project extended sidebar accordion ux

* fix: app sidebar mobile responsiveness ux

* chore: code refactor
2026-02-13 17:43:07 +05:30
sriram veeraghanta efc600ad8c chore(deps): update axios dependency 2026-02-13 16:35:16 +05:30
stelmskandGitHub a3a1d141cb i18n(ru): expand Russian translation coverage (#8603)
Added missing translations for:
- Profile preferences (language, timezone settings)
- Account settings sections (preferences, notifications, security, api-tokens, activity)
- Workspace settings (billing, exports, webhooks headings/descriptions)
- Project settings (states, labels, estimates, automations headings/descriptions)
- Power-K command palette (contextual actions, navigation, creation, preferences, help)
- Sidebar elements (stickies, your_work, pin/unpin)
- Common actions (copy_markdown, overview)
- Navigation customization options
2026-02-13 16:30:33 +05:30
sriram veeraghantaandGitHub dfce8c6278 chore: admin folder structure (#8632)
* chore: admin folder structure

* fix: copy right check and formatting

* fix: types
2026-02-13 16:29:45 +05:30
Vamsi KrishnaandGitHub fab84eb058 [WEB-5899]fix: project sort order (#8530)
* fix: project sort order

* chore: updated queryset for sort_order
2026-02-13 15:52:22 +05:30
Vipin ChaudharyandGitHub 60734b25ba [WIKI-877] fix: order of this dropdown options in pages #8563 2026-02-13 15:49:10 +05:30
SangeethaandGitHub cd613e5f8f [SECUR-105] fix: csv injection vulnerability sanitization #8611 2026-02-13 15:37:13 +05:30
Anmol Singh BhatiaandGitHub a8d81656fc [WEB-6137] fix: work item peek view outside click #8610 2026-02-12 16:28:51 +05:30
Anmol Singh BhatiaandGitHub dbe059b7b5 fix: workitem description input inital load (#8617) 2026-02-12 14:03:01 +05:30
Anmol Singh BhatiaandGitHub c93f9fc865 [WEB-6038] fix: work item empty title flicker #8618 2026-02-12 13:35:08 +05:30
Akshat JainandGitHub bcc8fb4d1d style: update ASCII art in install script header (#8628) 2026-02-12 13:34:31 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b59e541b35 chore(deps): bump cryptography (#8625)
Bumps the pip group with 1 update in the /apps/api/requirements directory: [cryptography](https://github.com/pyca/cryptography).


Updates `cryptography` from 44.0.1 to 46.0.5
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/44.0.1...46.0.5)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.5
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-11 13:06:42 +05:30
sriram veeraghantaandGitHub 2b6e24d526 chore: merge helpers and layouts (#8624)
* fix: remove constants and services

* fix: formatting

* chore: merge helpers and layouts

* fix: workspace disbale flag handling
2026-02-10 22:04:07 +05:30
sriram veeraghantaandGitHub 7793febcf8 chore: merge constants and services (#8623)
* fix: remove constants and services

* fix: formatting

* fix: types check
2026-02-10 21:10:54 +05:30
sriram veeraghantaandGitHub 06e4a1624c fix: remove ee folder from web (#8622) 2026-02-10 20:01:42 +05:30
SangeethaandGitHub 57ce2a5429 [WEB-6149] migration: change estimate point key max value to 50 #8620 2026-02-10 17:07:14 +05:30
sriramveeraghanta 0887cbbda8 chore(deps): update lodash package 2026-02-09 19:26:39 +05:30
M. PalanikannanandGitHub e1227f0b58 fix: type fix for description payload (#8619)
* fix: type fix

* fix: duplicate type fix
2026-02-09 15:18:09 +05:30
sriramveeraghanta ea7b30bc9c chore(deps): update the node pacakges 2026-02-09 14:59:33 +05:30
Vipin ChaudharyandGitHub dfbd043e50 [WEB-6058] chore : add logic to handle save#8614 2026-02-09 14:53:35 +05:30
sriramveeraghanta 13a679437d chore(deps): upgrade django version 2026-02-09 14:51:53 +05:30
SangeethaandGitHub 78729277e8 [SECUR-104] fix: Arbitrary Modification of API Token Rate Limits#8612 2026-02-09 14:50:29 +05:30
SangeethaandGitHub d191615a5e [SECUR-113] fix: ssrf for work item links (#8607) 2026-02-05 15:03:43 +05:30
sriramveeraghanta 587cb3ecfe fix: file fomatting 2026-02-02 18:08:44 +05:30
Bavisetti NarayanandGitHub b8d3b3c5eb fix: module percentage calculation (#8595) 2026-01-29 14:48:43 +05:30
sriram veeraghantaandGitHub 02d0ee3e0f chore: add copyright (#8584)
* feat: adding new copyright info on all files

* chore: adding CI
2026-01-27 13:54:22 +05:30
Ship itandGitHub 66decf6617 fully translated into Ukrainian language (#8579) 2026-01-27 01:29:56 +05:30
Prateek ShouryaandGitHub 32a2584578 [GIT-66] improvement: prevent disabling last enabled authentication method (#8570) 2026-01-27 00:47:37 +05:30
SangeethaandGitHub f7d5200ed8 migration: back migrate all product tour fields to set true (#8575) 2026-01-27 00:47:02 +05:30
b31c0195bc fix: pdf export (#8564)
* feat: pdf export

* fix: tests

* fix: tests

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2026-01-26 22:08:10 +05:30
M. PalanikannanandGitHub 20e266c9bb fix: node view renders (#8559)
* fix node renders

* fix handlers

* fix: duplicate id
2026-01-23 13:47:49 +05:30
puntoandGitHub 57806f9bd5 [GIT-45] fix: allow markdown file attachments (#8524)
* fix: allow markdown file attachments

- Add text/markdown to ATTACHMENT_MIME_TYPES
- Fixes issue where .md files were rejected with 'Invalid file type' error

* added the support for frontend mime type too
2026-01-23 13:38:47 +05:30
Aaryan KhandelwalandGitHub db8b67102d [WEB-5860] [WEB-5861] [WEB-5862] style: improved settings interface (#8520)
* style: improved profile settings

* chore: minor improvements

* style: improved workspace settings

* style: workspace settings content

* style: improved project settings

* fix: project settings flat map

* chore: add back navigation from settings pages

* style: settings content

* style: estimates list

* refactor: remove old code

* refactor: removed unnecessary line breaks

* refactor: create a common component for page header

* chore: add fade-in animation to sidebar

* fix: formatting

* fix: project settings sidebar header

* fix: workspace settings sidebar header

* fix: settings content wrapper scroll

* chore: separate project settings features

* fix: formatting

* refactor: custom theme selector

* refactor: settings headings

* refactor: settings headings

* fix: project settings sidebar padding

* fix: sidebar header padding

* fix: sidebar item permissions

* fix: missing editable check

* refactor: remove unused files

* chore: remove unnecessary code

* chore: add missing translations

* fix: formatting
2026-01-23 13:34:20 +05:30
SangeethaandGitHub ba5ba5bf54 [GIT-61] chore: allow .md files to be uploaded (#8571)
* chore: allow .md files to be uploaded

* chore: allow .md files to be uploaded
2026-01-23 13:33:20 +05:30
yyandGitHub bb4f172e26 chore: fix typos in comments (#8553) 2026-01-23 13:32:04 +05:30
2a29ab8d4a [WEB-5845] chore: changing description field to description json (#8230)
* chore: migrating description to description json

* chore: replace description with description_json

* chore: updated migration file

* chore: updated the migration file

* chore: added description key in external endpoint

* chore: updated the migration file

* chore: updated the typo

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2026-01-22 18:23:59 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6c8779c8d3 chore(deps): bump lodash-es in the npm_and_yarn group across 1 directory (#8573)
Bumps the npm_and_yarn group with 1 update in the / directory: [lodash-es](https://github.com/lodash/lodash).


Updates `lodash-es` from 4.17.21 to 4.17.23
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash-es
  dependency-version: 4.17.23
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-22 15:47:30 +05:30
Aaryan KhandelwalandGitHub f7debcde79 [WEB-5898] chore: update tailwind config #8516 2026-01-19 20:42:44 +05:30
Aaryan KhandelwalandGitHub 3de76206b5 [WIKI-735] fix: table insert handle z-index #8545 2026-01-19 20:41:50 +05:30
Vamsi KrishnaandGitHub f783447796 [WEB-5907] fix: magic code sign-in at Space app. #8552 2026-01-19 20:41:00 +05:30
SangeethaandGitHub fa1b4a102a [WEB-5890] migration: added getting_started_checklist, tips, explored_feature fields on the workspace member table (#8489)
* migration: added getting_started_checklist and tips field

* fix: remove defaults and added explored_features field

* fix: added user table migration
2026-01-15 14:25:31 +05:30
sriramveeraghanta 8399f64bee chore(deps): react router upgraded 2026-01-09 14:43:36 +05:30
Bavisetti NarayanandGitHub 5f3f9d2623 chore: updated migration file name (#8515) 2026-01-09 14:36:45 +05:30
Prateek ShouryaandGitHub 110dbd9acd [VPAT-50] chore(security): add X-Frame-Options header to nginx configuration to prevent clickjacking attacks (#8507)
* [VPAT-50] chore(security): add X-Frame-Options header to nginx configuration to prevent clickjacking attacks

* [SECURITY] chore: enhance nginx configuration with additional security headers
2026-01-09 04:03:41 +05:30
Vipin ChaudharyandGitHub b83d460938 [WIKI-826] chore: add unique id as key to logo selector (#8494) 2026-01-07 15:05:14 +05:30
ea1f92e0c6 [WEB-5537]refactor: rename IssueUserProperty to ProjectUserProperty and update related references (#8206)
* refactor: rename IssueUserProperty to ProjectUserProperty and update related references across the codebase

* migrate: move issue user properties to project user properties and update related fields and constraints

* refactor: rename IssueUserPropertySerializer and IssueUserDisplayPropertyEndpoint to ProjectUserPropertySerializer and ProjectUserDisplayPropertyEndpoint, updating all related references

* fix: enhance ProjectUserDisplayPropertyEndpoint to handle missing properties by creating new entries and improve response handling

* fix: correct formatting in migration for ProjectUserProperty model options

* migrate: add migration to update existing non-service API tokens to remove workspace association

* migrate: refine migration to update existing non-service API tokens by excluding bot users from workspace removal

* chore: changed the project sort order in project user property

* chore: remove allowed_rate_limit from APIToken

* chore: updated user-properties endpoint for frontend

* chore: removed the extra projectuserproperty

* chore: updated the migration file

* chore: code refactor

* fix: type error

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: sangeethailango <sangeethailango21@gmail.com>
Co-authored-by: vamsikrishnamathala <matalav55@gmail.com>
Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
2026-01-06 15:37:19 +05:30
Prateek ShouryaandGitHub 3d5e427894 chore: remove unused right sidebar component and clean up workspace member settings (#8477) 2025-12-31 00:08:40 +05:30
sriram veeraghantaandGitHub 6129e8e53b Revert "[WEB-4959]chore: refactor project member page #8464" (#8476)
This reverts commit c97e418515.
2025-12-30 21:46:32 +05:30
sriram veeraghantaandGitHub d61b157929 chore: remove posthog events (#8465)
* chore: remove posthog events

* chore: remove event tracking

* chore: lint errors

* chore: minor changes based on comments

* fix: type errors
2025-12-30 19:59:50 +05:30
sriram veeraghantaandGitHub 81cea3256a release: v1.2.1 #8322 2025-12-12 16:36:50 +05:30
sriramveeraghanta 07f269e7f3 chore: version bump 2025-12-12 15:09:53 +05:30
sriramveeraghanta ce69644d53 chore(deps): upgrade next themes package 2025-12-12 13:43:47 +05:30
13047 changed files with 132536 additions and 1257725 deletions
+67
View File
@@ -0,0 +1,67 @@
---
name: branch-name
description: Use when starting a new branch or renaming an existing one — produces a branch name in the format `<type>/<work-item-id>-<short-description>` that's compatible with the create-pr skill's work item ID extraction.
user_invocable: true
---
# Branch Naming
Create branch names that follow the convention `<type>/<work-item-id>-<short-description>`, where the work item ID can be cleanly extracted later (e.g., by the create-pr skill).
## Format
```
<type>/<work-item-id>-<short-description>
```
- All lowercase, hyphen-separated
- Work item ID stays in its original form but lowercased (e.g., `SILO-1146``silo-1146`)
- Short description is 25 words in kebab-case, focused on the _what_, not the _how_
## Workflow
1. **Determine the type** based on the work being done:
- `feat` — new functionality
- `fix` — bug fix
- `chore` — tooling, deps, config, non-user-facing housekeeping
- `refactor` — restructuring without behavior change
- `docs` — documentation only
- `perf` — performance improvement
2. **Determine the work item ID**:
- If the user gives one, use it
- If they reference a Plane work item (e.g., a URL or title), extract the ID
- If none exists, ask the user — don't invent one
3. **Write the short description**:
- 25 words in kebab-case
- Describe the outcome, not the implementation (`add-app-tile-visibility`, not `update-tile-component`)
- Skip filler words (`the`, `a`, `for`)
4. **Assemble and create the branch**:
```
git checkout -b <type>/<work-item-id-lowercased>-<short-description>
```
5. **Return the branch name** to the user.
## Examples
```
fix/silo-1146-relative-config-urls
feat/web-1234-app-tile-visibility
chore/web-2201-bump-eslint
refactor/silo-980-extract-auth-middleware
docs/web-1500-pr-template-update
perf/silo-1310-cache-workspace-lookup
```
## Common Mistakes
- Putting the work item ID at the end instead of after the type (breaks extraction)
- Using underscores or camelCase instead of hyphens
- Uppercasing the work item ID inside the branch name (it should be lowercase here, uppercased only when used as the PR title prefix)
- Writing a long, narrative description — keep it scannable
- Omitting the work item ID when one exists in Plane
- Using a type that won't match the eventual PR type (pick the type you'd use in the PR title)
@@ -0,0 +1,65 @@
---
name: create-pull-request
description: Use when creating a pull request for the current branch — gathers branch context, generates a PR description following the repo's pull_request_template.md, and creates the PR with a Plane work item ID prefix in the title.
user_invocable: true
---
# Create PR
Create a pull request using the repo's PR template, a Plane work item ID as the title prefix, and a fully filled-out description based on the actual diff.
## Workflow
1. **Determine the base branch**: Default to `preview` unless the user specifies otherwise.
2. **Gather context** (in parallel):
- `git status -s` — check for uncommitted changes
- `git diff <base>...HEAD --stat` — files changed
- `git log <base>...HEAD --oneline` — all commits on the branch
- `git diff <base>...HEAD --no-color` — full diff for understanding changes (if very large, focus on the most important files first)
- `git rev-parse --abbrev-ref --symbolic-full-name @{u}` — check if branch tracks a remote
- Read `.github/pull_request_template.md` from the repo root
3. **Determine work item ID**:
- Extract from branch name if it contains an identifier (e.g., `chore/silo-1146-foo``SILO-1146`, `feat/web-1234-x``WEB-1234`)
- If not found in branch name, ask the user
4. **Draft the PR** using the template from step 2:
**Title**: `[WORK-ITEM-ID] <type>: <concise summary>` (under 70 chars)
- Type reflects the change: `fix`, `feat`, `chore`, `refactor`, `docs`, `perf`, etc.
**Body**: Fill in every section from the PR template based on the actual diff:
- **Description** — Clear, concise summary of what the PR does and why. Focus on the "what" and "why", not line-by-line changes. Mention important implementation decisions.
- **Type of Change** — Check the appropriate box(es): Bug fix, Feature, Improvement, Code refactoring, Performance improvements, Documentation update.
- **Screenshots and Media** — Leave a placeholder: `<!-- Add screenshots here -->`
- **Test Scenarios** — Suggest concrete scenarios grounded in the actual changes (e.g., "Navigate to project settings and verify the new toggle works"), not generic ones.
- **References** — Include the work item ID, any linked issues the user mentions, and any Sentry issue links/IDs (e.g., `SENTRY-ABC123` or Sentry URLs) referenced earlier in the conversation.
Append a Claude Code session line at the bottom of the body.
5. **Push and create** (in parallel where possible):
- Push branch with `-u` if no upstream is set
- Create PR via `gh pr create` using a HEREDOC for the body
6. **Return the PR URL** to the user.
## Example Title
```
[SILO-1146] fix: allow relative URLs for configuration_url and improve app tile visibility
```
## Guidelines
- Keep the description concise but informative
- Use bullet points when listing multiple changes
- Focus on user-facing impact, not implementation details
- Don't fabricate test scenarios that aren't relevant to the actual changes
## Common Mistakes
- Summarizing only the latest commit instead of all commits on the branch
- Forgetting to check for an upstream before pushing
- Using a work item ID format that doesn't match the branch convention
- Wrapping the PR body in a code fence when passing it to `gh pr create`
-54
View File
@@ -1,54 +0,0 @@
---
name: pr-description
description: Generate a PR description following the project's GitHub PR template. Analyzes the current branch's changes against the base branch to produce a complete, filled-out PR description.
user_invocable: true
---
# PR Description Generator
Generate a pull request description based on the project's PR template at `.github/pull_request_template.md`.
## Steps
1. **Determine the base branch**: Use `preview` as the default base branch unless the user specifies otherwise.
2. **Analyze changes**: Run the following to understand what changed:
- `git log <base>...HEAD --oneline` to see all commits on this branch
- `git diff <base>...HEAD --stat` to see which files changed
- `git diff <base>...HEAD` to read the actual diff (use `--no-color`)
- If the diff is very large, focus on the most important files first
3. **Fill out the PR template** with the following sections:
### Description
Write a clear, concise summary of what the PR does and why. Focus on the "what" and "why", not line-by-line changes. Mention any important implementation decisions.
### Type of Change
Check the appropriate box(es) based on the changes:
- Bug fix (non-breaking change which fixes an issue)
- Feature (non-breaking change which adds functionality)
- Improvement (change that would cause existing functionality to not work as expected)
- Code refactoring
- Performance improvements
- Documentation update
### Screenshots and Media
Leave this section for the user to fill in, with a note: `<!-- Add screenshots here -->`
### Test Scenarios
Based on the code changes, suggest specific test scenarios that should be verified. Be concrete (e.g., "Navigate to project settings and verify the new toggle works") rather than generic.
### References
- If commit messages or branch name reference a work item identifier (e.g., `WEB-1234`), include it
- If the user provides a linked issue, include it
- If Sentry issue links or IDs (e.g., `SENTRY-ABC123`, Sentry URLs) were mentioned earlier in the conversation, include them as references
4. **Output format**: Print the filled-out markdown template so the user can copy it directly. Do NOT wrap it in a code fence — output the raw markdown.
## Guidelines
- Keep the description concise but informative
- Use bullet points for multiple changes
- Focus on user-facing impact, not implementation details
- If the branch has a Plane work item ID in its name (e.g., `WEB-1234`), reference it
- Don't fabricate test scenarios that aren't relevant to the actual changes
-163
View File
@@ -1,163 +0,0 @@
---
name: release-notes
description: "Generate release notes for a Plane release PR in either `makeplane/plane-cloud` (date-based versioning, e.g. `release: vYY.MM.DD-N`) or `makeplane/plane-ee` (semver, e.g. `release: vX.Y.Z`). Reads PR commits, filters out noise, categorizes by conventional-commit type, optionally enriches via Plane MCP, and writes the result as the PR description."
user_invocable: true
---
# Release Notes Generator
Generate structured release notes from a Plane release PR by parsing its commit list, then update the PR description. Works for both `makeplane/plane-cloud` and `makeplane/plane-ee`.
## Repo-specific versioning
Plane uses **different version schemes** across its two release repos. Detect which repo the PR belongs to and use the matching format.
| Repo | Version scheme | Example PR title | Source branch | Target branch |
| ----------------------- | -------------- | ---------------------- | ------------- | -------------------- |
| `makeplane/plane-cloud` | Date-based | `release: v26.04.13-1` | `uat` | `master` |
| `makeplane/plane-ee` | Semver | `release: v1.12.0` | `uat` | `master` / `preview` |
- **plane-cloud** ships daily — version is `vYY.MM.DD-N` where `N` is the counter for that date's release.
- **plane-ee** ships on a versioned cadence — version is `vX.Y.Z` (major.minor.patch) following semver.
- Detect the repo with `gh pr view <PR_NUM> --json headRepository,baseRepository` or from the URL the user shared. Never mix the two formats in one set of notes.
## When to Use
- User links/mentions a Plane release PR (e.g. `release: v26.04.13-1` for cloud or `release: v1.12.0` for EE) and asks for release notes
- User asks to "create release notes" / "update PR description" for a PR in `makeplane/plane-cloud` or `makeplane/plane-ee`
- The branch is named `uat` or `release/x.y.z` and the base is `master` or `preview`
## Steps
### 1. Fetch commits
```bash
gh pr view <PR_NUM> --json title,body,baseRefName,headRefName,commits \
--jq '.commits[] | .messageHeadline + "\n---BODY---\n" + .messageBody + "\n===END==="'
```
For a quick scan first:
```bash
gh pr view <PR_NUM> --json commits \
--jq '.commits[] | {oid: .oid[0:10], message: .messageHeadline}'
```
### 2. Filter out noise
**Always exclude** these commits — mechanical, not user-facing:
| Pattern | Reason |
| -------------------------------------------- | ------------------------------------- |
| `Sync: Enterprise Changes #NNNN` | Cross-repo sync, no functional change |
| `fix: merge conflicts` | Merge artifact |
| `Merge branch '...' of github.com:...` | Merge artifact |
| `Revert "..."` (when immediately re-applied) | Internal churn |
### 3. Parse work item IDs
Most meaningful commits begin with a Plane work item identifier in brackets:
- `[WEB-XXXX]` — web/frontend product items
- `[SILO-XXXX]` — Silo (integrations: Slack, GitHub, GitLab, Jira/Linear)
- `[MOBILE-XXXX]`, `[API-XXXX]`, etc.
Always preserve these IDs in the release notes — they let readers click through to the source ticket.
### 4. (Optional) Enrich via Plane MCP
For larger features where the commit headline is terse, fetch the work item:
```
mcp__plane__retrieve_work_item_by_identifier(project_identifier="WEB", issue_identifier=6874)
```
Use the returned `name` and `description_stripped` to flesh out the bullet. Skip this for routine fixes — commit body is usually enough. Don't enrich every item (slow + work item descriptions are often empty).
### 5. Categorize by conventional-commit type
| Commit prefix | Section |
| -------------------------------- | ------------------- |
| `feat:`, `feat(scope):` | ✨ New Features |
| `fix:`, `fix(scope):` | 🐛 Bug Fixes |
| `refactor:` | 🔧 Refactor & Chore |
| `chore:`, `chore(scope):` | 🔧 Refactor & Chore |
| `chore(deps):`, dependabot bumps | 📦 Dependencies |
### 6. Format
Use the version format that matches the repo (see **Repo-specific versioning** above):
- `plane-cloud``# Release vYY.MM.DD-N`
- `plane-ee``# Release vX.Y.Z`
```markdown
# Release <version>
## ✨ New Features
- **<Short title>** — [WEB-XXXX] (#PR_NUM)
Optional 12 sentence elaboration drawn from commit body.
## 🐛 Bug Fixes
- **<Short title>** — [WEB-XXXX] (#PR_NUM)
## 🔧 Refactor & Chore
- **<Short title>** — [WEB-XXXX] (#PR_NUM)
## 📦 Dependencies
- Bump `<package>` X.Y.Z → A.B.C (#PR_NUM)
```
Rules:
- Lead with a bold human-readable title (rewrite the commit subject if cryptic)
- Always include the work item ID in brackets and the merge PR number in parens
- Add a sub-line elaboration only when the commit body has substance worth surfacing (acceptance criteria, scope notes, gotchas like "behind feature flag", "requires migration", "requires Vercel setting")
- Drop empty sections
### 7. Update the PR description
```bash
gh pr edit <PR_NUM> --body "$(cat <<'EOF'
<release notes markdown>
EOF
)"
```
Always use a HEREDOC with single-quoted `'EOF'` so backticks/dollars in the notes are preserved.
## Quick Reference: end-to-end
```bash
PR=2498
gh pr view $PR --json commits --jq '.commits[] | .messageHeadline + "\n---\n" + .messageBody + "\n==="' > /tmp/commits.txt
# read /tmp/commits.txt, filter, categorize, draft notes
gh pr edit $PR --body "$(cat <<'EOF'
... release notes ...
EOF
)"
```
## Common Mistakes
- **Including `Sync: Enterprise Changes` commits** — these are sync PRs, never user-visible changes
- **Including `fix: merge conflicts`** — merge artifact, no functional content
- **Dropping the work item ID** — readers rely on `[WEB-XXXX]` to navigate to the ticket
- **Over-enriching with MCP lookups** — work item descriptions are often empty; commit body is usually richer
- **Missing the merge PR number** — always include `(#NNNN)` from the commit subject so reviewers can audit the source PR
- **Using `--body` without HEREDOC** — backticks/dollar signs get shell-interpreted and corrupt the notes
- **Editing the title** — release PR titles are version markers; only edit the body
- **Using the wrong version scheme** — `plane-cloud` is date-based (`vYY.MM.DD-N`), `plane-ee` is semver (`vX.Y.Z`). Check the repo before drafting the `# Release <version>` heading
## Plane-Specific Conventions
- Release PRs go from `uat``master` (or `preview`)
- PR title format:
- `plane-cloud`: `release: vYY.MM.DD-N` where N is the daily release counter for that date
- `plane-ee`: `release: vX.Y.Z` semver (major.minor.patch)
- Commits coming from feature branches always carry a work item ID; commits without one are usually infra/chores
- `Sync: Enterprise Changes #NNNN` are automated cross-repo syncs and are _always_ skipped in release notes
+200
View File
@@ -0,0 +1,200 @@
---
name: release-notes
description: "Generate release notes for a Plane release PR in either `makeplane/plane-cloud` (date-based versioning, e.g. `release: vYY.MM.DD-N`) or `makeplane/plane-ee` (semver, e.g. `release: vX.Y.Z`). Reads PR commits, filters out noise, categorizes by conventional-commit type, optionally enriches via Plane MCP, and writes the result as the PR description in the GitHub Releases format."
user_invocable: true
---
# Release Notes Generator
Generate structured release notes from a Plane release PR by parsing its commit list, then update the PR description. Output matches the format used on `github.com/makeplane/plane/releases` (e.g. [v1.2.0](https://github.com/makeplane/plane/releases/tag/v1.2.0)). Works for both `makeplane/plane-cloud` and `makeplane/plane-ee`.
## Repo-specific versioning
Plane uses **different version schemes** across its two release repos. Detect which repo the PR belongs to and use the matching scheme when communicating about the release — the version itself does **not** appear in the release notes body (GitHub's release tag carries it).
| Repo | Version scheme | Example PR title | Source branch | Target branch |
| ----------------------- | -------------- | ---------------------- | ------------- | -------------------- |
| `makeplane/plane-cloud` | Date-based | `release: v26.04.13-1` | `uat` | `master` |
| `makeplane/plane-ee` | Semver | `release: v1.12.0` | `uat` | `master` / `preview` |
- **plane-cloud** ships daily — version is `vYY.MM.DD-N` where `N` is the counter for that date's release.
- **plane-ee** ships on a versioned cadence — version is `vX.Y.Z` (major.minor.patch) following semver.
- Detect the repo with `gh pr view <PR_NUM> --json headRepository,baseRepository` or from the URL the user shared.
## When to Use
- User links/mentions a Plane release PR (e.g. `release: v26.04.13-1` for cloud or `release: v1.12.0` for EE) and asks for release notes
- User asks to "create release notes" / "update PR description" for a PR in `makeplane/plane-cloud` or `makeplane/plane-ee`
- The branch is named `uat` or `release/x.y.z` and the base is `master` or `preview`
## Steps
### 1. Fetch commits
```bash
gh pr view <PR_NUM> --json title,body,baseRefName,headRefName,commits \
--jq '.commits[] | .messageHeadline + "\n---BODY---\n" + .messageBody + "\n===END==="'
```
For a quick scan first:
```bash
gh pr view <PR_NUM> --json commits \
--jq '.commits[] | {oid: .oid[0:10], message: .messageHeadline}'
```
### 2. Filter out noise
**Always exclude** these commits — mechanical, not user-facing:
| Pattern | Reason |
| -------------------------------------------- | ------------------------------------- |
| `Sync: Enterprise Changes #NNNN` | Cross-repo sync, no functional change |
| `fix: merge conflicts` | Merge artifact |
| `Merge branch '...' of github.com:...` | Merge artifact |
| `Revert "..."` (when immediately re-applied) | Internal churn |
### 3. Identify work item IDs (for research only)
Most meaningful commits begin with a Plane work item identifier in brackets:
- `[WEB-XXXX]` — web/frontend product items
- `[SILO-XXXX]` — Silo (integrations: Slack, GitHub, GitLab, Jira/Linear)
- `[MOBILE-XXXX]`, `[API-XXXX]`, etc.
**Do not include these IDs in the release notes.** The GitHub Releases format is end-user-facing — IDs are only useful as a lookup key for fetching context in step 4.
### 4. (Optional) Enrich via Plane MCP
For larger features where the commit headline is terse, fetch the work item to write a richer paragraph:
```
mcp__plane__retrieve_work_item_by_identifier(project_identifier="WEB", issue_identifier=6874)
```
Use the returned `name` and `description_stripped` to flesh out the prose. Skip for routine fixes — commit body is usually enough. Don't enrich every item (slow + descriptions are often empty).
### 5. Categorize commits
Map each surviving commit into one of four sections:
| Commit signal | Section |
| --------------------------------------------------------------------------------------------------------------------- | --------------- |
| `feat:` that introduces a brand-new screen, flow, or capability | ✨ Features |
| `feat:` that improves an existing feature, plus most `refactor:` and behavioural `chore:` items that are user-visible | ⬆️ Enhancements |
| `fix:`, `fix(scope):` | 🐞 Bug fixes |
| CVE upgrades, dependency bumps that close a vulnerability, security hardening | 🛡️ Security |
**Drop entirely** (do not surface to users): pure infra `chore:`, dependabot bumps with no CVE, internal refactors with no behavioural impact, test-only changes, doc-only changes.
### 6. Format
Output follows the GitHub Releases convention — `###` for section headers, with two spaces between the emoji and the label for ✨ / ⬆️ / 🐞 (matches `v1.2.0`).
```markdown
### ✨ Features
#### **Short Feature Name in Title Case**
A 13 sentence paragraph describing what the user gets, why it matters, and any notable behaviour. Write in product-marketing voice, not commit-message voice.
- Optional nested bullets for sub-capabilities or callouts
- Keep them user-facing — what the user can now do
#### **Second Major Feature**
Another descriptive paragraph. Each major feature gets its own `####` subsection.
### ⬆️ Enhancements
- One-line description of an improvement to an existing capability
- Another improvement, written as a clean sentence (no commit prefix, no ticket ID)
### 🐞 Bug fixes
- Plain-English description of what was broken and is now fixed
- Another bug fix
### 🛡️ Security
- Upgraded <component> to <version> to mitigate [CVE-XXXX-NNNNN](https://link-to-advisory). Brief impact note.
- Other security-relevant change
```
Rules:
- Section headers use `###` (three hashes), then emoji + **two spaces** + label — exactly as in the published v1.2.0 release. Exception: 🛡️ Security uses a single space (matches v1.2.0).
- Features use `####` (four hashes) and the feature name is **bolded** inside the heading: `#### **Feature Name**`.
- Each feature gets a real paragraph, not a bullet — written for end users, not engineers.
- Enhancements, Bug fixes, and Security are simple bullets. No nested asterisks, no ticket IDs, no PR numbers.
- **Do not include work item IDs (`[WEB-XXXX]`) or PR numbers (`(#NNNN)`)** in any section — this format is user-facing.
- **Do not add a `# Release vX.Y.Z` heading.** The GitHub release tag carries the version; the body starts directly with the first `### ✨ Features` section.
- **Do not insert images.** The user adds screenshots manually after the notes are drafted. Leave space for them only if the user asks.
- Drop empty sections entirely.
- Blank line between section header and first bullet/feature, and between sections.
### 7. Update the PR description
```bash
gh pr edit <PR_NUM> --body "$(cat <<'EOF'
<release notes markdown>
EOF
)"
```
Always use a HEREDOC with single-quoted `'EOF'` so backticks/dollars in the notes are preserved.
## Quick Reference: end-to-end
```bash
PR=2498
gh pr view $PR --json commits --jq '.commits[] | .messageHeadline + "\n---\n" + .messageBody + "\n==="' > /tmp/commits.txt
# read /tmp/commits.txt, filter, categorize into the four sections, draft notes
gh pr edit $PR --body "$(cat <<'EOF'
### ✨ Features
#### **...**
...
### ⬆️ Enhancements
- ...
### 🐞 Bug fixes
- ...
### 🛡️ Security
- ...
EOF
)"
```
## Reference example
The canonical target format is [v1.2.0](https://github.com/makeplane/plane/releases/tag/v1.2.0) on `makeplane/plane`. When in doubt about heading levels, spacing, bolding, or paragraph voice, match that page exactly (minus images).
## Common Mistakes
- **Including work item IDs in bullets** — the GitHub Releases format is user-facing; `[WEB-XXXX]` belongs in internal research, not the output.
- **Adding a `# Release vX.Y.Z` heading** — GitHub's release tag is the version. The body starts with `### ✨ Features`.
- **Copy-pasting commit subjects verbatim** — rewrite into product-marketing English. "fix: peek overview reload on parent add" → "Fixed peek overview reloading on adding a parent".
- **Bulleting features instead of writing paragraphs** — major features get `#### **Name**` plus a real paragraph; only enhancements/bugs/security use bullets.
- **Including `Sync: Enterprise Changes` commits** — these are sync PRs, never user-visible.
- **Including `fix: merge conflicts`** — merge artifact, no functional content.
- **Inserting images** — leave images for the user; they add screenshots manually.
- **Using `--body` without HEREDOC** — backticks/dollar signs get shell-interpreted and corrupt the notes.
- **Editing the PR title** — release PR titles are version markers; only edit the body.
- **Adding a Chores section** — the GitHub Releases format has no Chores section; user-invisible chores are dropped entirely.
## Plane-Specific Conventions
- Release PRs go from `uat``master` (or `preview`).
- PR title format:
- `plane-cloud`: `release: vYY.MM.DD-N` where N is the daily release counter for that date.
- `plane-ee`: `release: vX.Y.Z` semver (major.minor.patch).
- Commits coming from feature branches always carry a work item ID; commits without one are usually infra/chores and almost always dropped from notes.
- `Sync: Enterprise Changes #NNNN` are automated cross-repo syncs and are _always_ skipped.
- CVE-related upgrades (NextJS, React, Django, nginx, etc.) belong under 🛡️ Security with a link to the advisory and a one-line impact note.
+608
View File
@@ -0,0 +1,608 @@
---
name: translate
description: Translate or update keys in packages/i18n/src/locales. Use whenever adding, changing, or reviewing strings across any target locale — enforces do-not-translate terminology, CLDR plural forms, placeholder/tag preservation, per-locale punctuation/register, and the AI-translation review workflow. Required reading before touching any *.json under src/locales.
user_invocable: true
---
<!--
`user_invocable: true` is advisory and follows the same convention as the existing
`pr-description.md` and `release-notes.md` skills in this repo. No harness behavior
in this codebase keys off the field today; it documents intent for the Claude Code
skill registry. The skill is invoked via the slash command (`/translate`) registered
in `.claude/commands/translate.md`, or by name when an agent infers relevance.
-->
# Translate (Plane i18n)
Single source of truth for turning English UI strings into every target locale under `packages/i18n/src/locales/`. Follow this skill exactly — every mistake here ships to every user in that language.
The locale list grows over time. This skill is intentionally generic: rules apply to any locale present now or added later. When you add a locale not explicitly named below, see **Adding a locale not documented here** at the bottom.
Sources distilled from: Microsoft Localization Style Guides, Mozilla L10n, Unicode CLDR plural rules, W3C i18n, Google developer style guide, Apple HIG, GitHub Primer, MQM error typology, Lokalise/Crowdin/Phrase best-practice docs, and Microsoft's AI/LLM-for-translation guidance.
**Source of truth**: `packages/i18n/src/locales/en/<namespace>.json`. Every other locale mirrors English key-for-key. The `sync-check.ts` script catches missing / stale / collision keys; it does **not** catch value-quality mistakes. That is what this skill is for.
## When to Use
- Adding a new key to any `packages/i18n/src/locales/en/*.json`
- Renaming or rewording an English value (every target is now stale)
- Adding a new language (copy from `en/`, then translate each file)
- Syncing after `pnpm --filter @plane/i18n run sync:check` reports drift
- Reviewing any PR that touches `packages/i18n/src/locales/`
Skip only for trivial English-only typo fixes that don't change meaning or length meaningfully.
## The Two Iron Rules
1. **Trademarks, brand marks, plan tier names, third-party product names, acronyms, and code tokens are never translated.** Plane's brand marks (Plane, Plane AI, Power K, PQL, Active Cycles, Sticky/Stickies, Intake), plan tiers (Pro, Business, Enterprise), third-party products (GitHub, Slack, Notion, etc.), and acronyms (API, OAuth, etc.) stay Latin in every locale.
2. **CLDR plural categories are mandatory.** Every target locale must include **every** plural keyword the language requires. Missing a form renders the wrong word at runtime and passes `sync-check` silently.
Common feature nouns — Cycle, Module, Epic, Page — **are translated** into the target language using the canonical glossary further down. They are not brand marks; they are everyday words that belong in the user's language.
The rest of this skill explains how to execute on those rules.
## Do-Not-Translate (DNT) Glossary
For each term: the source, the required rendering per script group, and **forbidden renderings** that have appeared historically and must be reverted when seen.
### Plane brand & features
| Source term | Latin locales (fr, es, it, de, pt-BR, pl, cs, sk, ro, tr-TR, vi-VN, id) | ja (katakana-default) | ko (Hangul-default) | zh-CN / zh-TW | ru | ua | **Forbidden** (never produce) |
| ---------------------------------- | ----------------------------------------------------------------------- | --------------------- | ------------------- | --------------------------------------------- | ----------------- | ----------------- | --------------------------------------------------------------------------------- |
| **Plane** | Plane | Plane | Plane | Plane | Plane | Plane | 飛行機, 飞机, 비행기, Самолёт, Літак, Avion, Avião, Aereo, Flugzeug |
| **Plane AI** (formerly PI Chat) | Plane AI | Plane AI | Plane AI | Plane AI | Plane AI | Plane AI | Чат ИИ, AI 聊天, AIチャット, AI 채팅, Chat IA, AI Çet, PI Chat (legacy) |
| **Power K** | Power K | Power K | Power K | Power K | Power K | Power K | Command K, Command Palette, コマンドパレット, 命令面板, Палитра команд |
| **PQL** | PQL | PQL | PQL | PQL | PQL | PQL | any expansion of the acronym into the target language |
| **Intake** (feature name) | Intake | Intake | Intake | Intake | Intake | Intake | Inbox, 受信箱, 收件箱, Входящие, Triage, Boîte de réception |
| **Active Cycles** (workspace view) | Active Cycles | Active Cycles | Active Cycles | Active Cycles | Active Cycles | Active Cycles | Translate as a unit; never split into generic "active" + localized "cycles" |
| **Sticky** / **Stickies** | Sticky / Stickies | Sticky / Stickies | Sticky / Stickies | Sticky / Stickies (Latin inside Chinese text) | Sticky / Stickies | Sticky / Stickies | 便签, 便利貼, メモ, 付箋, スティッキー, 메모, 스티키, заметка, Стикер, Note, Nota |
| **Pro** (plan tier) | Pro | Pro | Pro | Pro | Pro | Pro | Профессиональный, プロフェッショナル, 专业版, 專業版, Profesional |
| **Business** (plan tier) | Business | Business | Business | Business | Business | Business | Бизнес, ビジネス, 商业版, 商務版, Negocios, Negócios |
| **Enterprise** (plan tier) | Enterprise | Enterprise | Enterprise | Enterprise | Enterprise | Enterprise | Корпоративный, エンタープライズ, 企业版, 企業版, Empresarial |
### Plane feature noun translation glossary (translate, do not preserve)
These are common nouns. **Translate them into the target language** using the canonical form below. This follows Microsoft, Apple, and Mozilla style guides for product-UI translation: feature common nouns belong in the user's language; only trademarks, brand marks, and acronyms stay Latin. Leaving these in Latin in non-Latin locales reads as half-translated and is a measurable quality defect.
Use the table verbatim — never coin new variants; never leave the Latin form in the locale value.
| Source | zh-CN | zh-TW | ja | ko | ru | ua | de | fr | es | it | pt-BR | pl | cs | sk | ro | tr-TR | vi-VN | id |
| ----------- | ----- | ----- | ---------- | ------ | -------- | -------- | ------ | ------- | ------- | ------ | ------- | ------ | ------- | ------- | ------- | -------- | ------ | ------- |
| **Cycle** | 周期 | 週期 | サイクル | 사이클 | Цикл | Цикл | Zyklus | Cycle | Ciclo | Ciclo | Ciclo | Cykl | Cyklus | Cyklus | Ciclu | Döngü | Chu kỳ | Siklus |
| **Cycles** | 周期 | 週期 | サイクル | 사이클 | Циклы | Цикли | Zyklen | Cycles | Ciclos | Cicli | Ciclos | Cykle | Cykly | Cykly | Cicluri | Döngüler | Chu kỳ | Siklus |
| **Module** | 模块 | 模組 | モジュール | 모듈 | Модуль | Модуль | Modul | Module | Módulo | Modulo | Módulo | Moduł | Modul | Modul | Modul | Modül | Mô-đun | Modul |
| **Modules** | 模块 | 模組 | モジュール | 모듈 | Модули | Модулі | Module | Modules | Módulos | Moduli | Módulos | Moduły | Moduly | Moduly | Module | Modüller | Mô-đun | Modul |
| **Epic** | 史诗 | 史詩 | エピック | 에픽 | Эпик | Епік | Epic | Epic | Epic | Epic | Epic | Epik | Epik | Epik | Epic | Epik | Epic | Epik |
| **Epics** | 史诗 | 史詩 | エピック | 에픽 | Эпики | Епіки | Epics | Epics | Epics | Epics | Epics | Epiki | Epiky | Epiky | Epice | Epikler | Epic | Epik |
| **Page** | 页面 | 頁面 | ページ | 페이지 | Страница | Сторінка | Seite | Page | Página | Pagina | Página | Strona | Stránka | Stránka | Pagină | Sayfa | Trang | Halaman |
| **Pages** | 页面 | 頁面 | ページ | 페이지 | Страницы | Сторінки | Seiten | Pages | Páginas | Pagine | Páginas | Strony | Stránky | Stránky | Pagini | Sayfalar | Trang | Halaman |
Notes:
- Single-form locales (zh-CN, zh-TW, ja, ko, vi-VN, id) use the same form for singular and plural — the column repeats by design.
- Slavic locales (ru, ua, pl, cs, sk) show **nominative singular** and **nominative plural**. Inside ICU `{count, plural, ...}` blocks, use the case-correct form per CLDR keyword (see the Slavic case-form table further down).
- Some Latin locales (fr, vi-VN, id) have forms identical to English (`Cycle`, `Module`, `Page`) — that's the natural cognate, not a Latin-preservation rule.
- **Epic / Epics**: stays Latin in most Latin locales because there's no clean cognate (Spanish `épico` is for poetry/film; same for it/pt-BR/de/fr). Slavic locales use phonetic transliteration that's standard in their software industry (Эпик, Епік, Epik). CJK locales use the literal/transliterated form (史诗, エピック, 에픽).
- **Generic uses translate normally and don't follow this glossary:** `next page` (paginator), `the page` (browser refresh), `status page`, `web page`, `life cycle`, `release cycle`, `rate-limit cycle`, `cycle of releases` — these are not the Plane Cycle/Page feature; translate them as ordinary words in the surrounding prose.
- When editing an existing file, migrate occurrences you are already touching. Do not bulk-rewrite unrelated strings in the same PR — land a separate sweep PR with the `chore(i18n):` prefix.
#### Slavic case forms inside ICU plural blocks
When a Slavic plural block counts a Plane feature noun, use these forms:
| Locale | Term | one (nom.sg.) | few | many | other |
| ------ | -------- | ------------- | -------- | -------- | -------- |
| **ru** | Цикл | Цикл | Цикла | Циклов | Цикла |
| **ru** | Модуль | Модуль | Модуля | Модулей | Модуля |
| **ru** | Эпик | Эпик | Эпика | Эпиков | Эпика |
| **ru** | Страница | Страница | Страницы | Страниц | Страницы |
| **ua** | Цикл | Цикл | Цикла | Циклів | Цикла |
| **ua** | Модуль | Модуль | Модуля | Модулів | Модуля |
| **ua** | Епік | Епік | Епіка | Епіків | Епіка |
| **ua** | Сторінка | Сторінка | Сторінки | Сторінок | Сторінки |
| **pl** | Cykl | Cykl | Cykle | Cykli | Cyklu |
| **pl** | Moduł | Moduł | Moduły | Modułów | Modułu |
| **pl** | Epik | Epik | Epiki | Epików | Epika |
| **pl** | Strona | Strona | Strony | Stron | Strony |
| **cs** | Cyklus | Cyklus | Cykly | Cyklu | Cyklů |
| **cs** | Modul | Modul | Moduly | Modulu | Modulů |
| **cs** | Epik | Epik | Epiky | Epiku | Epiků |
| **cs** | Stránka | Stránka | Stránky | Stránky | Stránek |
| **sk** | Cyklus | Cyklus | Cykly | Cyklu | Cyklov |
| **sk** | Modul | Modul | Moduly | Modulu | Modulov |
| **sk** | Epik | Epik | Epiky | Epiku | Epikov |
| **sk** | Stránka | Stránka | Stránky | Stránky | Stránok |
Per CLDR for ru/ua: `one` fires for 1, 21, 31… (nom.sg.); `few` for 24, 2224… (gen.sg.); `many` for 0, 520, 2530… (gen.pl.); `other` for non-integer counts (gen.sg.). For pl: `one` (1), `few` (24 not 1214, nom.pl.), `many` (0, 520, gen.pl.), `other` (decimals, gen.sg.). cs/sk: `one` (1), `few` (24, nom.pl.), `many` (decimals, gen.sg.), `other` (0, 5+, gen.pl.).
### Third-party products & standards (always Latin, every locale)
GitHub, GitLab, Bitbucket, Slack, Discord, Zoom, Microsoft Teams, Jira, Linear, Asana, Notion, Confluence, Trello, Figma, Google, Google Drive, Google Calendar, Google Docs, Google Sheets, Gmail, YouTube, Dropbox, Zapier, Tiptap, ProseMirror, Yjs, Hocuspocus, Socket.IO, React, TypeScript, JavaScript, Python, Django, PostgreSQL, Redis, RabbitMQ.
Also preserve: OAuth, OIDC, SAML, SSO, LDAP, API, REST, GraphQL, URL, URI, ID, UUID, JSON, YAML, CSV, TSV, XML, HTML, PDF, PNG, JPG, SVG, CSS, HTTP, HTTPS, TLS, SSL, IP, CIDR, DNS, ARIA, WCAG.
### Inside-string tokens (mechanical — preserve exactly)
- **ICU variables**: `{count}`, `{name}`, `{workspace}`, `{userName}` — never translate, never rename, never move inside `{}`.
- **ICU pluralization / select keywords**: `plural`, `select`, `one`, `few`, `many`, `other`, `zero`, `two`, `=0`, `=1`, `#` — never translate.
- **HTML & JSX tags**: `<b>…</b>`, `<a href="…">…</a>`, `<br/>`, `<code>…</code>` — preserve attributes and nesting; translate only the visible text between tags.
- **i18next `Trans` numbered fragments**: `<0>…</0>`, `<1>…</1>` — preserve the numbers exactly; only translate the wrapped text. Never renumber.
- **Markdown**: `**bold**`, `*italic*`, `` `code` ``, `[text](url)` — keep the syntax; translate only human-readable prose.
- **Escape sequences**: `\n` (newline), `\t`, `\"`, `\\` — preserve at the same positions.
- **Keyboard keys & modifiers**: `Ctrl`, `Cmd`, `Shift`, `Alt`, `Option`, `Enter`, `Return`, `Esc`, `Tab`, `Space`, `Backspace`, arrow glyphs (`↑↓←→`), and OS glyphs (`⌘⌥⌃⇧`) — preserve exactly as shown on the physical key.
- **File extensions & formats**: `.json`, `.csv`, `MM/DD/YYYY` format strings — preserve.
## Per-Script Rules
Apply by target-language script, not by locale list. Adding a new Latin-script locale? It follows the Latin-script rules below. Adding a new Cyrillic locale? It follows the Cyrillic section. Scripts not yet shown here (Arabic, Hebrew, Thai, Greek, Hindi/Devanagari, etc.) — see **Adding a locale not documented here**.
### Latin-script locales
Currently includes fr, es, it, de, pt-BR, pl, cs, sk, ro, tr-TR, vi-VN, id — and any future locale that uses the Latin alphabet (hu, nl, sv, da, nb, fi, hr, bg-using-Latin variants, etc.).
**Translate Plane feature nouns** (Cycle, Cycles, Module, Modules, Epic, Epics, Page, Pages) using the per-locale form from the glossary above. **Keep Latin** for Plane brand marks (Plane, Plane AI, Power K, PQL, Active Cycles, Sticky, Stickies, Intake), plan tier names (Pro, Business, Enterprise), and third-party brands (GitHub, Slack, etc.).
```
✅ "Créer un Cycle" (fr — natural cognate from glossary, identical to English)
✅ "Crear un nuevo Ciclo" (es — natural Spanish cognate from glossary)
✅ "Archiviare questo Modulo" (it — natural Italian cognate from glossary)
✅ "Nueva Epic" (es — Epic has no clean cognate; stays Latin per glossary)
✅ "Archivieren Sie diesen Zyklus" (de — natural German form from glossary)
✅ "Buat Siklus baru" (id — natural Indonesian form from glossary)
✅ "Buat Sticky baru" (id — Sticky is a Plane brand mark, stays Latin)
✅ "Wechseln Sie zum Pro-Plan" (de — Pro is a plan tier name, stays Latin)
❌ "Créer un Cycle" WRONG when the locale should be `Zyklus` (de). Always use the glossary form.
❌ "Archivieren Sie diesen Cycle" (de — feature noun was left in Latin; use Zyklus per glossary)
❌ "Créer un Cercle" (fr — invented translation; use the glossary form)
❌ "Nueva Saga" (es — translated "Epic" with the wrong cognate)
❌ "Buat Catatan Tempel" (id — translated "Sticky" which is a brand mark; keep Latin)
```
Generic uses translate normally and do not follow the glossary: a paginator's `next page` is `nächste Seite` / `página siguiente` (generic noun, not the Plane Pages feature). The glossary applies only when EN refers to the Plane product feature.
### Japanese (ja) — natural Japanese rendering
**Plane feature nouns** (Cycle, Module, Epic, Page) use the natural Japanese form per the glossary above (サイクル, モジュール, エピック, ページ — katakana for foreign-origin nouns; native Japanese where one applies, like 付箋 for an Apple/Microsoft-style "sticky note"). **Brand marks** stay in Latin: Plane, Plane AI, Power K, PQL, Active Cycles, Sticky, Stickies, Intake, GitHub, Slack, Pro/Business/Enterprise.
For new katakana coinages and existing translations, the long-vowel mark `ー` is added for words ending in `-er`, `-or`, `-ar`, `-y` in English. This is the Microsoft Japanese style-guide convention and the current industry default:
- user → **ユーザー** (not ユーザ)
- server → **サーバー** (not サーバ)
- editor → **エディター** (not エディタ)
- property → **プロパティー** (_kept as_ プロパティ where the existing codebase already does — match the surrounding file's convention)
Tone: polite form です・ます. Never plain form だ・である. Avoid over-formal 尊敬語・謙譲語 for SaaS product copy — it reads stilted.
Quotation marks: 「」 for primary quotes, 『』 for nested or for titles of works. Full-width punctuation: 。、()・!?.
### Korean (ko) — Hangul rendering
**Plane feature nouns** (Cycle, Module, Epic, Page) use the natural Korean form per the glossary above (사이클, 모듈, 에픽, 페이지 — Hangul transliteration where the term is product-coined; native Korean word where one applies). **Brand marks** stay in Latin: Plane, Plane AI, Power K, PQL, Active Cycles, Sticky, Stickies, Intake, GitHub, Slack, Pro/Business/Enterprise.
For new terms not on the glossary: prefer the native Korean word where one exists (`설정` for Settings); use Hangul phonetic transliteration for product-coined nouns with no native equivalent. Do not coerce a phonetic loanword when a natural Korean word exists — `버킷` for "Bucket" reads as a foreign trademark, `장바구니` reads as a Korean noun.
**Korean particle agreement**: when introducing a consonant-ending noun (사이클, 에픽, 모듈), follow with the consonant-form particle (을/은/이/과), not the vowel-form (를/는/가/와). `사이클을 추가` not `사이클를 추가`.
Register:
- **System and error messages** → 합니다체 (high-formal: 합니다, 됩니다, 입니다). Existing files in this register today: `ko/auth.json`, `ko/error/*.json`.
- **Empty states, onboarding microcopy, tooltips** → 해요체 acceptable (softer: 해요, 돼요, 이에요) if the existing file uses it. Existing files in this register today: `ko/empty-state.json`, `ko/tour.json`. Always match the surrounding file rather than introducing a new register mid-namespace.
- Never casual 반말.
Punctuation: Western punctuation (`. , ? !`); straight quotes `"…"` and `'…'`.
### Simplified Chinese (zh-CN) and Traditional Chinese (zh-TW) — translate feature nouns
**Plane feature nouns** (Cycle, Module, Epic, Page) translate to natural Chinese per the glossary above (zh-CN: 周期, 模块, 史诗, 页面 — zh-TW: 週期, 模組, 史詩, 頁面). This is what Microsoft's zh-CN style guide and every mainstream zh-localized SaaS product (Notion, Slack, Atlassian) does for common feature nouns.
**Brand marks stay Latin**: Plane, Plane AI, Power K, PQL, Active Cycles, Sticky, Stickies, Intake, GitHub, Slack, Pro/Business/Enterprise — these are trademark-style terms.
**Insert a half-width space on each side of embedded Latin tokens** — this is Microsoft's zh-CN guideline and required for legibility. **Exception**: no space between a Latin token and adjacent full-width punctuation (`。,;:?!`); the punctuation already supplies visual breathing room.
```
✅ "使用 GitHub 登录" (Latin brand → half-width spaces around)
✅ "创建周期" (zh-CN feature noun translated; no Latin = no spaces)
✅ "归档此史诗" (Plane Epic → 史诗 per glossary)
✅ "添加 Sticky" (Sticky is a brand mark → Latin + half-width space)
✅ "升级到 Pro" (Pro is a plan tier → Latin)
✅ "登录 GitHub。" (no space between Latin token and full-width period)
❌ "使用GitHub登录" (Latin brand without surrounding half-width space)
❌ "创建 Cycle" (feature noun left in Latin — should be 周期)
❌ "创建Cycle" (feature noun in Latin AND missing space)
❌ "登录 GitHub 。" (stray space before full-width period)
❌ "创建赛克" (invented transliteration of Cycle — use the glossary's 周期)
```
Punctuation is **full-width**: 。,?!;:. Quotes:
- zh-CN: `"…"` (primary) and `'…'` (nested)
- zh-TW: `「…」` (primary) and `『…』` (nested)
- Work titles (book/movie/app/article names): `《…》`
Variant discipline: zh-CN ≠ zh-TW. They differ in script (简体 vs 繁體) **and** vocabulary (视频 vs 影片; 软件 vs 軟體; 网络 vs 網路). Never mass-copy between the two directories.
Register: 您 (formal polite) in Plane UI — the product is B2B/SaaS. Reserve 你 for consumer/youth contexts (not applicable here).
### Cyrillic locales
Currently includes ru, ua — and any future Cyrillic locale (bg-BG, sr-Cyrl, mk, be, kk-Cyrl, etc.).
**Plane feature nouns** (Cycle, Module, Epic, Page) use the natural Cyrillic form per the glossary above (Цикл/Циклы, Модуль/Модули, Эпик/Эпики, Страница/Страницы in ru — Цикл/Цикли, Модуль/Модулі, Епік/Епіки, Сторінка/Сторінки in ua). **Brand marks** stay in Latin: Plane, Plane AI, Power K, PQL, Active Cycles, Sticky, Stickies, Intake, GitHub, Slack, Pro/Business/Enterprise.
For new terms not on the glossary: prefer the native Cyrillic word where one exists; use phonetic transliteration only when no native word applies (`Бакет` is wrong for "Bucket" — use `Корзина`/`Кошик`).
**Slavic case forms inside `{count, plural, ...}` blocks** are case-correct per CLDR keyword (one=nom.sg., few=gen.sg., many=gen.pl., other=gen.sg.). See the case-form table in the glossary section.
Register:
- **ru**: formal **Вы** (capitalized) when addressing a **single user directly** in respectful/formal contexts (onboarding, settings, confirmation dialogs); lowercase **вы** for plural/general references ("все вы"). Default to capitalized Вы in tooltips/dialogs and lowercase вы in descriptive copy.
- **ua**: modern Ukrainian software convention is lowercase **ви** by default; capitalized **Ви** is reserved for very formal direct address. This is the opposite convention from Russian — do not copy-paste Russian capitalization rules into Ukrainian files.
Punctuation: primary quotes `«…»`, nested `„…"`.
**Plural forms — critical**: both ru and ua require `one / few / many / other`. See CLDR section below.
## CLDR Plural Rules
Every `{count, plural, …}` string must contain **every** keyword the target language's CLDR rule requires. `other` is always required, even in single-form languages.
**Canonical source**: Unicode CLDR Language Plural Rules chart — the definitive, versioned list of required categories for every language. Always verify against the current CLDR chart when adding a locale, since rules occasionally shift across CLDR releases. Libraries like `Intl.PluralRules` can resolve the rule at runtime.
Below are the required keywords for locales currently in the repo. When adding a new locale, look up its categories in CLDR and add the row here.
| Locale | Required keywords | Example mapping |
| -------------------------------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| en, es, de, tr-TR, vi-VN, id, ja, ko, zh-CN, zh-TW | `one, other` (single-form locales may use `other` alone, but **always emit `other`**) | 1 → one; 2+ → other |
| fr | `one, many, other` | 0, 1 → one; 1 000 000 → many; else other |
| it | `one, many, other` | 1 → one; 1 000 000 → many; else other |
| pt-BR | `one, many, other` | 0, 1 → one; 1 000 000 → many; else other |
| ro | `one, few, other` | 1 → one; 0, 219 → few; 20+ → other |
| pl | `one, few, many, other` | 1 → one; 24 (not 1214) → few; 0, 520, many-digit → many; decimals → other |
| cs | `one, few, many, other` | 1 → one; 24 → few; decimals → many; 0, 5+ → other |
| sk | `one, few, many, other` | same pattern as cs |
| ru | `one, few, many, other` | 1, 21, 31… → one; 24, 2224… → few; 0, 520, 2530… → many; decimals → other |
| ua | `one, few, many, other` | same pattern as ru |
| ar (when added) | `zero, one, two, few, many, other` | Six categories — the maximum any language uses |
> **Note on the consolidated row.** CLDR itself only requires `other` for `tr-TR`, `vi-VN`, `id`, `ja`, `ko`, `zh-CN`, `zh-TW` (single-form locales). Emitting both `one` and `other` with identical content is a **project convention** — it keeps tooling and linters consistent across the codebase. It is not a CLDR requirement, and `Intl.PluralRules` will return only `"other"` for these locales at runtime.
For any locale not listed: look up the CLDR categories before writing a single plural string. Arabic has six; Welsh has six; most Slavic languages have four; most Romance languages have two or three; CJK / Turkic / Thai have one (still emit `other`).
Rules in practice:
1. **Never drop a required form**, even when the word is identical across forms — emit each one explicitly.
2. **Never add a form the target doesn't have.** German does **not** use `few`. Any `few` clause in `de/*.json` is a bug and must be removed.
3. In single-form locales (ja/ko/zh/tr/vi/id), emit `one` + `other` with identical content so tooling and linters stay consistent.
4. The `other` case is always the fallback — it must render a grammatically complete sentence on its own.
Examples:
```json
// ✅ Correct — Russian (four forms; `other` fires for non-integer counts and takes genitive singular)
"members": "{count, plural, one {# участник} few {# участника} many {# участников} other {# участника}}"
// ✅ Correct — Polish (four forms)
"items": "{count, plural, one {# element} few {# elementy} many {# elementów} other {# elementu}}"
// ✅ Correct — Romanian (three forms)
"days": "{count, plural, one {# zi} few {# zile} other {# de zile}}"
// ✅ Correct — French (three forms; `many` covers 1 000 000, 2 000 000, …)
"members": "{count, plural, one {# membre} many {# membres} other {# membres}}"
// ✅ Correct — Japanese (single form, both keywords emitted)
"label": "{count, plural, one {サイクル} other {サイクル}}"
// ❌ Wrong — German with spurious `few`
"label": "{count, plural, one {Zyklus} few {Zyklen} other {Zyklen}}"
// ❌ Wrong — Russian missing `few` and `many`
"label": "{count, plural, one {Цикл} other {Циклы}}"
```
## Placeholders, HTML, Markdown (preservation checklist)
For every translated string, verify before saving:
- [ ] Identical set of `{variables}` in source and target (character-for-character — `{userName}` is not `{username}`)
- [ ] Identical HTML/JSX tags with identical attributes and nesting
- [ ] Identical i18next `Trans` numbered tags (`<0>…</0>`, `<1>…</1>` — never renumber)
- [ ] Identical Markdown syntax (`**bold**`, `` `code` ``, `[link text](url)`)
- [ ] Identical `\n` positions (layout relies on these)
- [ ] No additions (no extra explanatory words), no omissions (no collapsed clauses)
Variables **may be repositioned** for grammatical fit:
```
en: "Welcome, {name}! You have {count} new work items."
fr: "Bienvenue, {name} ! Vous avez {count} nouveaux work items."
ja: "{name}さん、ようこそ。{count}件の新しい作業項目があります。"
```
## Per-Locale Punctuation & Spacing
Apply in every translated string. These are MQM "Locale Conventions" violations when missed — a measurable quality defect.
Table below covers locales currently in the repo; add a row when you add a locale.
| Locale | Key rules |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **fr** | Narrow NBSP (U+202F, fallback U+00A0) **before** `:`, `;`, `?`, `!`, `%`, `»` and **after** `«`. Primary quotes `« »` (with NBSPs), nested `"…"`. |
| **de** | Primary quotes `„…"` (low-high); **no** NBSP before punctuation. Avoid imperatives in system strings; prefer infinitive constructions ("Werkelement erstellen", not "Erstelle ein Werkelement"). |
| **es** | Inverted `¿…?` and `¡…!` at the start of questions/exclamations. Primary quotes `«…»` or `"…"`. |
| **it** | Primary quotes `«…»` or `"…"`. |
| **pt-BR** | Straight quotes `"…"`. |
| **pl** | Primary quotes `„…"`. |
| **cs / sk** | Primary quotes `„…"`. |
| **ro** | Primary quotes `„…"`. |
| **tr-TR / vi-VN / id** | Straight quotes `"…"`. Vietnamese diacritics are mandatory — never strip (no `bạn``ban`). |
| **ru / ua** | Primary `«…»`, nested `„…"`. |
| **ja** | Full-width `。、()!?・`. Primary quotes 「」, nested 『』. Ellipsis `…` (U+2026), typically doubled as `……`. |
| **ko** | Western `. , ? !`; straight quotes `"…"`. |
| **zh-CN** | Full-width `。,?!;:`. Primary `"…"`, nested `'…'`. Work titles `《…》`. Half-width space around embedded Latin tokens. |
| **zh-TW** | Full-width `。,?!;:`. Primary `「…」`, nested `『…』`. Work titles `《…》`. Half-width space around embedded Latin tokens. |
## Tone & Register (SaaS defaults)
Formality defaults for locales currently in the repo. Add a row for each new locale, consulting the Microsoft Style Guide for that locale as the default authority on product-UI register.
| Locale | Default "you" | Notes |
| ------- | ---------------------------------------------------------------------------- | ------------------------------------------------ |
| fr | **vous** | Never "tu" in product UI |
| es | **usted** + **ustedes** | Neutral Spanish — no "vosotros", no "tú" |
| it | **Lei** (formal third-person) | B2B/enterprise convention |
| de | **Sie** | Use infinitive constructions for system messages |
| pt-BR | **você** | Semi-formal default; never "tu" |
| pl | **Pan/Pani** + 3rd-person, or impersonal | Never "ty" |
| cs / sk | **Vy** (formal, 3rd-person plural) | Never "ty" |
| ro | **dumneavoastră** (formal) | Or impersonal |
| tr-TR | **siz** + `-iniz` endings | Never "sen" |
| vi-VN | **bạn** (safe neutral) | Never "mày/tao"; consistency > variety |
| id | **Anda** (always capitalized) | Never "kamu" in product UI |
| ja | **です・ます体** | Never plain form; avoid 尊敬語・謙譲語 |
| ko | **합니다체** for system/errors; **해요체** acceptable for onboarding | Match surrounding file |
| zh-CN | **您** | B2B convention; never 你 |
| zh-TW | **您** | B2B convention; never 你 |
| ru | **Вы** (cap.) for singular direct address, **вы** (lower) for plural/general | Context-dependent |
| ua | **ви** (lowercase, modern convention) | Capitalized Ви only for very formal |
General writing rules (apply across all locales):
- Sentence case in buttons, headings, tooltips — not Title Case. Only capitalize proper nouns.
- No exclamation marks except in genuine celebrations (first success, milestone).
- No emoji in UI copy.
- No slang, idioms, humor, cultural references — they don't translate.
- Consistent terminology — if "work item" is used once, never switch to "issue" / "ticket" mid-flow.
- Active voice, present tense.
- Write out abbreviations on first use if not on the DNT list.
## Text Expansion Budget
Design strings knowing translations will grow. Typical expansion vs. English for locales currently in the repo; lookup values from Andiamo / Eriksen / W3C tables when adding a locale.
| Locale | Expansion | Implication |
| ------------ | ------------------------------------------------- | ----------------------------------------------- |
| de | +1035%, single words up to +180% | Reserve generous space on buttons, tabs, labels |
| fr | +1525% | |
| es | +1530% | |
| it | +1025% | |
| pt-BR | +1530% | |
| pl | +2030% | |
| cs, sk | +1020% | |
| ro | +1525% | |
| ru, ua | +15% typical, spikes to +30% | |
| tr-TR | +1030% (agglutination) | |
| vi-VN | +3040% | Diacritic-heavy, many small words |
| id | +1020% | |
| ja | 10 to 55% character count (similar pixel width) | |
| ko | 10 to 15% | |
| zh-CN, zh-TW | 40% character count (≈2× char width) | |
Rule of thumb: any UI surface must absorb **+35%** without truncation. If a string is length-capped, add a comment or context in the source.
## Numbers, Dates, Currency, Units
**Never hard-code formats.** Use ICU skeletons so the runtime localizes automatically:
```json
"updated_on": "Updated {date, date, medium}",
"percent_done": "{ratio, number, percent} complete",
"item_count": "{count, number} items"
```
Never write `"{date}MM/DD/YYYY"` in the source — that guarantees a locale bug.
Decimal/thousands separators (runtime-handled):
- en: `1,234.56` — de/it/es/pt-BR/ru/ua/pl/cs/sk/tr-TR: `1.234,56` or `1 234,56` — fr: `1 234,56` (thin space) — ja/ko/zh: `1,234.56`.
Date formats (runtime-handled):
- en-US: `MM/DD/YYYY` — most of Europe: `DD.MM.YYYY` — fr: `DD/MM/YYYY` — ja: `YYYY/MM/DD` or `YYYY年MM月DD日` — zh: `YYYY年MM月DD日` — ko: `YYYY년 MM월 DD일`.
Currency: prefer ISO codes (`USD`, `EUR`, `INR`) in dense UI; localize symbol position via ICU. Units: keep the system (metric/imperial) a product decision, localized consistently.
## AI Translation Workflow
The repo has no machine-readable translation-status field today (no sidecar, no `__meta`, no per-key flag) — so "preserve human-reviewed strings" must be enforced via git history, not status metadata. The disciplines below are what actually fires:
1. **Inject the DNT glossary into every AI translation prompt** — both approved targets and forbidden renderings. LLMs do not natively honor glossaries; they obey them only when prompted.
2. **Prompt the variant explicitly**: `target=zh-CN` vs `target=zh-TW`, `target=pt-BR` vs `target=pt-PT`. Auto-detect calls blend them and produce mixed vocabulary.
3. **Pass 25 sentences of real context** per string (surrounding UI, screen, flow). Keep DNT instructions out of context — route them through the glossary/system prompt channel.
4. **Hallucination check**: AI output may add or drop content. Diff placeholder/tag inventory before and after; any mismatch is a reject.
5. **On re-translation, preserve human-touched lines.** Before overwriting any value in an existing locale file, run `git log -- <file>` (or `git blame -L <line>,<line>`) on the key. If the most recent non-bot, non-mass-rewrite commit was authored by a human, treat that line as human-reviewed and keep it. Only overwrite lines whose last edit was a machine sweep or a copy-from-en migration.
6. **Variants by language-resource tier**: expect more review iterations for id, vi-VN, ua, ro (lower-resource LLMs) than for de, fr, es, ja (higher-resource).
### Per-string review rubric (MQM-aligned)
When reviewing a translation — yours or an AI's — score against these categories and reject if any Major issue is present:
1. **Terminology** — DNT violation, inconsistent with the glossary above, or inconsistent with past strings in the same namespace
2. **Accuracy** — Mistranslation, addition (invented content), omission (dropped content), wrong variant (zh-CN vs zh-TW), hallucination
3. **Linguistic** — Grammar, punctuation (per-locale table), spelling, encoding (mojibake, half-width where full-width required)
4. **Style / Register** — Informal pronoun used where formal is required; inconsistent tone; idiom/cultural reference
5. **Locale conventions** — Date / number / currency format hard-coded; decimal separator wrong; keyboard key translated
6. **Markup** — Placeholder changed, HTML tag dropped/modified, Markdown broken, `Trans` numbered fragment renumbered
7. **Plural** — Missing required CLDR form; spurious form the language doesn't have; `#` or `=0` dropped
## Workflow
### Add a new key
1. Add to `packages/i18n/src/locales/en/<namespace>.json` first. (If the namespace file does not yet exist, see **Add a new namespace** below — that case has extra steps.)
2. Use in the component via `t("my.new_key")`.
3. Translate into every target locale present in `src/locales/` — one file at a time. **Do not** copy the English value into the non-English files as a shortcut — `sync-check` treats presence as synced and will silently ship English copy to every locale.
4. Run `pnpm --filter @plane/i18n run generate:types` (or let the build do it).
5. Run `pnpm --filter @plane/i18n run sync:check` — expect `0 missing, 0 stale, 0 collisions`.
6. Spot-check one non-Latin locale manually for punctuation/plural correctness.
### Add a new namespace
A "namespace" is a top-level JSON file (e.g. `common.json`, `auth.json`, `epic.json`). The set of valid namespace names is a hard-coded const array — adding a JSON file alone is not enough; the runtime will not load it.
1. Add the new namespace name to the `NAMESPACES` array in `packages/i18n/src/constants/namespaces.ts`. Keep alphabetical order with the existing entries.
2. Create `<namespace>.json` in **every** locale directory under `packages/i18n/src/locales/` — start with `en/<namespace>.json` (the source of truth), then create the file in all 18 target locales. An empty `{}` is fine for the targets at this step; `sync-check` will report missing keys as you add them in step 4.
3. Add at least one key in `en/<namespace>.json` so the namespace has content.
4. Translate every key from `en/<namespace>.json` into each target locale — apply every rule in this skill.
5. Run `pnpm --filter @plane/i18n run generate:types` to regenerate the `TTranslationKeys` union so component-level `t()` calls type-check.
6. Run `pnpm --filter @plane/i18n run sync:check` — expect `0 missing, 0 stale, 0 collisions`.
7. Use the new namespace from a component via the namespace-prefixed key (e.g. `t("<namespace>.my_key")`) and spot-check the rendered UI in at least one non-Latin locale.
### Update an English value
The meaning changed — every target is now stale even if the key still exists. `sync-check` will **not** flag this.
1. Edit `en/<namespace>.json`.
2. Update the same key in every target locale in the same PR.
3. If a locale has no native speaker available in the PR, mark the string status as `machine_translated` (in PR description or commit message) and open a follow-up for native review.
### Add a new language
1. `cp -r packages/i18n/src/locales/en packages/i18n/src/locales/<xx>`
2. Translate every file, applying every rule in this skill.
3. Register in `packages/i18n/src/constants/language.ts` (`SUPPORTED_LANGUAGES`) and `packages/i18n/src/types/language.ts` (`TLanguage`).
4. Run `sync:check` — must show 100% coverage before merging.
5. Before merging, **pseudolocalize**: temporarily replace the new locale's values with bracketed expanded forms (`"Plane" → "[Plàññéé——]"`) in a local build and click through the UI. Catches truncation, unextracted strings, and layout bugs before real users see them.
6. If the new locale isn't yet covered by this skill (script, plural rules, punctuation, register), follow **Adding a locale not documented here** below and update this file in the same PR.
### Commands
```bash
# Regenerate the TTranslationKeys union (auto on build)
pnpm --filter @plane/i18n run generate:types
# Report drift
pnpm --filter @plane/i18n run sync:check
# Same, exit 1 on drift (for CI)
pnpm --filter @plane/i18n run check:sync
```
## Quick Reference
| Question | Answer |
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Translate "Plane" / "Plane AI" / "Power K" / "PQL"? | Never. Latin, every locale. |
| Translate "Sticky" / "Stickies" / "Intake"? | Never. Plane brand marks. Latin, every locale. |
| Translate "Active Cycles"? | Never as a unit (it's a feature page name). Lowercase generic "active cycles" in prose translates normally per the glossary. |
| Translate "Pro" / "Business" / "Enterprise"? | Never. Plan tier names. Latin, every locale. |
| Translate "Cycle" / "Module" / "Epic" / "Page"? | **Yes** — use the per-locale form from the translation glossary (zh-CN 周期/模块/史诗/页面, ja サイクル/モジュール/エピック/ページ, de Zyklus/Modul/Epic/Seite, etc.). |
| Translate "GitHub" / "Slack" / third-party brands? | Never. Latin, every locale. |
| Translate a variable name `{name}`? | Never. Preserve exactly. |
| Translate `<0>…</0>`? | Translate only the inside text. Never renumber. |
| Russian plural forms? | `one / few / many / other` — four forms mandatory; case-correct per CLDR. |
| German plural forms? | `one / other` — never `few`. |
| French plural forms? | `one / many / other``many` covers 1M+. |
| CJK plural forms? | `one / other` (single-form) — still emit both. |
| Informal "you" in de/fr/ru/ja? | Never in product UI. |
| Chinese + embedded "GitHub"? | `使用 GitHub 登录` — half-width space around Latin **brand** tokens. Feature nouns translate (`创建周期`, no space because no Latin). |
| Japanese "user"? | ユーザー with long-vowel ー, not ユーザ. |
| Hard-code date formats? | Never. Use ICU `{d, date, medium}`. |
| Copy English into non-English locale? | Never. Worse than leaving the key missing. |
## Common Mistakes (revert on sight)
- **Translating Plane brand marks** — `Plane → 飞机`, `Plane AI → AI 聊天`, `Power K → 命令面板`, `Sticky → 便签` (Sticky is a brand, even though "sticky note" generally translates), `Intake → 收件箱`. Brand marks stay Latin.
- **Leaving feature nouns in Latin in non-Latin locales** — `创建 Cycle` (zh-CN), `Создать Cycle` (ru), `エピックを作成` is fine but `Epicを作成` is not. Use the per-locale form from the glossary.
- **Coining new feature-noun translations** — `Cycle → Cercle` (fr — invented; the natural cognate is the same `Cycle`), `Cycle → 循环` (zh-CN — non-glossary; use `周期`), `Epic → Saga` (es — non-glossary; use `Epic`). The glossary is the source of truth.
- **Missing `few` / `many` in Slavic languages** — Russian/Polish/Czech/Slovak/Ukrainian strings with only `one / other` are grammatically wrong for counts 24 and 5+. Fix in the same PR.
- **Wrong Slavic case form inside ICU plurals** — for ru/ua, `few` is genitive singular (`# Цикла`), not nominative plural (`# Циклы`). See the case-form table.
- **Inventing `few` in German, `many` in Spanish, etc.** — languages not in CLDR for that form. Remove the spurious keyword.
- **Translating `{count}` or renaming `{name}`** — ICU variables are lookup keys; rename breaks runtime substitution.
- **Dropping `<0>`/`<1>` numbering in `Trans`** — react-i18next matches by number; renumbering breaks the component.
- **Copying English as a translation** — `sync-check` passes, users see English. The fastest way to ship bad i18n.
- **No half-width space around Latin tokens in Chinese** — `使用GitHub登录` is a readability bug. The space rule applies around any remaining Latin token (brand mark), not around translated feature nouns.
- **Missing `ー` in Japanese katakana (`ユーザ` instead of `ユーザー`)** — inconsistent with the Microsoft style guide and the rest of the product.
- **Using informal pronouns** — `du/tu/ты/tú/you (informal)` breaks Plane's formal register in languages that distinguish.
- **Translating keyboard keys** — `Ctrl` stays `Ctrl`, never `Strg` or `コントロール`, because the physical key says `Ctrl`.
- **Hard-coded date/number strings** — `"Due on MM/DD/YYYY"` is a locale bug waiting to ship; use ICU formatters.
- **Translating `PQL`, `SSO`, `API`** — acronyms are DNT. The prose around them may be translated.
- **Translating plan tier names** — `Pro → Профессиональный`, `Business → 商业版`, `Enterprise → エンタープライズ`. Plan tiers stay Latin per industry convention (Notion/Slack/Linear/Asana).
- **Korean particle mismatch after Hangul transliteration** — `사이클를` is wrong (vowel-particle after consonant-ending noun); use `사이클을`. Same for 모듈, 에픽.
- **Vietnamese diacritic stripping** — `bạn` is not `ban`; diacritics are mandatory.
- **Lowercase `anda` in Indonesian** — `Anda` is always capitalized in product UI.
- **Mixing zh-CN and zh-TW vocabulary** — `视频``影片`, `软件``軟體`. Never copy-paste between the two directories.
## Red Flags — Stop and Revert
You see yourself about to do any of the following → stop, delete, restart this string:
- Replacing `Plane`, `Plane AI`, `Power K`, `PQL`, `Active Cycles`, `Sticky`, `Stickies`, `Intake`, `Pro`, `Business`, `Enterprise`, or any third-party brand (`GitHub`, `Slack`, etc.) with a translated form.
- Leaving `Cycle` / `Module` / `Epic` / `Page` in Latin in a non-Latin locale (zh-CN, zh-TW, ja, ko, ru, ua) — these are common nouns and must be translated per the glossary.
- Coining a feature-noun translation that's not in the glossary (`Cycle → 循环`, `Cycle → Cercle`, `Epic → Saga`).
- Using a vowel-form Korean particle (을/은/이/과 vs. 를/는/가/와) that doesn't agree with the noun's final character.
- Pasting the English value into a non-English file.
- Renaming or removing a `{variable}`, `<tag>`, numbered `<0>`, or Markdown marker.
- Dropping `few` or `many` from a Slavic-language plural, or adding `few` to German.
- Using nominative plural for `few` in ru/ua plural blocks (it should be genitive singular per CLDR).
- Editing one locale file and deciding to "do the others later" without updating the PR description.
- Using `du`, `tu`, `ты`, `tú`, plain-form Japanese, or 반말 Korean in any product string.
- Stripping diacritics from Vietnamese, lowercasing `Anda` in Indonesian.
- Hard-coding a date or number format.
## Rationalizations — Use Reality Column Instead
| Excuse | Reality |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| "Keeping `Cycle` Latin in zh-CN keeps it consistent with English docs." | That's a minority position; Microsoft, Apple, Mozilla, Notion, Atlassian, Slack all translate. Monolingual zh users can't read Latin words; the glossary form is the standard. |
| "史诗 is the established Chinese word for Epic, but Latin reads more brand-y." | Brand-y is the wrong goal for a feature noun. The glossary form (`史诗` for zh-CN, `Эпик` for ru) is what users in those locales expect from a SaaS product. |
| "Our Russian users understand `Cycle` Latin." | They understand it; they don't expect to encounter it. The glossary form (`Цикл`) is what every other Russian SaaS product uses for the same concept. |
| "I'll just transliterate the Plane brand name into Cyrillic for accessibility." | Brand marks stay Latin in every locale. `Плейн` is wrong; `Plane` is right. |
| "Plan tier names should be translated since they're plain words." | Industry standard (Notion, Slack, Linear, Asana, GitHub) is to keep `Pro`, `Business`, `Enterprise` Latin for marketing consistency. Don't translate. |
| "This string is internal / rarely seen, good-enough is fine." | Every string is someone's main screen. Rules are cheap to follow; consistency compounds. |
| "`sync-check` passed, I'm done." | `sync-check` only compares presence, not value quality. Green does not mean translated. |
| "I'll fix the missing plural forms later." | Missing `few` in Russian renders the wrong word for counts 24 right now in production. Same PR. |
| "The old file already used Latin Cycle everywhere — stay consistent." | Consistency with a bug is still a bug. Migrate occurrences you touch; open a sweep PR for the rest. |
| "AI said this was the right Japanese word for Cycle." | AI does not know our glossary unless you inject it. Check against the glossary table above; the glossary wins. |
| "The German translation is a bit long but still fits on my screen." | It won't fit on every user's screen. Design for +35% expansion; test in a narrow viewport. |
| "I renamed `{userName}` to `{nomeUtente}` so the Italian reads naturally." | Variables are code. The runtime has no `{nomeUtente}` in scope — it renders as literal text. Revert. |
| "Capitalizing `Anda` / `您` / `Sie` looks over-formal." | It's Plane's register in those languages. Deviating breaks brand voice across the product. |
## Adding a locale not documented here
When you add a language whose script, plural rules, punctuation, or register defaults aren't covered above:
1. **Plural rules** — Look up the target language in the Unicode CLDR Language Plural Rules chart. Note every required keyword (`zero`, `one`, `two`, `few`, `many`, `other`). Add a row to the CLDR table above.
2. **Script & transliteration** — Identify the script family. Latin → preserve DNT verbatim. Non-Latin → decide per-script: transliterate common-noun feature names phonetically (Cyrillic / Devanagari / Greek / Thai), keep brand marks in Latin. For RTL scripts (Arabic, Hebrew, Persian, Urdu) set `dir="rtl"` at the root and ensure Latin tokens remain LTR inside RTL context; preserve `&rlm;`/`&lrm;` markers if present.
3. **Punctuation & spacing** — Consult the Microsoft Style Guide for the locale (canonical authority on SaaS-style product punctuation). Add a row to the Per-Locale Punctuation & Spacing table.
4. **Register** — Default to the formal "you" and whatever honorific/polite register the Microsoft guide prescribes for B2B SaaS. Add a row to the Tone & Register table.
5. **Text expansion** — Look up typical expansion in Andiamo / Eriksen / W3C text-size tables; add a row to the Text Expansion Budget.
6. **DNT glossary** — Add a column (or row entries) for the new locale across the DNT tables. Specify forbidden literal translations (the words an LLM would produce if uninstructed). Example: if adding Arabic, the forbidden rendering for `Plane` includes `طائرة`; for `Epic` includes `ملحمة`; for `Sticky` includes `ملاحظة`.
7. **Commit this file in the same PR** that introduces the locale. The skill must stay ahead of the codebase — empty per-locale rows guarantee inconsistent translations in future PRs.
Canonical references to consult:
- **Unicode CLDR** — plural categories, number/date formats, collation.
- **Microsoft Style Guides** — per-locale register, punctuation, abbreviation handling, trademark rules (90+ locales covered).
- **Mozilla L10n Style Guides** — additional per-locale typography and brand-preservation rules.
- **W3C i18n** — RTL/bidi, placeholder handling, UTF-8, escapes, text expansion.
## References
- Microsoft Localization Style Guides — per-locale definitive source, formal-register defaults
- Unicode CLDR Plural Rules — canonical per-locale plural category list
- Mozilla L10n Style Guides — trademark/brand preservation rules, per-locale typography
- W3C i18n Quick Tips — placeholder handling, text expansion, UTF-8 and escapes
- Google developer style guide "Writing for a global audience" — source-string best practices
- MQM (Multidimensional Quality Metrics) — review rubric used in this skill's `Per-string review rubric` section
- Microsoft AI/LLMs for translation guidance — glossary injection, variant prompting, human-review gates
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+11
View File
@@ -28,6 +28,11 @@ AWS_S3_BUCKET_NAME="uploads"
# Maximum file upload limit
FILE_SIZE_LIMIT=5242880
# GPT settings
OPENAI_API_BASE="https://api.openai.com/v1" # deprecated
OPENAI_API_KEY="sk-" # deprecated
GPT_ENGINE="gpt-3.5-turbo" # deprecated
# Settings related to Docker
DOCKERIZED=1 # deprecated
@@ -43,3 +48,9 @@ CERT_EMAIL=
# For DNS Challenge based certificate generation, set the CERT_ACME_DNS, CERT_EMAIL
# CERT_ACME_DNS="acme_dns <CERT_DNS_PROVIDER> <CERT_DNS_PROVIDER_API_KEY>"
CERT_ACME_DNS=
# Force HTTPS for handling SSL Termination
MINIO_ENDPOINT_SSL=0
# API key rate limit
API_KEY_RATE_LIMIT="60/minute"
-3
View File
@@ -1,3 +0,0 @@
self-hosted-runner:
labels:
- ubuntu-22.04-4core
-17
View File
@@ -1,17 +0,0 @@
See the root `AGENTS.md` for comprehensive project instructions including tech stack, monorepo structure, commands, and architecture.
Each app and package has its own `AGENTS.md` with module-specific context.
## Quick Reference
- **Package manager**: pnpm (never use npm or yarn)
- **Build**: Turbo (`turbo.json`)
- **Lint/types**: `pnpm check` from root
- **Auto-fix**: `pnpm fix` from root
- **Frontend**: React 18, React Router 7, TypeScript, MobX, Tailwind CSS
- **Backend**: Django, DRF, Celery, PostgreSQL, Redis
- **Code style**: camelCase for variables/functions, PascalCase for components/types
- **TypeScript**: Strict mode, `workspace:*` for internal packages, `catalog:` for external deps
- **Python**: Ruff for linting/formatting, line length 120
- **Formatting**: Prettier with Tailwind plugin
- **Linting**: ESLint 9 with typed linting
-15
View File
@@ -10,13 +10,11 @@ This document outlines the standard tools and commands used in this monorepo.
## Package Manager
We use **pnpm** for package management.
- **Do not use `npm` or `yarn`.**
- Lockfile: `pnpm-lock.yaml`
- Workspace configuration: `pnpm-workspace.yaml`
### Common Commands
- Install dependencies: `pnpm install`
- Run a script in a specific package: `pnpm --filter <package_name> run <script>`
- Run a script in all packages: `pnpm -r run <script>`
@@ -24,7 +22,6 @@ We use **pnpm** for package management.
## Monorepo Tooling
We use **Turbo** for build system orchestration.
- Configuration: `turbo.json`
## Project Structure
@@ -49,15 +46,3 @@ We use **Turbo** for build system orchestration.
- Local development uses `docker-compose-local.yml`.
- Production/Staging uses `docker-compose.yml`.
### Docker Compose Profiles
The local compose file supports profiles for selective service startup:
| Profile | Services | Command |
| ---------- | ------------------------------------------------ | ------------------------------------------------------------------ |
| `all` | All services | `docker compose -f docker-compose-local.yml --profile all up` |
| `services` | External only (postgres, redis, rabbitmq, minio) | `docker compose -f docker-compose-local.yml --profile services up` |
| `api` | External + api, worker, beat-worker, migrator | `docker compose -f docker-compose-local.yml --profile api up` |
To set a default profile, add `COMPOSE_PROFILES=all` to your `.env` file.
-305
View File
@@ -1,305 +0,0 @@
name: AMI Build - AWS
on:
workflow_dispatch:
inputs:
ami_prefix:
description: 'AMI Prefix'
required: true
default: 'plane-commercial'
prime_host:
description: 'Prime Host'
required: true
default: 'https://prime.plane.so'
ami_publish_region:
description: 'AMI Publish Regions (comma separated)'
type: string
required: true
default: 'us-east-1'
mark_manifest_latest:
description: 'Mark manifest as latest'
type: boolean
required: false
default: false
env:
# Inputs
AMI_PREFIX: ${{ inputs.ami_prefix || 'plane-commercial' }}
PRIME_HOST: ${{ inputs.prime_host || 'https://prime.plane.so' }}
AMI_PUBLISH_REGION: ${{ inputs.ami_publish_region || 'us-east-1' }}
# Inputs by Devops
AWS_MANIFEST_BUCKET: 'plane-terraform-marketplace'
AWS_VPC_CIDR: '10.34.0.0/16'
AWS_SUBNET_CIDR: '10.34.1.0/24'
AWS_VPC_REGION: 'us-east-1'
AWS_BASE_IMAGE_OWNER: '099720109477'
# Secrets
AWS_ACCESS_KEY: ${{ secrets.MARKETPLACE_AWS_ACCESS_KEY_ID }}
AWS_SECRET_KEY: ${{ secrets.MARKETPLACE_AWS_SECRET_ACCESS_KEY }}
# Constants
CURRENT_MANIFEST_FILE: 'ee-docker-aws-ami-manifest.json'
EE_PACKER_FILE: 'ee-docker-aws-ami.pkr.hcl'
CF_TEMPLATE_FILE: deployments/ami/commercial/ee-cloudformation-template.yaml
CF_OUTPUT_FILE: deployments/ami/commercial/plane-commercial-cloudformation.yaml
MARK_MANIFEST_LATEST: ${{ inputs.mark_manifest_latest || false }}
jobs:
build_ami:
runs-on: ubuntu-22.04
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v6
with:
aws-access-key-id: ${{ env.AWS_ACCESS_KEY }}
aws-secret-access-key: ${{ env.AWS_SECRET_KEY }}
aws-region: ${{ env.AWS_VPC_REGION }}
- name: Setup `packer`
uses: hashicorp/setup-packer@v3
id: setup
with:
version: latest
- name: Copy Upload Assets
run: |
mkdir -p plane-dist
cp deployments/ami/commercial/cloudinit-ee/* plane-dist/
- name: Run `packer init`
id: init
run: "packer init ./deployments/ami/commercial/${{ env.EE_PACKER_FILE }}"
- name: Run `packer validate`
id: validate
run: "packer validate ./deployments/ami/commercial/${{ env.EE_PACKER_FILE }}"
- name: Make Variables File
id: make_variables_file
run: |
touch variables.pkrvars.hcl
echo "aws_region = \"${AWS_VPC_REGION}\"" >> variables.pkrvars.hcl
echo "ami_name_prefix = \"${AMI_PREFIX}\"" >> variables.pkrvars.hcl
echo "vpc_cidr = \"${AWS_VPC_CIDR}\"" >> variables.pkrvars.hcl
echo "subnet_cidr = \"${AWS_SUBNET_CIDR}\"" >> variables.pkrvars.hcl
echo "base_image_owner = \"${AWS_BASE_IMAGE_OWNER}\"" >> variables.pkrvars.hcl
echo "prime_host = \"${PRIME_HOST}\"" >> variables.pkrvars.hcl
echo "instance_type = \"t3a.xlarge\"" >> variables.pkrvars.hcl
echo "manifest_file_name = \"${{ env.CURRENT_MANIFEST_FILE }}\"" >> variables.pkrvars.hcl
# split AMI_PUBLISH_REGION by comma and add to ami_regions
ami_regions=$(echo "${AMI_PUBLISH_REGION}" | sed 's/[[:space:]]*,[[:space:]]*/\n/g' | jq -R . | jq -s -c .)
echo "ami_regions = ${ami_regions}" >> variables.pkrvars.hcl
cat variables.pkrvars.hcl
- name: Run `packer build`
id: build
run: |
packer build \
-var "aws_access_key=${{ env.AWS_ACCESS_KEY }}" \
-var "aws_secret_key=${{ env.AWS_SECRET_KEY }}" \
-var-file=variables.pkrvars.hcl \
./deployments/ami/commercial/${{ env.EE_PACKER_FILE }}
- name: Extract AMI Information and Create Summary
id: ami_info
run: |
# Extract AMI details from manifest
AMI_STRING=$(jq -r '.builds[-1].artifact_id' ${{env.CURRENT_MANIFEST_FILE}})
AMI_NAME=$(jq -r '.builds[-1].custom_data.ami_name' ${{env.CURRENT_MANIFEST_FILE}})
BUILD_TIME=$(jq -r '.builds[-1].custom_data.build_time' ${{env.CURRENT_MANIFEST_FILE}})
# Create array of AMI information
declare -a AMI_INFO
IFS=',' read -ra AMI_ARRAY <<< "$AMI_STRING"
for ami in "${AMI_ARRAY[@]}"; do
REGION=$(echo "$ami" | cut -d ":" -f1)
AMI_ID=$(echo "$ami" | cut -d ":" -f2)
AMI_INFO+=("$REGION:$AMI_ID")
done
# Add git information to manifest
jq --arg branch "${{ github.ref_name }}" \
--arg commit "${{ github.sha }}" \
--arg build_time "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
'.builds[-1].custom_data += {git_branch: $branch, git_commit: $commit, build_timestamp: $build_time}' \
${{env.CURRENT_MANIFEST_FILE}} > temp-manifest.json
mv temp-manifest.json ${{env.CURRENT_MANIFEST_FILE}}
- name: Store Manifest in S3
run: |
# Also store a versioned copy
aws s3 cp ${{env.CURRENT_MANIFEST_FILE}} "s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/ami/manifests/plane-commercial-manifest-${{ github.sha }}.json"
- name: Store Manifest in S3 as latest
if: ${{ env.MARK_MANIFEST_LATEST == 'true' }}
run: |
# Store the current manifest as latest
aws s3 cp ${{env.CURRENT_MANIFEST_FILE}} s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/ami/manifests/plane-commercial-manifest-latest.json || true
- name: Upload Build Manifest as Artifact
uses: actions/upload-artifact@v7
with:
name: ee-docker-aws-ami-manifest
path: ${{env.CURRENT_MANIFEST_FILE}}
retention-days: 30
- name: Tag AMIs
run: |
# Extract AMI string again
AMI_STRING=$(jq -r '.builds[-1].artifact_id' ${{env.CURRENT_MANIFEST_FILE}})
# Process and tag each AMI in its respective region
IFS=',' read -ra AMI_ARRAY <<< "$AMI_STRING"
for ami in "${AMI_ARRAY[@]}"; do
REGION=$(echo "$ami" | cut -d ":" -f1)
AMI_ID=$(echo "$ami" | cut -d ":" -f2)
echo "Tagging AMI ${AMI_ID} in region ${REGION}"
aws ec2 create-tags \
--region "$REGION" \
--resources "$AMI_ID" \
--tags \
Key=GitBranch,Value=${{ github.ref_name }} \
Key=GitCommit,Value=${{ github.sha }} \
Key=BuildTime,Value=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
done
- name: Update CloudFormation Template
run: |
# Install yq if not present
if ! command -v yq &> /dev/null; then
echo "Installing yq..."
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
sudo chmod +x /usr/local/bin/yq
fi
if ! command -v jq &> /dev/null; then
echo "Error: jq is required but not installed. Please install jq to continue."
exit 1
fi
ARTIFACT_ID=$(jq -r '.builds[0].artifact_id' "${{ env.CURRENT_MANIFEST_FILE }}")
if [[ "$ARTIFACT_ID" == "null" || -z "$ARTIFACT_ID" ]]; then
echo "Error: Could not extract artifact_id from manifest file"
exit 1
fi
echo "Found artifact_id: $ARTIFACT_ID"
REGIONS=()
AMIS=()
# Split by comma and process each region:ami pair
IFS=',' read -ra PAIRS <<< "$ARTIFACT_ID"
for pair in "${PAIRS[@]}"; do
# Trim whitespace
pair=$(echo "$pair" | xargs)
# Split by colon to get region and ami
IFS=':' read -ra REGION_AMI <<< "$pair"
if [[ ${#REGION_AMI[@]} -eq 2 ]]; then
region="${REGION_AMI[0]}"
ami="${REGION_AMI[1]}"
REGIONS+=("$region")
AMIS+=("$ami")
echo " $region -> $ami"
fi
done
# Check if we found any AMI mappings
if [[ ${#REGIONS[@]} -eq 0 ]]; then
echo "Error: No valid region:ami pairs found in artifact_id"
exit 1
fi
# Copy the original template to output file
cp "${{ env.CF_TEMPLATE_FILE }}" "${{ env.CF_OUTPUT_FILE }}"
echo "Regions: ${REGIONS[@]}"
echo "AMIs: ${AMIS[@]}"
echo "Updating AMI IDs in template..."
REGION_RESTRICTIONS=()
# Update AMI IDs for each region found in the manifest
REGIONS_STRING=""
for i in "${!REGIONS[@]}"; do
region="${REGIONS[$i]}"
ami_id="${AMIS[$i]}"
echo " Updating $region with AMI: $ami_id"
REGIONS_STRING+="${region}, "
yq eval ".Parameters.AMIId.Default = \"${ami_id}\"" -i "${{ env.CF_OUTPUT_FILE }}"
done
cat "${{ env.CF_OUTPUT_FILE }}"
echo "Updated template saved as: ${{ env.CF_OUTPUT_FILE }}"
- name: Store CloudFormation Template in S3
run: |
# Store the current manifest as latest
aws s3 cp ${{env.CF_OUTPUT_FILE}} s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/cloudformation/plane-commercial-cloudformation-latest.yaml
date_string=$(date +%d%b%Y)
aws s3 cp ${{env.CF_OUTPUT_FILE}} s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/cloudformation/plane-commercial-cloudformation-${date_string}.yaml
- name: Upload Updated CloudFormation Template
uses: actions/upload-artifact@v7
with:
name: cloudformation-template
path: ${{ env.CF_OUTPUT_FILE }}
retention-days: 30
- name: Print Build Summary
id: print_build_summary
run: |
# Extract AMI details from manifest
AMI_STRING=$(jq -r '.builds[-1].artifact_id' ${{env.CURRENT_MANIFEST_FILE}})
AMI_NAME=$(jq -r '.builds[-1].custom_data.ami_name' ${{env.CURRENT_MANIFEST_FILE}})
BUILD_TIME=$(jq -r '.builds[-1].custom_data.build_time' ${{env.CURRENT_MANIFEST_FILE}})
# Create array of AMI information
declare -a AMI_INFO
IFS=',' read -ra AMI_ARRAY <<< "$AMI_STRING"
for ami in "${AMI_ARRAY[@]}"; do
REGION=$(echo "$ami" | cut -d ":" -f1)
AMI_ID=$(echo "$ami" | cut -d ":" -f2)
AMI_INFO+=("$REGION:$AMI_ID")
done
# Create build summary with all AMIs
{
echo "### 🌎 Regional AMI Distribution"
echo "| Region | AMI ID |"
echo "| --- | --- |"
for ami_info in "${AMI_INFO[@]}"; do
region=${ami_info%:*}
ami_id=${ami_info#*:}
echo "| \`${region}\` | \`${ami_id}\` |"
done
} >> $GITHUB_STEP_SUMMARY
date_string=$(date +%d%b%Y)
{
echo "### 📁 S3 Files"
echo "| File | Path | "
echo "| --- | --- |"
echo "| Latest CF Template | \`s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/cloudformation/plane-commercial-cloudformation-latest.yaml\` |"
echo "| CF Template | \`s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/cloudformation/plane-commercial-cloudformation-${date_string}.yaml\` |"
echo "| Current Manifest | \`s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/ami/manifests/plane-commercial-manifest-${{ github.sha }}.json\` |"
echo "| Latest Manifest | \`s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/ami/manifests/plane-commercial-manifest-latest.json\` |"
} >> $GITHUB_STEP_SUMMARY
# Console output for logs
echo "✅ AMI built successfully!"
echo "🔹 AMI Information:"
for ami_info in "${AMI_INFO[@]}"; do
region=${ami_info%:*}
ami_id=${ami_info#*:}
echo " • Region: ${region}, AMI ID: ${ami_id}"
done
@@ -1,180 +0,0 @@
name: AMI Build - DigitalOcean
on:
push:
branches:
- appliance-digitalocean
workflow_dispatch:
inputs:
ami_prefix:
description: 'AMI Prefix'
required: true
default: 'plane-commercial'
prime_host:
description: 'Prime Host'
required: true
default: 'https://prime.plane.so'
mark_manifest_latest:
description: 'Mark manifest as latest'
type: boolean
required: false
default: false
env:
# Inputs
AMI_PREFIX: ${{ inputs.ami_prefix || 'plane-commercial' }}
PRIME_HOST: ${{ inputs.prime_host || 'https://prime.plane.so' }}
# Inputs by Devops
AWS_MANIFEST_BUCKET: 'plane-terraform-marketplace'
AWS_VPC_REGION: 'us-east-1'
# Secrets
AWS_ACCESS_KEY: ${{ secrets.MARKETPLACE_AWS_ACCESS_KEY_ID }}
AWS_SECRET_KEY: ${{ secrets.MARKETPLACE_AWS_SECRET_ACCESS_KEY }}
# Constants
CURRENT_MANIFEST_FILE: 'ee-docker-digital-ocean-manifest.json'
EE_PACKER_FILE: 'ee-docker-digital-ocean.pkr.hcl'
MARK_MANIFEST_LATEST: ${{ inputs.mark_manifest_latest || false }}
jobs:
build_ami:
runs-on: ubuntu-22.04
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v6
with:
aws-access-key-id: ${{ env.AWS_ACCESS_KEY }}
aws-secret-access-key: ${{ env.AWS_SECRET_KEY }}
aws-region: ${{ env.AWS_VPC_REGION }}
- name: Setup `packer`
uses: hashicorp/setup-packer@v3
id: setup
with:
version: latest
- name: Copy Upload Assets
run: |
mkdir -p plane-dist
cp deployments/ami/commercial/cloudinit-ee/* plane-dist/
- name: Run `packer init`
id: init
run: "packer init ./deployments/ami/commercial/${{ env.EE_PACKER_FILE }}"
- name: Run `packer validate`
id: validate
run: "packer validate ./deployments/ami/commercial/${{ env.EE_PACKER_FILE }}"
- name: Make Variables File
id: make_variables_file
run: |
touch variables.pkrvars.hcl
echo "ami_name_prefix = \"${AMI_PREFIX}\"" >> variables.pkrvars.hcl
echo "prime_host = \"${PRIME_HOST}\"" >> variables.pkrvars.hcl
echo "instance_type = \"s-2vcpu-4gb\"" >> variables.pkrvars.hcl
echo "manifest_file_name = \"${{ env.CURRENT_MANIFEST_FILE }}\"" >> variables.pkrvars.hcl
cat variables.pkrvars.hcl
- name: Run `packer build`
id: build
run: |
packer build \
-var "api_token=${{ secrets.MARKETPLACE_DIGITAL_OCEAN_API_TOKEN }}" \
-var-file=variables.pkrvars.hcl \
./deployments/ami/commercial/${{ env.EE_PACKER_FILE }}
- name: Extract AMI Information and Create Summary
id: ami_info
run: |
# Extract AMI details from manifest
AMI_STRING=$(jq -r '.builds[-1].artifact_id' ${{env.CURRENT_MANIFEST_FILE}})
AMI_NAME=$(jq -r '.builds[-1].custom_data.ami_name' ${{env.CURRENT_MANIFEST_FILE}})
BUILD_TIME=$(jq -r '.builds[-1].custom_data.build_time' ${{env.CURRENT_MANIFEST_FILE}})
# Create array of AMI information
declare -a AMI_INFO
IFS=',' read -ra AMI_ARRAY <<< "$AMI_STRING"
for ami in "${AMI_ARRAY[@]}"; do
REGION=$(echo "$ami" | cut -d ":" -f1)
AMI_ID=$(echo "$ami" | cut -d ":" -f2)
AMI_INFO+=("$REGION:$AMI_ID")
done
# Add git information to manifest
jq --arg branch "${{ github.ref_name }}" \
--arg commit "${{ github.sha }}" \
--arg build_time "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
'.builds[-1].custom_data += {git_branch: $branch, git_commit: $commit, build_timestamp: $build_time}' \
${{env.CURRENT_MANIFEST_FILE}} > temp-manifest.json
mv temp-manifest.json ${{env.CURRENT_MANIFEST_FILE}}
- name: Store Manifest in S3
run: |
# Also store a versioned copy
aws s3 cp ${{env.CURRENT_MANIFEST_FILE}} "s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/ami/manifests/plane-commercial-digitalocean-manifest-${{ github.sha }}.json"
- name: Store Manifest in S3 as latest
if: ${{ env.MARK_MANIFEST_LATEST == 'true' }}
run: |
# Store the current manifest as latest
aws s3 cp ${{env.CURRENT_MANIFEST_FILE}} s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/ami/manifests/plane-commercial-digitalocean-manifest-latest.json || true
- name: Upload Build Manifest as Artifact
uses: actions/upload-artifact@v7
with:
name: ee-docker-digital-ocean-manifest
path: ${{env.CURRENT_MANIFEST_FILE}}
retention-days: 30
- name: Print Build Summary
id: print_build_summary
run: |
# Extract AMI details from manifest
AMI_STRING=$(jq -r '.builds[-1].artifact_id' ${{env.CURRENT_MANIFEST_FILE}})
AMI_NAME=$(jq -r '.builds[-1].custom_data.ami_name' ${{env.CURRENT_MANIFEST_FILE}})
BUILD_TIME=$(jq -r '.builds[-1].custom_data.build_time' ${{env.CURRENT_MANIFEST_FILE}})
# Create array of AMI information
declare -a AMI_INFO
IFS=',' read -ra AMI_ARRAY <<< "$AMI_STRING"
for ami in "${AMI_ARRAY[@]}"; do
REGION=$(echo "$ami" | cut -d ":" -f1)
AMI_ID=$(echo "$ami" | cut -d ":" -f2)
AMI_INFO+=("$REGION:$AMI_ID")
done
# Create build summary with all AMIs
{
echo "### 🌎 Regional AMI Distribution"
echo "| Region | AMI ID |"
echo "| --- | --- |"
for ami_info in "${AMI_INFO[@]}"; do
region=${ami_info%:*}
ami_id=${ami_info#*:}
echo "| \`${region}\` | \`${ami_id}\` |"
done
} >> $GITHUB_STEP_SUMMARY
date_string=$(date +%d%b%Y)
{
echo "### 📁 S3 Files"
echo "| File | Path | "
echo "| --- | --- |"
echo "| Current Manifest | \`s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/ami/manifests/plane-commercial-manifest-${{ github.sha }}.json\` |"
echo "| Latest Manifest | \`s3://${{ env.AWS_MANIFEST_BUCKET }}/plane-commercial/ami/manifests/plane-commercial-manifest-latest.json\` |"
} >> $GITHUB_STEP_SUMMARY
# Console output for logs
echo "✅ AMI built successfully!"
echo "🔹 AMI Information:"
for ami_info in "${AMI_INFO[@]}"; do
region=${ami_info%:*}
ami_id=${ami_info#*:}
echo " • Region: ${region}, AMI ID: ${ami_id}"
done
-176
View File
@@ -1,176 +0,0 @@
name: API Pytest Suite
on:
workflow_dispatch:
inputs:
test_marker:
description: "pytest -m filter"
required: false
default: "unit or contract or smoke"
type: string
run_slow_tests:
description: "Include slow-marked tests"
required: false
default: false
type: boolean
branch:
description: "Branch to run tests against"
required: false
default: "preview"
type: string
workflow_call:
inputs:
test_marker:
description: "pytest -m filter"
required: false
default: "unit or contract or smoke"
type: string
run_slow_tests:
description: "Include slow-marked tests"
required: false
default: false
type: boolean
branch:
description: "Branch to run tests against"
required: false
default: "preview"
type: string
secrets:
AWS_EKS_ROLE_ARN:
required: false
AWS_SECRET_ROLE_ARN:
required: false
AWS_SECRET_NAME:
required: false
AWS_REGION:
required: false
EKS_CLUSTER_NAME:
required: false
jobs:
run-pytests:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.branch || 'preview' }}
- name: Configure AWS credentials to assume the secret role
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.AWS_SECRET_ROLE_ARN }}
aws-region: ${{ secrets.AWS_REGION || 'us-east-1' }}
- name: Retrieve deploy secrets
run: |
SECRET_JSON=$(aws secretsmanager get-secret-value \
--secret-id ${{ secrets.AWS_SECRET_NAME || 'github-actions/github-actions-secrets' }} \
--query SecretString \
--output text)
REDIS_URL=$(echo "$SECRET_JSON" | jq -r '.redis_url')
echo "::add-mask::$REDIS_URL"
DELIMITER=$(openssl rand -hex 16)
{
echo "redis_url<<${DELIMITER}"
echo "$REDIS_URL"
echo "${DELIMITER}"
} >> "$GITHUB_ENV"
- name: Configure AWS credentials to assume the EKS role
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.AWS_EKS_ROLE_ARN }}
aws-region: ${{ secrets.AWS_REGION || 'us-east-1' }}
- name: Update kubeconfig
run: |
aws eks update-kubeconfig \
--region ${{ secrets.AWS_REGION || 'us-east-1' }} \
--name ${{ secrets.EKS_CLUSTER_NAME || 'plane-eks-dev' }}
- name: Create test database
run: |
set -e
DB_NAME="t${{ github.run_id }}"
cat <<EOF | kubectl apply -f -
---
apiVersion: db.movetokube.com/v1alpha1
kind: Postgres
metadata:
name: ${DB_NAME}
namespace: postgres-operator
spec:
database: ${DB_NAME}
dropOnDelete: true
schemas:
- public
---
apiVersion: db.movetokube.com/v1alpha1
kind: PostgresUser
metadata:
name: ${DB_NAME}-apptest
namespace: postgres-operator
spec:
database: ${DB_NAME}
role: ${DB_NAME}_apptest
privileges: OWNER
secretName: ${DB_NAME}-apptest-secret
EOF
SECRET_NAME="${DB_NAME}-apptest-secret-${DB_NAME}-apptest"
echo "Waiting for database secret ${SECRET_NAME} to be created..."
kubectl wait --for=jsonpath='{.data.POSTGRES_URL}' \
secret/${SECRET_NAME} \
-n postgres-operator \
--timeout=120s
PG_URI=$(kubectl get secret "${SECRET_NAME}" -n postgres-operator -o jsonpath='{.data.POSTGRES_URL}' | base64 -d)
echo "::add-mask::$PG_URI"
DELIMITER=$(openssl rand -hex 16)
{
echo "DATABASE_URL<<${DELIMITER}"
echo "$PG_URI"
echo "${DELIMITER}"
} >> "$GITHUB_ENV"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: apps/api/requirements/test.txt
- name: Install system dependencies
run: sudo apt-get install -y libldap2-dev libsasl2-dev
- name: Install test dependencies
working-directory: apps/api
run: pip install -r requirements/test.txt
- name: Run pytest
working-directory: apps/api
env:
DATABASE_URL: ${{ env.DATABASE_URL }}
REDIS_URL: ${{ env.redis_url }}
SECRET_KEY: ci-test-secret-key
run: |
MARKER="${{ inputs.test_marker || 'unit or contract or smoke' }}"
if [[ "${{ inputs.run_slow_tests }}" == "true" ]]; then
MARKER="${MARKER} or slow"
fi
pytest -m "${MARKER}" --timeout=60
- name: Cleanup test database
if: always()
run: |
DB_NAME="t${{ github.run_id }}"
kubectl delete postgres "${DB_NAME}" -n postgres-operator --ignore-not-found || true
kubectl delete postgresuser "${DB_NAME}-apptest" -n postgres-operator --ignore-not-found || true
-415
View File
@@ -1,415 +0,0 @@
name: Branch Build Cloud
on:
workflow_dispatch:
inputs:
build_type:
description: "Type of build to run"
required: true
type: choice
default: "Build"
options:
- "Build"
- "Release"
releaseVersion:
description: "Release Version"
type: string
default: v0.0.0-cloud
isPrerelease:
description: "Is Pre-release"
type: boolean
default: false
required: true
env:
TARGET_BRANCH: ${{ github.ref_name }}
BUILD_TYPE: ${{ github.event.inputs.build_type }}
RELEASE_VERSION: ${{ github.event.inputs.releaseVersion }}
IS_PRERELEASE: ${{ github.event.inputs.isPrerelease }}
jobs:
branch_build_setup:
name: Build Setup
runs-on: ubuntu-22.04-4core
outputs:
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
dh_img_web: ${{ steps.set_env_variables.outputs.DH_IMG_WEB }}
dh_img_space: ${{ steps.set_env_variables.outputs.DH_IMG_SPACE }}
dh_img_admin: ${{ steps.set_env_variables.outputs.DH_IMG_ADMIN }}
dh_img_live: ${{ steps.set_env_variables.outputs.DH_IMG_LIVE }}
dh_img_silo: ${{ steps.set_env_variables.outputs.DH_IMG_SILO }}
dh_img_backend: ${{ steps.set_env_variables.outputs.DH_IMG_BACKEND }}
dh_img_email: ${{ steps.set_env_variables.outputs.DH_IMG_EMAIL }}
dh_img_pi: ${{ steps.set_env_variables.outputs.dh_img_pi }}
dh_img_flux: ${{ steps.set_env_variables.outputs.DH_IMG_FLUX }}
dh_img_node_runner: ${{ steps.set_env_variables.outputs.DH_IMG_NODE_RUNNER }}
build_type: ${{steps.set_env_variables.outputs.BUILD_TYPE}}
build_release: ${{ steps.set_env_variables.outputs.BUILD_RELEASE }}
build_prerelease: ${{ steps.set_env_variables.outputs.BUILD_PRERELEASE }}
release_version: ${{ steps.set_env_variables.outputs.RELEASE_VERSION }}
steps:
- id: set_env_variables
name: Set Environment Variables
run: |
echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT
echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT
echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT
echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT
BR_NAME=$( echo "${{ env.TARGET_BRANCH }}" | sed 's/[^a-zA-Z0-9.-]//g')
echo "TARGET_BRANCH=$BR_NAME" >> $GITHUB_OUTPUT
echo "DH_IMG_WEB=web-cloud" >> $GITHUB_OUTPUT
echo "DH_IMG_SPACE=space-cloud" >> $GITHUB_OUTPUT
echo "DH_IMG_ADMIN=admin-cloud" >> $GITHUB_OUTPUT
echo "DH_IMG_LIVE=live-cloud" >> $GITHUB_OUTPUT
echo "DH_IMG_SILO=silo-cloud" >> $GITHUB_OUTPUT
echo "DH_IMG_BACKEND=backend-cloud" >> $GITHUB_OUTPUT
echo "DH_IMG_EMAIL=email-cloud" >> $GITHUB_OUTPUT
echo "dh_img_pi=plane-pi-cloud" >> $GITHUB_OUTPUT
echo "DH_IMG_FLUX=flux-cloud" >> $GITHUB_OUTPUT
echo "DH_IMG_NODE_RUNNER=node-runner-cloud" >> $GITHUB_OUTPUT
echo "BUILD_TYPE=${{env.BUILD_TYPE}}" >> $GITHUB_OUTPUT
BUILD_RELEASE=false
BUILD_PRERELEASE=false
RELVERSION="latest"
if [ "${{ env.BUILD_TYPE }}" == "Release" ]; then
FLAT_RELEASE_VERSION=$(echo "${{ env.RELEASE_VERSION }}" | sed 's/[^a-zA-Z0-9.-]//g')
echo "FLAT_RELEASE_VERSION=${FLAT_RELEASE_VERSION}" >> $GITHUB_OUTPUT
semver_regex="^v([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)?$"
if [[ ! $FLAT_RELEASE_VERSION =~ $semver_regex ]]; then
echo "Invalid Release Version Format : $FLAT_RELEASE_VERSION"
echo "Please provide a valid SemVer version"
echo "e.g. v1.2.3 or v1.2.3-alpha-1"
echo "Exiting the build process"
exit 1 # Exit with status 1 to fail the step
fi
BUILD_RELEASE=true
RELVERSION=$FLAT_RELEASE_VERSION
if [ "${{ env.IS_PRERELEASE }}" == "true" ]; then
BUILD_PRERELEASE=true
fi
fi
echo "BUILD_RELEASE=${BUILD_RELEASE}" >> $GITHUB_OUTPUT
echo "BUILD_PRERELEASE=${BUILD_PRERELEASE}" >> $GITHUB_OUTPUT
echo "RELEASE_VERSION=${RELVERSION}" >> $GITHUB_OUTPUT
branch_build_push_admin:
name: Build-Push Admin Docker Image
runs-on: ubuntu-22.04-4core
needs: [branch_build_setup]
steps:
- name: Admin Build and Push
uses: makeplane/actions/build-push@v1.5.1
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_admin }}
build-context: .
dockerfile-path: ./apps/admin/Dockerfile.admin
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
secret-envs: |
TURBO_TOKEN=TURBO_TOKEN
TURBO_REMOTE_CACHE_SIGNATURE_KEY=TURBO_REMOTE_CACHE_SIGNATURE_KEY
branch_build_push_web:
name: Build-Push Web Docker Image
runs-on: ubuntu-22.04-4core
needs: [branch_build_setup]
steps:
- name: Web Build and Push
uses: makeplane/actions/build-push@v1.5.1
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_web }}
build-context: .
dockerfile-path: ./apps/web/Dockerfile.web
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
build-args: |
VITE_APP_VERSION=${{ needs.branch_build_setup.outputs.release_version }}
secret-envs: |
TURBO_TOKEN=TURBO_TOKEN
TURBO_REMOTE_CACHE_SIGNATURE_KEY=TURBO_REMOTE_CACHE_SIGNATURE_KEY
SENTRY_AUTH_TOKEN=SENTRY_AUTH_TOKEN
branch_build_push_space:
name: Build-Push Space Docker Image
runs-on: ubuntu-22.04-4core
needs: [branch_build_setup]
steps:
- name: Space Build and Push
uses: makeplane/actions/build-push@v1.5.1
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_space }}
build-context: .
dockerfile-path: ./apps/space/Dockerfile.space
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
build-args: |
VITE_APP_VERSION=${{ needs.branch_build_setup.outputs.release_version }}
secret-envs: |
TURBO_TOKEN=TURBO_TOKEN
TURBO_REMOTE_CACHE_SIGNATURE_KEY=TURBO_REMOTE_CACHE_SIGNATURE_KEY
SENTRY_AUTH_TOKEN=SENTRY_AUTH_TOKEN
branch_build_push_live:
name: Build-Push Live Collaboration Docker Image
runs-on: ubuntu-22.04-4core
needs: [branch_build_setup]
steps:
- name: Live Build and Push
uses: makeplane/actions/build-push@v1.5.1
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_live }}
build-context: .
dockerfile-path: ./apps/live/Dockerfile.live
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
secret-envs: |
TURBO_TOKEN=TURBO_TOKEN
TURBO_REMOTE_CACHE_SIGNATURE_KEY=TURBO_REMOTE_CACHE_SIGNATURE_KEY
branch_build_push_silo:
name: Build-Push Silo Docker Image
runs-on: ubuntu-22.04-4core
needs: [branch_build_setup]
steps:
- name: Silo Build and Push
uses: makeplane/actions/build-push@v1.5.1
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_silo }}
build-context: .
dockerfile-path: ./apps/silo/Dockerfile.silo
secret-envs: |
TURBO_TOKEN=TURBO_TOKEN
TURBO_REMOTE_CACHE_SIGNATURE_KEY=TURBO_REMOTE_CACHE_SIGNATURE_KEY
branch_build_push_api:
name: Build-Push API Server Docker Image
runs-on: ubuntu-22.04-4core
needs: [branch_build_setup]
steps:
- name: Backend Build and Push
uses: makeplane/actions/build-push@v1.5.1
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_backend }}
build-context: ./apps/api
dockerfile-path: ./apps/api/Dockerfile.api
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_email:
name: Build-Push Email Docker Image
runs-on: ubuntu-22.04-4core
needs: [branch_build_setup]
steps:
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v6
- name: Email Build and Push
uses: makeplane/actions/build-push@v1.5.1
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_email }}
build-context: ./apps/email
dockerfile-path: ./apps/email/Dockerfile
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_plane_pi:
name: Build-Push Plane PI Docker Image
runs-on: ubuntu-22.04-4core
needs: [branch_build_setup]
steps:
- name: Plane PI Build and Push
uses: makeplane/actions/build-push@v1.5.1
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_pi }}
build-context: ./apps/pi
dockerfile-path: ./apps/pi/Dockerfile.api
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_flux:
name: Build-Push Flux Docker Image
runs-on: ubuntu-22.04-4core
needs: [branch_build_setup]
steps:
- name: Flux Build and Push
uses: makeplane/actions/build-push@v1.5.1
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_flux }}
build-context: .
dockerfile-path: ./apps/flux/Dockerfile.flux
secret-envs: |
TURBO_TOKEN=TURBO_TOKEN
TURBO_REMOTE_CACHE_SIGNATURE_KEY=TURBO_REMOTE_CACHE_SIGNATURE_KEY
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_node_runner:
name: Build-Push Node Runner Docker Image
runs-on: ubuntu-22.04-4core
needs: [branch_build_setup]
steps:
- name: Node Runner Build and Push
uses: makeplane/actions/build-push@v1.5.1
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_node_runner }}
build-context: .
dockerfile-path: ./apps/runners/node-runner/Dockerfile.runner
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
secret-envs: |
TURBO_TOKEN=TURBO_TOKEN
TURBO_REMOTE_CACHE_SIGNATURE_KEY=TURBO_REMOTE_CACHE_SIGNATURE_KEY
publish_release:
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
name: Build Release
runs-on: ubuntu-22.04-4core
needs:
[
branch_build_setup,
branch_build_push_admin,
branch_build_push_web,
branch_build_push_space,
branch_build_push_live,
branch_build_push_silo,
branch_build_push_api,
branch_build_push_email,
branch_build_push_plane_pi,
branch_build_push_flux,
branch_build_push_node_runner,
]
env:
REL_VERSION: ${{ needs.branch_build_setup.outputs.release_version }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
with:
tag_name: ${{ env.REL_VERSION }}
name: ${{ env.REL_VERSION }}
target_commitish: ${{ github.ref_name }}
draft: false
prerelease: ${{ env.IS_PRERELEASE }}
generate_release_notes: true
-962
View File
@@ -1,962 +0,0 @@
name: Branch Build Enterprise
on:
workflow_dispatch:
inputs:
build_type:
description: "Type of build to run"
required: true
type: choice
default: "Build"
options:
- "Build"
- "Release"
build_images_type:
description: "Images to build"
required: true
type: choice
default: "regular"
options:
- "regular"
- "fips"
releaseVersion:
description: "Release Version"
type: string
default: v0.0.0
isPrerelease:
description: "Is Pre-release"
type: boolean
default: false
required: true
arm64:
description: "Build for ARM64 architecture"
required: false
default: false
type: boolean
aio_build:
description: "Build for AIO docker image"
required: false
default: false
type: boolean
cloud_builder:
description: "Build using Cloud Builder"
required: false
default: false
type: boolean
register_on_prime:
description: "Register this release on Prime"
required: false
default: false
type: boolean
prime_is_pre_release:
description: "Mark this Version as pre-release on Prime"
required: false
default: false
type: boolean
release_date:
description: "Release date for Prime (ISO8601, e.g. 2026-04-24T10:00:00Z). Empty = now."
required: false
default: ""
type: string
env:
TARGET_BRANCH: ${{ github.ref_name }}
ARM64_BUILD: ${{ github.event.inputs.arm64 }}
BUILD_TYPE: ${{ github.event.inputs.build_type }}
RELEASE_VERSION: ${{ github.event.inputs.releaseVersion }}
IS_PRERELEASE: ${{ github.event.inputs.isPrerelease }}
AIO_BUILD: ${{ github.event.inputs.aio_build }}
CLOUD_BUILDER: ${{ github.event.inputs.cloud_builder }}
BUILD_IMAGES_TYPE: ${{ github.event.inputs.build_images_type }}
jobs:
branch_build_setup:
name: Build Setup
runs-on: ubuntu-22.04-4core
outputs:
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
artifact_upload_to_s3: ${{ steps.set_env_variables.outputs.artifact_upload_to_s3 }}
artifact_s3_suffix: ${{ steps.set_env_variables.outputs.artifact_s3_suffix }}
dh_img_web: ${{ steps.set_env_variables.outputs.DH_IMG_WEB }}
dh_img_space: ${{ steps.set_env_variables.outputs.DH_IMG_SPACE }}
dh_img_admin: ${{ steps.set_env_variables.outputs.DH_IMG_ADMIN }}
dh_img_live: ${{ steps.set_env_variables.outputs.DH_IMG_LIVE }}
dh_img_backend: ${{ steps.set_env_variables.outputs.DH_IMG_BACKEND }}
dh_img_proxy: ${{ steps.set_env_variables.outputs.DH_IMG_PROXY }}
dh_img_monitor: ${{ steps.set_env_variables.outputs.DH_IMG_MONITOR }}
dh_img_silo: ${{ steps.set_env_variables.outputs.DH_IMG_SILO }}
dh_img_email: ${{ steps.set_env_variables.outputs.DH_IMG_EMAIL }}
dh_img_pi: ${{ steps.set_env_variables.outputs.DH_IMG_PI }}
dh_img_aio: ${{ steps.set_env_variables.outputs.DH_IMG_AIO }}
dh_img_node_runner: ${{ steps.set_env_variables.outputs.DH_IMG_NODE_RUNNER }}
dh_img_flux: ${{ steps.set_env_variables.outputs.DH_IMG_FLUX }}
build_type: ${{steps.set_env_variables.outputs.BUILD_TYPE}}
build_release: ${{ steps.set_env_variables.outputs.BUILD_RELEASE }}
build_prerelease: ${{ steps.set_env_variables.outputs.BUILD_PRERELEASE }}
release_version: ${{ steps.set_env_variables.outputs.RELEASE_VERSION }}
arm64_build: ${{ steps.set_env_variables.outputs.ARM64_BUILD }}
aio_build: ${{ steps.set_env_variables.outputs.AIO_BUILD }}
fips_build: ${{ steps.set_env_variables.outputs.FIPS_BUILD }}
steps:
- id: set_env_variables
name: Set Environment Variables
run: |
if [ "${{ env.CLOUD_BUILDER }}" == "true" ]; then
echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT
echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT
echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT
else
echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT
echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT
echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT
fi
if [ "${{ env.ARM64_BUILD }}" == "true" ] || ([ "${{ env.BUILD_TYPE }}" == "Release" ] && [ "${{ env.IS_PRERELEASE }}" != "true" ]); then
echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
echo "ARM64_BUILD=true" >> $GITHUB_OUTPUT
else
echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT
echo "ARM64_BUILD=false" >> $GITHUB_OUTPUT
fi
BR_NAME=$( echo "${{ env.TARGET_BRANCH }}" |sed 's/[^a-zA-Z0-9.-]//g')
echo "TARGET_BRANCH=$BR_NAME" >> $GITHUB_OUTPUT
if [ "${{ env.BUILD_IMAGES_TYPE }}" == "fips" ]; then
echo "FIPS_BUILD=true" >> $GITHUB_OUTPUT
echo "DH_IMG_WEB=web-commercial-fips" >> $GITHUB_OUTPUT
echo "DH_IMG_SPACE=space-commercial-fips" >> $GITHUB_OUTPUT
echo "DH_IMG_ADMIN=admin-commercial-fips" >> $GITHUB_OUTPUT
echo "DH_IMG_LIVE=live-commercial-fips" >> $GITHUB_OUTPUT
echo "DH_IMG_BACKEND=backend-commercial-fips" >> $GITHUB_OUTPUT
echo "DH_IMG_PROXY=proxy-commercial-fips" >> $GITHUB_OUTPUT
echo "DH_IMG_MONITOR=monitor-commercial-fips" >> $GITHUB_OUTPUT
echo "DH_IMG_SILO=silo-commercial-fips" >> $GITHUB_OUTPUT
echo "DH_IMG_EMAIL=email-commercial-fips" >> $GITHUB_OUTPUT
echo "DH_IMG_PI=plane-pi-commercial-fips" >> $GITHUB_OUTPUT
echo "DH_IMG_AIO=plane-aio-commercial-fips" >> $GITHUB_OUTPUT
echo "DH_IMG_NODE_RUNNER=node-runner-commercial-fips" >> $GITHUB_OUTPUT
echo "DH_IMG_FLUX=flux-commercial-fips" >> $GITHUB_OUTPUT
else
echo "FIPS_BUILD=false" >> $GITHUB_OUTPUT
echo "DH_IMG_WEB=web-commercial" >> $GITHUB_OUTPUT
echo "DH_IMG_SPACE=space-commercial" >> $GITHUB_OUTPUT
echo "DH_IMG_ADMIN=admin-commercial" >> $GITHUB_OUTPUT
echo "DH_IMG_LIVE=live-commercial" >> $GITHUB_OUTPUT
echo "DH_IMG_BACKEND=backend-commercial" >> $GITHUB_OUTPUT
echo "DH_IMG_PROXY=proxy-commercial" >> $GITHUB_OUTPUT
echo "DH_IMG_MONITOR=monitor-commercial" >> $GITHUB_OUTPUT
echo "DH_IMG_SILO=silo-commercial" >> $GITHUB_OUTPUT
echo "DH_IMG_EMAIL=email-commercial" >> $GITHUB_OUTPUT
echo "DH_IMG_PI=plane-pi-commercial" >> $GITHUB_OUTPUT
echo "DH_IMG_AIO=plane-aio-commercial" >> $GITHUB_OUTPUT
echo "DH_IMG_NODE_RUNNER=node-runner-commercial" >> $GITHUB_OUTPUT
echo "DH_IMG_FLUX=flux-commercial" >> $GITHUB_OUTPUT
fi
echo "BUILD_TYPE=${{env.BUILD_TYPE}}" >> $GITHUB_OUTPUT
BUILD_RELEASE=false
BUILD_PRERELEASE=false
RELVERSION="latest"
BUILD_AIO=${{ env.AIO_BUILD }}
if [ "${{ env.BUILD_TYPE }}" == "Release" ]; then
FLAT_RELEASE_VERSION=$(echo "${{ env.RELEASE_VERSION }}" | sed 's/[^a-zA-Z0-9.-]//g')
echo "FLAT_RELEASE_VERSION=${FLAT_RELEASE_VERSION}" >> $GITHUB_OUTPUT
semver_regex="^v([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)?$"
if [[ ! $FLAT_RELEASE_VERSION =~ $semver_regex ]]; then
echo "Invalid Release Version Format : $FLAT_RELEASE_VERSION"
echo "Please provide a valid SemVer version"
echo "e.g. v1.2.3 or v1.2.3-alpha-1"
echo "Exiting the build process"
exit 1 # Exit with status 1 to fail the step
fi
BUILD_RELEASE=true
RELVERSION=$FLAT_RELEASE_VERSION
if [ "${{ env.IS_PRERELEASE }}" == "true" ]; then
BUILD_PRERELEASE=true
fi
fi
echo "BUILD_RELEASE=${BUILD_RELEASE}" >> $GITHUB_OUTPUT
echo "BUILD_PRERELEASE=${BUILD_PRERELEASE}" >> $GITHUB_OUTPUT
echo "RELEASE_VERSION=${RELVERSION}" >> $GITHUB_OUTPUT
if [ "${{ env.BUILD_TYPE }}" == "Release" ]; then
echo "artifact_upload_to_s3=true" >> $GITHUB_OUTPUT
echo "artifact_s3_suffix=${{ env.RELEASE_VERSION }}" >> $GITHUB_OUTPUT
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
echo "artifact_upload_to_s3=true" >> $GITHUB_OUTPUT
echo "artifact_s3_suffix=$BR_NAME" >> $GITHUB_OUTPUT
elif [ "${{ env.TARGET_BRANCH }}" == "preview" ] || [ "${{ env.TARGET_BRANCH }}" == "develop" ] || [ "${{ env.TARGET_BRANCH }}" == "uat" ]; then
echo "artifact_upload_to_s3=true" >> $GITHUB_OUTPUT
echo "artifact_s3_suffix=$BR_NAME" >> $GITHUB_OUTPUT
else
echo "artifact_upload_to_s3=false" >> $GITHUB_OUTPUT
echo "artifact_s3_suffix=$BR_NAME" >> $GITHUB_OUTPUT
fi
echo "AIO_BUILD=${BUILD_AIO}" >> $GITHUB_OUTPUT
branch_build_push_admin:
name: Build-Push Admin Docker Image
runs-on: ubuntu-22.04-4core
needs: [branch_build_setup]
steps:
- name: Admin Build and Push
uses: makeplane/actions/build-push@v1.5.1
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_admin }}
build-context: .
dockerfile-path: ${{ needs.branch_build_setup.outputs.fips_build == 'true' && './apps/admin/Dockerfile.fips' || './apps/admin/Dockerfile.admin' }}
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
secret-envs: |
TURBO_TOKEN=TURBO_TOKEN
TURBO_REMOTE_CACHE_SIGNATURE_KEY=TURBO_REMOTE_CACHE_SIGNATURE_KEY
branch_build_push_flux:
name: Build-Push Flux Docker Image
runs-on: ubuntu-22.04-4core
needs: [branch_build_setup]
steps:
- name: Flux Build and Push
uses: makeplane/actions/build-push@v1.5.1
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
fips-docker-file-path: ${{ (needs.branch_build_setup.outputs.fips_build == 'true' && './apps/flux/Dockerfile.fips') || '' }}
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_flux }}
build-context: .
dockerfile-path: ./apps/flux/Dockerfile.flux
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
secret-envs: |
TURBO_TOKEN=TURBO_TOKEN
TURBO_REMOTE_CACHE_SIGNATURE_KEY=TURBO_REMOTE_CACHE_SIGNATURE_KEY
branch_build_push_node_runner:
name: Build-Push Node Runner Docker Image
runs-on: ubuntu-22.04-4core
needs: [branch_build_setup]
steps:
- name: Node Runner Build and Push
uses: makeplane/actions/build-push@v1.5.1
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
fips-docker-file-path: ${{ (needs.branch_build_setup.outputs.fips_build == 'true' && './apps/runners/node-runner/Dockerfile.fips') || '' }}
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_node_runner }}
build-context: .
dockerfile-path: ./apps/runners/node-runner/Dockerfile.runner
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
secret-envs: |
TURBO_TOKEN=TURBO_TOKEN
TURBO_REMOTE_CACHE_SIGNATURE_KEY=TURBO_REMOTE_CACHE_SIGNATURE_KEY
branch_build_push_web:
name: Build-Push Web Docker Image
runs-on: ubuntu-22.04-4core
needs: [branch_build_setup]
steps:
- name: Web Build and Push
uses: makeplane/actions/build-push@v1.5.1
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_web }}
build-context: .
dockerfile-path: ${{ needs.branch_build_setup.outputs.fips_build == 'true' && './apps/web/Dockerfile.fips' || './apps/web/Dockerfile.web' }}
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
secret-envs: |
TURBO_TOKEN=TURBO_TOKEN
TURBO_REMOTE_CACHE_SIGNATURE_KEY=TURBO_REMOTE_CACHE_SIGNATURE_KEY
branch_build_push_space:
name: Build-Push Space Docker Image
runs-on: ubuntu-22.04-4core
needs: [branch_build_setup]
steps:
- name: Space Build and Push
uses: makeplane/actions/build-push@v1.5.1
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_space }}
build-context: .
dockerfile-path: ${{ needs.branch_build_setup.outputs.fips_build == 'true' && './apps/space/Dockerfile.fips' || './apps/space/Dockerfile.space' }}
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
secret-envs: |
TURBO_TOKEN=TURBO_TOKEN
TURBO_REMOTE_CACHE_SIGNATURE_KEY=TURBO_REMOTE_CACHE_SIGNATURE_KEY
branch_build_push_live:
name: Build-Push Live Collaboration Docker Image
runs-on: ubuntu-22.04-4core
needs: [branch_build_setup]
steps:
- name: Live Build and Push
uses: makeplane/actions/build-push@v1.5.1
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_live }}
build-context: .
dockerfile-path: ${{ needs.branch_build_setup.outputs.fips_build == 'true' && './apps/live/Dockerfile.fips' || './apps/live/Dockerfile.live' }}
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
secret-envs: |
TURBO_TOKEN=TURBO_TOKEN
TURBO_REMOTE_CACHE_SIGNATURE_KEY=TURBO_REMOTE_CACHE_SIGNATURE_KEY
branch_build_push_silo:
name: Build-Push Silo Docker Image
runs-on: ubuntu-22.04-4core
needs: [branch_build_setup]
steps:
- name: Silo Build and Push
uses: makeplane/actions/build-push@v1.5.1
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_silo }}
build-context: .
dockerfile-path: ${{ needs.branch_build_setup.outputs.fips_build == 'true' && './apps/silo/Dockerfile.fips' || './apps/silo/Dockerfile.silo' }}
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
secret-envs: |
TURBO_TOKEN=TURBO_TOKEN
TURBO_REMOTE_CACHE_SIGNATURE_KEY=TURBO_REMOTE_CACHE_SIGNATURE_KEY
branch_build_push_api:
name: Build-Push API Server Docker Image
runs-on: ubuntu-22.04-4core
needs: [branch_build_setup]
steps:
- name: Backend Build and Push
uses: makeplane/actions/build-push@v1.5.1
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_backend }}
build-context: ./apps/api
dockerfile-path: ${{ needs.branch_build_setup.outputs.fips_build == 'true' && './apps/api/Dockerfile.fips' || './apps/api/Dockerfile.api' }}
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_proxy:
name: Build-Push Proxy Docker Image
runs-on: ubuntu-22.04-4core
needs: [branch_build_setup]
steps:
- name: Proxy Build and Push
uses: makeplane/actions/build-push@v1.5.1
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_proxy }}
build-context: ./apps/proxy
dockerfile-path: ${{ needs.branch_build_setup.outputs.fips_build == 'true' && './apps/proxy/Dockerfile.fips' || './apps/proxy/Dockerfile.ee' }}
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_monitor:
name: Build-Push Monitor Docker Image
runs-on: ubuntu-22.04-4core
needs: [branch_build_setup]
steps:
- name: Generate Keypair
run: |
if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ env.BUILD_TYPE }}" == "Release" ]; then
openssl genrsa -out private_key.pem 2048
else
echo "${{ secrets.DEFAULT_PRIME_PRIVATE_KEY }}" > private_key.pem
fi
openssl rsa -in private_key.pem -pubout -out public_key.pem
cat public_key.pem
# Generating the private key env for the generated keys
PRIVATE_KEY=$(cat private_key.pem | base64 -w 0)
echo "PRIVATE_KEY=${PRIVATE_KEY}" >> $GITHUB_ENV
- name: Upload Private Key for Prime
if: ${{ github.event.inputs.register_on_prime == 'true' }}
uses: actions/upload-artifact@v7
with:
name: prime-private-key
path: private_key.pem
retention-days: 1
- name: Monitor Build and Push
uses: makeplane/actions/build-push@v1.5.1
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_monitor }}
build-context: ./apps/monitor
dockerfile-path: ${{ needs.branch_build_setup.outputs.fips_build == 'true' && './apps/monitor/Dockerfile.fips' || './apps/monitor/Dockerfile' }}
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
build-args: |
PRIVATE_KEY=${{ env.PRIVATE_KEY }}
branch_build_push_email:
name: Build-Push Email Docker Image
runs-on: ubuntu-22.04-4core
needs: [branch_build_setup]
steps:
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v6
- name: Email Build and Push
uses: makeplane/actions/build-push@v1.5.1
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_email }}
build-context: ./apps/email
dockerfile-path: ${{ needs.branch_build_setup.outputs.fips_build == 'true' && './apps/email/Dockerfile.fips' || './apps/email/Dockerfile' }}
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_plane_pi:
name: Build-Push Plane PI Docker Image
runs-on: ubuntu-22.04-4core
needs: [branch_build_setup]
steps:
- name: Plane PI Build and Push
uses: makeplane/actions/build-push@v1.5.1
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_pi }}
build-context: ./apps/pi
dockerfile-path: ${{ needs.branch_build_setup.outputs.fips_build == 'true' && './apps/pi/Dockerfile.fips' || './apps/pi/Dockerfile.api' }}
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_aio:
if: ${{ needs.branch_build_setup.outputs.aio_build == 'true' }}
name: Build-Push AIO Docker Image
runs-on: ubuntu-22.04-4core
needs:
[
branch_build_setup,
branch_build_push_admin,
branch_build_push_web,
branch_build_push_space,
branch_build_push_live,
branch_build_push_api,
branch_build_push_proxy,
branch_build_push_monitor,
branch_build_push_email,
branch_build_push_silo,
branch_build_push_plane_pi,
branch_build_push_node_runner,
branch_build_push_flux,
]
steps:
- name: Checkout Files
uses: actions/checkout@v6
- name: Prepare AIO Assets
id: prepare_aio_assets
run: |
cd deployments/aio/commercial
if [ "${{ needs.branch_build_setup.outputs.build_type }}" == "Release" ]; then
aio_version=${{ needs.branch_build_setup.outputs.release_version }}
else
aio_version=${{ needs.branch_build_setup.outputs.gh_branch_name }}
fi
bash ./build.sh --release $aio_version
echo "AIO_BUILD_VERSION=${aio_version}" >> $GITHUB_OUTPUT
- name: Upload AIO Assets
uses: actions/upload-artifact@v7
with:
path: ./deployments/aio/commercial/dist
name: aio-assets-dist
- name: AIO Build and Push
uses: makeplane/actions/build-push@v1.5.1
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_aio }}
build-context: ./deployments/aio/commercial
dockerfile-path: ./deployments/aio/commercial/Dockerfile
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
additional-assets: aio-assets-dist
additional-assets-dir: ./deployments/aio/commercial/dist
build-args: |
PLANE_VERSION=${{ steps.prepare_aio_assets.outputs.AIO_BUILD_VERSION }}
upload_artifacts_s3:
if: ${{ needs.branch_build_setup.outputs.artifact_upload_to_s3 == 'true' }}
name: Upload artifacts to S3 Bucket
runs-on: ubuntu-22.04-4core
needs: [branch_build_setup]
container:
image: docker:29.3.1-cli-alpine3.23
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
ARTIFACT_SUFFIX: ${{ needs.branch_build_setup.outputs.artifact_s3_suffix }}
AWS_ACCESS_KEY_ID: ${{ secrets.SELF_HOST_BUCKET_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SELF_HOST_BUCKET_SECRET_KEY }}
steps:
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v6
- name: Upload artifacts
run: |
apk update
apk add --no-cache aws-cli
mkdir -p ~/${{ env.ARTIFACT_SUFFIX }}
if [ "${{ needs.branch_build_setup.outputs.fips_build }}" = "true" ]; then
cp ~/${{ env.ARTIFACT_SUFFIX }}/docker-compose.yml ~/${{ env.ARTIFACT_SUFFIX }}/docker-compose-fips.yml
cp ~/${{ env.ARTIFACT_SUFFIX }}/coolify-compose.yml ~/${{ env.ARTIFACT_SUFFIX }}/coolify-compose-fips.yml
cp ~/${{ env.ARTIFACT_SUFFIX }}/portainer-compose.yml ~/${{ env.ARTIFACT_SUFFIX }}/portainer-compose-fips.yml
sed -i 's/-commercial:/-commercial-fips:/g' ~/${{ env.ARTIFACT_SUFFIX }}/docker-compose-fips.yml
sed -i 's/-commercial:/-commercial-fips:/g' ~/${{ env.ARTIFACT_SUFFIX }}/coolify-compose-fips.yml
sed -i 's/-commercial:/-commercial-fips:/g' ~/${{ env.ARTIFACT_SUFFIX }}/portainer-compose-fips.yml
else
sed -i 's@APP_RELEASE_VERSION=.*@APP_RELEASE_VERSION='${{ env.ARTIFACT_SUFFIX }}'@' deployments/cli/commercial/variables.env
cp deployments/cli/commercial/variables.env ~/${{ env.ARTIFACT_SUFFIX }}/variables.env
sed -i 's@${APP_RELEASE_VERSION.*@'${{ env.ARTIFACT_SUFFIX }}'@' deployments/cli/commercial/docker-compose.yml
cp deployments/cli/commercial/docker-compose.yml ~/${{ env.ARTIFACT_SUFFIX }}/docker-compose-caddy.yml
cp deployments/cli/commercial/docker-compose.yml ~/${{ env.ARTIFACT_SUFFIX }}/docker-compose.yml
cp ~/${{ env.ARTIFACT_SUFFIX }}/docker-compose.yml ~/${{ env.ARTIFACT_SUFFIX }}/docker-compose-airgapped.yml
sed -i 's@${IS_AIRGAPPED.*@"1"@' ~/${{ env.ARTIFACT_SUFFIX }}/docker-compose-airgapped.yml
cp ~/${{ env.ARTIFACT_SUFFIX }}/variables.env ~/${{ env.ARTIFACT_SUFFIX }}/variables-airgapped.env
sed -i 's@IS_AIRGAPPED=.*@IS_AIRGAPPED=1@' ~/${{ env.ARTIFACT_SUFFIX }}/variables-airgapped.env
sed -i 's@${APP_RELEASE_VERSION.*@'${{ env.ARTIFACT_SUFFIX }}'@' deployments/coolify/commercial/coolify-compose.yml
cp deployments/coolify/commercial/coolify-compose.yml ~/${{ env.ARTIFACT_SUFFIX }}/coolify-compose.yml
sed -i 's@${APP_RELEASE_VERSION.*@'${{ env.ARTIFACT_SUFFIX }}'@' deployments/portainer/commercial/portainer-compose.yml
cp deployments/portainer/commercial/portainer-compose.yml ~/${{ env.ARTIFACT_SUFFIX }}/portainer-compose.yml
fi
# required for prime-cli backward compatibility
cp apps/proxy/Caddyfile.ee ~/${{ env.ARTIFACT_SUFFIX }}/Caddyfile
# Process podman quadlets
cd deployments/podman/commercial/
rm -rf plane-podman
mkdir -p plane-podman/plane/
mkdir -p plane-podman/config/
# Create a new file with initial content
cp ./install.sh ./plane-podman/install.sh
cp ../../cli/commercial/variables.env ./plane-podman/plane/plane.env
echo "" >> ./plane-podman/plane/plane.env
# Process quadlets.env
while IFS= read -r line; do
# Replace APP_VERSION with ARTIFACT_SUFFIX
if echo "$line" | grep -q "^APP_VERSION="; then
echo "APP_VERSION=${{ env.ARTIFACT_SUFFIX }}" >> ./plane-podman/plane/plane.env
continue
fi
# If line starts with #, write it and a single empty line
if echo "$line" | grep -q '^[[:space:]]*#'; then
echo "" >> ./plane-podman/plane/plane.env
echo "$line" >> ./plane-podman/plane/plane.env
continue
fi
# Skip empty lines
if [ -z "$line" ]; then
continue
fi
# Process key-value pairs
key=$(echo "$line" | cut -d'=' -f1)
value=$(echo "$line" | cut -d'=' -f2)
if ! grep -q "^${key}=" ./plane-podman/plane/plane.env; then
echo "${key}=${value}" >> ./plane-podman/plane/plane.env
elif [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "s|${key}=.*|${key}=${value}|" ./plane-podman/plane/plane.env
else
sed -i "s|${key}=.*|${key}=${value}|" ./plane-podman/plane/plane.env
fi
done < quadlets.env
# Verify files exist before copying
if [ ! -f Caddyfile ]; then
echo "Error: Caddyfile not found"
exit 1
fi
cp Caddyfile ./plane-podman/plane/Caddyfile
# Verify podman-quadlets.ini exists
if [ ! -f podman-quadlets.ini ]; then
echo "Error: podman-quadlets.ini not found"
exit 1
fi
# check APP_RELEASE_VERSION in podman-quadlets.ini and change it to ${ARTIFACT_SUFFIX}
sed -i 's@${APP_RELEASE_VERSION}.*@'${{ env.ARTIFACT_SUFFIX }}'@' podman-quadlets.ini
# Split podman-quadlets.ini into separate files in plane-podman folder
cat podman-quadlets.ini | awk '/^# [a-z_-]+\.(container|network)$/ {filename="plane-podman/config/" substr($2,1); next} /^---$/ {next} filename && !/^#/ {print > filename}'
# Create tar of podman quadlets with proper directory structure
cd plane-podman
tar -czf ~/${{ env.ARTIFACT_SUFFIX }}/podman-quadlets.tar.gz .
cd ..
# Verify the tar was created successfully
if [ ! -f ~/${{ env.ARTIFACT_SUFFIX }}/podman-quadlets.tar.gz ]; then
echo "Error: Failed to create podman-quadlets.tar.gz"
exit 1
fi
aws s3 cp ~/${{ env.ARTIFACT_SUFFIX }} s3://${{ vars.SELF_HOST_BUCKET_NAME }}/plane-enterprise/${{ env.ARTIFACT_SUFFIX }} --recursive
- name: Upload tar artifacts
uses: actions/upload-artifact@v7
with:
name: podman-quadlets.tar.gz
path: ~/${{ env.ARTIFACT_SUFFIX }}/podman-quadlets.tar.gz
- name: Upload docker-compose.yml
if: ${{ needs.branch_build_setup.outputs.fips_build == 'false' }}
uses: actions/upload-artifact@v7
with:
name: docker-compose.yml
path: ~/${{ env.ARTIFACT_SUFFIX }}/docker-compose.yml
- name: Upload docker-compose-airgapped.yml
if: ${{ needs.branch_build_setup.outputs.fips_build == 'false' }}
uses: actions/upload-artifact@v7
with:
name: docker-compose-airgapped.yml
path: ~/${{ env.ARTIFACT_SUFFIX }}/docker-compose-airgapped.yml
- name: Upload docker-compose.yml
if: ${{ needs.branch_build_setup.outputs.fips_build == 'true' }}
uses: actions/upload-artifact@v7
with:
name: docker-compose-fips.yml
path: ~/${{ env.ARTIFACT_SUFFIX }}/docker-compose-fips.yml
- name: Upload coolify-compose.yml
if: ${{ needs.branch_build_setup.outputs.fips_build == 'false' }}
uses: actions/upload-artifact@v7
with:
name: coolify-compose.yml
path: ~/${{ env.ARTIFACT_SUFFIX }}/coolify-compose.yml
- name: Upload coolify-compose.yml
if: ${{ needs.branch_build_setup.outputs.fips_build == 'true' }}
uses: actions/upload-artifact@v7
with:
name: coolify-compose-fips.yml
path: ~/${{ env.ARTIFACT_SUFFIX }}/coolify-compose-fips.yml
- name: Upload portainer-compose.yml
if: ${{ needs.branch_build_setup.outputs.fips_build == 'false' }}
uses: actions/upload-artifact@v7
with:
name: portainer-compose.yml
path: ~/${{ env.ARTIFACT_SUFFIX }}/portainer-compose.yml
- name: Upload portainer-compose.yml
if: ${{ needs.branch_build_setup.outputs.fips_build == 'true' }}
uses: actions/upload-artifact@v7
with:
name: portainer-compose-fips.yml
path: ~/${{ env.ARTIFACT_SUFFIX }}/portainer-compose-fips.yml
- name: Upload variables.env
uses: actions/upload-artifact@v7
with:
name: variables.env
path: ~/${{ env.ARTIFACT_SUFFIX }}/variables.env
- name: Upload variables-airgapped.env
if: ${{ needs.branch_build_setup.outputs.fips_build == 'false' }}
uses: actions/upload-artifact@v7
with:
name: variables-airgapped.env
path: ~/${{ env.ARTIFACT_SUFFIX }}/variables-airgapped.env
publish_release:
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
name: Build Release
runs-on: ubuntu-22.04-4core
needs:
[
branch_build_setup,
branch_build_push_admin,
branch_build_push_web,
branch_build_push_space,
branch_build_push_live,
branch_build_push_api,
branch_build_push_proxy,
branch_build_push_monitor,
branch_build_push_email,
branch_build_push_silo,
branch_build_push_plane_pi,
branch_build_push_node_runner,
branch_build_push_flux,
upload_artifacts_s3,
]
env:
REL_VERSION: ${{ needs.branch_build_setup.outputs.release_version }}
ARTIFACT_SUFFIX: ${{ needs.branch_build_setup.outputs.artifact_s3_suffix }}
AIRGAPPED_ARM64_BUILD: ${{ needs.branch_build_setup.outputs.arm64_build == 'true' }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Download podman quadlets
uses: actions/download-artifact@v8
with:
name: podman-quadlets.tar.gz
path: deployments/podman/commercial/
- name: Update docker-compose
run: |
if [ "${{ needs.branch_build_setup.outputs.fips_build }}" = "true" ]; then
sed -i 's@APP_RELEASE_VERSION=.*@APP_RELEASE_VERSION='${{ env.ARTIFACT_SUFFIX }}'@' deployments/cli/commercial/variables.env
sed -i 's@${APP_RELEASE_VERSION.*@'${{ env.ARTIFACT_SUFFIX }}'@' deployments/cli/commercial/docker-compose.yml
sed -i 's@${APP_RELEASE_VERSION.*@'${{ env.ARTIFACT_SUFFIX }}'@' deployments/coolify/commercial/coolify-compose.yml
sed -i 's@${APP_RELEASE_VERSION.*@'${{ env.ARTIFACT_SUFFIX }}'@' deployments/portainer/commercial/portainer-compose.yml
else
sed -i 's@APP_RELEASE_VERSION=.*@APP_RELEASE_VERSION='${{ env.ARTIFACT_SUFFIX }}'@' deployments/cli/commercial/variables.env
sed -i 's@${APP_RELEASE_VERSION.*@'${{ env.ARTIFACT_SUFFIX }}'@' deployments/cli/commercial/docker-compose.yml
sed -i 's@${APP_RELEASE_VERSION.*@'${{ env.ARTIFACT_SUFFIX }}'@' deployments/coolify/commercial/coolify-compose.yml
sed -i 's@${APP_RELEASE_VERSION.*@'${{ env.ARTIFACT_SUFFIX }}'@' deployments/portainer/commercial/portainer-compose.yml
cp deployments/cli/commercial/docker-compose.yml deployments/cli/commercial/docker-compose-caddy.yml
cp deployments/portainer/commercial/portainer-compose.yml deployments/portainer/commercial/swarm-compose.yml
cp deployments/cli/commercial/docker-compose.yml deployments/cli/commercial/docker-compose-airgapped.yml
sed -i 's@${IS_AIRGAPPED.*@"1"@' deployments/cli/commercial/docker-compose-airgapped.yml
cp deployments/cli/commercial/variables.env deployments/cli/commercial/variables-airgapped.env
sed -i 's@IS_AIRGAPPED=.*@IS_AIRGAPPED=1@' deployments/cli/commercial/variables-airgapped.env
fi
- name: Create Release
if: ${{ needs.branch_build_setup.outputs.fips_build == 'false' }}
id: create_release
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
with:
tag_name: ${{ env.REL_VERSION }}
name: ${{ env.REL_VERSION }}
target_commitish: ${{ github.ref_name }}
draft: false
prerelease: ${{ env.IS_PRERELEASE }}
generate_release_notes: true
files: |
${{ github.workspace }}/deployments/cli/commercial/variables.env
${{ github.workspace }}/deployments/cli/commercial/variables-airgapped.env
${{ github.workspace }}/deployments/cli/commercial/docker-compose.yml
${{ github.workspace }}/deployments/cli/commercial/docker-compose-airgapped.yml
${{ github.workspace }}/deployments/coolify/commercial/coolify-compose.yml
${{ github.workspace }}/deployments/portainer/commercial/portainer-compose.yml
${{ github.workspace }}/deployments/portainer/commercial/swarm-compose.yml
${{ github.workspace }}/deployments/podman/commercial/podman-quadlets.tar.gz
${{ (env.FIPS_BUILD == 'true' && format('{0}/deployments/cli/commercial/docker-compose-fips.yml', github.workspace)) || '' }}
${{ (env.FIPS_BUILD == 'true' && format('{0}/deployments/coolify/commercial/coolify-compose-fips.yml', github.workspace)) || '' }}
${{ (env.FIPS_BUILD == 'true' && format('{0}/deployments/portainer/commercial/portainer-compose-fips.yml', github.workspace)) || '' }}
- name: Create Release FIPS
if: ${{ needs.branch_build_setup.outputs.fips_build == 'true' }}
id: create_release_fips
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
with:
tag_name: ${{ env.REL_VERSION }}
name: ${{ env.REL_VERSION }}
target_commitish: ${{ github.ref_name }}
draft: false
prerelease: ${{ env.IS_PRERELEASE }}
generate_release_notes: true
files: |
${{ github.workspace }}/deployments/cli/commercial/variables.env
${{ github.workspace }}/deployments/cli/commercial/docker-compose-fips.yml
${{ github.workspace }}/deployments/coolify/commercial/coolify-compose-fips.yml
${{ github.workspace }}/deployments/portainer/commercial/portainer-compose-fips.yml
register_on_prime_version:
if: ${{ github.event.inputs.register_on_prime == 'true' }}
name: Register Release on Prime
runs-on: ubuntu-22.04-4core
needs:
[
branch_build_setup,
branch_build_push_admin,
branch_build_push_web,
branch_build_push_space,
branch_build_push_live,
branch_build_push_api,
branch_build_push_proxy,
branch_build_push_monitor,
branch_build_push_email,
branch_build_push_silo,
branch_build_push_plane_pi,
branch_build_push_node_runner,
branch_build_push_flux,
]
steps:
- name: Download Private Key
uses: actions/download-artifact@v8
with:
name: prime-private-key
- name: Register on Prime
env:
PRIME_URL: ${{ secrets.PRIME_VERSION_WEBHOOK_URL }}
PRIME_SECRET: ${{ secrets.PRIME_VERSION_REGISTRATION_SECRET }}
TAG: ${{ needs.branch_build_setup.outputs.release_version }}
PRIME_IS_PRERELEASE: ${{ github.event.inputs.prime_is_pre_release }}
RELEASE_DATE: ${{ github.event.inputs.release_date }}
run: |
# jq --rawfile reads private_key.pem verbatim (including newlines)
# and JSON-escapes it. Prime receives the raw PEM text, not base64.
curl --fail-with-body -sS -X POST "$PRIME_URL" \
-H "Content-Type: application/json" \
-H "X-Prime-Registration-Secret: $PRIME_SECRET" \
-d "$(jq -n \
--arg tag "$TAG" \
--arg pre "$PRIME_IS_PRERELEASE" \
--arg rd "$RELEASE_DATE" \
--rawfile key private_key.pem \
'{tag:$tag, is_pre_release:($pre == "true"), release_date:$rd, encryption_key:$key}')"
- name: Delete Private Key Artifact
if: always()
uses: geekyeggo/delete-artifact@v5
with:
name: prime-private-key
failOnError: false
+19 -43
View File
@@ -50,7 +50,7 @@ env:
jobs:
branch_build_setup:
name: Build Setup
runs-on: ubuntu-22.04-4core
runs-on: ubuntu-22.04
outputs:
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
@@ -138,14 +138,11 @@ jobs:
branch_build_push_admin:
name: Build-Push Admin Docker Image
runs-on: ubuntu-22.04-4core
runs-on: ubuntu-22.04
needs: [branch_build_setup]
steps:
- name: Admin Build and Push
uses: makeplane/actions/build-push@v1.5.1
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
uses: makeplane/actions/build-push@v1.4.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -160,20 +157,14 @@ jobs:
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
secret-envs: |
TURBO_TOKEN=TURBO_TOKEN
TURBO_REMOTE_CACHE_SIGNATURE_KEY=TURBO_REMOTE_CACHE_SIGNATURE_KEY
branch_build_push_web:
name: Build-Push Web Docker Image
runs-on: ubuntu-22.04-4core
runs-on: ubuntu-22.04
needs: [branch_build_setup]
steps:
- name: Web Build and Push
uses: makeplane/actions/build-push@v1.5.1
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
uses: makeplane/actions/build-push@v1.4.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -188,20 +179,14 @@ jobs:
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
secret-envs: |
TURBO_TOKEN=TURBO_TOKEN
TURBO_REMOTE_CACHE_SIGNATURE_KEY=TURBO_REMOTE_CACHE_SIGNATURE_KEY
branch_build_push_space:
name: Build-Push Space Docker Image
runs-on: ubuntu-22.04-4core
runs-on: ubuntu-22.04
needs: [branch_build_setup]
steps:
- name: Space Build and Push
uses: makeplane/actions/build-push@v1.5.1
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
uses: makeplane/actions/build-push@v1.4.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -216,20 +201,14 @@ jobs:
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
secret-envs: |
TURBO_TOKEN=TURBO_TOKEN
TURBO_REMOTE_CACHE_SIGNATURE_KEY=TURBO_REMOTE_CACHE_SIGNATURE_KEY
branch_build_push_live:
name: Build-Push Live Collaboration Docker Image
runs-on: ubuntu-22.04-4core
runs-on: ubuntu-22.04
needs: [branch_build_setup]
steps:
- name: Live Build and Push
uses: makeplane/actions/build-push@v1.5.1
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
uses: makeplane/actions/build-push@v1.4.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -244,17 +223,14 @@ jobs:
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
secret-envs: |
TURBO_TOKEN=TURBO_TOKEN
TURBO_REMOTE_CACHE_SIGNATURE_KEY=TURBO_REMOTE_CACHE_SIGNATURE_KEY
branch_build_push_api:
name: Build-Push API Server Docker Image
runs-on: ubuntu-22.04-4core
runs-on: ubuntu-22.04
needs: [branch_build_setup]
steps:
- name: Backend Build and Push
uses: makeplane/actions/build-push@v1.5.1
uses: makeplane/actions/build-push@v1.4.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -272,11 +248,11 @@ jobs:
branch_build_push_proxy:
name: Build-Push Proxy Docker Image
runs-on: ubuntu-22.04-4core
runs-on: ubuntu-22.04
needs: [branch_build_setup]
steps:
- name: Proxy Build and Push
uses: makeplane/actions/build-push@v1.5.1
uses: makeplane/actions/build-push@v1.4.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -295,7 +271,7 @@ jobs:
branch_build_push_aio:
if: ${{ needs.branch_build_setup.outputs.aio_build == 'true' }}
name: Build-Push AIO Docker Image
runs-on: ubuntu-22.04-4core
runs-on: ubuntu-22.04
needs:
- branch_build_setup
- branch_build_push_admin
@@ -328,7 +304,7 @@ jobs:
name: aio-assets-dist
- name: AIO Build and Push
uses: makeplane/actions/build-push@v1.5.1
uses: makeplane/actions/build-push@v1.4.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -350,7 +326,7 @@ jobs:
upload_build_assets:
name: Upload Build Assets
runs-on: ubuntu-22.04-4core
runs-on: ubuntu-22.04
needs:
- branch_build_setup
- branch_build_push_admin
@@ -390,7 +366,7 @@ jobs:
publish_release:
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
name: Build Release
runs-on: ubuntu-22.04-4core
runs-on: ubuntu-22.04
needs:
[
branch_build_setup,
@@ -415,13 +391,13 @@ jobs:
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
with:
tag_name: ${{ env.REL_VERSION }}
name: ${{ env.REL_VERSION }}
target_commitish: ${{ github.ref_name }}
target_commitish: ${{ github.sha }}
draft: false
prerelease: ${{ env.IS_PRERELEASE }}
generate_release_notes: true
-112
View File
@@ -1,112 +0,0 @@
name: Cleanup Closed PRs
on:
workflow_dispatch:
schedule:
- cron: "0 0 * * *"
workflow_call:
inputs:
pr_number:
description: 'PR number whose deployment to tear down'
required: true
type: string
deploy_type:
description: 'Deploy type: enterprise or cloud'
required: true
type: string
jobs:
execute:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
id-token: write
contents: read
pull-requests: read
steps:
# Checkout the code
- uses: actions/checkout@v6
- name: Configure AWS credentials to assume the EKS role
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.AWS_EKS_ROLE_ARN }}
aws-region: ${{ secrets.AWS_REGION || 'us-east-1' }}
- name: Update kubeconfig
run: |
aws eks update-kubeconfig \
--region ${{ secrets.AWS_REGION || 'us-east-1' }} \
--name ${{ secrets.EKS_CLUSTER_NAME || 'plane-eks-dev' }}
- name: Verify access
run: |
kubectl get nodes
- name: Install Helm
uses: azure/setup-helm@v4
with:
version: v3.14.4
# ---------------- CLEANUP ----------------
- name: Cleanup merged or closed PR deployments
env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: |
set -e
if [ -n "${{ inputs.pr_number }}" ]; then
# Single-PR recreate: target only the specified namespace
PR="${{ inputs.pr_number }}"
DEPLOY_TYPE="${{ inputs.deploy_type }}"
if [ "${DEPLOY_TYPE}" == "enterprise" ]; then
deployed_charts="ee-${PR}-ent"
else
deployed_charts="ee-${PR}-cloud"
fi
else
# Scheduled/manual: match all deployed PR releases
deployed_charts="$(helm ls -A --filter 'ee-[0-9]+-(ent|cloud)' --output json | jq -r '.[].name')"
fi
for deployed_chart in $deployed_charts; do
namespace="$deployed_chart"
pr=$(echo "$deployed_chart" | sed -n 's/^ee-\([0-9]*\)-.*/\1/p')
# For scheduled/manual runs only: skip PRs that are still open
if [ -z "${{ inputs.pr_number }}" ]; then
pr_state="$(gh pr view "$pr" --json state --jq .state)"
if [[ "$pr_state" != "MERGED" && "$pr_state" != "CLOSED" ]]; then
continue
fi
fi
echo "Cleaning up namespace $namespace for PR $pr"
# K8s cleanup - failures must not prevent DB drop (wrapped so set -e won't exit)
{
helm uninstall "$namespace" --namespace "$namespace" || true
kubectl delete namespace "$namespace" --grace-period=0 --force || true
pv_json=$(kubectl get pv -o json 2>/dev/null || echo '{}')
pv_list=$(echo "$pv_json" | jq -r --arg ns "$namespace" '.items[]? | select(.spec.claimRef.namespace == $ns) | .metadata.name' 2>/dev/null) || pv_list=""
for pv in $pv_list; do
echo "Deleting PV $pv"
kubectl patch pv "$pv" --type=json \
-p='[{"op": "remove", "path": "/metadata/finalizers"}]' || true
kubectl delete pv "$pv" --grace-period=0 --force || true
done
} || true
DB_ENT="ee${pr}ent"
DB_CLOUD="ee${pr}cloud"
# Delete postgres-operator CRDs (operator will drop databases via dropOnDelete)
kubectl delete postgres "${DB_ENT}" -n postgres-operator --ignore-not-found || true
kubectl delete postgresuser "${DB_ENT}-appuser" -n postgres-operator --ignore-not-found || true
kubectl delete postgres "${DB_CLOUD}" -n postgres-operator --ignore-not-found || true
kubectl delete postgresuser "${DB_CLOUD}-appuser" -n postgres-operator --ignore-not-found || true
done
echo "Cleanup complete."
+3 -23
View File
@@ -16,46 +16,26 @@ jobs:
contents: read
security-events: write
env:
CODEQL_ACTION_FILE_COVERAGE_ON_PRS: "false"
strategy:
fail-fast: false
matrix:
language: ["python", "javascript"]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Use only 'java' to analyze code written in Java, Kotlin or both
# Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v6
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v4
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
+36 -48
View File
@@ -1,57 +1,45 @@
name: Copy Right Check
on:
workflow_dispatch:
pull_request:
branches:
- "preview"
types:
- "opened"
- "synchronize"
- "ready_for_review"
- "reopened"
paths-ignore:
- ".github/**"
- "**/*.md"
- "docs/**"
- ".gitignore"
- ".editorconfig"
- "LICENSE"
- "**/Dockerfile*"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
workflow_dispatch:
pull_request:
branches:
- "preview"
types:
- "opened"
- "synchronize"
- "ready_for_review"
- "review_requested"
- "reopened"
jobs:
license-check:
name: Licence Check
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
steps:
- name: Checkout repository
uses: actions/checkout@v6
license-check:
name: Copy Right Check
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.22"
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.22"
- name: Install addlicense
run: |
go install github.com/google/addlicense@latest
echo "$(go env GOPATH)/bin" >> $GITHUB_PATH
- name: Install addlicense
run: |
go install github.com/google/addlicense@latest
echo "$(go env GOPATH)/bin" >> $GITHUB_PATH
- name: Check Copyright For Python Files
run: |
set -e
echo "Running copyright check..."
addlicense -check -f COPYRIGHT.txt -ignore "**/migrations/**" $(git ls-files '*.py')
echo "Copyright check passed."
- name: Check Copyright For Python Files
run: |
set -e
echo "Running copyright check..."
addlicense -check -f COPYRIGHT.txt -ignore "**/migrations/**" $(git ls-files '*.py')
echo "Copyright check passed."
- name: Check Copyright For TypeScript Files
run: |
set -e
echo "Running copyright check..."
addlicense -check -f COPYRIGHT.txt -ignore "**/*.config.ts" -ignore "**/*.d.ts" $(git ls-files '*.ts' '*.tsx')
echo "Copyright check passed."
- name: Check Copyright For TypeScript Files
run: |
set -e
echo "Running copyright check..."
addlicense -check -f COPYRIGHT.txt -ignore "**/*.config.ts" -ignore "**/*.d.ts" $(git ls-files '*.ts' '*.tsx')
echo "Copyright check passed."
File diff suppressed because it is too large Load Diff
@@ -1,59 +0,0 @@
name: Deploy Storybook (Blocks)
on:
workflow_dispatch:
push:
branches:
- preview
paths:
- "packages/blocks/**"
- "packages/propel/**"
- "packages/ui/**"
- "packages/types/**"
- "packages/constants/**"
- "packages/utils/**"
- "packages/hooks/**"
- "packages/tailwindcss/**"
- "pnpm-lock.yaml"
- "package.json"
- "pnpm-workspace.yaml"
- "turbo.json"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy-storybook:
name: Build & Deploy Storybook
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "22.18.0"
cache: "pnpm"
- name: Enable Corepack
run: corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Storybook
run: pnpm turbo run build-storybook --filter=@plane/blocks
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.STORYBOOK_CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
packageManager: npx
command: pages deploy packages/blocks/storybook-static --project-name=ui-blocks --branch=preview
-58
View File
@@ -1,58 +0,0 @@
name: Deploy Storybook (Propel)
on:
workflow_dispatch:
push:
branches:
- preview
paths:
- "packages/propel/**"
- "packages/ui/**"
- "packages/types/**"
- "packages/constants/**"
- "packages/utils/**"
- "packages/hooks/**"
- "packages/tailwindcss/**"
- "pnpm-lock.yaml"
- "package.json"
- "pnpm-workspace.yaml"
- "turbo.json"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy-storybook:
name: Build & Deploy Storybook
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "22.18.0"
cache: "pnpm"
- name: Enable Corepack
run: corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Storybook
run: pnpm turbo run build-storybook --filter=@plane/propel
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.STORYBOOK_CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
packageManager: npx
command: pages deploy packages/propel/storybook-static --project-name=ui-kit --branch=preview
+2 -2
View File
@@ -79,7 +79,7 @@ jobs:
uses: actions/checkout@v6
- name: Build and Push to Docker Hub
uses: docker/build-push-action@v7
uses: docker/build-push-action@v7.0.0
with:
context: .
file: ./aio/Dockerfile-app
@@ -112,7 +112,7 @@ jobs:
sudo apt-get install -y python3-pip
pip3 install awscli
- name: Tailscale
uses: tailscale/github-action@v3
uses: tailscale/github-action@v4
with:
oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }}
-5
View File
@@ -5,9 +5,6 @@ on:
pull_request:
branches:
- "preview"
- "phoenix-releases"
- "master"
- "release/**"
types:
- "opened"
- "synchronize"
@@ -19,8 +16,6 @@ on:
push:
branches:
- "preview"
- "phoenix-releases"
- "master"
paths:
- "packages/i18n/**"
@@ -1,21 +1,18 @@
name: Build and lint API and PI
name: Build and lint API
on:
workflow_dispatch:
pull_request:
branches:
- "preview"
- "phoenix-releases"
- "master"
- "release/**"
types:
- "opened"
- "synchronize"
- "ready_for_review"
- "review_requested"
- "reopened"
paths:
- "apps/api/**"
- "apps/pi/**"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -26,25 +23,20 @@ jobs:
name: Lint API
runs-on: ubuntu-latest
timeout-minutes: 25
if: github.event.pull_request.draft == false
if: |
github.event.pull_request.draft == false &&
github.event.pull_request.requested_reviewers != null
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12.x"
cache: "pip"
cache-dependency-path: "apps/api/requirements.txt"
# - name: Install OpenLDAP dependencies
# run: |
# sudo apt-get update
# sudo apt-get install -y libldap2-dev libsasl2-dev
cache: 'pip'
cache-dependency-path: 'apps/api/requirements.txt'
- name: Install Pylint
run: python -m pip install ruff
# - name: Install API Dependencies
# run: cd apps/api && pip install -r requirements.txt
- name: Install API Dependencies
run: cd apps/api && pip install -r requirements.txt
- name: Lint apps/api
run: ruff check apps/api
- name: Lint apps/pi
run: ruff check apps/pi
run: ruff check --fix apps/api
@@ -5,44 +5,24 @@ on:
pull_request:
branches:
- "preview"
- "phoenix-releases"
- "master"
- "release/**"
types:
- "opened"
- "synchronize"
- "ready_for_review"
- "reopened"
paths:
- "apps/web/**"
- "apps/admin/**"
- "apps/space/**"
- "apps/live/**"
- "apps/silo/**"
- "apps/flux/**"
- "packages/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- "tsconfig.json"
- ".oxlintrc.json"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
jobs:
# Format check has no build dependencies - run immediately in parallel
check-format:
name: check:format
runs-on: ubuntu-22.04-4core
runs-on: ubuntu-latest
timeout-minutes: 10
if: github.event.pull_request.draft == false
if: |
github.event.pull_request.draft == false &&
github.event.pull_request.requested_reviewers != null
env:
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }}
TURBO_SCM_HEAD: ${{ github.sha }}
@@ -55,8 +35,6 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "22.18.0"
- name: Enable Corepack and pnpm
run: corepack enable pnpm
@@ -82,13 +60,15 @@ jobs:
# Build packages - required for lint and type checks
build:
name: Build packages
runs-on: ubuntu-22.04-4core
runs-on: ubuntu-latest
timeout-minutes: 15
if: github.event.pull_request.draft == false
if: |
github.event.pull_request.draft == false &&
github.event.pull_request.requested_reviewers != null
env:
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }}
TURBO_SCM_HEAD: ${{ github.sha }}
NODE_OPTIONS: "--max-old-space-size=6144"
NODE_OPTIONS: "--max-old-space-size=4096"
steps:
- name: Checkout code
uses: actions/checkout@v6
@@ -98,8 +78,6 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "22.18.0"
- name: Enable Corepack and pnpm
run: corepack enable pnpm
@@ -120,10 +98,10 @@ jobs:
uses: actions/cache/restore@v5
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.event.pull_request.base.sha }}-
turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-
turbo-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -135,12 +113,54 @@ jobs:
uses: actions/cache/save@v5
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
# Lint check depends on build artifacts (type checking is covered by the build step)
# Lint check - no build dependency, OxLint is a standalone Rust binary
check-lint:
name: check:lint
runs-on: ubuntu-22.04-4core
runs-on: ubuntu-latest
timeout-minutes: 10
if: |
github.event.pull_request.draft == false &&
github.event.pull_request.requested_reviewers != null
env:
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }}
TURBO_SCM_HEAD: ${{ github.sha }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 50
filter: blob:none
- name: Set up Node.js
uses: actions/setup-node@v6
- name: Enable Corepack and pnpm
run: corepack enable pnpm
- name: Get pnpm store directory
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Cache pnpm store
uses: actions/cache@v5
with:
path: ${{ env.STORE_PATH }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
pnpm-store-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run check:lint
run: pnpm turbo run check:lint --affected
# Type check depends on build artifacts
check-types:
name: check:types
runs-on: ubuntu-latest
needs: build
timeout-minutes: 15
env:
@@ -156,8 +176,6 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "22.18.0"
- name: Enable Corepack and pnpm
run: corepack enable pnpm
@@ -178,61 +196,10 @@ jobs:
uses: actions/cache/restore@v5
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run check:lint
run: pnpm turbo run check:lint --affected
test:
name: test
runs-on: ubuntu-22.04-4core
timeout-minutes: 30
if: github.event.pull_request.draft == false
env:
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }}
TURBO_SCM_HEAD: ${{ github.sha }}
NODE_OPTIONS: "--max-old-space-size=6144"
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 50
filter: blob:none
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "22.18.0"
- name: Enable Corepack and pnpm
run: corepack enable pnpm
- name: Get pnpm store directory
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Cache pnpm store
uses: actions/cache@v5
with:
path: ${{ env.STORE_PATH }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
pnpm-store-${{ runner.os }}-
- name: Restore Turbo cache
uses: actions/cache/restore@v5
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.event.pull_request.base.sha }}-
turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests
run: pnpm test --affected
- name: Run check:types
run: pnpm turbo run check:types --affected
-140
View File
@@ -1,140 +0,0 @@
name: React Doctor
on:
pull_request:
branches:
- "preview"
- "master"
- "release/**"
types:
- "opened"
- "synchronize"
- "ready_for_review"
- "reopened"
paths:
- "apps/web/**"
- "apps/admin/**"
- "apps/space/**"
- "packages/**"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
react-doctor:
name: React Doctor
runs-on: ubuntu-22.04-4core
timeout-minutes: 10
if: github.event.pull_request.draft == false
# Non-blocking: this job can fail without preventing merge
continue-on-error: true
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
- name: Run React Doctor
run: |
npx -y react-doctor@latest . --diff ${{ github.event.pull_request.base.ref }} 2>&1 | tee /tmp/react-doctor-output.txt || true
- name: Generate summary comment
if: always()
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');
const raw = fs.readFileSync('/tmp/react-doctor-output.txt', 'utf8');
// Strip ANSI escape codes
const clean = raw.replace(/\x1b\[[0-9;]*m/g, '');
// Parse each project scan
const lines = clean.split('\n');
const projects = [];
let currentProject = null;
for (const line of lines) {
const scanMatch = line.match(/Scanning .*\/(apps|packages)\/([^.]+)\.\.\./);
if (scanMatch) {
currentProject = { name: `${scanMatch[1]}/${scanMatch[2]}` };
projects.push(currentProject);
continue;
}
if (!currentProject) continue;
const scoreMatch = line.match(/(\d+)\s*\/\s*100\s+(Great|Needs work|Critical)/);
if (scoreMatch) {
currentProject.score = parseInt(scoreMatch[1]);
currentProject.label = scoreMatch[2];
}
const statsMatch = line.match(/(?:✗\s*(\d+)\s*errors?\s*)?(?:⚠\s*(\d+)\s*warnings?)?\s*across\s*(\d+)\/(\d+)\s*files/);
if (statsMatch) {
currentProject.errors = parseInt(statsMatch[1] || '0');
currentProject.warnings = parseInt(statsMatch[2] || '0');
currentProject.filesAffected = parseInt(statsMatch[3]);
currentProject.filesTotal = parseInt(statsMatch[4]);
}
if (line.includes('No issues found')) {
currentProject.score = currentProject.score || 100;
currentProject.label = currentProject.label || 'Great';
currentProject.errors = 0;
currentProject.warnings = 0;
}
}
if (projects.length === 0) {
console.log('No project scores found in output');
return;
}
const emoji = (score) => {
if (score >= 90) return '🟢';
if (score >= 75) return '🟡';
return '🔴';
};
let body = `<!-- react-doctor -->\n## 🩺 React Doctor\n\n`;
body += `| Project | Score | Status | Errors | Warnings |\n`;
body += `|---------|-------|--------|--------|----------|\n`;
for (const p of projects) {
if (p.score === undefined) continue;
body += `| \`${p.name}\` | ${emoji(p.score)} **${p.score}**/100 | ${p.label} | ${p.errors || 0} | ${p.warnings || 0} |\n`;
}
body += `\n> Non-blocking — this check is informational only.`;
// Find and update or create comment
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes('<!-- react-doctor -->'));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
-98
View File
@@ -1,98 +0,0 @@
name: Generate SBOM
on:
workflow_dispatch:
inputs:
tag_name:
description: "Docker image tag to generate SBOMs for (e.g. preview, v1.14.0)"
required: true
type: string
image_variant:
description: "Image variant to scan"
required: true
type: choice
default: "commercial"
options:
- "commercial"
- "community"
sbom_format:
description: "SBOM output format"
required: true
type: choice
default: "spdx-json"
options:
- "spdx-json"
- "cyclonedx-json"
env:
TAG_NAME: ${{ github.event.inputs.tag_name }}
IMAGE_VARIANT: ${{ github.event.inputs.image_variant }}
SBOM_FORMAT: ${{ github.event.inputs.sbom_format }}
jobs:
setup:
name: Setup Image Matrix
runs-on: ubuntu-22.04
outputs:
images: ${{ steps.set_images.outputs.images }}
steps:
- id: set_images
name: Set Image List
run: |
if [ "${{ env.IMAGE_VARIANT }}" == "commercial" ]; then
echo 'images=["web-commercial","space-commercial","admin-commercial","live-commercial","backend-commercial","proxy-commercial","monitor-commercial","silo-commercial","email-commercial","plane-pi-commercial","plane-aio-commercial"]' >> $GITHUB_OUTPUT
elif [ "${{ env.IMAGE_VARIANT }}" == "community" ]; then
echo 'images=["plane-frontend","plane-space","plane-admin","plane-live","plane-backend","plane-proxy","plane-aio-community"]' >> $GITHUB_OUTPUT
fi
generate-sbom:
name: SBOM - ${{ matrix.image }}
needs: setup
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
image: ${{ fromJson(needs.setup.outputs.images) }}
steps:
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Pull Docker Image
run: docker pull makeplane/${{ matrix.image }}:${{ env.TAG_NAME }}
- name: Install Syft
uses: anchore/sbom-action/download-syft@v0
- name: Generate SBOM
run: |
syft makeplane/${{ matrix.image }}:${{ env.TAG_NAME }} \
--output ${{ env.SBOM_FORMAT }}=sbom-${{ matrix.image }}-${{ env.TAG_NAME }}.${{ env.SBOM_FORMAT == 'spdx-json' && 'spdx.json' || 'cdx.json' }}
- name: Upload SBOM Artifact
uses: actions/upload-artifact@v7
with:
name: sbom-${{ matrix.image }}-${{ env.TAG_NAME }}
path: sbom-${{ matrix.image }}-*
retention-days: 90
merge-sboms:
name: Merge All SBOMs
needs: [setup, generate-sbom]
runs-on: ubuntu-22.04
steps:
- name: Download All SBOMs
uses: actions/download-artifact@v8
with:
pattern: sbom-*
merge-multiple: true
path: sboms/
- name: Upload Merged SBOM Bundle
uses: actions/upload-artifact@v7
with:
name: sbom-bundle-${{ env.IMAGE_VARIANT }}-${{ env.TAG_NAME }}
path: sboms/
retention-days: 90
@@ -1,24 +0,0 @@
name: Slash Command Dispatch
on:
issue_comment:
types: [created]
jobs:
slashCommandDispatch:
runs-on: ubuntu-latest
# Only run if comment is on a PR and contains the slash command
if: |
github.event.issue.pull_request != null &&
(startsWith(github.event.comment.body, '/deploy'))
permissions:
contents: read
pull-requests: write
issues: write
steps:
- name: Slash Command Dispatch
uses: peter-evans/slash-command-dispatch@v5
with:
token: ${{ secrets.PAT }}
permission: write
issue-type: pull-request
commands: |
deploy
-56
View File
@@ -1,56 +0,0 @@
name: Create PR on Sync
on:
workflow_dispatch:
push:
branches:
- "sync/**"
env:
CURRENT_BRANCH: ${{ github.ref_name }}
TARGET_BRANCH: "preview" # The target branch that you would like to merge changes like develop
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows
ACCOUNT_USER_NAME: ${{ vars.ACCOUNT_USER_NAME }}
ACCOUNT_USER_EMAIL: ${{ vars.ACCOUNT_USER_EMAIL }}
jobs:
create_pull_request:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0 # Fetch all history for all branches and tags
- name: Setup Git
run: |
git config user.name "$ACCOUNT_USER_NAME"
git config user.email "$ACCOUNT_USER_EMAIL"
- name: Setup GH CLI and Git Config
run: |
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
sudo apt update
sudo apt install gh -y
- name: Create PR to Target Branch
run: |
# Determine target branch based on current branch prefix
TARGET_BRANCH="preview"
PR_TITLE="${{vars.SYNC_PR_TITLE}}"
# get all pull requests and check if there is already a PR
PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $CURRENT_BRANCH --state open --json number | jq '.[] | .number')
if [ -n "$PR_EXISTS" ]; then
echo "Pull Request already exists: $PR_EXISTS"
else
echo "Creating new pull request"
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $CURRENT_BRANCH --title "$PR_TITLE" --body "")
echo "Pull Request created: $PR_URL"
fi
-44
View File
@@ -1,44 +0,0 @@
name: Sync Repositories
on:
workflow_dispatch:
push:
branches:
- preview
env:
SOURCE_BRANCH_NAME: ${{ github.ref_name }}
jobs:
sync_changes:
runs-on: ubuntu-22.04
permissions:
pull-requests: write
contents: read
steps:
- name: Checkout Code
uses: actions/checkout@v6
with:
persist-credentials: false
fetch-depth: 0
- name: Setup GH CLI
run: |
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
sudo apt update
sudo apt install gh -y
- name: Push Changes to Target Repo
env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: |
TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}"
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}"
git checkout $SOURCE_BRANCH
git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH
+10 -17
View File
@@ -9,7 +9,7 @@ node_modules
.pnp.js
# Testing
coverage/
/coverage
# Next.js
/.next/
@@ -73,7 +73,6 @@ pnpm-debug.log
*.sln
package-lock.json
.vscode
.zed
# Sentry
.sentryclirc
@@ -88,7 +87,6 @@ tmp/
## packages
dist
.flatfile
.temp/
deploy/selfhost/plane-app/
@@ -102,25 +100,20 @@ dev-editor
*.rdb.gz
storybook-static
# Monitor
monitor/prime.key
monitor/prime.key.pub
monitor.db
.cursor
CLAUDE.md
build/
.react-router/
build/
.react-router/
temp/
# Auto-generated i18n translation key types (regenerated on build)
packages/i18n/src/types/keys.generated.ts
# Ignore any test results JSON files
*test_results*.json
apps/pi/tests/test_results.json
scripts/
!packages/i18n/scripts/
.agents
# i18n auto-generated types (regenerated on every build)
packages/i18n/src/types/keys.generated.ts
# Local security notes (not for version control)
/security/
+16
View File
@@ -0,0 +1,16 @@
{ pkgs, ... }: {
# Which nixpkgs channel to use.
channel = "stable-23.11"; # or "unstable"
# Use https://search.nixos.org/packages to find packages
packages = [
pkgs.nodejs_20
pkgs.python3
];
services.docker.enable = true;
services.postgres.enable = true;
services.redis.enable = true;
}
+1 -5
View File
@@ -1,6 +1,2 @@
[env]
_.file = ".env"
[tools]
node = { version = "22", postinstall = "corepack enable" }
actionlint = "1.7.12"
node = "22.18.0"
+10 -5
View File
@@ -2,10 +2,6 @@
# Core Workspace Behavior
# ------------------------------
# Use isolated node_modules (no symlinks) for Docker compatibility
# Required for `turbo prune --docker` to work correctly
node-linker = isolated
# Always prefer using local workspace packages when available
prefer-workspace-packages = true
@@ -16,7 +12,7 @@ link-workspace-packages = true
shared-workspace-lockfile = true
# Ensure packages added from workspace save using workspace: protocol
save-workspace-protocol = rolling
save-workspace-protocol = true
# ------------------------------
@@ -47,3 +43,12 @@ prefer-offline = true
# In CI, refuse to modify lockfile (prevents drift)
prefer-frozen-lockfile = true
# Use isolated linker (best compatibility with Node ecosystem tools)
node-linker = isolated
# Hoist commonly used tools to the root to prevent duplicates and speed up resolution
public-hoist-pattern[] = typescript
public-hoist-pattern[] = eslint
public-hoist-pattern[] = *@plane/*
public-hoist-pattern[] = vite
public-hoist-pattern[] = turbo
+4
View File
@@ -2,6 +2,10 @@
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5",
"sortTailwindcss": {
"stylesheet": "packages/tailwind-config/index.css",
"functions": ["cn", "clsx", "cva"]
},
"overrides": [
{
"files": ["packages/codemods/**/*"],
+33 -334
View File
@@ -1,354 +1,53 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["promise", "react", "jsx-a11y", "typescript", "unicorn"],
"plugins": ["react", "typescript", "jsx-a11y", "import", "promise", "unicorn", "oxc"],
"categories": {
"correctness": "off"
"correctness": "warn",
"suspicious": "warn",
"perf": "warn"
},
"env": {
"builtin": true
"browser": true,
"node": true,
"es2024": true
},
"settings": {
"react": {
"version": "18.3"
},
"jsx-a11y": {
"polymorphicPropName": "as"
}
},
"ignorePatterns": [
"**/.cache/**",
"**/.env.*",
"**/.env",
"**/.next/**",
"**/.react-router/**",
"**/.storybook/**",
"**/.turbo/**",
"**/.vite/**",
"**/*.config.{js,mjs,cjs,ts}",
"**/build/**",
"**/coverage/**",
"**/dist/**",
"**/node_modules/**",
".cache/**",
".next/**",
".react-router/**",
".storybook/**",
".turbo/**",
".vite/**",
"*.config.{js,mjs,cjs,ts}",
"build/**",
"coverage/**",
"dist/**",
"**/public/**",
"**/storybook-static/**"
"storybook-static/**"
],
"rules": {
"constructor-super": "error",
"for-direction": "error",
"no-async-promise-executor": "error",
"no-case-declarations": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "warn",
"no-const-assign": "error",
"no-constant-binary-expression": "warn",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-dupe-class-members": "error",
"no-dupe-else-if": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "warn",
"no-empty-character-class": "error",
"no-empty-pattern": "warn",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-extra-boolean-cast": "warn",
"no-fallthrough": "error",
"no-func-assign": "error",
"no-global-assign": "error",
"no-import-assign": "error",
"no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-misleading-character-class": "error",
"no-new-native-nonconstructor": "error",
"no-nonoctal-decimal-escape": "error",
"no-obj-calls": "error",
"no-prototype-builtins": "warn",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-self-assign": "error",
"no-setter-return": "error",
"no-shadow-restricted-names": "error",
"no-sparse-arrays": "error",
"no-this-before-super": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unsafe-optional-chaining": "warn",
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"unicorn/filename-case": "off",
"unicorn/no-null": "off",
"unicorn/prevent-abbreviations": "off",
"no-unused-vars": [
"warn",
{
"args": "all",
"argsIgnorePattern": "^_",
"caughtErrors": "all",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
"destructuredArrayIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"ignoreRestSiblings": true
}
],
"no-useless-backreference": "error",
"no-useless-catch": "warn",
"no-useless-escape": "warn",
"no-with": "error",
"require-yield": "error",
"use-isnan": "error",
"valid-typeof": "warn",
"promise/always-return": "warn",
"promise/no-return-wrap": "error",
"promise/param-names": "warn",
"promise/catch-or-return": "warn",
"promise/no-nesting": "warn",
"promise/no-promise-in-callback": "warn",
"promise/no-callback-in-promise": "warn",
"promise/no-new-statics": "error",
"promise/valid-params": "warn",
"react/display-name": "warn",
"react/jsx-key": "error",
"react/jsx-no-comment-textnodes": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-target-blank": "warn",
"react/jsx-no-undef": "error",
"react/no-children-prop": "error",
"react/no-danger-with-children": "error",
"react/no-direct-mutation-state": "error",
"react/no-find-dom-node": "error",
"react/no-is-mounted": "error",
"react/no-render-return-value": "error",
"react/no-string-refs": "error",
"react/no-unescaped-entities": "error",
"react/no-unknown-property": "warn",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"jsx-a11y/alt-text": "warn",
"jsx-a11y/anchor-has-content": "error",
"jsx-a11y/anchor-is-valid": "warn",
"jsx-a11y/aria-activedescendant-has-tabindex": "error",
"jsx-a11y/aria-props": "error",
"jsx-a11y/aria-proptypes": "error",
"jsx-a11y/aria-role": "error",
"jsx-a11y/aria-unsupported-elements": "error",
"jsx-a11y/autocomplete-valid": "error",
"jsx-a11y/click-events-have-key-events": "warn",
"jsx-a11y/heading-has-content": "error",
"jsx-a11y/html-has-lang": "error",
"jsx-a11y/iframe-has-title": "warn",
"jsx-a11y/img-redundant-alt": "warn",
"jsx-a11y/label-has-associated-control": "warn",
"jsx-a11y/media-has-caption": "error",
"jsx-a11y/mouse-events-have-key-events": "warn",
"jsx-a11y/no-access-key": "error",
"jsx-a11y/no-autofocus": "warn",
"jsx-a11y/no-distracting-elements": "error",
"jsx-a11y/no-noninteractive-tabindex": [
"warn",
{
"tags": [],
"roles": ["tabpanel"],
"allowExpressionValues": true
}
],
"jsx-a11y/no-redundant-roles": "warn",
"jsx-a11y/no-static-element-interactions": [
"warn",
{
"allowExpressionValues": true,
"handlers": ["onClick", "onMouseDown", "onMouseUp", "onKeyPress", "onKeyDown", "onKeyUp"]
}
],
"jsx-a11y/role-has-required-aria-props": "error",
"jsx-a11y/role-supports-aria-props": "error",
"jsx-a11y/scope": "error",
"jsx-a11y/tabindex-no-positive": "warn",
"turbo/no-undeclared-env-vars": "error",
"@typescript-eslint/await-thenable": "warn",
"@typescript-eslint/ban-ts-comment": "error",
"no-array-constructor": "error",
"@typescript-eslint/no-array-delete": "error",
"@typescript-eslint/no-base-to-string": "warn",
"@typescript-eslint/no-duplicate-enum-values": "error",
"@typescript-eslint/no-duplicate-type-constituents": "warn",
"@typescript-eslint/no-empty-object-type": "error",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-extra-non-null-assertion": "error",
"@typescript-eslint/no-floating-promises": "warn",
"@typescript-eslint/no-for-in-array": "warn",
"@typescript-eslint/no-implied-eval": "error",
"@typescript-eslint/no-misused-new": "error",
"@typescript-eslint/no-misused-promises": "warn",
"@typescript-eslint/no-namespace": "error",
"@typescript-eslint/no-non-null-asserted-optional-chain": "error",
"@typescript-eslint/no-redundant-type-constituents": "warn",
"@typescript-eslint/no-require-imports": "error",
"@typescript-eslint/no-this-alias": "error",
"@typescript-eslint/no-unnecessary-type-assertion": "warn",
"@typescript-eslint/no-unnecessary-type-constraint": "error",
"@typescript-eslint/no-unsafe-argument": "warn",
"@typescript-eslint/no-unsafe-assignment": "warn",
"@typescript-eslint/no-unsafe-call": "warn",
"@typescript-eslint/no-unsafe-declaration-merging": "error",
"@typescript-eslint/no-unsafe-enum-comparison": "warn",
"@typescript-eslint/no-unsafe-function-type": "error",
"@typescript-eslint/no-unsafe-member-access": "warn",
"@typescript-eslint/no-unsafe-return": "warn",
"@typescript-eslint/no-unsafe-unary-minus": "error",
"no-unused-expressions": "warn",
"@typescript-eslint/no-wrapper-object-types": "error",
"@typescript-eslint/only-throw-error": "warn",
"@typescript-eslint/prefer-as-const": "error",
"@typescript-eslint/prefer-namespace-keyword": "error",
"@typescript-eslint/prefer-promise-reject-errors": "warn",
"@typescript-eslint/require-await": "warn",
"@typescript-eslint/restrict-plus-operands": "warn",
"@typescript-eslint/restrict-template-expressions": "warn",
"@typescript-eslint/triple-slash-reference": "error",
"@typescript-eslint/unbound-method": "warn",
"@typescript-eslint/no-import-type-side-effects": "error",
"jsx-a11y-js/interactive-supports-focus": "warn",
"jsx-a11y-js/no-noninteractive-element-to-interactive-role": "warn",
"react-hooks-js/immutability": "warn",
"react-hooks-js/preserve-manual-memoization": "warn",
"react-hooks-js/purity": "warn",
"react-hooks-js/refs": "warn",
"react-hooks-js/set-state-in-effect": "warn",
"react-hooks-js/static-components": "warn",
"react/only-export-components": [
"warn",
{
"allowExportNames": [
"action",
"clientAction",
"clientLoader",
"clientMiddleware",
"ErrorBoundary",
"handle",
"headers",
"HydrateFallback",
"links",
"loader",
"meta",
"middleware",
"shouldRevalidate"
],
"customHOCs": ["observer"]
}
]
},
"jsPlugins": [
"eslint-plugin-turbo",
{ "name": "jsx-a11y-js", "specifier": "eslint-plugin-jsx-a11y" },
{ "name": "react-hooks-js", "specifier": "eslint-plugin-react-hooks" }
],
"overrides": [
{
"files": ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"],
"rules": {
"constructor-super": "off",
"no-class-assign": "off",
"no-const-assign": "off",
"no-dupe-class-members": "off",
"no-dupe-keys": "off",
"no-func-assign": "off",
"no-import-assign": "off",
"no-new-native-nonconstructor": "off",
"no-obj-calls": "off",
"no-redeclare": "off",
"no-setter-return": "off",
"no-this-before-super": "off",
"no-unsafe-negation": "off",
"no-var": "error",
"no-with": "off",
"prefer-const": "error",
"prefer-rest-params": "error",
"prefer-spread": "error"
}
},
{
"files": ["**/*.{ts,tsx}"],
"rules": {
"import/namespace": "error",
"import/default": "error",
"import/no-named-as-default": "warn",
"import/no-named-as-default-member": "warn",
"import/no-duplicates": "warn",
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "@headlessui/react",
"importNames": ["Disclosure", "Switch", "Tabs"],
"message": "Use @plane/propel components instead of @headlessui/react"
},
{
"name": "@plane/ui",
"importNames": ["Collapsible", "CollapsibleButton", "Switch", "Tabs"],
"message": "Use @plane/propel components instead of @plane/ui"
}
]
}
]
},
"env": {
"es2018": true
},
"plugins": ["import"]
},
{
"files": ["**/*.{js,mjs,cjs,jsx}"],
"rules": {
"@typescript-eslint/await-thenable": "off",
"@typescript-eslint/no-array-delete": "off",
"@typescript-eslint/no-base-to-string": "off",
"@typescript-eslint/no-confusing-void-expression": "off",
"@typescript-eslint/no-deprecated": "off",
"@typescript-eslint/no-duplicate-type-constituents": "off",
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/no-for-in-array": "off",
"@typescript-eslint/no-implied-eval": "off",
"@typescript-eslint/no-meaningless-void-operator": "off",
"@typescript-eslint/no-misused-promises": "off",
"@typescript-eslint/no-misused-spread": "off",
"@typescript-eslint/no-mixed-enums": "off",
"@typescript-eslint/no-redundant-type-constituents": "off",
"@typescript-eslint/no-unnecessary-boolean-literal-compare": "off",
"@typescript-eslint/no-unnecessary-template-expression": "off",
"@typescript-eslint/no-unnecessary-type-arguments": "off",
"@typescript-eslint/no-unnecessary-type-assertion": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-enum-comparison": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-unsafe-type-assertion": "off",
"@typescript-eslint/no-unsafe-unary-minus": "off",
"@typescript-eslint/non-nullable-type-assertion-style": "off",
"@typescript-eslint/only-throw-error": "off",
"@typescript-eslint/prefer-includes": "off",
"@typescript-eslint/prefer-nullish-coalescing": "off",
"@typescript-eslint/prefer-promise-reject-errors": "off",
"@typescript-eslint/prefer-reduce-type-parameter": "off",
"@typescript-eslint/prefer-return-this-type": "off",
"@typescript-eslint/promise-function-async": "off",
"@typescript-eslint/related-getter-setter-pairs": "off",
"@typescript-eslint/require-array-sort-compare": "off",
"@typescript-eslint/require-await": "off",
"@typescript-eslint/restrict-plus-operands": "off",
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/return-await": "off",
"@typescript-eslint/strict-boolean-expressions": "off",
"@typescript-eslint/switch-exhaustiveness-check": "off",
"@typescript-eslint/unbound-method": "off",
"@typescript-eslint/use-unknown-in-catch-callback-variable": "off"
}
},
{
"files": ["**/*.{mjs,cjs}"],
"rules": {
"@typescript-eslint/no-require-imports": "off"
},
"env": {
"node": true
}
}
]
}
}
+10
View File
@@ -0,0 +1,10 @@
# Trivy ignore file
# Document the rationale for each suppressed finding.
# CVE-2026-30242: SSRF in Plane webhook URL serializer.
# False positive: Trivy matches our backend's distribution name "Plane" +
# version 0.24.0 against the makeplane/plane CVE. The "fixed in 1.2.3" refers
# to the upstream public release version scheme, not this distribution's
# pyproject.toml version - the SSRF mitigation has been in place for the
# applicable webhook validation code path.
CVE-2026-30242
+25 -175
View File
@@ -1,186 +1,36 @@
# AGENTS.md
# Agent Development Guide
This file provides guidance to AI coding agents when working with code in this repository.
## Commands
## Project Overview
Plane is an open-source project management tool (similar to Jira/Linear). This is the Enterprise Edition repository containing both open-source and commercial features.
## Tech Stack
- **Frontend**: React 18 with React Router 7, TypeScript, MobX, Tailwind CSS
- **Backend**: Django with Django REST Framework, Celery for background tasks
- **Real-time**: Node.js with Socket.IO and Hocuspocus (collaborative editing)
- **Database**: PostgreSQL 15, Redis (Valkey), RabbitMQ
- **Build**: pnpm 10.24.0, Turbo, Vite
- **Node**: 22.18.0+
- **Python**: 3.8+
## Monorepo Structure
```
apps/
web/ # Main UI (React Router, port 3000)
admin/ # Admin panel (port 3001)
api/ # Django REST backend (port 8000)
live/ # Real-time collaboration server (Socket.IO + Hocuspocus)
space/ # Public project space portal
silo/ # Integration system (Slack, GitHub, Jira/Linear imports)
flux/ # Request routing proxy
pi/ # Plane Intelligence (AI features)
monitor/ # Go-based health check service
email/ # Email processing service
packages/
propel/ # New Storybook component library (@plane/propel) - actively developed
ui/ # Legacy component library (@plane/ui) - being replaced by propel
types/ # Shared TypeScript types (@plane/types)
shared-state/ # MobX stores (@plane/shared-state)
services/ # API client services (@plane/services)
hooks/ # React hooks (@plane/hooks)
editor/ # Rich text editor (Tiptap/ProseMirror)
i18n/ # Internationalization
constants/ # Shared constants
utils/ # Utility functions
```
## Common Commands
### Monorepo (from root)
```bash
pnpm dev # Start all dev servers
pnpm build # Build all packages and apps
pnpm check # Run format, lint, and type checks
pnpm check:lint # ESLint only
pnpm check:types # TypeScript only
pnpm fix # Auto-fix format and lint issues
pnpm fix:lint # Fix lint issues only
pnpm fix:format # Fix formatting only
pnpm clean # Remove node_modules, dist, build folders
```
### Target Specific Package
```bash
pnpm turbo run <command> --filter=<package>
pnpm --filter=@plane/propel storybook # Start Storybook on port 6006
pnpm --filter=web dev # Run only web app
```
### Django API (from apps/api)
```bash
# Run with Docker (recommended for local dev)
# Start all services
docker compose -f docker-compose-local.yml --profile all up
# External services only (postgres, redis, rabbitmq, minio)
docker compose -f docker-compose-local.yml --profile services up
# External services + api, worker, beat-worker
docker compose -f docker-compose-local.yml --profile api up
# Run tests
pytest # All tests
pytest -m unit # Unit tests only
pytest -m contract # Contract tests only
pytest plane/tests/unit/models/test_*.py # Specific test file
pytest -k "test_function_name" # Specific test by name
# Django commands (inside container or with venv)
python manage.py migrate
python manage.py runserver
```
### Test Markers (pytest)
- `unit` - Unit tests for models, serializers, utilities
- `contract` - Contract tests for API endpoints
- `smoke` - Smoke tests for critical functionality
- `slow` - Tests that may be skipped in CI
## Local Development Setup
1. Clone the repo and run `./setup.sh`
2. Start backend services: `docker compose -f docker-compose-local.yml --profile all up`
- Use `--profile services` for only external services (postgres, redis, rabbitmq, minio)
- Use `--profile api` for external services + api, worker, beat-worker, migrator
3. Start frontend: `pnpm dev`
4. Admin setup: http://localhost:3001/god-mode/
5. Main app: http://localhost:3000
**Requirements**: Docker, Node.js 22+, Python 3.8+, 12GB+ RAM recommended
## Copyright Headers
Every source file in this repository contains a copyright/license header. When reading files, **ignore these headers** — they are boilerplate and not relevant to understanding the code logic. Do **not** remove, modify, or omit them when editing existing files. When creating **new** files, include the appropriate header.
**TypeScript / JavaScript / TSX / JSX:**
```ts
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
```
**Python:**
```python
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
#
# Licensed under the Plane Commercial License (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://plane.so/legals/eula
#
# DO NOT remove or modify this notice.
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
```
- `pnpm dev` - Start all dev servers (web:3000, admin:3001)
- `pnpm build` - Build all packages and apps
- `pnpm check` - Run all checks (format, lint, types)
- `pnpm check:lint` - OxLint across all packages
- `pnpm check:types` - TypeScript type checking
- `pnpm fix` - Auto-fix format and lint issues
- `pnpm turbo run <command> --filter=<package>` - Target specific package/app
- `pnpm --filter=@plane/ui storybook` - Start Storybook on port 6006
## Code Style
- **TypeScript**: Strict mode, use `workspace:*` for internal packages, `catalog:` for external deps
- **Formatting**: oxfmt with built-in Tailwind class sorting (runs on commit via Husky)
- **Linting**: ESLint 9 with typed linting from root config
- **Imports**: Use `workspace:*` for internal packages, `catalog:` for external deps
- **TypeScript**: Strict mode enabled, all files must be typed
- **Formatting**: oxfmt, run `pnpm fix:format`
- **Linting**: OxLint with shared `.oxlintrc.json` config
- **Naming**: camelCase for variables/functions, PascalCase for components/types
- **State**: MobX stores in `@plane/shared-state`
- **Python**: Ruff for linting/formatting, line length 120
- **Error Handling**: Use try-catch with proper error types, log errors appropriately
- **State Management**: MobX stores in `packages/shared-state`, reactive patterns
- **Testing**: All features require unit tests, use existing test framework per package
- **Components**: Build in `@plane/ui` with Storybook for isolated development
## Architecture Notes
## Backend tests (Docker)
### Frontend Data Flow
The Django/pytest suite for `apps/api` runs in an isolated stack defined by `docker-compose-test.yml` at the repo root.
Components use MobX stores from `@plane/shared-state`. API calls go through services in `@plane/services` which wrap axios. Real-time updates come via Socket.IO from the `live` server.
Prereq (once): `./setup.sh` — generates `apps/api/.env` from `.env.example`.
### Backend Structure (apps/api/plane)
- Full suite: `docker compose -f docker-compose-test.yml up --build --abort-on-container-exit --exit-code-from api-tests`
- Subset: `docker compose -f docker-compose-test.yml run --rm api-tests pytest -m unit`
- Teardown: `docker compose -f docker-compose-test.yml down -v`
- `api/` - REST API endpoints (DRF ViewSets)
- `app/` - Core application logic
- `bgtasks/` - Celery background tasks
- `authentication/` - Auth providers (OAuth, SAML, LDAP, OIDC)
- `automations/` - Workflow automation engine
- `ee/` - Enterprise Edition features
- `event_stream/` - Event publishing for real-time
- `graphql/` - GraphQL API layer
### Real-time Server (apps/live)
- `socket-io/` - Socket.IO for workspace events
- `hocuspocus.ts` - Collaborative document editing (Yjs)
## Important Files
- `turbo.json` - Turbo build configuration
- `pnpm-workspace.yaml` - Workspace and catalog definitions
- `eslint.config.mjs` - Root ESLint configuration
- `apps/api/pytest.ini` - Python test configuration
- `apps/api/plane/settings/` - Django settings by environment
See `apps/api/tests/RUNNING_TESTS.md` for the full walkthrough and troubleshooting; see `apps/api/tests/TESTING_GUIDE.md` for test conventions and fixtures.
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+7 -5
View File
@@ -1,6 +1,8 @@
eslint.config.mjs @sriramveeraghanta @lifeiscontent
apps/silo/ @Prashant-Surya
.oxlintrc.json @sriramveeraghanta @lifeiscontent
.oxfmtrc.json @sriramveeraghanta @lifeiscontent
apps/api/ @dheeru0198 @pablohashescobar
apps/pi/ @sunder-ch
apps/flux/ @sriramveeraghanta
deployments/ @mguptahub @pratapalakshmi
apps/web/ @sriramveeraghanta
apps/space/ @sriramveeraghanta
apps/admin/ @sriramveeraghanta
apps/live/ @Palanikannan1437
deployments/ @mguptahub
+2 -12
View File
@@ -67,19 +67,9 @@ chmod +x setup.sh
3. Start the containers
```bash
# Start all services (recommended for first-time setup)
docker compose -f docker-compose-local.yml --profile all up
# Or start only external services (postgres, redis, rabbitmq, minio)
# if you want to run api/workers outside Docker
docker compose -f docker-compose-local.yml --profile services up
# Or start external services + api, worker, beat-worker, and migrator
docker compose -f docker-compose-local.yml --profile api up
docker compose -f docker-compose-local.yml up
```
> **Tip:** To avoid passing `--profile` every time, add `COMPOSE_PROFILES=all` to your `.env` file. Then you can simply run `docker compose -f docker-compose-local.yml up`.
4. Start web apps:
```bash
@@ -101,7 +91,7 @@ If you would like to _implement_ it, an issue with your proposal must be submitt
To ensure consistency throughout the source code, please keep these rules in mind as you are working:
- All features or bug fixes must be tested by one or more specs (unit-tests).
- We lint with [ESLint 9](https://eslint.org/docs/latest/) using the shared `eslint.config.mjs` (type-aware via `typescript-eslint`) and format with [oxfmt](https://oxc.rs/docs/guide/usage/formatter) using `.oxfmtrc.json`.
- We lint with [OxLint](https://oxc.rs/docs/guide/usage/linter) using the shared `.oxlintrc.json` and format with [oxfmt](https://oxc.rs/docs/guide/usage/formatter) using `.oxfmtrc.json`.
## Ways to contribute
+3 -10
View File
@@ -1,10 +1,3 @@
SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
SPDX-License-Identifier: LicenseRef-Plane-Commercial
Licensed under the Plane Commercial License (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://plane.so/legals/eula
DO NOT remove or modify this notice.
NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
Copyright (c) 2023-present Plane Software, Inc. and contributors
SPDX-License-Identifier: AGPL-3.0-only
See the LICENSE file for details.
-265
View File
@@ -1,265 +0,0 @@
# FIPS Compliance Changes Required
This document lists all locations that need to be modified to achieve FIPS compliance.
## Critical Changes (Must Fix)
### 1. Remove `pycryptodome` Dependency
**Status:****COMPLETED** - Replaced with FIPS-compliant `cryptography` library
#### Files Updated:
1. **`apps/api/requirements/base.txt`** ✅ **COMPLETED**
- ✅ Removed: `pycryptodome==3.22.0`
- ✅ Now using: `cryptography==44.0.1` (FIPS-compliant when built with FIPS-enabled OpenSSL)
2. **`apps/pi/requirements.txt`** ✅ **COMPLETED**
- ✅ Removed: `pycryptodome==3.23.0`
- ✅ Now using: `cryptography==46.0.2` (FIPS-compliant when built with FIPS-enabled OpenSSL)
#### Code Files Updated:
3. **`apps/api/plane/utils/encryption.py`** ✅ **COMPLETED**
```python
# IMPLEMENTED (FIPS-COMPLIANT):
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import secrets # For FIPS-compliant random bytes generation
```
- ✅ Functions updated:
- `encrypt()` - Now uses `AESGCM(key).encrypt()` and `secrets.token_bytes()`
- `decrypt()` - Now uses `AESGCM(key).decrypt()`
4. **`apps/pi/pi/app/utils/encryption.py`** ✅ **COMPLETED**
```python
# IMPLEMENTED (FIPS-COMPLIANT):
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
```
- ✅ Functions updated:
- `decrypt()` - Now uses `AESGCM(key).decrypt()`
- `decrypt_from_string()` - Uses updated `decrypt()` function
### 2. Configure `cryptography` Library for FIPS
**Status:** ⚠️ **CONDITIONAL** - Can be FIPS-compliant if properly configured
#### Files Using `cryptography`:
5. **`apps/api/plane/license/utils/encryption.py`** (Line 15)
- Uses: `from cryptography.fernet import Fernet`
- **Action Required:** Ensure OpenSSL is FIPS-enabled and verify FIPS mode enforcement
- Functions: `encrypt_data()`, `decrypt_data()`
6. **`apps/api/plane/utils/integrations/github.py`** (Lines 17-18)
- Uses: `from cryptography.hazmat.primitives.serialization import load_pem_private_key`
- Uses: `from cryptography.hazmat.backends import default_backend`
- **Action Required:** Verify FIPS mode is enforced
7. **`apps/pi/pi/services/actions/oauth_url_encoder.py`** (Line 21)
- Uses: `from cryptography.fernet import Fernet`
- **Action Required:** Ensure FIPS mode enforcement
#### Dependency Files:
8. **`apps/api/requirements/base.txt`** (Line 53)
- Current: `cryptography==44.0.1`
- **Action Required:**
- Ensure version is compatible with FIPS-enabled OpenSSL
- Verify build against FIPS-validated OpenSSL in Dockerfile
9. **`apps/pi/requirements.txt`** (Line 12)
- Current: `cryptography==46.0.2`
- **Action Required:** Same as above
### 3. Dockerfile Configuration for FIPS
#### Python Applications:
10. **`apps/api/Dockerfile.fips`**
- **Current:** Uses `registry.access.redhat.com/ubi10/python-312-minimal` ✅ (Good - UBI has FIPS support)
- **Action Required:**
- Ensure OpenSSL is FIPS-enabled in the container
- Add environment variable to enable FIPS mode: `OPENSSL_CONF=/path/to/openssl-fips.cnf`
- Verify `cryptography` library is built against FIPS-enabled OpenSSL
- Consider adding: `RUN pip install --no-binary cryptography cryptography` to ensure proper linking
11. **`apps/pi/Dockerfile.fips`**
- **Current:** Multi-stage build with `python:3.12-slim` builder and `registry.access.redhat.com/ubi10/python-312-minimal` runtime
- **Action Required:**
- Ensure builder stage also has FIPS-enabled OpenSSL if building cryptography
- Verify runtime stage has FIPS mode enabled
- Add FIPS configuration
#### Node.js Applications:
12. **`apps/silo/Dockerfile.fips`**
- **Current:** Uses `registry.access.redhat.com/ubi10/nodejs-22` ✅ (Good)
- **Action Required:**
- Verify Node.js is built with FIPS support
- Ensure OpenSSL FIPS mode is enabled
- The code uses Node.js built-in `crypto` module which should be FIPS-compliant when Node.js is FIPS-enabled
13. **`apps/web/Dockerfile.fips`**
- **Current:** Uses `registry.access.redhat.com/ubi10/nginx-126` ✅ (Good)
- **Status:** Already fixed (USER root added)
14. **`apps/admin/Dockerfile.fips`**
- **Current:** Uses `registry.access.redhat.com/ubi10/nginx-126` ✅ (Good)
- **Status:** Already fixed (USER root added)
15. **`apps/space/Dockerfile.fips`**
- **Action Required:** Verify FIPS configuration
16. **`apps/live/Dockerfile.fips`**
- **Action Required:** Verify FIPS configuration
#### Go Applications:
17. **`apps/monitor/Dockerfile.fips`**
- **Current:** Uses `registry.access.redhat.com/ubi10/ubi-minimal` ✅ (Good)
- **Status:** Go standard library crypto should be FIPS-compliant when using FIPS-validated system libraries
18. **`apps/email/Dockerfile.fips`**
- **Current:** Uses `registry.access.redhat.com/ubi10/ubi-minimal` ✅ (Good)
- **Status:** Go standard library crypto should be FIPS-compliant
### 4. Node.js Package Overrides
19. **`package.json`** (Line 80)
- Current: `"pbkdf2": "3.1.3"` in pnpm overrides
- **Action Required:**
- Verify if this package is actually used
- If used, replace with Node.js built-in `crypto.pbkdf2Sync()` (which is FIPS-compliant)
- The codebase already uses `crypto.pbkdf2Sync()` in `apps/silo/src/helpers/decrypt.ts`, so this override may be unnecessary
## Implementation Status
### Phase 1: Critical (Blocking FIPS Compliance) - ✅ COMPLETED
1. ✅ **COMPLETED** - Removed `pycryptodome` from requirements files
- `apps/api/requirements/base.txt` - Removed `pycryptodome==3.22.0`
- `apps/pi/requirements.txt` - Removed `pycryptodome==3.23.0`
2. ✅ **COMPLETED** - Replaced `Crypto.Cipher.AES` usage with `cryptography.hazmat.primitives.ciphers.aead.AESGCM`
- `apps/api/plane/utils/encryption.py` - Updated imports and functions
- `apps/pi/pi/app/utils/encryption.py` - Updated imports and functions
3. ✅ **COMPLETED** - Replaced `Crypto.Random.get_random_bytes()` with `secrets.token_bytes()`
- `apps/api/plane/utils/encryption.py` - Uses `secrets.token_bytes(12)` for nonce generation
4. ✅ **COMPLETED** - Updated encryption/decryption functions:
- `apps/api/plane/utils/encryption.py` - Both `encrypt()` and `decrypt()` functions updated
- `apps/pi/pi/app/utils/encryption.py` - `decrypt()` and `decrypt_from_string()` functions updated
### Phase 2: Configuration (Ensure FIPS Mode) - ⚠️ PENDING
5. ⚠️ **PENDING** - Configure Dockerfiles to enable FIPS mode
- `apps/api/Dockerfile.fips` - Needs FIPS mode configuration
- `apps/pi/Dockerfile.fips` - Needs FIPS mode configuration
6. ⚠️ **PENDING** - Verify `cryptography` library is built against FIPS-enabled OpenSSL
- Ensure Dockerfiles build cryptography with FIPS-enabled OpenSSL
7. ⚠️ **PENDING** - Add FIPS mode verification in application startup
- Add runtime checks to verify FIPS mode is enabled
8. ⚠️ **PENDING** - Test encryption/decryption with FIPS mode enabled
- Verify functionality in FIPS-enabled environment
### Phase 3: Verification (Testing & Validation) - ⚠️ PENDING
9. ⚠️ **PENDING** - Add FIPS compliance tests
10. ⚠️ **PENDING** - Verify all cryptographic operations use FIPS-validated algorithms
11. ⚠️ **PENDING** - Document FIPS configuration requirements
## Code Migration Example
### Before (Non-FIPS Compliant):
```python
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
def encrypt(plain_text: str):
key = derive_key()
iv = get_random_bytes(12)
cipher = AES.new(key, AES.MODE_GCM, nonce=iv)
ciphertext, tag = cipher.encrypt_and_digest(plain_text.encode())
return {
"iv": base64.b64encode(iv).decode(),
"ciphertext": base64.b64encode(ciphertext).decode(),
"tag": base64.b64encode(tag).decode(),
}
```
### After (FIPS Compliant) - ✅ IMPLEMENTED:
```python
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import secrets
import base64
def encrypt(plain_text: str):
key = derive_key()
aesgcm = AESGCM(key)
nonce = secrets.token_bytes(12) # 12 bytes for GCM (FIPS-compliant random generation)
# AESGCM.encrypt returns ciphertext + tag (16 bytes) concatenated
encrypted = aesgcm.encrypt(nonce, plain_text.encode(), None)
# Split ciphertext and tag (last 16 bytes are the tag)
ciphertext = encrypted[:-16]
tag = encrypted[-16:]
return {
"iv": base64.b64encode(nonce).decode(),
"ciphertext": base64.b64encode(ciphertext).decode(),
"tag": base64.b64encode(tag).decode(),
}
def decrypt(encrypted_data: dict):
key = derive_key()
aesgcm = AESGCM(key)
nonce = base64.b64decode(encrypted_data["iv"])
ciphertext = base64.b64decode(encrypted_data["ciphertext"])
tag = base64.b64decode(encrypted_data["tag"])
# Reconstruct: ciphertext + tag for AESGCM.decrypt()
encrypted = ciphertext + tag
return aesgcm.decrypt(nonce, encrypted, None).decode()
```
## Testing Requirements
### Completed Verification:
1. ✅ **VERIFIED** - Encryption/decryption works correctly with new implementation
- Code tested and confirmed working with `cryptography` library
- Maintains same data format (iv, ciphertext, tag) for backward compatibility
### Pending Verification:
2. ⚠️ **PENDING** - Backward compatibility with existing encrypted data
- Need to verify that data encrypted with old `pycryptodome` can be decrypted with new implementation
- Note: This may require migration if data format differs
3. ⚠️ **PENDING** - FIPS mode is actually enabled and enforced
- Verify in FIPS-enabled environment
4. ⚠️ **PENDING** - No fallback to non-FIPS algorithms
- Verify cryptography library uses FIPS-validated algorithms only
5. ⚠️ **PENDING** - All tests pass with FIPS mode enabled
- Run full test suite in FIPS-enabled environment
## Summary
### ✅ Completed Changes:
- Removed all `pycryptodome` dependencies
- Replaced with FIPS-compliant `cryptography` library using `AESGCM`
- Updated all encryption/decryption functions in:
- `apps/api/plane/utils/encryption.py`
- `apps/pi/pi/app/utils/encryption.py`
- Code tested and verified working
### ⚠️ Remaining Tasks:
- Configure Dockerfiles for FIPS mode
- Verify FIPS mode enforcement at runtime
- Test backward compatibility with existing encrypted data
- Add FIPS compliance tests
## Notes
- **Node.js `crypto` module:** Already FIPS-compliant when Node.js is built with FIPS support. No changes needed for TypeScript/JavaScript code using built-in `crypto`.
- **Python `hashlib`:** Uses OpenSSL when available, so FIPS-compliant if OpenSSL is FIPS-enabled. No changes needed.
- **Go standard library:** FIPS-compliant when using FIPS-validated system libraries. No changes needed.
- **`cryptography` library:** The code now uses `cryptography.hazmat.primitives.ciphers.aead.AESGCM` which is FIPS-compliant when the library is built against FIPS-enabled OpenSSL (as provided in Red Hat UBI images).
+653 -36
View File
@@ -1,44 +1,661 @@
The Plane Commercial License (the "Commercial License")
Copyright (c) 2023-present Plane Software, Inc. All rights reserved.
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
With regard to the Plane Software:
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This software and associated documentation files (the "Software") may only be
used in production if you (and any entity that you represent) have agreed to,
and are in compliance with, the Plane End User License Agreement available at
https://plane.so/legals/eula (the "EULA"), or other agreements governing the
use of the Software, as mutually agreed by you and Plane Software, Inc.
("Plane"), and otherwise have a valid Plane Enterprise subscription or other
commercial entitlement for the correct number of seats (the "Commercial Terms").
Preamble
Subject to the foregoing paragraph, you are free to modify this Software and
publish patches to the Software. You agree that Plane and/or its licensors (as
applicable) retain all right, title and interest in and to all such
modifications and/or patches, and all such modifications and/or patches may
only be used, copied, modified, displayed, distributed, or otherwise exploited
in accordance with the EULA and the applicable Commercial Terms.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
Notwithstanding the foregoing, you may copy and modify the Software for
development and testing purposes without requiring a subscription, provided
that such use is non-production and internal. You agree that Plane and/or its
licensors (as applicable) retain all right, title and interest in and to all
such modifications.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
You are not granted any other rights beyond what is expressly stated herein.
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute,
sublicense, and/or sell the Software.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
The full text of this Commercial License shall be included in all copies or
substantial portions of the Software.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
For all third party components incorporated into the Plane Software, those
components are licensed under the original license provided by the owner of the
applicable component.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+4 -4
View File
@@ -10,7 +10,7 @@
<p align="center">
<a href="https://plane.so/"><b>Website</b></a> •
<a href="https://forum.plane.so"><b>Forum</b></a> •
<a href="https://twitter.com/planepowers"><b>Twitter</b></a> •
<a href="https://x.com/planepowers"><b>X</b></a> •
<a href="https://docs.plane.so/"><b>Documentation</b></a>
</p>
@@ -26,7 +26,7 @@
Meet [Plane](https://plane.so/), an open-source project management tool to track issues, run ~sprints~ cycles, and manage product roadmaps without the chaos of managing the tool itself. 🧘‍♀️
> Plane is evolving every day. Your suggestions, ideas, and reported bugs help us immensely. Do not hesitate to join in the conversation on [Discord](https://discord.com/invite/A92xrEGCge) or raise a GitHub issue. We read everything and respond to most.
> Plane is evolving every day. Your suggestions, ideas, and reported bugs help us immensely. Do not hesitate to join in the conversation on [Forum](https://forum.plane.so) or raise a GitHub issue. We read everything and respond to most.
## 🚀 Installation
@@ -129,7 +129,7 @@ Explore Plane's [product documentation](https://docs.plane.so/) and [developer d
## ❤️ Community
Join the Plane community on [GitHub Discussions](https://github.com/orgs/makeplane/discussions) and our [Discord server](https://discord.com/invite/A92xrEGCge). We follow a [Code of conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) in all our community channels.
Join the Plane community on [GitHub Discussions](https://github.com/orgs/makeplane/discussions) and our [Forum](https://forum.plane.so). We follow a [Code of conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) in all our community channels.
Feel free to ask questions, report bugs, participate in discussions, share ideas, request features, or showcase your projects. Wed love to hear from you!
@@ -145,7 +145,7 @@ There are many ways you can contribute to Plane:
- Report [bugs](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%F0%9F%90%9Bbug&projects=&template=--bug-report.yaml&title=%5Bbug%5D%3A+) or submit [feature requests](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%E2%9C%A8feature&projects=&template=--feature-request.yaml&title=%5Bfeature%5D%3A+).
- Review the [documentation](https://docs.plane.so/) and submit [pull requests](https://github.com/makeplane/docs) to improve it—whether it's fixing typos or adding new content.
- Talk or write about Plane or any other ecosystem integration and [let us know](https://discord.com/invite/A92xrEGCge)!
- Talk or write about Plane or any other ecosystem integration and [let us know](https://forum.plane.so)!
- Show your support by upvoting [popular feature requests](https://github.com/makeplane/plane/issues).
Please read [CONTRIBUTING.md](https://github.com/makeplane/plane/blob/master/CONTRIBUTING.md) for details on the process for submitting pull requests to us.
-1
View File
@@ -1 +0,0 @@
Monorepo applications. See each app's `AGENTS.md` for details.
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+1 -1
View File
@@ -3,7 +3,7 @@ VITE_API_BASE_URL="http://localhost:8000"
VITE_WEB_BASE_URL="http://localhost:3000"
VITE_ADMIN_BASE_URL="http://localhost:3001"
VITE_ADMIN_BASE_PATH="/god-mode/"
VITE_ADMIN_BASE_PATH="/god-mode"
VITE_SPACE_BASE_URL="http://localhost:3002"
VITE_SPACE_BASE_PATH="/spaces"
-8
View File
@@ -1,8 +0,0 @@
# Admin App
Admin panel for Plane instance configuration (port 3001).
## Guidelines
- **TypeScript**: [docs/TYPESCRIPT.md](../../docs/TYPESCRIPT.md)
- **Design System**: [docs/DESIGN_SYSTEM.md](../../docs/DESIGN_SYSTEM.md)
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+10 -28
View File
@@ -4,7 +4,7 @@ WORKDIR /app
ENV TURBO_TELEMETRY_DISABLED=1
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
ENV CI=1
RUN corepack enable pnpm
@@ -13,8 +13,7 @@ RUN corepack enable pnpm
FROM base AS builder
ARG TURBO_VERSION=2.8.21
RUN pnpm add -g turbo@${TURBO_VERSION}
RUN pnpm add -g turbo@2.9.14
COPY . .
@@ -25,6 +24,9 @@ RUN turbo prune --scope=admin --docker
FROM base AS installer
# Build in production mode; we still install dev deps explicitly below
ENV NODE_ENV=production
# Public envs required at build time (pick up via process.env)
ARG VITE_API_BASE_URL=""
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
@@ -33,7 +35,7 @@ ENV VITE_API_BASE_PATH=$VITE_API_BASE_PATH
ARG VITE_ADMIN_BASE_URL=""
ENV VITE_ADMIN_BASE_URL=$VITE_ADMIN_BASE_URL
ARG VITE_ADMIN_BASE_PATH="/god-mode/"
ARG VITE_ADMIN_BASE_PATH="/god-mode"
ENV VITE_ADMIN_BASE_PATH=$VITE_ADMIN_BASE_PATH
ARG VITE_SPACE_BASE_URL=""
@@ -69,40 +71,20 @@ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store --prod=false
# Build only the admin package
RUN --mount=type=secret,id=TURBO_TOKEN,required=false \
--mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,required=false \
--mount=type=secret,id=SENTRY_AUTH_TOKEN,required=false \
sh -ec 'if [ -s /run/secrets/TURBO_TOKEN ]; then export TURBO_TOKEN="$(cat /run/secrets/TURBO_TOKEN)"; fi; \
if [ -s /run/secrets/TURBO_REMOTE_CACHE_SIGNATURE_KEY ]; then export TURBO_REMOTE_CACHE_SIGNATURE_KEY="$(cat /run/secrets/TURBO_REMOTE_CACHE_SIGNATURE_KEY)"; fi; \
if [ -s /run/secrets/SENTRY_AUTH_TOKEN ]; then export SENTRY_AUTH_TOKEN="$(cat /run/secrets/SENTRY_AUTH_TOKEN)"; fi; \
pnpm turbo run build --filter=admin'
RUN pnpm turbo run build --filter=admin
# =========================================================================== #
FROM nginx:1.29-alpine AS production
RUN apk update && apk upgrade --no-cache && rm -rf /var/cache/apk/*
COPY apps/admin/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=installer /app/apps/admin/build/client /usr/share/nginx/html/god-mode
COPY LICENSE.txt .
RUN mkdir -p /usr/share/licenses/plane/
COPY LICENSE.txt /usr/share/licenses/plane/LICENSE.txt
RUN addgroup -g 1000 plane && adduser -u 1000 -G plane -s /bin/sh -D plane
RUN chown -R plane:plane \
/usr/share/nginx/html \
/var/cache/nginx \
/var/log/nginx \
/etc/nginx/conf.d && \
touch /var/run/nginx.pid && \
chown plane:plane /var/run/nginx.pid
USER plane
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -fsS http://127.0.0.1:3000/ >/dev/null || exit 1
CMD ["nginx", "-g", "daemon off;"]
CMD ["nginx", "-g", "daemon off;"]
+2 -3
View File
@@ -5,11 +5,10 @@ WORKDIR /app
COPY . .
ARG TURBO_VERSION=2.8.21
RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION}
RUN corepack enable pnpm && pnpm add -g turbo
RUN pnpm install
ENV VITE_ADMIN_BASE_PATH="/god-mode/"
ENV VITE_ADMIN_BASE_PATH="/god-mode"
EXPOSE 3000
-109
View File
@@ -1,109 +0,0 @@
FROM node:22-alpine AS base
WORKDIR /app
ENV TURBO_TELEMETRY_DISABLED=1
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV CI=1
RUN corepack enable pnpm
# =========================================================================== #
FROM base AS builder
ARG TURBO_VERSION=2.8.21
RUN pnpm add -g turbo@${TURBO_VERSION}
COPY . .
# Create a pruned workspace for just the admin app
RUN turbo prune --scope=admin --docker
# =========================================================================== #
FROM base AS installer
# Build in production mode; we still install dev deps explicitly below
ENV NODE_ENV=production
# Public envs required at build time (pick up via process.env)
ARG VITE_API_BASE_URL=""
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ARG VITE_API_BASE_PATH="/api"
ENV VITE_API_BASE_PATH=$VITE_API_BASE_PATH
ARG VITE_ADMIN_BASE_URL=""
ENV VITE_ADMIN_BASE_URL=$VITE_ADMIN_BASE_URL
ARG VITE_ADMIN_BASE_PATH="/god-mode/"
ENV VITE_ADMIN_BASE_PATH=$VITE_ADMIN_BASE_PATH
ARG VITE_SPACE_BASE_URL=""
ENV VITE_SPACE_BASE_URL=$VITE_SPACE_BASE_URL
ARG VITE_SPACE_BASE_PATH="/spaces"
ENV VITE_SPACE_BASE_PATH=$VITE_SPACE_BASE_PATH
ARG VITE_LIVE_BASE_URL=""
ENV VITE_LIVE_BASE_URL=$VITE_LIVE_BASE_URL
ARG VITE_LIVE_BASE_PATH="/live"
ENV VITE_LIVE_BASE_PATH=$VITE_LIVE_BASE_PATH
ARG VITE_WEB_BASE_URL=""
ENV VITE_WEB_BASE_URL=$VITE_WEB_BASE_URL
ARG VITE_WEB_BASE_PATH=""
ENV VITE_WEB_BASE_PATH=$VITE_WEB_BASE_PATH
ARG VITE_WEBSITE_URL="https://plane.so"
ENV VITE_WEBSITE_URL=$VITE_WEBSITE_URL
ARG VITE_SUPPORT_EMAIL="support@plane.so"
ENV VITE_SUPPORT_EMAIL=$VITE_SUPPORT_EMAIL
COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
# Copy full directory structure before fetch to ensure all package.json files are available
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
# Fetch dependencies to cache store, then install offline with dev deps
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store --prod=false
# Build only the admin package
RUN --mount=type=secret,id=TURBO_TOKEN,required=false \
--mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,required=false \
--mount=type=secret,id=SENTRY_AUTH_TOKEN,required=false \
sh -ec 'if [ -s /run/secrets/TURBO_TOKEN ]; then export TURBO_TOKEN="$(cat /run/secrets/TURBO_TOKEN)"; fi; \
if [ -s /run/secrets/TURBO_REMOTE_CACHE_SIGNATURE_KEY ]; then export TURBO_REMOTE_CACHE_SIGNATURE_KEY="$(cat /run/secrets/TURBO_REMOTE_CACHE_SIGNATURE_KEY)"; fi; \
if [ -s /run/secrets/SENTRY_AUTH_TOKEN ]; then export SENTRY_AUTH_TOKEN="$(cat /run/secrets/SENTRY_AUTH_TOKEN)"; fi; \
pnpm turbo run build --filter=admin'
# =========================================================================== #
FROM registry.access.redhat.com/ubi10/nginx-126 AS production
COPY apps/admin/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=installer /app/apps/admin/build/client /usr/share/nginx/html/god-mode
USER root
RUN dnf install -y crypto-policies crypto-policies-scripts && dnf clean all
RUN update-crypto-policies --set FIPS
COPY LICENSE.txt .
RUN mkdir -p /usr/share/licenses/plane/
COPY LICENSE.txt /usr/share/licenses/plane/LICENSE.txt
RUN mkdir -p /var/lib/nginx/tmp/client_body /var/log/nginx /var/cache/nginx && \
chown -R nginx:nginx /var/lib/nginx /var/log/nginx /var/cache/nginx /run
USER nginx
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -fsS http://127.0.0.1:3000/ >/dev/null || exit 1
CMD ["nginx", "-g", "daemon off;"]
@@ -1,147 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { useForm } from "react-hook-form";
import { Lightbulb } from "lucide-react";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@plane/types";
// components
import type { TControllerInputFormField } from "@/components/common/controller-input";
import { ControllerInput } from "@/components/common/controller-input";
// hooks
import { useInstance } from "@/hooks/store";
type IInstanceAIForm = {
config: IFormattedInstanceConfiguration;
};
type AIFormValues = Record<TInstanceAIConfigurationKeys, string>;
export function InstanceAIForm(props: IInstanceAIForm) {
const { config } = props;
// store
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
formState: { errors, isSubmitting },
} = useForm<AIFormValues>({
defaultValues: {
LLM_API_KEY: config["LLM_API_KEY"],
LLM_MODEL: config["LLM_MODEL"],
},
});
const aiFormFields: TControllerInputFormField[] = [
{
key: "LLM_MODEL",
type: "text",
label: "LLM Model",
description: (
<>
Choose an OpenAI engine.{" "}
<a
href="https://platform.openai.com/docs/models/overview"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
Learn more
</a>
</>
),
placeholder: "gpt-4o-mini",
error: Boolean(errors.LLM_MODEL),
required: false,
},
{
key: "LLM_API_KEY",
type: "password",
label: "API key",
description: (
<>
You will find your API key{" "}
<a
href="https://platform.openai.com/api-keys"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
here.
</a>
</>
),
placeholder: "sk-asddassdfasdefqsdfasd23das3dasdcasd",
error: Boolean(errors.LLM_API_KEY),
required: false,
},
];
const onSubmit = async (formData: AIFormValues) => {
const payload: Partial<AIFormValues> = { ...formData };
await updateInstanceConfigurations(payload)
.then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success",
message: "AI Settings updated successfully",
})
)
.catch((err) => console.error(err));
};
return (
<div className="space-y-8">
<div className="space-y-3">
<div>
<div className="pb-1 text-18 font-medium text-primary">OpenAI</div>
<div className="text-13 font-regular text-tertiary">If you use ChatGPT, this is for you.</div>
</div>
<div className="grid-col grid w-full grid-cols-1 items-center justify-between gap-x-12 gap-y-8 lg:grid-cols-3">
{aiFormFields.map((field) => (
<ControllerInput
key={field.key}
control={control}
type={field.type}
name={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
error={field.error}
required={field.required}
/>
))}
</div>
</div>
<div className="flex flex-col gap-4 items-start">
<Button variant="primary" size="lg" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving" : "Save changes"}
</Button>
<div className="relative inline-flex items-center gap-1.5 rounded-sm border border-accent-subtle bg-accent-subtle px-4 py-2 text-caption-sm-regular text-accent-secondary ">
<Lightbulb className="size-4" />
<div>
If you have a preferred AI models vendor, please get in{" "}
<a className="underline font-medium" href="https://plane.so/contact">
touch with us.
</a>
</div>
</div>
</div>
</div>
);
}
@@ -1,64 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { observer } from "mobx-react";
import useSWR from "swr";
import { Loader } from "@plane/ui";
// components
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { useInstance } from "@/hooks/store";
// types
import type { Route } from "./+types/page";
// local
import { InstanceAIForm } from "./form";
import { TriangleAlert } from "lucide-react";
const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentProps) {
// store
const { fetchInstanceConfigurations, formattedConfig } = useInstance();
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
return (
<PageWrapper
header={{
title: "AI features for all your workspaces",
description: "Configure your AI API credentials so Plane AI features are turned on for all your workspaces.",
}}
banner={
<div className="relative inline-flex items-center gap-1.5 rounded-sm border border-warning-subtle bg-warning-subtle px-4 py-2 text-caption-sm-regular text-warning-primary">
<TriangleAlert className="size-4" />
<div>These features will be deprecated in the next release, please configure Plane AI.</div>
</div>
}
>
{formattedConfig ? (
<InstanceAIForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="40%" />
<div className="w-2/3 grid grid-cols-2 gap-x-8 gap-y-4">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</div>
<Loader.Item height="50px" width="20%" />
</Loader>
)}
</PageWrapper>
);
});
export const meta: Route.MetaFunction = () => [{ title: "Artificial Intelligence Settings - God Mode" }];
export default InstanceAIPage;
@@ -1,259 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { useState } from "react";
import { isEmpty } from "lodash-es";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { Monitor, ShieldUser } from "lucide-react";
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceGiteaAuthenticationConfigurationKeys } from "@plane/types";
// components
import { CodeBlock } from "@/components/common/code-block";
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
import type { TControllerInputFormField } from "@/components/common/controller-input";
import { ControllerInput } from "@/components/common/controller-input";
import type { TControllerSwitchFormField } from "@/components/common/controller-switch";
import { ControllerSwitch } from "@/components/common/controller-switch";
import type { TCopyField } from "@/components/common/copy-field";
import { ServiceDetailsSection } from "@/components/authentication/service-details-section";
// hooks
import { useInstance } from "@/hooks/store";
type Props = {
config: IFormattedInstanceConfiguration;
};
type GiteaConfigFormValues = Record<TInstanceGiteaAuthenticationConfigurationKeys, string>;
export function InstanceGiteaConfigForm(props: Props) {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
// store hooks
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
reset,
formState: { errors, isDirty, isSubmitting },
} = useForm<GiteaConfigFormValues>({
defaultValues: {
GITEA_HOST: config["GITEA_HOST"] || "https://gitea.com",
GITEA_CLIENT_ID: config["GITEA_CLIENT_ID"],
GITEA_CLIENT_SECRET: config["GITEA_CLIENT_SECRET"],
ENABLE_GITEA_SYNC: config["ENABLE_GITEA_SYNC"] || "0",
},
});
const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : "";
const GITEA_FORM_FIELDS: TControllerInputFormField[] = [
{
key: "GITEA_HOST",
type: "text",
label: "Gitea Host",
description: (
<>Use the URL of your Gitea instance. For the official Gitea instance, use &quot;https://gitea.com&quot;.</>
),
placeholder: "https://gitea.com",
error: Boolean(errors.GITEA_HOST),
required: true,
},
{
key: "GITEA_CLIENT_ID",
type: "text",
label: "Client ID",
description: (
<>
You will get this from your{" "}
<a
tabIndex={-1}
href="https://gitea.com/user/settings/applications"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
Gitea OAuth application settings.
</a>
</>
),
placeholder: "70a44354520df8bd9bcd",
error: Boolean(errors.GITEA_CLIENT_ID),
required: true,
},
{
key: "GITEA_CLIENT_SECRET",
type: "password",
label: "Client secret",
description: (
<>
Your client secret is also found in your{" "}
<a
tabIndex={-1}
href="https://gitea.com/user/settings/applications"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
Gitea OAuth application settings.
</a>
</>
),
placeholder: "9b0050f94ec1b744e32ce79ea4ffacd40d4119cb",
error: Boolean(errors.GITEA_CLIENT_SECRET),
required: true,
},
];
const GITEA_FORM_SWITCH_FIELD: TControllerSwitchFormField<GiteaConfigFormValues> = {
name: "ENABLE_GITEA_SYNC",
label: "Gitea",
};
const GITEA_SERVICE_DETAILS: TCopyField[] = [
{
key: "Callback_URI",
label: "Callback URI",
url: `${originURL}/auth/gitea/callback/`,
description: (
<>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Callback URI</CodeBlock>{" "}
field{" "}
<a
tabIndex={-1}
href={`${control._formValues.GITEA_HOST || "https://gitea.com"}/user/settings/applications`}
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
here.
</a>
</>
),
},
];
const GITEA_ADMIN_SERVICE_DETAILS: TCopyField[] = [
{
key: "admin_callback_uri",
label: "Callback URI",
url: `${originURL}/api/instances/admin/gitea/callback/`,
description: (
<>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Callback URI</CodeBlock>{" "}
field{" "}
<a
tabIndex={-1}
href={`${control._formValues.GITEA_HOST || "https://gitea.com"}/user/settings/applications`}
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
here.
</a>
</>
),
},
];
const onSubmit = async (formData: GiteaConfigFormValues) => {
const payload: Partial<GiteaConfigFormValues> = { ...formData };
try {
const response = await updateInstanceConfigurations(payload);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Done!",
message: "Your Gitea authentication is configured. You should test it now.",
});
reset({
GITEA_HOST: response.find((item) => item.key === "GITEA_HOST")?.value,
GITEA_CLIENT_ID: response.find((item) => item.key === "GITEA_CLIENT_ID")?.value,
GITEA_CLIENT_SECRET: response.find((item) => item.key === "GITEA_CLIENT_SECRET")?.value,
ENABLE_GITEA_SYNC: response.find((item) => item.key === "ENABLE_GITEA_SYNC")?.value,
});
} catch (err) {
console.error(err);
}
};
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
if (isDirty) {
e.preventDefault();
setIsDiscardChangesModalOpen(true);
}
};
return (
<>
<ConfirmDiscardModal
isOpen={isDiscardChangesModalOpen}
onDiscardHref="/authentication"
handleClose={() => setIsDiscardChangesModalOpen(false)}
/>
<div className="flex flex-col gap-8">
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1 pt-1">
<div className="pt-2.5 text-18 font-medium">Gitea-provided details for Plane</div>
{GITEA_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
control={control}
type={field.type}
name={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
error={field.error}
required={field.required}
/>
))}
<ControllerSwitch control={control} field={GITEA_FORM_SWITCH_FIELD} />
<div className="flex flex-col gap-1 pt-4">
<div className="flex items-center gap-4">
<Button
variant="primary"
size="lg"
onClick={(e) => void handleSubmit(onSubmit)(e)}
loading={isSubmitting}
disabled={!isDirty}
>
{isSubmitting ? "Saving" : "Save changes"}
</Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
Go back
</Link>
</div>
</div>
</div>
<div className="col-span-2 md:col-span-1 flex flex-col gap-y-6">
<div className="pt-2 text-18 font-medium">Plane-provided details for Gitea</div>
<div className="flex flex-col gap-y-4">
{/* web service details */}
<ServiceDetailsSection icon={Monitor} title="Web" fields={GITEA_SERVICE_DETAILS} />
{/* admin service details */}
<ServiceDetailsSection icon={ShieldUser} title="Admin" fields={GITEA_ADMIN_SERVICE_DETAILS} />
</div>
</div>
</div>
</div>
</>
);
}
@@ -1,112 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// plane internal packages
import { setPromiseToast } from "@plane/propel/toast";
import { Switch } from "@plane/propel/switch";
import { Loader } from "@plane/ui";
// assets
import giteaLogo from "@/app/assets/logos/gitea-logo.svg?url";
// components
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { useInstance } from "@/hooks/store";
// types
import type { Route } from "./+types/page";
// local
import { InstanceGiteaConfigForm } from "./form";
const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthenticationPage() {
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// state
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// config
const enableGiteaConfig = formattedConfig?.IS_GITEA_ENABLED ?? "";
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
const updateConfig = async (key: "IS_GITEA_ENABLED", value: string) => {
setIsSubmitting(true);
const payload = {
[key]: value,
};
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration",
success: {
title: "Configuration saved",
message: () => `Gitea authentication is now ${value === "1" ? "active" : "disabled"}.`,
},
error: {
title: "Error",
message: () => "Failed to save configuration",
},
});
await updateConfigPromise
.then(() => {
setIsSubmitting(false);
})
.catch((err) => {
console.error(err);
setIsSubmitting(false);
});
};
const isGiteaEnabled = enableGiteaConfig === "1";
return (
<PageWrapper
customHeader={
<AuthenticationMethodCard
name="Gitea"
description="Allow members to login or sign up to plane with their Gitea accounts."
icon={<img src={giteaLogo} height={24} width={24} alt="Gitea Logo" />}
config={
<Switch
value={isGiteaEnabled}
onChange={() => {
updateConfig("IS_GITEA_ENABLED", isGiteaEnabled ? "0" : "1");
}}
disabled={isSubmitting || !formattedConfig}
/>
}
disabled={isSubmitting || !formattedConfig}
withBorder={false}
/>
}
>
{formattedConfig ? (
<InstanceGiteaConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="25%" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" width="50%" />
</Loader>
)}
</PageWrapper>
);
});
export const meta: Route.MetaFunction = () => [{ title: "Gitea Authentication - God Mode" }];
export default InstanceGiteaAuthenticationPage;
@@ -1,311 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { useState } from "react";
import { isEmpty } from "lodash-es";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { Monitor, Smartphone, ShieldUser } from "lucide-react";
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types";
// components
import { CodeBlock } from "@/components/common/code-block";
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
import type { TControllerInputFormField } from "@/components/common/controller-input";
import type { TControllerSwitchFormField } from "@/components/common/controller-switch";
import { ControllerSwitch } from "@/components/common/controller-switch";
import { ControllerInput } from "@/components/common/controller-input";
import type { TCopyField } from "@/components/common/copy-field";
import { CopyField } from "@/components/common/copy-field";
import { ServiceDetailsSection } from "@/components/authentication/service-details-section";
// hooks
import { useInstance } from "@/hooks/store";
type Props = {
config: IFormattedInstanceConfiguration;
};
type GithubConfigFormValues = Record<TInstanceGithubAuthenticationConfigurationKeys, string>;
export function InstanceGithubConfigForm(props: Props) {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
// store hooks
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
reset,
formState: { errors, isDirty, isSubmitting },
} = useForm<GithubConfigFormValues>({
defaultValues: {
GITHUB_CLIENT_ID: config["GITHUB_CLIENT_ID"],
GITHUB_CLIENT_SECRET: config["GITHUB_CLIENT_SECRET"],
GITHUB_ORGANIZATION_ID: config["GITHUB_ORGANIZATION_ID"],
ENABLE_GITHUB_SYNC: config["ENABLE_GITHUB_SYNC"] || "0",
},
});
const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : "";
const GITHUB_FORM_FIELDS: TControllerInputFormField[] = [
{
key: "GITHUB_CLIENT_ID",
type: "text",
label: "Client ID",
description: (
<>
You will get this from your{" "}
<a
tabIndex={-1}
href="https://github.com/settings/applications/new"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
GitHub OAuth application settings.
</a>
</>
),
placeholder: "70a44354520df8bd9bcd",
error: Boolean(errors.GITHUB_CLIENT_ID),
required: true,
},
{
key: "GITHUB_CLIENT_SECRET",
type: "password",
label: "Client secret",
description: (
<>
Your client secret is also found in your{" "}
<a
tabIndex={-1}
href="https://github.com/settings/applications/new"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
GitHub OAuth application settings.
</a>
</>
),
placeholder: "9b0050f94ec1b744e32ce79ea4ffacd40d4119cb",
error: Boolean(errors.GITHUB_CLIENT_SECRET),
required: true,
},
{
key: "GITHUB_ORGANIZATION_ID",
type: "text",
label: "Organization ID",
description: <>The organization github ID.</>,
placeholder: "123456789",
error: Boolean(errors.GITHUB_ORGANIZATION_ID),
required: false,
},
];
const GITHUB_FORM_SWITCH_FIELD: TControllerSwitchFormField<GithubConfigFormValues> = {
name: "ENABLE_GITHUB_SYNC",
label: "GitHub",
};
const GITHUB_COMMON_SERVICE_DETAILS: TCopyField[] = [
{
key: "Origin_URL",
label: "Origin URL",
url: originURL,
description: (
<>
We will auto-generate this. Paste this into the <CodeBlock darkerShade>Authorized origin URL</CodeBlock> field{" "}
<a
tabIndex={-1}
href="https://github.com/settings/applications/new"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
here.
</a>
</>
),
},
];
const GITHUB_SERVICE_DETAILS: TCopyField[] = [
{
key: "Callback_URI",
label: "Callback URI",
url: `${originURL}/auth/github/callback/`,
description: (
<>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Callback URI</CodeBlock>{" "}
field{" "}
<a
tabIndex={-1}
href="https://github.com/settings/applications/new"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
here.
</a>
</>
),
},
];
const GITHUB_MOBILE_SERVICE_DETAILS: TCopyField[] = [
{
key: "mobile_callback_uri",
label: "Callback URI",
url: `${originURL}/auth/mobile/github/callback/`,
description: (
<p>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Redirect URI</CodeBlock>{" "}
field. For this OAuth client{" "}
<a
href="https://github.com/settings/applications/new"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
here.
</a>
</p>
),
},
];
const GITHUB_ADMIN_SERVICE_DETAILS: TCopyField[] = [
{
key: "admin_callback_uri",
label: "Callback URI",
url: `${originURL}/api/instances/admin/github/callback/`,
description: (
<p>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Redirect URI</CodeBlock>{" "}
field. For this OAuth client{" "}
<a
href="https://github.com/settings/applications/new"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
here.
</a>
</p>
),
},
];
const onSubmit = async (formData: GithubConfigFormValues) => {
const payload: Partial<GithubConfigFormValues> = { ...formData };
try {
const response = await updateInstanceConfigurations(payload);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Done!",
message: "Your GitHub authentication is configured. You should test it now.",
});
reset({
GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value,
GITHUB_CLIENT_SECRET: response.find((item) => item.key === "GITHUB_CLIENT_SECRET")?.value,
GITHUB_ORGANIZATION_ID: response.find((item) => item.key === "GITHUB_ORGANIZATION_ID")?.value,
ENABLE_GITHUB_SYNC: response.find((item) => item.key === "ENABLE_GITHUB_SYNC")?.value,
});
} catch (err) {
console.error(err);
}
};
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
if (isDirty) {
e.preventDefault();
setIsDiscardChangesModalOpen(true);
}
};
return (
<>
<ConfirmDiscardModal
isOpen={isDiscardChangesModalOpen}
onDiscardHref="/authentication"
handleClose={() => setIsDiscardChangesModalOpen(false)}
/>
<div className="flex flex-col gap-8">
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1 pt-1">
<div className="pt-2.5 text-18 font-medium">GitHub-provided details for Plane</div>
{GITHUB_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
control={control}
type={field.type}
name={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
error={field.error}
required={field.required}
/>
))}
<ControllerSwitch control={control} field={GITHUB_FORM_SWITCH_FIELD} />
<div className="flex flex-col gap-1 pt-4">
<div className="flex items-center gap-4">
<Button
variant="primary"
size="lg"
onClick={(e) => void handleSubmit(onSubmit)(e)}
loading={isSubmitting}
disabled={!isDirty}
>
{isSubmitting ? "Saving" : "Save changes"}
</Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
Go back
</Link>
</div>
</div>
</div>
<div className="col-span-2 md:col-span-1 flex flex-col gap-y-6">
<div className="pt-2 text-18 font-medium">Plane-provided details for GitHub</div>
<div className="flex flex-col gap-y-4">
{/* common service details */}
<div className="flex flex-col gap-y-4 px-6 py-4 bg-layer-1 rounded-lg">
{GITHUB_COMMON_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
{/* web service details */}
<ServiceDetailsSection icon={Monitor} title="Web" fields={GITHUB_SERVICE_DETAILS} />
{/* mobile service details */}
<ServiceDetailsSection icon={Smartphone} title="Mobile" fields={GITHUB_MOBILE_SERVICE_DETAILS} />
{/* admin service details */}
<ServiceDetailsSection icon={ShieldUser} title="Admin" fields={GITHUB_ADMIN_SERVICE_DETAILS} />
</div>
</div>
</div>
</div>
</>
);
}
@@ -1,128 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import { useTheme } from "@plane/react-theme";
import useSWR from "swr";
// plane internal packages
import { setPromiseToast } from "@plane/propel/toast";
import { Switch } from "@plane/propel/switch";
import { Loader } from "@plane/ui";
import { resolveGeneralTheme } from "@plane/utils";
// assets
import githubLightModeImage from "@/app/assets/logos/github-black.png?url";
import githubDarkModeImage from "@/app/assets/logos/github-white.png?url";
// components
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { useInstance } from "@/hooks/store";
// types
import type { Route } from "./+types/page";
// local
import { InstanceGithubConfigForm } from "./form";
const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthenticationPage(
_props: Route.ComponentProps
) {
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// state
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// theme
const { resolvedTheme } = useTheme();
// config
const enableGithubConfig = formattedConfig?.IS_GITHUB_ENABLED ?? "";
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
const updateConfig = async (key: "IS_GITHUB_ENABLED", value: string) => {
setIsSubmitting(true);
const payload = {
[key]: value,
};
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration",
success: {
title: "Configuration saved",
message: () => `GitHub authentication is now ${value === "1" ? "active" : "disabled"}.`,
},
error: {
title: "Error",
message: () => "Failed to save configuration",
},
});
await updateConfigPromise
.then(() => {
setIsSubmitting(false);
})
.catch((err) => {
console.error(err);
setIsSubmitting(false);
});
};
const isGithubEnabled = enableGithubConfig === "1";
return (
<PageWrapper
customHeader={
<AuthenticationMethodCard
name="GitHub"
description="Allow members to login or sign up to plane with their GitHub accounts."
icon={
<img
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
height={24}
width={24}
alt="GitHub Logo"
/>
}
config={
<Switch
value={isGithubEnabled}
onChange={() => {
updateConfig("IS_GITHUB_ENABLED", isGithubEnabled ? "0" : "1");
}}
disabled={isSubmitting || !formattedConfig}
/>
}
disabled={isSubmitting || !formattedConfig}
withBorder={false}
/>
}
>
{formattedConfig ? (
<InstanceGithubConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="25%" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" width="50%" />
</Loader>
)}
</PageWrapper>
);
});
export const meta: Route.MetaFunction = () => [{ title: "GitHub Authentication - God Mode" }];
export default InstanceGithubAuthenticationPage;
@@ -1,264 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { useState } from "react";
import { isEmpty } from "lodash-es";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { Monitor, ShieldUser } from "lucide-react";
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types";
// components
import { CodeBlock } from "@/components/common/code-block";
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
import type { TControllerInputFormField } from "@/components/common/controller-input";
import type { TControllerSwitchFormField } from "@/components/common/controller-switch";
import { ControllerSwitch } from "@/components/common/controller-switch";
import { ControllerInput } from "@/components/common/controller-input";
import type { TCopyField } from "@/components/common/copy-field";
import { CopyField } from "@/components/common/copy-field";
import { ServiceDetailsSection } from "@/components/authentication/service-details-section";
// hooks
import { useInstance } from "@/hooks/store";
type Props = {
config: IFormattedInstanceConfiguration;
};
type GitlabConfigFormValues = Record<TInstanceGitlabAuthenticationConfigurationKeys, string>;
export function InstanceGitlabConfigForm(props: Props) {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
// store hooks
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
reset,
formState: { errors, isDirty, isSubmitting },
} = useForm<GitlabConfigFormValues>({
defaultValues: {
GITLAB_HOST: config["GITLAB_HOST"],
GITLAB_CLIENT_ID: config["GITLAB_CLIENT_ID"],
GITLAB_CLIENT_SECRET: config["GITLAB_CLIENT_SECRET"],
ENABLE_GITLAB_SYNC: config["ENABLE_GITLAB_SYNC"] || "0",
},
});
const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : "";
const GITLAB_FORM_FIELDS: TControllerInputFormField[] = [
{
key: "GITLAB_HOST",
type: "text",
label: "Host",
description: (
<>
This is either https://gitlab.com or the <CodeBlock>domain.tld</CodeBlock> where you host GitLab.
</>
),
placeholder: "https://gitlab.com",
error: Boolean(errors.GITLAB_HOST),
required: true,
},
{
key: "GITLAB_CLIENT_ID",
type: "text",
label: "Application ID",
description: (
<>
Get this from your{" "}
<a
tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
GitLab OAuth application settings
</a>
.
</>
),
placeholder: "c2ef2e7fc4e9d15aa7630f5637d59e8e4a27ff01dceebdb26b0d267b9adcf3c3",
error: Boolean(errors.GITLAB_CLIENT_ID),
required: true,
},
{
key: "GITLAB_CLIENT_SECRET",
type: "password",
label: "Secret",
description: (
<>
The client secret is also found in your{" "}
<a
tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
GitLab OAuth application settings
</a>
.
</>
),
placeholder: "gloas-f79cfa9a03c97f6ffab303177a5a6778a53c61e3914ba093412f68a9298a1b28",
error: Boolean(errors.GITLAB_CLIENT_SECRET),
required: true,
},
];
const GITLAB_FORM_SWITCH_FIELD: TControllerSwitchFormField<GitlabConfigFormValues> = {
name: "ENABLE_GITLAB_SYNC",
label: "GitLab",
};
const GITLAB_SERVICE_DETAILS: TCopyField[] = [
{
key: "Callback_URL",
label: "Callback URL",
url: `${originURL}/auth/gitlab/callback/`,
description: (
<>
We will auto-generate this. Paste this into the <CodeBlock darkerShade>Redirect URI</CodeBlock> field of your{" "}
<a
tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
GitLab OAuth application
</a>
.
</>
),
},
];
const GITLAB_ADMIN_SERVICE_DETAILS: TCopyField[] = [
{
key: "admin_callback_uri",
label: "Callback URL",
url: `${originURL}/api/instances/admin/gitlab/callback/`,
description: (
<>
We will auto-generate this. Paste this into the <CodeBlock darkerShade>Redirect URI</CodeBlock> field of your{" "}
<a
tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
GitLab OAuth application
</a>
.
</>
),
},
];
const onSubmit = async (formData: GitlabConfigFormValues) => {
const payload: Partial<GitlabConfigFormValues> = { ...formData };
try {
const response = await updateInstanceConfigurations(payload);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Done!",
message: "Your GitLab authentication is configured. You should test it now.",
});
reset({
GITLAB_HOST: response.find((item) => item.key === "GITLAB_HOST")?.value,
GITLAB_CLIENT_ID: response.find((item) => item.key === "GITLAB_CLIENT_ID")?.value,
GITLAB_CLIENT_SECRET: response.find((item) => item.key === "GITLAB_CLIENT_SECRET")?.value,
ENABLE_GITLAB_SYNC: response.find((item) => item.key === "ENABLE_GITLAB_SYNC")?.value,
});
} catch (err) {
console.error(err);
}
};
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
if (isDirty) {
e.preventDefault();
setIsDiscardChangesModalOpen(true);
}
};
return (
<>
<ConfirmDiscardModal
isOpen={isDiscardChangesModalOpen}
onDiscardHref="/authentication"
handleClose={() => setIsDiscardChangesModalOpen(false)}
/>
<div className="flex flex-col gap-8">
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1 pt-1">
<div className="pt-2.5 text-18 font-medium">GitLab-provided details for Plane</div>
{GITLAB_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
control={control}
type={field.type}
name={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
error={field.error}
required={field.required}
/>
))}
<ControllerSwitch control={control} field={GITLAB_FORM_SWITCH_FIELD} />
<div className="flex flex-col gap-1 pt-4">
<div className="flex items-center gap-4">
<Button
variant="primary"
size="lg"
onClick={(e) => void handleSubmit(onSubmit)(e)}
loading={isSubmitting}
disabled={!isDirty}
>
{isSubmitting ? "Saving" : "Save changes"}
</Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
Go back
</Link>
</div>
</div>
</div>
<div className="col-span-2 md:col-span-1 flex flex-col gap-y-6">
<div className="pt-2 text-18 font-medium">Plane-provided details for GitLab</div>
<div className="flex flex-col gap-y-4">
{/* web service details */}
<ServiceDetailsSection icon={Monitor} title="Web" fields={GITLAB_SERVICE_DETAILS} />
{/* admin service details */}
<ServiceDetailsSection icon={ShieldUser} title="Admin" fields={GITLAB_ADMIN_SERVICE_DETAILS} />
</div>
</div>
</div>
</div>
</>
);
}
@@ -1,116 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { setPromiseToast } from "@plane/propel/toast";
import { Switch } from "@plane/propel/switch";
import { Loader } from "@plane/ui";
// assets
import GitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url";
// components
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { useInstance } from "@/hooks/store";
// types
import type { Route } from "./+types/page";
// local
import { InstanceGitlabConfigForm } from "./form";
const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthenticationPage(
_props: Route.ComponentProps
) {
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// state
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// config
const enableGitlabConfig = formattedConfig?.IS_GITLAB_ENABLED ?? "";
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
const updateConfig = async (key: "IS_GITLAB_ENABLED", value: string) => {
setIsSubmitting(true);
const payload = {
[key]: value,
};
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration",
success: {
title: "Configuration saved",
message: () => `GitLab authentication is now ${value === "1" ? "active" : "disabled"}.`,
},
error: {
title: "Error",
message: () => "Failed to save configuration",
},
});
await updateConfigPromise
.then(() => {
setIsSubmitting(false);
})
.catch((err) => {
console.error(err);
setIsSubmitting(false);
});
};
return (
<PageWrapper
customHeader={
<AuthenticationMethodCard
name="GitLab"
description="Allow members to login or sign up to plane with their GitLab accounts."
icon={<img src={GitlabLogo} height={24} width={24} alt="GitLab Logo" />}
config={
<Switch
value={Boolean(parseInt(enableGitlabConfig))}
onChange={() => {
if (Boolean(parseInt(enableGitlabConfig)) === true) {
updateConfig("IS_GITLAB_ENABLED", "0");
} else {
updateConfig("IS_GITLAB_ENABLED", "1");
}
}}
disabled={isSubmitting || !formattedConfig}
/>
}
disabled={isSubmitting || !formattedConfig}
withBorder={false}
/>
}
>
{formattedConfig ? (
<InstanceGitlabConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="25%" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" width="50%" />
</Loader>
)}
</PageWrapper>
);
});
export const meta: Route.MetaFunction = () => [{ title: "GitLab Authentication - God Mode" }];
export default InstanceGitlabAuthenticationPage;
@@ -1,299 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { useState } from "react";
import { isEmpty } from "lodash-es";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { Monitor, Smartphone, ShieldUser } from "lucide-react";
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types";
// components
import { CodeBlock } from "@/components/common/code-block";
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
import type { TControllerInputFormField } from "@/components/common/controller-input";
import type { TControllerSwitchFormField } from "@/components/common/controller-switch";
import { ControllerSwitch } from "@/components/common/controller-switch";
import { ControllerInput } from "@/components/common/controller-input";
import type { TCopyField } from "@/components/common/copy-field";
import { CopyField } from "@/components/common/copy-field";
import { ServiceDetailsSection } from "@/components/authentication/service-details-section";
// hooks
import { useInstance } from "@/hooks/store";
type Props = {
config: IFormattedInstanceConfiguration;
};
type GoogleConfigFormValues = Record<TInstanceGoogleAuthenticationConfigurationKeys, string>;
export function InstanceGoogleConfigForm(props: Props) {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
// store hooks
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
reset,
formState: { errors, isDirty, isSubmitting },
} = useForm<GoogleConfigFormValues>({
defaultValues: {
GOOGLE_CLIENT_ID: config["GOOGLE_CLIENT_ID"],
GOOGLE_CLIENT_SECRET: config["GOOGLE_CLIENT_SECRET"],
ENABLE_GOOGLE_SYNC: config["ENABLE_GOOGLE_SYNC"] || "0",
},
});
const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : "";
const GOOGLE_FORM_FIELDS: TControllerInputFormField[] = [
{
key: "GOOGLE_CLIENT_ID",
type: "text",
label: "Client ID",
description: (
<>
Your client ID lives in your Google API Console.{" "}
<a
tabIndex={-1}
href="https://developers.google.com/identity/protocols/oauth2/javascript-implicit-flow#creatingcred"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
Learn more
</a>
</>
),
placeholder: "840195096245-0p2tstej9j5nc4l8o1ah2dqondscqc1g.apps.googleusercontent.com",
error: Boolean(errors.GOOGLE_CLIENT_ID),
required: true,
},
{
key: "GOOGLE_CLIENT_SECRET",
type: "password",
label: "Client secret",
description: (
<>
Your client secret should also be in your Google API Console.{" "}
<a
tabIndex={-1}
href="https://developers.google.com/identity/oauth2/web/guides/get-google-api-clientid"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
Learn more
</a>
</>
),
placeholder: "GOCShX-ADp4cI0kPqav1gGCBg5bE02E",
error: Boolean(errors.GOOGLE_CLIENT_SECRET),
required: true,
},
];
const GOOGLE_FORM_SWITCH_FIELD: TControllerSwitchFormField<GoogleConfigFormValues> = {
name: "ENABLE_GOOGLE_SYNC",
label: "Google",
};
const GOOGLE_COMMON_SERVICE_DETAILS: TCopyField[] = [
{
key: "Origin_URL",
label: "Origin URL",
url: originURL,
description: (
<p>
We will auto-generate this. Paste this into your{" "}
<CodeBlock darkerShade>Authorized JavaScript origins</CodeBlock> field. For this OAuth client{" "}
<a
href="https://console.cloud.google.com/apis/credentials/oauthclient"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
here.
</a>
</p>
),
},
];
const GOOGLE_SERVICE_DETAILS: TCopyField[] = [
{
key: "Callback_URI",
label: "Callback URI",
url: `${originURL}/auth/google/callback/`,
description: (
<p>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Redirect URI</CodeBlock>{" "}
field. For this OAuth client{" "}
<a
href="https://console.cloud.google.com/apis/credentials/oauthclient"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
here.
</a>
</p>
),
},
];
const GOOGLE_MOBILE_SERVICE_DETAILS: TCopyField[] = [
{
key: "mobile_callback_uri",
label: "Callback URI",
url: `${originURL}/auth/mobile/google/callback/`,
description: (
<p>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Redirect URI</CodeBlock>{" "}
field. For this OAuth client{" "}
<a
href="https://console.cloud.google.com/apis/credentials/oauthclient"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
here.
</a>
</p>
),
},
];
const GOOGLE_ADMIN_SERVICE_DETAILS: TCopyField[] = [
{
key: "admin_callback_uri",
label: "Callback URI",
url: `${originURL}/api/instances/admin/google/callback/`,
description: (
<p>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Redirect URI</CodeBlock>{" "}
field. For this OAuth client{" "}
<a
href="https://console.cloud.google.com/apis/credentials/oauthclient"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
here.
</a>
</p>
),
},
];
const onSubmit = async (formData: GoogleConfigFormValues) => {
const payload: Partial<GoogleConfigFormValues> = { ...formData };
try {
const response = await updateInstanceConfigurations(payload);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Done!",
message: "Your Google authentication is configured. You should test it now.",
});
reset({
GOOGLE_CLIENT_ID: response.find((item) => item.key === "GOOGLE_CLIENT_ID")?.value,
GOOGLE_CLIENT_SECRET: response.find((item) => item.key === "GOOGLE_CLIENT_SECRET")?.value,
ENABLE_GOOGLE_SYNC: response.find((item) => item.key === "ENABLE_GOOGLE_SYNC")?.value,
});
} catch (err) {
console.error(err);
}
};
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
if (isDirty) {
e.preventDefault();
setIsDiscardChangesModalOpen(true);
}
};
return (
<>
<ConfirmDiscardModal
isOpen={isDiscardChangesModalOpen}
onDiscardHref="/authentication"
handleClose={() => setIsDiscardChangesModalOpen(false)}
/>
<div className="flex flex-col gap-8">
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1 pt-1">
<div className="pt-2.5 text-18 font-medium">Google-provided details for Plane</div>
{GOOGLE_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
control={control}
type={field.type}
name={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
error={field.error}
required={field.required}
/>
))}
<ControllerSwitch control={control} field={GOOGLE_FORM_SWITCH_FIELD} />
<div className="flex flex-col gap-1 pt-4">
<div className="flex items-center gap-4">
<Button
variant="primary"
size="lg"
onClick={(e) => void handleSubmit(onSubmit)(e)}
loading={isSubmitting}
disabled={!isDirty}
>
{isSubmitting ? "Saving" : "Save changes"}
</Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
Go back
</Link>
</div>
</div>
</div>
<div className="col-span-2 md:col-span-1 flex flex-col gap-y-6">
<div className="pt-2 text-18 font-medium">Plane-provided details for Google</div>
<div className="flex flex-col gap-y-4">
{/* common service details */}
<div className="flex flex-col gap-y-4 px-6 py-4 bg-layer-1 rounded-lg">
{GOOGLE_COMMON_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
{/* web service details */}
<ServiceDetailsSection icon={Monitor} title="Web" fields={GOOGLE_SERVICE_DETAILS} />
{/* mobile service details */}
<ServiceDetailsSection icon={Smartphone} title="Mobile" fields={GOOGLE_MOBILE_SERVICE_DETAILS} />
{/* admin service details */}
<ServiceDetailsSection icon={ShieldUser} title="Admin" fields={GOOGLE_ADMIN_SERVICE_DETAILS} />
</div>
</div>
</div>
</div>
</>
);
}
@@ -1,117 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { setPromiseToast } from "@plane/propel/toast";
import { Switch } from "@plane/propel/switch";
import { Loader } from "@plane/ui";
// assets
import GoogleLogo from "@/app/assets/logos/google-logo.svg?url";
// components
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { useInstance } from "@/hooks/store";
// types
import type { Route } from "./+types/page";
// local
import { InstanceGoogleConfigForm } from "./form";
const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthenticationPage(
_props: Route.ComponentProps
) {
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// state
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// config
const enableGoogleConfig = formattedConfig?.IS_GOOGLE_ENABLED ?? "";
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
const updateConfig = async (key: "IS_GOOGLE_ENABLED", value: string) => {
setIsSubmitting(true);
const payload = {
[key]: value,
};
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration",
success: {
title: "Configuration saved",
message: () => `Google authentication is now ${value === "1" ? "active" : "disabled"}.`,
},
error: {
title: "Error",
message: () => "Failed to save configuration",
},
});
await updateConfigPromise
.then(() => {
setIsSubmitting(false);
})
.catch((err) => {
console.error(err);
setIsSubmitting(false);
});
};
return (
<PageWrapper
customHeader={
<AuthenticationMethodCard
name="Google"
description="Allow members to login or sign up to plane with their Google
accounts."
icon={<img src={GoogleLogo} height={24} width={24} alt="Google Logo" />}
config={
<Switch
value={Boolean(parseInt(enableGoogleConfig))}
onChange={() => {
if (Boolean(parseInt(enableGoogleConfig)) === true) {
updateConfig("IS_GOOGLE_ENABLED", "0");
} else {
updateConfig("IS_GOOGLE_ENABLED", "1");
}
}}
disabled={isSubmitting || !formattedConfig}
/>
}
disabled={isSubmitting || !formattedConfig}
withBorder={false}
/>
}
>
{formattedConfig ? (
<InstanceGoogleConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="25%" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" width="50%" />
</Loader>
)}
</PageWrapper>
);
});
export const meta: Route.MetaFunction = () => [{ title: "Google Authentication - God Mode" }];
export default InstanceGoogleAuthenticationPage;
@@ -1,265 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import type { FC } from "react";
import { useState } from "react";
import Link from "next/link";
import { useForm } from "react-hook-form";
// plane internal packages
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceLDAPAuthenticationConfigurationKeys } from "@plane/types";
import { Button, getButtonStyling } from "@plane/propel/button";
// components
import { CodeBlock } from "@/components/common/code-block";
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
import type { TControllerInputFormField } from "@/components/common/controller-input";
import { ControllerInput } from "@/components/common/controller-input";
// hooks
import { useInstance } from "@/hooks/store";
import { Monitor, ShieldUser } from "lucide-react";
import type { TCopyField } from "@/components/common/copy-field";
import { ServiceDetailsSection } from "@/components/authentication/service-details-section";
type Props = {
config: IFormattedInstanceConfiguration;
};
type LDAPConfigFormValues = Record<TInstanceLDAPAuthenticationConfigurationKeys, string>;
export const InstanceLDAPConfigForm: FC<Props> = (props) => {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
// store hooks
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
reset,
formState: { errors, isDirty, isSubmitting },
} = useForm<LDAPConfigFormValues>({
defaultValues: {
LDAP_SERVER_URI: config["LDAP_SERVER_URI"],
LDAP_BIND_DN: config["LDAP_BIND_DN"],
LDAP_BIND_PASSWORD: config["LDAP_BIND_PASSWORD"],
LDAP_USER_SEARCH_BASE: config["LDAP_USER_SEARCH_BASE"],
LDAP_USER_SEARCH_FILTER: config["LDAP_USER_SEARCH_FILTER"],
LDAP_GROUP_SYNC_SEARCH_FILTER: config["LDAP_GROUP_SYNC_SEARCH_FILTER"],
LDAP_USER_ATTRIBUTES: config["LDAP_USER_ATTRIBUTES"],
LDAP_PROVIDER_NAME: config["LDAP_PROVIDER_NAME"],
},
});
const originURL = typeof window !== "undefined" ? window.location.origin : "";
const LDAP_FORM_FIELDS: TControllerInputFormField[] = [
{
key: "LDAP_SERVER_URI",
type: "text",
label: "Server URI",
description: "The URI of your LDAP server (e.g., ldap://ldap.example.com:389)",
placeholder: "ldap://ldap.example.com:389",
error: Boolean(errors.LDAP_SERVER_URI),
required: true,
},
{
key: "LDAP_BIND_DN",
type: "text",
label: "Bind DN",
description: "The distinguished name to bind to the LDAP server",
placeholder: "cn=admin,dc=example,dc=com",
error: Boolean(errors.LDAP_BIND_DN),
required: true,
},
{
key: "LDAP_BIND_PASSWORD",
type: "password",
label: "Bind Password",
description: "The password for the bind DN",
placeholder: "Enter bind password",
error: Boolean(errors.LDAP_BIND_PASSWORD),
required: true,
},
{
key: "LDAP_USER_SEARCH_BASE",
type: "text",
label: "User Search Base",
description: "The base DN to search for users",
placeholder: "ou=users,dc=example,dc=com",
error: Boolean(errors.LDAP_USER_SEARCH_BASE),
required: true,
},
{
key: "LDAP_USER_SEARCH_FILTER",
type: "text",
label: "User Search Filter",
description: "The LDAP filter to find users (e.g., (uid={username}))",
placeholder: "(uid={username})",
error: Boolean(errors.LDAP_USER_SEARCH_FILTER),
required: false,
},
{
key: "LDAP_GROUP_SYNC_SEARCH_FILTER",
type: "text",
label: "Group Sync Search Filter",
description: "The LDAP filter used to find users during background group sync (e.g., (mail={email}))",
placeholder: "(mail={email})",
error: Boolean(errors.LDAP_GROUP_SYNC_SEARCH_FILTER),
required: false,
},
{
key: "LDAP_USER_ATTRIBUTES",
type: "text",
label: "User Attributes",
description: "Comma-separated list of attributes to retrieve (e.g., uid,cn,mail)",
placeholder: "uid,cn,mail,displayName",
error: Boolean(errors.LDAP_USER_ATTRIBUTES),
required: false,
},
{
key: "LDAP_PROVIDER_NAME",
type: "text",
label: "Provider's name",
description: (
<>
Optional field for the name that your users see on the <CodeBlock>Continue with</CodeBlock> button
</>
),
placeholder: "LDAP",
error: Boolean(errors.LDAP_PROVIDER_NAME),
required: false,
},
];
const LDAP_SERVICE_DETAILS: TCopyField[] = [
{
key: "Authentication_Endpoint",
label: "Authentication Endpoint",
url: `${originURL}/auth/ldap/`,
description: (
<>
The endpoint where users authenticate with their LDAP credentials. Ensure your LDAP server is accessible from
Plane. Standard ports: <CodeBlock darkerShade>389</CodeBlock> for LDAP, <CodeBlock darkerShade>636</CodeBlock>{" "}
for LDAPS (LDAP over SSL).
</>
),
},
];
// const LDAP_MOBILE_SERVICE_DETAILS: TCopyField[] = [];
const LDAP_ADMIN_SERVICE_DETAILS: TCopyField[] = [
{
key: "admin_authentication_endpoint",
label: "Authentication Endpoint",
url: `${originURL}/api/instances/admin/ldap/`,
description: (
<>
The endpoint where admins authenticate with their LDAP credentials. Ensure your LDAP server is accessible from
Plane. Standard ports: <CodeBlock darkerShade>389</CodeBlock> for LDAP, <CodeBlock darkerShade>636</CodeBlock>{" "}
for LDAPS (LDAP over SSL).
</>
),
},
];
const onSubmit = async (formData: LDAPConfigFormValues) => {
const payload: Partial<LDAPConfigFormValues> = { ...formData };
await updateInstanceConfigurations(payload)
.then((response = []) => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Done!",
message: "Your LDAP-based authentication is configured. You should test it now.",
});
reset({
LDAP_SERVER_URI: response.find((item) => item.key === "LDAP_SERVER_URI")?.value,
LDAP_BIND_DN: response.find((item) => item.key === "LDAP_BIND_DN")?.value,
LDAP_BIND_PASSWORD: response.find((item) => item.key === "LDAP_BIND_PASSWORD")?.value,
LDAP_USER_SEARCH_BASE: response.find((item) => item.key === "LDAP_USER_SEARCH_BASE")?.value,
LDAP_USER_SEARCH_FILTER: response.find((item) => item.key === "LDAP_USER_SEARCH_FILTER")?.value,
LDAP_GROUP_SYNC_SEARCH_FILTER: response.find((item) => item.key === "LDAP_GROUP_SYNC_SEARCH_FILTER")?.value,
LDAP_USER_ATTRIBUTES: response.find((item) => item.key === "LDAP_USER_ATTRIBUTES")?.value,
LDAP_PROVIDER_NAME: response.find((item) => item.key === "LDAP_PROVIDER_NAME")?.value,
});
})
.catch((err) => console.error(err));
};
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
if (isDirty) {
e.preventDefault();
setIsDiscardChangesModalOpen(true);
}
};
return (
<>
<ConfirmDiscardModal
isOpen={isDiscardChangesModalOpen}
onDiscardHref="/authentication"
handleClose={() => setIsDiscardChangesModalOpen(false)}
/>
<div className="flex flex-col gap-8">
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1 pt-1">
<div className="pt-2.5 text-18 font-medium">IdP-provided details for Plane</div>
{LDAP_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
control={control}
type={field.type}
name={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
error={field.error}
required={field.required}
/>
))}
<div className="flex flex-col gap-1 pt-4">
<div className="flex items-center gap-4">
<Button
variant="primary"
size="lg"
onClick={(e) => {
void handleSubmit(onSubmit)(e);
}}
loading={isSubmitting}
disabled={!isDirty}
>
{isSubmitting ? "Saving" : "Save changes"}
</Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
Go back
</Link>
</div>
</div>
</div>
<div className="col-span-2 md:col-span-1 flex flex-col gap-y-6">
<div className="pt-2 text-18 font-medium">Plane-provided details for your IdP</div>
<div className="flex flex-col gap-y-4">
{/* web service details */}
<ServiceDetailsSection icon={Monitor} title="Web" fields={LDAP_SERVICE_DETAILS} />
{/* admin service details */}
<ServiceDetailsSection icon={ShieldUser} title="Admin" fields={LDAP_ADMIN_SERVICE_DETAILS} />
</div>
</div>
</div>
</div>
</>
);
};
@@ -1,137 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// ui
import { setPromiseToast } from "@plane/propel/toast";
import { Switch } from "@plane/propel/switch";
import { Loader } from "@plane/ui";
// assets
import ldapLogo from "@/app/assets/logos/ldap.webp?url";
// components
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
import { PageHeader } from "@/components/common/page-header";
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { useInstance } from "@/hooks/store";
// plane admin hooks
import { useInstanceFlag } from "@/plane-admin/hooks/store/use-instance-flag";
// local components
import { InstanceLDAPConfigForm } from "./form";
// types
import type { Route } from "./+types/page";
const InstanceLDAPAuthenticationPage = observer(() => {
// state
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// plane admin store
const isLDAPEnabled = useInstanceFlag("LDAP_AUTH");
// config
const enableLDAPConfig = formattedConfig?.IS_LDAP_ENABLED ?? "";
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
const updateConfig = async (key: "IS_LDAP_ENABLED", value: string) => {
setIsSubmitting(true);
const payload = {
[key]: value,
};
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration...",
success: {
title: "Configuration saved",
message: () => `LDAP authentication is now ${value ? "active" : "disabled"}.`,
},
error: {
title: "Error",
message: () => "Failed to save configuration",
},
});
await updateConfigPromise
.then(() => {
setIsSubmitting(false);
})
.catch((err) => {
console.error(err);
setIsSubmitting(false);
});
};
if (isLDAPEnabled === false) {
return (
<div className="relative container mx-auto w-full h-full p-4 py-4 my-6 space-y-6 flex flex-col">
<PageHeader title="Authentication - God Mode" />
<div className="text-center text-lg text-gray-500">
<p>LDAP authentication is not enabled for this instance.</p>
<p>Activate any of your workspace to get this feature.</p>
</div>
</div>
);
}
return (
<>
<PageHeader title="Authentication - God Mode" />
<PageWrapper
customHeader={
<AuthenticationMethodCard
name="LDAP"
description="Authenticate your users via LDAP directory services."
icon={<img src={ldapLogo} height={24} width={24} alt="LDAP Logo" />}
config={
<Switch
value={Boolean(parseInt(enableLDAPConfig))}
onChange={() => {
if (Boolean(parseInt(enableLDAPConfig)) === true) {
updateConfig("IS_LDAP_ENABLED", "0");
} else {
updateConfig("IS_LDAP_ENABLED", "1");
}
}}
disabled={isSubmitting || !formattedConfig}
/>
}
disabled={isSubmitting || !formattedConfig}
withBorder={false}
/>
}
>
{formattedConfig ? (
<InstanceLDAPConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="25%" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" width="50%" />
</Loader>
)}
</PageWrapper>
</>
);
});
export const meta: Route.MetaFunction = () => [{ title: "LDAP Authentication - God Mode" }];
export default InstanceLDAPAuthenticationPage;
@@ -1,337 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { useState } from "react";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { Monitor, Smartphone, ShieldUser } from "lucide-react";
// plane internal packages
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceOIDCAuthenticationConfigurationKeys } from "@plane/types";
// components
import { CodeBlock } from "@/components/common/code-block";
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
import type { TControllerInputFormField } from "@/components/common/controller-input";
import { ControllerInput } from "@/components/common/controller-input";
import type { TControllerSwitchFormField } from "@/components/common/controller-switch";
import { ControllerSwitch } from "@/components/common/controller-switch";
import type { TCopyField } from "@/components/common/copy-field";
import { ServiceDetailsSection } from "@/components/authentication/service-details-section";
// hooks
import { useInstance } from "@/hooks/store";
type Props = {
config: IFormattedInstanceConfiguration;
};
type OIDCConfigFormValues = Record<TInstanceOIDCAuthenticationConfigurationKeys, string>;
export function InstanceOIDCConfigForm(props: Props) {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
// store hooks
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
reset,
formState: { errors, isDirty, isSubmitting },
} = useForm<OIDCConfigFormValues>({
defaultValues: {
OIDC_CLIENT_ID: config["OIDC_CLIENT_ID"],
OIDC_CLIENT_SECRET: config["OIDC_CLIENT_SECRET"],
OIDC_TOKEN_URL: config["OIDC_TOKEN_URL"],
OIDC_USERINFO_URL: config["OIDC_USERINFO_URL"],
OIDC_AUTHORIZE_URL: config["OIDC_AUTHORIZE_URL"],
OIDC_LOGOUT_URL: config["OIDC_LOGOUT_URL"],
OIDC_PROVIDER_NAME: config["OIDC_PROVIDER_NAME"],
ENABLE_OIDC_IDP_SYNC: config["ENABLE_OIDC_IDP_SYNC"] || "0",
},
});
const originURL = typeof window !== "undefined" ? window.location.origin : "";
const OIDC_FORM_FIELDS: TControllerInputFormField[] = [
{
key: "OIDC_CLIENT_ID",
type: "text",
label: "Client ID",
description: "A unique ID for this Plane app that you register on your IdP",
placeholder: "abc123xyz789",
error: Boolean(errors.OIDC_CLIENT_ID),
required: true,
},
{
key: "OIDC_CLIENT_SECRET",
type: "password",
label: "Client secret",
description: "The secret key that authenticates this Plane app to your IdP",
placeholder: "s3cr3tK3y123!",
error: Boolean(errors.OIDC_CLIENT_SECRET),
required: true,
},
{
key: "OIDC_AUTHORIZE_URL",
type: "text",
label: "Authorize URL",
description: (
<>
The URL that brings up your IdP{"'"}s authentication screen when your users click the{" "}
<CodeBlock>{"Continue with"}</CodeBlock>
</>
),
placeholder: "https://example.com/",
error: Boolean(errors.OIDC_AUTHORIZE_URL),
required: true,
},
{
key: "OIDC_TOKEN_URL",
type: "text",
label: "Token URL",
description: "The URL that talks to the IdP and persists user authentication on Plane",
placeholder: "https://example.com/oauth/token",
error: Boolean(errors.OIDC_TOKEN_URL),
required: true,
},
{
key: "OIDC_USERINFO_URL",
type: "text",
label: "Users' info URL",
description: "The URL that fetches your users' info from your IdP",
placeholder: "https://example.com/userinfo",
error: Boolean(errors.OIDC_USERINFO_URL),
required: true,
},
{
key: "OIDC_LOGOUT_URL",
type: "text",
label: "Logout URL",
description: "Optional field that controls where your users go after they log out of Plane",
placeholder: "https://example.com/logout",
error: Boolean(errors.OIDC_LOGOUT_URL),
required: false,
},
{
key: "OIDC_PROVIDER_NAME",
type: "text",
label: "IdP's name",
description: (
<>
Optional field for the name that your users see on the <CodeBlock>Continue with</CodeBlock> button
</>
),
placeholder: "Okta",
error: Boolean(errors.OIDC_PROVIDER_NAME),
required: false,
},
];
const OIDC_FORM_SWITCH_FIELD: TControllerSwitchFormField<OIDCConfigFormValues> = {
name: "ENABLE_OIDC_IDP_SYNC",
label: "Refresh user attributes from IdP during sign in",
};
const OIDC_SERVICE_DETAILS: TCopyField[] = [
{
key: "Origin_URI",
label: "Origin URL",
url: `${originURL}/auth/oidc/`,
description:
"We will generate this for this Plane app. Add this as a trusted origin on your IdP's corresponding field.",
},
{
key: "Callback_URI",
label: "Redirect URL",
url: `${originURL}/auth/oidc/callback/`,
description: (
<>
We will generate this for you. Add this in the <CodeBlock darkerShade>Sign-in redirect URI</CodeBlock> field
of your IdP.
</>
),
},
{
key: "Logout_URI",
label: "Logout URL",
url: `${originURL}/auth/oidc/logout/`,
description: (
<>
We will generate this for you. Add this in the <CodeBlock darkerShade>Logout redirect URI</CodeBlock> field of
your IdP.
</>
),
},
];
const OIDC_MOBILE_SERVICE_DETAILS: TCopyField[] = [
{
key: "mobile_origin_uri",
label: "Origin URL",
url: `${originURL}/auth/mobile/oidc/`,
description:
"We will generate this for this Plane app. Add this as a trusted origin on your IdP's corresponding field.",
},
{
key: "mobile_callback_uri",
label: "Redirect URL",
url: `${originURL}/auth/mobile/oidc/callback/`,
description: (
<>
We will generate this for you. Add this in the <CodeBlock darkerShade>Sign-in redirect URI</CodeBlock> field
of your IdP.
</>
),
},
{
key: "mobile_logout_uri",
label: "Logout URL",
url: `${originURL}/auth/mobile/oidc/logout/`,
description: (
<>
We will generate this for you. Add this in the <CodeBlock darkerShade>Logout redirect URI</CodeBlock> field of
your IdP.
</>
),
},
];
const OIDC_ADMIN_SERVICE_DETAILS: TCopyField[] = [
{
key: "admin_origin_uri",
label: "Origin URL",
url: `${originURL}/api/instances/admin/oidc/`,
description:
"We will generate this for this Plane app. Add this as a trusted origin on your IdP's corresponding field.",
},
{
key: "admin_callback_uri",
label: "Redirect URL",
url: `${originURL}/api/instances/admin/oidc/callback/`,
description: (
<>
We will generate this for you. Add this in the <CodeBlock darkerShade>Sign-in redirect URI</CodeBlock> field
of your IdP.
</>
),
},
{
key: "admin_logout_uri",
label: "Logout URL",
url: `${originURL}/api/instances/admin/oidc/logout/`,
description: (
<>
We will generate this for you. Add this in the <CodeBlock darkerShade>Logout redirect URI</CodeBlock> field of
your IdP.
</>
),
},
];
const onSubmit = async (formData: OIDCConfigFormValues) => {
const payload: Partial<OIDCConfigFormValues> = { ...formData };
try {
const response = await updateInstanceConfigurations(payload);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Done!",
message: "Your OIDC-based authentication is configured. You should test it now.",
});
reset({
OIDC_CLIENT_ID: response.find((item) => item.key === "OIDC_CLIENT_ID")?.value,
OIDC_CLIENT_SECRET: response.find((item) => item.key === "OIDC_CLIENT_SECRET")?.value,
OIDC_AUTHORIZE_URL: response.find((item) => item.key === "OIDC_AUTHORIZE_URL")?.value,
OIDC_TOKEN_URL: response.find((item) => item.key === "OIDC_TOKEN_URL")?.value,
OIDC_USERINFO_URL: response.find((item) => item.key === "OIDC_USERINFO_URL")?.value,
OIDC_LOGOUT_URL: response.find((item) => item.key === "OIDC_LOGOUT_URL")?.value,
OIDC_PROVIDER_NAME: response.find((item) => item.key === "OIDC_PROVIDER_NAME")?.value,
ENABLE_OIDC_IDP_SYNC: response.find((item) => item.key === "ENABLE_OIDC_IDP_SYNC")?.value,
});
} catch (err) {
console.error(err);
}
};
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
if (isDirty) {
e.preventDefault();
setIsDiscardChangesModalOpen(true);
}
};
return (
<>
<ConfirmDiscardModal
isOpen={isDiscardChangesModalOpen}
onDiscardHref="/authentication"
handleClose={() => setIsDiscardChangesModalOpen(false)}
/>
<div className="flex flex-col gap-8">
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1 pt-1">
<div className="pt-2.5 text-18 font-medium">IdP-provided details for Plane</div>
{OIDC_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
control={control}
type={field.type}
name={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
error={field.error}
required={field.required}
/>
))}
<ControllerSwitch control={control} field={OIDC_FORM_SWITCH_FIELD} />
<div className="flex flex-col gap-1 pt-4">
<div className="flex items-center gap-4">
<Button
variant="primary"
size="lg"
onClick={(e) => {
void handleSubmit(onSubmit)(e);
}}
loading={isSubmitting}
disabled={!isDirty}
>
{isSubmitting ? "Saving" : "Save changes"}
</Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
Go back
</Link>
</div>
</div>
</div>
<div className="col-span-2 md:col-span-1 flex flex-col gap-y-6">
<div className="pt-2 text-18 font-medium">Plane-provided details for your IdP</div>
<div className="flex flex-col gap-y-4">
{/* web service details */}
<ServiceDetailsSection icon={Monitor} title="Web" fields={OIDC_SERVICE_DETAILS} />
{/* mobile service details */}
<ServiceDetailsSection icon={Smartphone} title="Mobile" fields={OIDC_MOBILE_SERVICE_DETAILS} />
{/* admin service details */}
<ServiceDetailsSection icon={ShieldUser} title="Admin" fields={OIDC_ADMIN_SERVICE_DETAILS} />
</div>
</div>
</div>
</div>
</>
);
}
@@ -1,137 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// ui
import { setPromiseToast } from "@plane/propel/toast";
import { Switch } from "@plane/propel/switch";
import { Loader } from "@plane/ui";
// assets
import OIDCLogo from "@/app/assets/logos/oidc-logo.svg?url";
// components
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
import { PageHeader } from "@/components/common/page-header";
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { useInstance } from "@/hooks/store";
// plane admin hooks
import { useInstanceFlag } from "@/plane-admin/hooks/store/use-instance-flag";
// types
import type { Route } from "./+types/page";
// local
import { InstanceOIDCConfigForm } from "./form";
const InstanceOIDCAuthenticationPage = observer(function InstanceOIDCAuthenticationPage(_props: Route.ComponentProps) {
// state
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// plane admin store
const isOIDCEnabled = useInstanceFlag("OIDC_SAML_AUTH");
// config
const enableOIDCConfig = formattedConfig?.IS_OIDC_ENABLED ?? "";
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
const updateConfig = async (key: "IS_OIDC_ENABLED", value: string) => {
setIsSubmitting(true);
const payload = {
[key]: value,
};
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration",
success: {
title: "Configuration saved",
message: () => `OIDC authentication is now ${value === "1" ? "active" : "disabled"}.`,
},
error: {
title: "Error",
message: () => "Failed to save configuration",
},
});
await updateConfigPromise
.then(() => {
setIsSubmitting(false);
})
.catch((err) => {
console.error(err);
setIsSubmitting(false);
});
};
if (isOIDCEnabled === false) {
return (
<div className="relative container mx-auto w-full h-full p-4 py-4 my-6 space-y-6 flex flex-col">
<PageHeader title="Authentication - God Mode" />
<div className="text-center text-16 text-gray-500">
<p>OpenID Connect (OIDC) authentication is not enabled for this instance.</p>
<p>Activate any of your workspace to get this feature.</p>
</div>
</div>
);
}
return (
<>
<PageHeader title="Authentication - God Mode" />
<PageWrapper
customHeader={
<AuthenticationMethodCard
name="OIDC"
description="Authenticate your users via the OpenID connect protocol."
icon={<img src={OIDCLogo} height={24} width={24} alt="OIDC Logo" />}
config={
<Switch
value={Boolean(parseInt(enableOIDCConfig))}
onChange={() => {
if (Boolean(parseInt(enableOIDCConfig)) === true) {
updateConfig("IS_OIDC_ENABLED", "0");
} else {
updateConfig("IS_OIDC_ENABLED", "1");
}
}}
disabled={isSubmitting || !formattedConfig}
/>
}
disabled={isSubmitting || !formattedConfig}
withBorder={false}
/>
}
>
{formattedConfig ? (
<InstanceOIDCConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="25%" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" width="50%" />
</Loader>
)}
</PageWrapper>
</>
);
});
export const meta: Route.MetaFunction = () => [{ title: "OIDC Authentication - God Mode" }];
export default InstanceOIDCAuthenticationPage;
@@ -1,181 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { useCallback, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useTheme } from "@plane/react-theme";
import useSWR from "swr";
// plane internal packages
import { setPromiseToast, setToast, TOAST_TYPE } from "@plane/propel/toast";
import { Switch } from "@plane/propel/switch";
import type { TInstanceConfigurationKeys, TInstanceAuthenticationModes } from "@plane/types";
import { Loader } from "@plane/ui";
import { cn, resolveGeneralTheme } from "@plane/utils";
// components
import { PageWrapper } from "@/components/common/page-wrapper";
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
// helpers
import { canDisableAuthMethod } from "@/helpers/authentication";
// hooks
import { useAuthenticationModes } from "@/hooks/oauth";
import { useInstance } from "@/hooks/store";
// types
import type { Route } from "./+types/page";
const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(_props: Route.ComponentProps) {
// theme
const { resolvedTheme: resolvedThemeAdmin } = useTheme();
const resolvedTheme = resolveGeneralTheme(resolvedThemeAdmin);
// Ref to store authentication modes for validation (avoids circular dependency)
const authenticationModesRef = useRef<TInstanceAuthenticationModes[]>([]);
// state
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// store hooks
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// derived values
const enableSignUpConfig = formattedConfig?.ENABLE_SIGNUP ?? "";
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
// Create updateConfig with validation - uses authenticationModesRef for current modes
const updateConfig = useCallback(
(key: TInstanceConfigurationKeys, value: string): void => {
// Check if trying to disable (value === "0")
if (value === "0") {
// Check if this key is an authentication method key
const currentAuthModes = authenticationModesRef.current;
const isAuthMethodKey = currentAuthModes.some((method) => method.enabledConfigKey === key);
// Only validate if this is an authentication method key
if (isAuthMethodKey) {
const canDisable = canDisableAuthMethod(key, currentAuthModes, formattedConfig);
if (!canDisable) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Cannot disable authentication",
message:
"At least one authentication method must remain enabled. Please enable another method before disabling this one.",
});
return;
}
}
}
// Proceed with the update
setIsSubmitting(true);
const payload = {
[key]: value,
};
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving configuration",
success: {
title: "Success",
message: () => "Configuration saved successfully",
},
error: {
title: "Error",
message: () => "Failed to save configuration",
},
});
void updateConfigPromise
.then(() => {
setIsSubmitting(false);
return undefined;
})
.catch((err) => {
console.error(err);
setIsSubmitting(false);
});
},
[formattedConfig, updateInstanceConfigurations]
);
// Get authentication modes - this will use updateConfig which includes validation
const authenticationModes = useAuthenticationModes({
disabled: isSubmitting,
updateConfig,
resolvedTheme,
});
// Update ref with latest authentication modes
authenticationModesRef.current = authenticationModes;
return (
<PageWrapper
header={{
title: "Manage authentication modes for your instance",
description: "Configure authentication modes for your team and restrict sign-ups to be invite only.",
}}
>
{formattedConfig ? (
<div className="space-y-3">
<div className={cn("w-full flex items-center gap-14 rounded-sm")}>
<div className="flex grow items-center gap-4">
<div className="grow">
<div className="text-16 font-medium pb-1">Allow anyone to sign up even without an invite</div>
<div className={cn("font-regular leading-5 text-tertiary text-11")}>
Toggling this off will only let users sign up when they are invited.
</div>
</div>
</div>
<div className={`shrink-0 pr-4 ${isSubmitting && "opacity-70"}`}>
<div className="flex items-center gap-4">
<Switch
value={Boolean(parseInt(enableSignUpConfig))}
onChange={() => {
if (Boolean(parseInt(enableSignUpConfig)) === true) {
updateConfig("ENABLE_SIGNUP", "0");
} else {
updateConfig("ENABLE_SIGNUP", "1");
}
}}
disabled={isSubmitting}
/>
</div>
</div>
</div>
<div className="text-lg font-medium pt-6">Available authentication modes</div>
{authenticationModes.map((method) => (
<AuthenticationMethodCard
key={method.key}
name={method.name}
description={method.description}
icon={method.icon}
config={method.config}
disabled={isSubmitting}
unavailable={method.unavailable}
/>
))}
</div>
) : (
<Loader className="space-y-10">
<Loader.Item height="50px" width="75%" />
<Loader.Item height="50px" width="75%" />
<Loader.Item height="50px" width="40%" />
<Loader.Item height="50px" width="40%" />
<Loader.Item height="50px" width="20%" />
</Loader>
)}
</PageWrapper>
);
});
export const meta: Route.MetaFunction = () => [{ title: "Authentication Settings - Plane Web" }];
export default InstanceAuthenticationPage;
@@ -1,419 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { useMemo, useState } from "react";
import Link from "next/link";
import { Controller, useForm } from "react-hook-form";
import { Monitor, Smartphone, Cable } from "lucide-react";
// plane internal packages
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceSAMLAuthenticationConfigurationKeys } from "@plane/types";
import { CustomSelect, Input, TextArea } from "@plane/ui";
// components
import { CodeBlock } from "@/components/common/code-block";
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
import type { TControllerInputFormField } from "@/components/common/controller-input";
import { ControllerInput } from "@/components/common/controller-input";
import type { TControllerSwitchFormField } from "@/components/common/controller-switch";
import { ControllerSwitch } from "@/components/common/controller-switch";
import type { TCopyField } from "@/components/common/copy-field";
import { ServiceDetailsSection } from "@/components/authentication/service-details-section";
// hooks
import { useInstance } from "@/hooks/store";
import { NAME_ID_FORMAT_OPTIONS, SAMLAttributeMappingTable } from "@/plane-admin/components/authentication";
const DEFAULT_ATTRIBUTE_MAPPING: Record<string, string> = {
email: "email",
first_name: "first_name",
last_name: "last_name",
display_name: "preferred_username",
};
const ATTRIBUTE_MAPPING_FIELDS = [
{ planeField: "email", label: "Email", required: true },
{ planeField: "first_name", label: "First name", required: false },
{ planeField: "last_name", label: "Last name", required: false },
{ planeField: "display_name", label: "Display name", required: false },
] as const;
const SAML_FORM_SWITCH_FIELDS: TControllerSwitchFormField<SAMLConfigFormValues>[] = [
{
name: "ENABLE_SAML_IDP_SYNC",
label: "Refresh user attributes from IdP during sign in",
},
{
name: "SAML_DISABLE_REQUESTED_AUTHN_CONTEXT",
label: "Disable RequestedAuthnContext to allow any authentication method",
},
];
function parseAttributeMapping(value: string | undefined): Record<string, string> {
if (!value) return { ...DEFAULT_ATTRIBUTE_MAPPING };
try {
const parsed = JSON.parse(value);
if (typeof parsed === "object" && parsed !== null) return parsed as Record<string, string>;
} catch {
// ignore parse errors
}
return { ...DEFAULT_ATTRIBUTE_MAPPING };
}
type Props = {
config: IFormattedInstanceConfiguration;
};
type SAMLConfigFormValues = Record<TInstanceSAMLAuthenticationConfigurationKeys, string>;
export function InstanceSAMLConfigForm(props: Props) {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
// store hooks
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
reset,
watch,
setValue,
formState: { errors, isDirty, isSubmitting },
} = useForm<SAMLConfigFormValues>({
defaultValues: {
SAML_ENTITY_ID: config["SAML_ENTITY_ID"],
SAML_SSO_URL: config["SAML_SSO_URL"],
SAML_LOGOUT_URL: config["SAML_LOGOUT_URL"],
SAML_CERTIFICATE: config["SAML_CERTIFICATE"],
SAML_PROVIDER_NAME: config["SAML_PROVIDER_NAME"],
ENABLE_SAML_IDP_SYNC: config["ENABLE_SAML_IDP_SYNC"] || "0",
SAML_DISABLE_REQUESTED_AUTHN_CONTEXT: config["SAML_DISABLE_REQUESTED_AUTHN_CONTEXT"] ?? "1",
SAML_NAME_ID_FORMAT: config["SAML_NAME_ID_FORMAT"] || "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
SAML_ATTRIBUTE_MAPPING: config["SAML_ATTRIBUTE_MAPPING"] || JSON.stringify(DEFAULT_ATTRIBUTE_MAPPING),
},
});
const watchedNameIdFormat = watch("SAML_NAME_ID_FORMAT");
const watchedAttributeMapping = watch("SAML_ATTRIBUTE_MAPPING");
const currentMapping = useMemo(() => parseAttributeMapping(watchedAttributeMapping), [watchedAttributeMapping]);
const updateAttributeMapping = (planeField: string, idpAttr: string) => {
setValue("SAML_ATTRIBUTE_MAPPING", JSON.stringify({ ...currentMapping, [planeField]: idpAttr }), {
shouldDirty: true,
});
};
const originURL = typeof window !== "undefined" ? window.location.origin : "";
const SAML_FORM_FIELDS: TControllerInputFormField[] = [
{
key: "SAML_ENTITY_ID",
type: "text",
label: "Entity ID",
description: "A unique ID for this Plane app that you register on your IdP",
placeholder: "70a44354520df8bd9bcd",
error: Boolean(errors.SAML_ENTITY_ID),
required: true,
},
{
key: "SAML_SSO_URL",
type: "text",
label: "SSO URL",
description: (
<>
The URL that brings up your IdP{"'"}s authentication screen when your users click the{" "}
<CodeBlock>{"Continue with"}</CodeBlock> button
</>
),
placeholder: "https://example.com/sso",
error: Boolean(errors.SAML_SSO_URL),
required: true,
},
{
key: "SAML_LOGOUT_URL",
type: "text",
label: "Logout URL",
description: "Optional field that tells your IdP your users have logged out of this Plane app",
placeholder: "https://example.com/logout",
error: Boolean(errors.SAML_LOGOUT_URL),
required: false,
},
{
key: "SAML_PROVIDER_NAME",
type: "text",
label: "IdP's name",
description: (
<>
Optional field for the name that your users see on the <CodeBlock>Continue with</CodeBlock> button
</>
),
placeholder: "Okta",
error: Boolean(errors.SAML_PROVIDER_NAME),
required: false,
},
];
const SAML_WEB_SERVICE_DETAILS: TCopyField[] = [
{
key: "Metadata_Information",
label: "Entity ID | Audience | Metadata information",
url: `${originURL}/auth/saml/metadata/`,
description:
"We will generate this bit of the metadata that identifies this Plane app as an authorized service on your IdP.",
},
{
key: "Callback_URI",
label: "SSO URL",
url: `${originURL}/auth/saml/callback/`,
description: (
<>
We will generate this <CodeBlock darkerShade>http-post request</CodeBlock> URL that you should paste into your{" "}
<CodeBlock darkerShade>ACS URL</CodeBlock> or <CodeBlock darkerShade>Sign-in call back URL</CodeBlock> field
on your IdP.
</>
),
},
{
key: "Logout_URI",
label: "SLO URL",
url: `${originURL}/auth/saml/logout/`,
description: (
<>
We will generate this <CodeBlock darkerShade>http-redirect request</CodeBlock> URL that you should paste into
your <CodeBlock darkerShade>SLS URL</CodeBlock> or <CodeBlock darkerShade>Logout URL</CodeBlock>
field on your IdP.
</>
),
},
];
const SAML_MOBILE_SERVICE_DETAILS: TCopyField[] = [
{
key: "mobile_metadata_information",
label: "Entity ID | Audience | Metadata information",
url: `${originURL}/auth/mobile/saml/metadata/`,
description:
"We will generate this bit of the metadata that identifies this Plane app as an authorized service on your IdP.",
},
{
key: "mobile_callback_uri",
label: "SSO URL",
url: `${originURL}/auth/mobile/saml/callback/`,
description: (
<>
We will generate this <CodeBlock darkerShade>http-post request</CodeBlock> URL that you should paste into your{" "}
<CodeBlock darkerShade>ACS URL</CodeBlock> or <CodeBlock darkerShade>Sign-in call back URL</CodeBlock> field
on your IdP.
</>
),
},
{
key: "mobile_logout_uri",
label: "SLO URL",
url: `${originURL}/auth/mobile/saml/logout/`,
description: (
<>
We will generate this <CodeBlock darkerShade>http-redirect request</CodeBlock> URL that you should paste into
your <CodeBlock darkerShade>SLS URL</CodeBlock> or <CodeBlock darkerShade>Logout URL</CodeBlock>
field on your IdP.
</>
),
},
];
const onSubmit = async (formData: SAMLConfigFormValues) => {
const payload: Partial<SAMLConfigFormValues> = { ...formData };
try {
const response = await updateInstanceConfigurations(payload);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Done!",
message: "Your SAML-based authentication is configured. You should test it now.",
});
reset({
SAML_ENTITY_ID: response.find((item) => item.key === "SAML_ENTITY_ID")?.value,
SAML_SSO_URL: response.find((item) => item.key === "SAML_SSO_URL")?.value,
SAML_LOGOUT_URL: response.find((item) => item.key === "SAML_LOGOUT_URL")?.value,
SAML_CERTIFICATE: response.find((item) => item.key === "SAML_CERTIFICATE")?.value,
SAML_PROVIDER_NAME: response.find((item) => item.key === "SAML_PROVIDER_NAME")?.value,
ENABLE_SAML_IDP_SYNC: response.find((item) => item.key === "ENABLE_SAML_IDP_SYNC")?.value,
SAML_DISABLE_REQUESTED_AUTHN_CONTEXT:
response.find((item) => item.key === "SAML_DISABLE_REQUESTED_AUTHN_CONTEXT")?.value ?? "1",
SAML_NAME_ID_FORMAT:
response.find((item) => item.key === "SAML_NAME_ID_FORMAT")?.value ||
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
SAML_ATTRIBUTE_MAPPING:
response.find((item) => item.key === "SAML_ATTRIBUTE_MAPPING")?.value ||
JSON.stringify(DEFAULT_ATTRIBUTE_MAPPING),
});
} catch (err) {
console.error(err);
}
};
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
if (isDirty) {
e.preventDefault();
setIsDiscardChangesModalOpen(true);
}
};
return (
<>
<ConfirmDiscardModal
isOpen={isDiscardChangesModalOpen}
onDiscardHref="/authentication"
handleClose={() => setIsDiscardChangesModalOpen(false)}
/>
<div className="flex flex-col gap-8">
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1 pt-1">
<div className="pt-2.5 text-18 font-medium">IdP-provided details for Plane</div>
{SAML_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
control={control}
type={field.type}
name={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
error={field.error}
required={field.required}
/>
))}
<div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">SAML certificate</h4>
<Controller
control={control}
name="SAML_CERTIFICATE"
rules={{ required: "Certificate is required." }}
render={({ field: { value, onChange } }) => (
<TextArea
id="SAML_CERTIFICATE"
name="SAML_CERTIFICATE"
value={value}
onChange={onChange}
hasError={Boolean(errors.SAML_CERTIFICATE)}
placeholder="---BEGIN CERTIFICATE---\n2yWn1gc7DhOFB9\nr0gbE+\n---END CERTIFICATE---"
className="min-h-[102px] w-full rounded-md font-medium text-12"
/>
)}
/>
<p className="pt-0.5 text-11 text-tertiary">
IdP-generated certificate for signing this Plane app as an authorized service provider for your IdP
</p>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">
Name ID format <span className="text-red-500">*</span>
</h4>
<Controller
control={control}
name="SAML_NAME_ID_FORMAT"
render={({ field: { value, onChange } }) => {
const selectedOption = NAME_ID_FORMAT_OPTIONS.find((opt) => opt.value === value);
const displayLabel = selectedOption ? `${selectedOption.label} (${selectedOption.value})` : value;
return (
<CustomSelect
value={value}
label={displayLabel}
onChange={onChange}
buttonClassName="rounded-md border-subtle text-left"
input
>
{NAME_ID_FORMAT_OPTIONS.map((option) => (
<CustomSelect.Option key={option.value} value={option.value} className="w-full">
{option.label} ({option.value})
</CustomSelect.Option>
))}
</CustomSelect>
);
}}
/>
<p className="pt-0.5 text-11 text-tertiary">
The NameID format that your IdP uses to identify users. Must match what your IdP sends in the SAML
response.
</p>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">Attribute mapping</h4>
<p className="pb-2 text-11 text-tertiary">
Map your IdP attribute names to Plane fields. Email is required.
</p>
<div className="flex flex-col gap-2">
{ATTRIBUTE_MAPPING_FIELDS.map((attr) => (
<div key={attr.planeField} className="flex items-center gap-3">
<span className="w-24 text-12 text-secondary shrink-0">
{attr.label}
{attr.required && <span className="text-red-500"> *</span>}
</span>
<Input
type="text"
value={currentMapping[attr.planeField] || ""}
onChange={(e) => updateAttributeMapping(attr.planeField, e.target.value)}
placeholder={attr.planeField}
className="flex-1 text-12"
/>
</div>
))}
</div>
</div>
{SAML_FORM_SWITCH_FIELDS.map((field) => (
<ControllerSwitch key={field.name} control={control} field={field} />
))}
<div className="flex flex-col gap-1 pt-4">
<div className="flex items-center gap-4">
<Button
variant="primary"
size="lg"
onClick={(e) => void handleSubmit(onSubmit)(e)}
loading={isSubmitting}
disabled={!isDirty}
>
{isSubmitting ? "Saving" : "Save changes"}
</Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
Go back
</Link>
</div>
</div>
</div>
<div className="col-span-2 md:col-span-1 flex flex-col gap-y-6">
<div className="pt-2 text-18 font-medium">Plane-provided details for your IdP</div>
<div className="flex flex-col gap-y-4">
{/* web service details */}
<ServiceDetailsSection icon={Monitor} title="Web" fields={SAML_WEB_SERVICE_DETAILS} />
{/* mobile service details */}
<ServiceDetailsSection icon={Smartphone} title="Mobile" fields={SAML_MOBILE_SERVICE_DETAILS} />
{/* mapping details */}
<div className="flex flex-col rounded-lg overflow-hidden">
<div className="px-6 py-3 bg-layer-3 font-medium text-11 uppercase flex items-center gap-x-3 text-secondary">
<Cable className="w-3 h-3" />
Mapping
</div>
<div className="px-6 py-4 bg-layer-1">
<SAMLAttributeMappingTable nameIdFormat={watchedNameIdFormat} attributeMapping={currentMapping} />
</div>
</div>
</div>
</div>
</div>
</div>
</>
);
}
@@ -1,137 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// ui
import { setPromiseToast } from "@plane/propel/toast";
import { Switch } from "@plane/propel/switch";
import { Loader } from "@plane/ui";
// assets
import SAMLLogo from "@/app/assets/logos/saml-logo.svg?url";
// components
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
import { PageHeader } from "@/components/common/page-header";
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { useInstance } from "@/hooks/store";
// plane admin hooks
import { useInstanceFlag } from "@/plane-admin/hooks/store/use-instance-flag";
// types
import type { Route } from "./+types/page";
// local
import { InstanceSAMLConfigForm } from "./form";
const InstanceSAMLAuthenticationPage = observer(function InstanceSAMLAuthenticationPage(_props: Route.ComponentProps) {
// state
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// plane admin store
const isSAMLEnabled = useInstanceFlag("OIDC_SAML_AUTH");
// config
const enableSAMLConfig = formattedConfig?.IS_SAML_ENABLED ?? "";
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
const updateConfig = async (key: "IS_SAML_ENABLED", value: string) => {
setIsSubmitting(true);
const payload = {
[key]: value,
};
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration",
success: {
title: "Configuration saved",
message: () => `SAML authentication is now ${value === "1" ? "active" : "disabled"}.`,
},
error: {
title: "Error",
message: () => "Failed to save configuration",
},
});
await updateConfigPromise
.then(() => {
setIsSubmitting(false);
})
.catch((err) => {
console.error(err);
setIsSubmitting(false);
});
};
if (isSAMLEnabled === false) {
return (
<div className="relative container mx-auto w-full h-full p-4 py-4 my-6 space-y-6 flex flex-col">
<PageHeader title="Authentication - God Mode" />
<div className="text-center text-16 text-gray-500">
<p>Security Assertion Markup Language (SAML) authentication is not enabled for this instance.</p>
<p>Activate any of your workspace to get this feature.</p>
</div>
</div>
);
}
return (
<>
<PageHeader title="Authentication - God Mode" />
<PageWrapper
customHeader={
<AuthenticationMethodCard
name="SAML"
description="Authenticate your users via Security Assertion Markup Language
protocol."
icon={<img src={SAMLLogo} height={24} width={24} alt="SAML Logo" className="pl-0.5" />}
config={
<Switch
value={Boolean(parseInt(enableSAMLConfig))}
onChange={() => {
if (Boolean(parseInt(enableSAMLConfig)) === true) {
updateConfig("IS_SAML_ENABLED", "0");
} else {
updateConfig("IS_SAML_ENABLED", "1");
}
}}
disabled={isSubmitting || !formattedConfig}
/>
}
disabled={isSubmitting || !formattedConfig}
withBorder={false}
/>
}
>
{formattedConfig ? (
<InstanceSAMLConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="25%" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" width="50%" />
</Loader>
)}
</PageWrapper>
</>
);
});
export const meta: Route.MetaFunction = () => [{ title: "SAML Authentication - God Mode" }];
export default InstanceSAMLAuthenticationPage;
@@ -1,37 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { observer } from "mobx-react";
// components
import { PageWrapper } from "@/components/common/page-wrapper";
// plane admin imports
import { EnterpriseLicenseManagement } from "@/plane-admin/components/enterprise-license/root";
// types
import type { Route } from "./+types/page";
function BillingPage() {
return (
<PageWrapper
header={{
title: "Billings and plans",
description: "Manage your instance license, view billing details, and upgrade your plan.",
}}
>
<EnterpriseLicenseManagement />
</PageWrapper>
);
}
export const meta: Route.MetaFunction = () => [{ title: "Billing and Plans - God Mode" }];
export default observer(BillingPage);
@@ -1,126 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { Controller, useForm } from "react-hook-form";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { DEFAULT_PROJECT_IDENTIFIER_MAX_LENGTH } from "@plane/constants";
import type { IFormattedInstanceConfiguration, TInstanceConfigKeys } from "@plane/types";
import { Input } from "@plane/ui";
// hooks
import { useInstance } from "@/hooks/store";
type ConfigurationsFormProps = {
config: IFormattedInstanceConfiguration;
};
type ConfigurationsFormValues = Record<TInstanceConfigKeys, string>;
export function ConfigurationsForm(props: ConfigurationsFormProps) {
const { config } = props;
// store hooks
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
formState: { errors, isSubmitting },
} = useForm<ConfigurationsFormValues>({
defaultValues: {
PROJECT_IDENTIFIER_MAX_LENGTH:
config["PROJECT_IDENTIFIER_MAX_LENGTH"] || String(DEFAULT_PROJECT_IDENTIFIER_MAX_LENGTH),
},
});
const onSubmit = async (formData: ConfigurationsFormValues) => {
const payload: Partial<ConfigurationsFormValues> = { ...formData };
await updateInstanceConfigurations(payload)
.then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success",
message: "Configuration settings updated successfully.",
})
)
.catch((err) => {
console.error(err);
setToast({
type: TOAST_TYPE.ERROR,
title: "Error",
message: "Failed to update configuration settings.",
});
});
};
return (
<div className="space-y-8">
<div className="space-y-4">
<div className="text-16 font-medium text-primary">Project settings</div>
<div className="grid-col grid w-full grid-cols-1 items-center justify-between gap-x-16 gap-y-8 lg:grid-cols-2">
<div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">Project identifier max length</h4>
<Controller
control={control}
name="PROJECT_IDENTIFIER_MAX_LENGTH"
rules={{
required: "This field is required.",
validate: (value) => {
const num = parseInt(value, 10);
if (isNaN(num)) return "Must be a number.";
if (num < 1) return "Minimum value is 1.";
if (num > 255) return "Maximum value is 255.";
return true;
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="PROJECT_IDENTIFIER_MAX_LENGTH"
name="PROJECT_IDENTIFIER_MAX_LENGTH"
type="number"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.PROJECT_IDENTIFIER_MAX_LENGTH)}
placeholder="10"
className="w-full rounded-md font-medium"
min={1}
max={255}
/>
)}
/>
{errors.PROJECT_IDENTIFIER_MAX_LENGTH && (
<p className="text-11 text-danger-primary">{errors.PROJECT_IDENTIFIER_MAX_LENGTH.message}</p>
)}
<p className="pt-0.5 text-11 text-tertiary">
Maximum number of characters allowed for project identifiers (1255). Default is 10.
</p>
</div>
</div>
</div>
<div>
<Button
variant="primary"
size="lg"
onClick={() => {
void handleSubmit(onSubmit)();
}}
loading={isSubmitting}
>
{isSubmitting ? "Saving" : "Save changes"}
</Button>
</div>
</div>
);
}
@@ -1,53 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { observer } from "mobx-react";
import useSWR from "swr";
import { Loader } from "@plane/ui";
// components
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { useInstance } from "@/hooks/store";
// types
import type { Route } from "./+types/page";
// local
import { ConfigurationsForm } from "./form";
const ConfigurationsPage = observer(function ConfigurationsPage(_props: Route.ComponentProps) {
// store
const { formattedConfig, fetchInstanceConfigurations } = useInstance();
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
return (
<PageWrapper
header={{
title: "Configurations",
description: "Manage project and system configurations for your instance.",
}}
>
{formattedConfig ? (
<ConfigurationsForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="50%" />
<Loader.Item height="50px" width="20%" />
</Loader>
)}
</PageWrapper>
);
});
export const meta: Route.MetaFunction = () => [{ title: "Configurations - God Mode" }];
export default ConfigurationsPage;
@@ -1,111 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Switch } from "@plane/propel/switch";
import { Loader } from "@plane/ui";
// components
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { useInstance } from "@/hooks/store";
// types
import type { Route } from "./+types/page";
// local
import { InstanceEmailForm } from "./email-config-form";
const InstanceEmailPage = observer(function InstanceEmailPage(_props: Route.ComponentProps) {
// store
const { fetchInstanceConfigurations, formattedConfig, disableEmail } = useInstance();
const { isLoading } = useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSMTPEnabled, setIsSMTPEnabled] = useState(false);
const handleToggle = async () => {
if (isSMTPEnabled) {
setIsSubmitting(true);
try {
await disableEmail();
setIsSMTPEnabled(false);
setToast({
title: "Email feature disabled",
message: "Email feature has been disabled",
type: TOAST_TYPE.SUCCESS,
});
} catch (_error) {
setToast({
title: "Error disabling email",
message: "Failed to disable email feature. Please try again.",
type: TOAST_TYPE.ERROR,
});
} finally {
setIsSubmitting(false);
}
return;
}
setIsSMTPEnabled(true);
};
useEffect(() => {
if (formattedConfig) {
setIsSMTPEnabled(formattedConfig.ENABLE_SMTP === "1");
}
}, [formattedConfig]);
return (
<PageWrapper
header={{
title: "Secure emails from your own instance",
description: (
<>
Plane can send useful emails to you and your users from your own instance without talking to the Internet.
<div className="text-13 font-regular text-tertiary">
Set it up below and please test your settings before you save them.&nbsp;
<span className="text-danger-primary">Misconfigs can lead to email bounces and errors.</span>
</div>
</>
),
actions: isLoading ? (
<Loader>
<Loader.Item width="24px" height="16px" className="rounded-full" />
</Loader>
) : (
<Switch value={isSMTPEnabled} onChange={handleToggle} disabled={isSubmitting} />
),
}}
>
{isSMTPEnabled && !isLoading && (
<>
{formattedConfig ? (
<InstanceEmailForm config={formattedConfig} />
) : (
<Loader className="space-y-10">
<Loader.Item height="50px" width="75%" />
<Loader.Item height="50px" width="75%" />
<Loader.Item height="50px" width="40%" />
<Loader.Item height="50px" width="40%" />
<Loader.Item height="50px" width="20%" />
</Loader>
)}
</>
)}
</PageWrapper>
);
});
export const meta: Route.MetaFunction = () => [{ title: "Email Settings - God Mode" }];
export default InstanceEmailPage;
@@ -1,91 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { MessageSquare } from "lucide-react";
import type { IFormattedInstanceConfiguration } from "@plane/types";
import { Switch } from "@plane/propel/switch";
// hooks
import { useInstance } from "@/hooks/store";
type TChatSupportConfig = {
isTelemetryEnabled: boolean;
};
export const ChatSupportConfig = observer(function ChatSupportConfig(props: TChatSupportConfig) {
const { isTelemetryEnabled } = props;
// hooks
const { instanceConfigurations, updateInstanceConfigurations, fetchInstanceConfigurations } = useInstance();
// states
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// derived values
const isChatSupportEnabled = isTelemetryEnabled
? instanceConfigurations
? instanceConfigurations?.find((config) => config.key === "IS_CHAT_SUPPORT_ENABLED")?.value === "1"
? true
: false
: undefined
: false;
const { isLoading } = useSWR(isTelemetryEnabled ? "INSTANCE_CONFIGURATIONS" : null, () =>
isTelemetryEnabled ? fetchInstanceConfigurations() : null
);
const initialLoader = isLoading && isChatSupportEnabled === undefined;
const submitInstanceConfigurations = async (payload: Partial<IFormattedInstanceConfiguration>) => {
try {
await updateInstanceConfigurations(payload);
} catch (error) {
console.error(error);
} finally {
setIsSubmitting(false);
}
};
const enableChatSupportConfig = () => {
void submitInstanceConfigurations({ IS_CHAT_SUPPORT_ENABLED: isChatSupportEnabled ? "0" : "1" });
};
return (
<>
<div className="flex items-center gap-14">
<div className="grow flex items-center gap-4">
<div className="shrink-0">
<div className="flex items-center justify-center size-11 bg-layer-1 rounded-lg">
<MessageSquare className="size-5 text-tertiary p-0.5" />
</div>
</div>
<div className="grow">
<div className="text-13 font-medium text-primary leading-5">Chat with us</div>
<div className="text-11 font-regular text-tertiary leading-5">
Let your users chat with us. Toggling Telemetry off turns this off automatically.
</div>
</div>
<div className="ml-auto">
<Switch
value={isChatSupportEnabled ? true : false}
onChange={enableChatSupportConfig}
disabled={!isTelemetryEnabled || isSubmitting || initialLoader}
/>
</div>
</div>
</div>
</>
);
});
@@ -1,174 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { Telescope } from "lucide-react";
// plane imports
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Switch } from "@plane/propel/switch";
import type { IInstance, IInstanceAdmin } from "@plane/types";
import { Input } from "@plane/ui";
// components
import { ControllerInput } from "@/components/common/controller-input";
// hooks
import { useInstance } from "@/hooks/store";
// components
import { ChatSupportConfig } from "./chat-support";
export interface IGeneralConfigurationForm {
instance: IInstance;
instanceAdmins: IInstanceAdmin[];
}
export const GeneralConfigurationForm = observer(function GeneralConfigurationForm(props: IGeneralConfigurationForm) {
const { instance, instanceAdmins } = props;
// hooks
const { instanceConfigurations, updateInstanceInfo, updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
formState: { errors, isSubmitting },
watch,
} = useForm<Partial<IInstance>>({
defaultValues: {
instance_name: instance?.instance_name,
is_telemetry_enabled: instance?.is_telemetry_enabled,
},
});
const onSubmit = async (formData: Partial<IInstance>) => {
const payload: Partial<IInstance> = { ...formData };
// update the chat support configuration
const isChatSupportEnabled =
instanceConfigurations?.find((config) => config.key === "IS_CHAT_SUPPORT_ENABLED")?.value === "1";
if (!payload.is_telemetry_enabled && isChatSupportEnabled) {
try {
await updateInstanceConfigurations({ IS_CHAT_SUPPORT_ENABLED: "0" });
} catch (error) {
console.error(error);
}
}
await updateInstanceInfo(payload)
.then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success",
message: "Settings updated successfully",
})
)
.catch((err) => console.error(err));
};
return (
<div className="space-y-8">
<div className="space-y-4">
<div className="text-16 font-medium text-primary">Instance details</div>
<div className="grid-col grid w-full grid-cols-1 items-center justify-between gap-8 md:grid-cols-2 lg:grid-cols-3">
<ControllerInput
key="instance_name"
name="instance_name"
control={control}
type="text"
label="Name of instance"
placeholder="Instance name"
error={Boolean(errors.instance_name)}
required
/>
<div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">Email</h4>
<Input
id="email"
name="email"
type="email"
value={instanceAdmins[0]?.user_detail?.email ?? ""}
placeholder="Admin email"
className="w-full cursor-not-allowed !text-placeholder"
autoComplete="on"
disabled
/>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">Instance ID</h4>
<Input
id="instance_id"
name="instance_id"
type="text"
value={instance.instance_id}
className="w-full cursor-not-allowed rounded-md font-medium !text-placeholder"
disabled
/>
</div>
</div>
</div>
<div className="space-y-6">
<div className="text-16 font-medium text-primary pb-1.5 border-b border-subtle">Chat + telemetry</div>
<ChatSupportConfig isTelemetryEnabled={watch("is_telemetry_enabled") ?? false} />
<div className="flex items-center gap-14">
<div className="grow flex items-center gap-4">
<div className="shrink-0">
<div className="flex items-center justify-center size-11 bg-layer-1 rounded-lg">
<Telescope className="size-5 text-tertiary" />
</div>
</div>
<div className="grow">
<div className="text-13 font-medium text-primary leading-5">Let Plane collect anonymous usage data</div>
<div className="text-11 font-regular text-tertiary leading-5">
No PII is collected.This anonymized data is used to understand how you use Plane and build new features
in line with{" "}
<a
href="https://developers.plane.so/self-hosting/telemetry"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
our Telemetry Policy.
</a>
</div>
</div>
</div>
<div className={`shrink-0 ${isSubmitting && "opacity-70"}`}>
<Controller
control={control}
name="is_telemetry_enabled"
render={({ field: { value, onChange } }) => (
<Switch value={value ?? false} onChange={onChange} disabled={isSubmitting} />
)}
/>
</div>
</div>
</div>
<div>
<Button
variant="primary"
size="lg"
onClick={() => {
void handleSubmit(onSubmit)();
}}
loading={isSubmitting}
>
{isSubmitting ? "Saving" : "Save changes"}
</Button>
</div>
</div>
);
});
@@ -1,42 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { observer } from "mobx-react";
// components
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { useInstance } from "@/hooks/store";
// local imports
import { GeneralConfigurationForm } from "./form";
// types
import type { Route } from "./+types/page";
function GeneralPage() {
const { instance, instanceAdmins } = useInstance();
return (
<PageWrapper
header={{
title: "General settings",
description:
"Change the name of your instance and instance admin e-mail addresses. Enable or disable telemetry in your instance.",
}}
>
{instance && instanceAdmins && <GeneralConfigurationForm instance={instance} instanceAdmins={instanceAdmins} />}
</PageWrapper>
);
}
export const meta: Route.MetaFunction = () => [{ title: "General Settings - God Mode" }];
export default observer(GeneralPage);
@@ -1,92 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { useForm } from "react-hook-form";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types";
// components
import { ControllerInput } from "@/components/common/controller-input";
// hooks
import { useInstance } from "@/hooks/store";
type IInstanceImageConfigForm = {
config: IFormattedInstanceConfiguration;
};
type ImageConfigFormValues = Record<TInstanceImageConfigurationKeys, string>;
export function InstanceImageConfigForm(props: IInstanceImageConfigForm) {
const { config } = props;
// store hooks
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
formState: { errors, isSubmitting },
} = useForm<ImageConfigFormValues>({
defaultValues: {
UNSPLASH_ACCESS_KEY: config["UNSPLASH_ACCESS_KEY"],
},
});
const onSubmit = async (formData: ImageConfigFormValues) => {
const payload: Partial<ImageConfigFormValues> = { ...formData };
await updateInstanceConfigurations(payload)
.then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success",
message: "Image Configuration Settings updated successfully",
})
)
.catch((err) => console.error(err));
};
return (
<div className="space-y-8">
<div className="grid-col grid w-full grid-cols-1 items-center justify-between gap-x-16 gap-y-8 lg:grid-cols-2">
<ControllerInput
control={control}
type="password"
name="UNSPLASH_ACCESS_KEY"
label="Access key from your Unsplash account"
description={
<>
You will find your access key in your Unsplash developer console.&nbsp;
<a
href="https://unsplash.com/documentation#creating-a-developer-account"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
Learn more.
</a>
</>
}
placeholder="oXgq-sdfadsaeweqasdfasdf3234234rassd"
error={Boolean(errors.UNSPLASH_ACCESS_KEY)}
required
/>
</div>
<div>
<Button variant="primary" size="lg" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving" : "Save changes"}
</Button>
</div>
</div>
);
}
@@ -1,53 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { observer } from "mobx-react";
import useSWR from "swr";
import { Loader } from "@plane/ui";
// components
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { useInstance } from "@/hooks/store";
// types
import type { Route } from "./+types/page";
// local
import { InstanceImageConfigForm } from "./form";
const InstanceImagePage = observer(function InstanceImagePage(_props: Route.ComponentProps) {
// store
const { formattedConfig, fetchInstanceConfigurations } = useInstance();
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
return (
<PageWrapper
header={{
title: "Third-party image libraries",
description: "Let your users search and choose images from third-party libraries",
}}
>
{formattedConfig ? (
<InstanceImageConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="50%" />
<Loader.Item height="50px" width="20%" />
</Loader>
)}
</PageWrapper>
);
});
export const meta: Route.MetaFunction = () => [{ title: "Images Settings - God Mode" }];
export default InstanceImagePage;
@@ -1,40 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { observer } from "mobx-react";
import { Outlet } from "react-router";
// local components
import type { Route } from "./+types/layout";
import { InstanceAdminAuthWrapper } from "../auth-wrapper";
import { AdminSidebar } from "../sidebar";
import { AdminHeader } from "@/components/common/header";
import { NewUserPopup } from "@/components/new-user-popup";
function AdminLayoutWithSidebar(_props: Route.ComponentProps) {
return (
<InstanceAdminAuthWrapper>
<div className="relative flex h-screen w-screen overflow-hidden">
<AdminSidebar />
<main className="relative flex h-full w-full flex-col overflow-hidden bg-surface-1">
<AdminHeader />
<div className="h-full w-full overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md">
<Outlet />
</div>
</main>
<NewUserPopup />
</div>
</InstanceAdminAuthWrapper>
);
}
export default observer(AdminLayoutWithSidebar);
@@ -1,86 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { Controller, useForm } from "react-hook-form";
// plane imports
import { Switch } from "@plane/propel/switch";
import { setPromiseToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceConfigKeys } from "@plane/types";
// hooks
import { useInstance } from "@/hooks/store";
type SecurityFormProps = { config: IFormattedInstanceConfiguration };
type SecurityFormValues = Record<TInstanceConfigKeys, string>;
export function SecurityForm(props: SecurityFormProps) {
const { config } = props;
// store hooks
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
formState: { isSubmitting },
} = useForm<SecurityFormValues>({
defaultValues: {
DISABLE_ACCESS_TOKENS: String(config["DISABLE_ACCESS_TOKENS"] || "0"),
},
});
const updateConfig = (formData: SecurityFormValues) => {
const payload: Partial<SecurityFormValues> = { ...formData };
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving security settings",
success: {
title: "Success",
message: () => "Security settings updated successfully.",
},
error: {
title: "Error",
message: () => "Failed to update security settings.",
},
});
};
return (
<div className="flex items-center gap-14">
<div className="grow">
<div className="text-16 font-medium pb-1">Disable access tokens</div>
<div className="font-regular text-tertiary text-11">
When enabled, users cannot create new personal or workspace access tokens, and existing tokens will stop
working. Service and integration tokens are not affected.
</div>
</div>
<div className={`flex shrink-0 pr-4 ${isSubmitting && "opacity-70"}`}>
<Controller
control={control}
name="DISABLE_ACCESS_TOKENS"
render={({ field: { value, onChange } }) => (
<Switch
value={value === "1"}
onChange={(next: boolean) => {
onChange(next ? "1" : "0");
void handleSubmit(updateConfig)();
}}
disabled={isSubmitting}
/>
)}
/>
</div>
</div>
);
}
@@ -1,54 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { observer } from "mobx-react";
import useSWR from "swr";
// plane imports
import { Loader } from "@plane/ui";
// components
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { useInstance } from "@/hooks/store";
// types
import type { Route } from "./+types/page";
// local
import { SecurityForm } from "./form";
const SecurityPage = observer(function SecurityPage(_props: Route.ComponentProps) {
// store
const { formattedConfig, fetchInstanceConfigurations } = useInstance();
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
return (
<PageWrapper
header={{
title: "Security",
description: "Manage instance security settings.",
}}
>
{formattedConfig ? (
<SecurityForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="50%" />
<Loader.Item height="50px" width="20%" />
</Loader>
)}
</PageWrapper>
);
});
export const meta: Route.MetaFunction = () => [{ title: "Security - God Mode" }];
export default SecurityPage;
@@ -1,25 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
// plane admin imports
import { UserManagementRoot } from "@/plane-admin/components/user-management/root";
// types
import type { Route } from "./+types/page";
function UserManagementPage() {
return <UserManagementRoot />;
}
export const meta: Route.MetaFunction = () => [{ title: "User Management - God Mode" }];
export default UserManagementPage;
@@ -1,214 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { useState, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
// plane imports
import { WEB_BASE_URL, ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants";
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { InstanceWorkspaceService } from "@plane/services";
import type { IWorkspace } from "@plane/types";
import { validateSlug, validateWorkspaceName } from "@plane/utils";
// components
import { CustomSelect, Input } from "@plane/ui";
// hooks
import { useWorkspace } from "@/hooks/store";
const instanceWorkspaceService = new InstanceWorkspaceService();
export function WorkspaceCreateForm() {
// router
const router = useRouter();
// states
const [slugError, setSlugError] = useState(false);
const [invalidSlug, setInvalidSlug] = useState(false);
const [defaultValues, setDefaultValues] = useState<Partial<IWorkspace>>({
name: "",
slug: "",
organization_size: "",
});
// store hooks
const { createWorkspace } = useWorkspace();
// form info
const {
handleSubmit,
control,
setValue,
getValues,
formState: { errors, isSubmitting, isValid },
} = useForm<IWorkspace>({ defaultValues, mode: "onChange" });
// derived values
const workspaceBaseURL = encodeURI(WEB_BASE_URL || window.location.origin + "/");
const handleCreateWorkspace = async (formData: IWorkspace) => {
await instanceWorkspaceService
.slugCheck(formData.slug)
.then(async (res) => {
if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) {
setSlugError(false);
await createWorkspace(formData)
.then(async () => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Workspace created successfully.",
});
router.push(`/workspace`);
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Workspace could not be created. Please try again.",
});
});
} else setSlugError(true);
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Some error occurred while creating workspace. Please try again.",
});
});
};
useEffect(
() => () => {
// when the component unmounts set the default values to whatever user typed in
setDefaultValues(getValues());
},
[getValues, setDefaultValues]
);
return (
<div className="space-y-8">
<div className="grid-col grid w-full max-w-4xl grid-cols-1 items-start justify-between gap-x-10 gap-y-6 lg:grid-cols-2">
<div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">Name your workspace</h4>
<div className="flex flex-col gap-1">
<Controller
control={control}
name="name"
rules={{
validate: (value) => validateWorkspaceName(value, true),
}}
render={({ field: { value, ref, onChange } }) => (
<Input
id="workspaceName"
type="text"
value={value}
onChange={(e) => {
onChange(e.target.value);
setValue("name", e.target.value);
setValue("slug", e.target.value.toLocaleLowerCase().trim().replace(/ /g, "-"), {
shouldValidate: true,
});
}}
ref={ref}
hasError={Boolean(errors.name)}
placeholder="Something familiar and recognizable is always best."
className="w-full"
/>
)}
/>
<span className="text-11 text-danger-primary">{errors?.name?.message}</span>
</div>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">Set your workspace&apos;s URL</h4>
<div className="flex gap-0.5 w-full items-center rounded-md border-[0.5px] border-subtle px-3">
<span className="whitespace-nowrap text-13 text-secondary">{workspaceBaseURL}</span>
<Controller
control={control}
name="slug"
rules={{
validate: (value) => validateSlug(value),
}}
render={({ field: { onChange, value, ref } }) => (
<Input
id="workspaceUrl"
type="text"
value={value.toLocaleLowerCase().trim().replace(/ /g, "-")}
onChange={(e) => {
if (/^[a-zA-Z0-9_-]+$/.test(e.target.value)) setInvalidSlug(false);
else setInvalidSlug(true);
onChange(e.target.value.toLowerCase());
}}
ref={ref}
hasError={Boolean(errors.slug)}
placeholder="workspace-name"
className="block w-full rounded-md border-none bg-transparent !px-0 py-2 text-13"
/>
)}
/>
</div>
{slugError && <p className="text-13 text-danger-primary">This URL is taken. Try something else.</p>}
{invalidSlug && (
<p className="text-13 text-danger-primary">{`URLs can contain only ( - ), ( _ ) and alphanumeric characters.`}</p>
)}
{errors.slug && <span className="text-11 text-danger-primary">{errors.slug.message}</span>}
</div>
<div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">How many people will use this workspace?</h4>
<div className="w-full">
<Controller
name="organization_size"
control={control}
rules={{ required: "This is a required field." }}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={
ORGANIZATION_SIZE.find((c) => c === value) ?? (
<span className="text-placeholder">Select a range</span>
)
}
buttonClassName="!border-[0.5px] !border-subtle !shadow-none"
input
>
{ORGANIZATION_SIZE.map((item) => (
<CustomSelect.Option key={item} value={item}>
{item}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
{errors.organization_size && (
<span className="text-13 text-danger-primary">{errors.organization_size.message}</span>
)}
</div>
</div>
</div>
<div className="flex max-w-4xl items-center py-1 gap-4">
<Button
variant="primary"
size="lg"
onClick={handleSubmit(handleCreateWorkspace)}
disabled={!isValid}
loading={isSubmitting}
>
{isSubmitting ? "Creating workspace" : "Create workspace"}
</Button>
<Link className={getButtonStyling("secondary", "lg")} href="/workspace">
Go back
</Link>
</div>
</div>
);
}
@@ -1,37 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { observer } from "mobx-react";
// components
import { PageWrapper } from "@/components/common/page-wrapper";
// types
import type { Route } from "./+types/page";
// local
import { WorkspaceCreateForm } from "./form";
const WorkspaceCreatePage = observer(function WorkspaceCreatePage(_props: Route.ComponentProps) {
return (
<PageWrapper
header={{
title: "Create a new workspace on this instance.",
description: "You will need to invite users from Workspace Settings after you create this workspace.",
}}
>
<WorkspaceCreateForm />
</PageWrapper>
);
});
export const meta: Route.MetaFunction = () => [{ title: "Create Workspace - God Mode" }];
export default WorkspaceCreatePage;
@@ -1,179 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import useSWR from "swr";
import { Loader as LoaderIcon } from "lucide-react";
// types
import { Button, getButtonStyling } from "@plane/propel/button";
import { setPromiseToast } from "@plane/propel/toast";
import { Switch } from "@plane/propel/switch";
import type { TInstanceConfigurationKeys } from "@plane/types";
import { Loader } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { PageWrapper } from "@/components/common/page-wrapper";
import { WorkspaceListItem } from "@/components/workspace/list-item";
// hooks
import { useInstance, useWorkspace } from "@/hooks/store";
// types
import type { Route } from "./+types/page";
const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props: Route.ComponentProps) {
// states
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// store
const { formattedConfig, fetchInstanceConfigurations, updateInstanceConfigurations } = useInstance();
const {
workspaceIds,
loader: workspaceLoader,
paginationInfo,
fetchWorkspaces,
fetchNextWorkspaces,
} = useWorkspace();
// derived values
const disableWorkspaceCreation = formattedConfig?.DISABLE_WORKSPACE_CREATION ?? "";
const hasNextPage = paginationInfo?.next_page_results && paginationInfo?.next_cursor !== undefined;
// fetch data
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
useSWR("INSTANCE_WORKSPACES", () => fetchWorkspaces());
const updateConfig = async (key: TInstanceConfigurationKeys, value: string) => {
setIsSubmitting(true);
const payload = {
[key]: value,
};
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving configuration",
success: {
title: "Success",
message: () => "Configuration saved successfully",
},
error: {
title: "Error",
message: () => "Failed to save configuration",
},
});
await updateConfigPromise
.then(() => {
setIsSubmitting(false);
})
.catch((err) => {
console.error(err);
setIsSubmitting(false);
});
};
return (
<PageWrapper
header={{
title: "Workspaces on this instance",
description: "See all workspaces and control who can create them.",
}}
>
<div className="space-y-3">
{formattedConfig ? (
<div className={cn("w-full flex items-center gap-14 rounded-sm")}>
<div className="flex grow items-center gap-4">
<div className="grow">
<div className="text-16 font-medium pb-1">Prevent anyone else from creating a workspace.</div>
<div className={cn("font-regular leading-5 text-tertiary text-11")}>
Toggling this on will let only you create workspaces. You will have to invite users to new workspaces.
</div>
</div>
</div>
<div className={`shrink-0 pr-4 ${isSubmitting && "opacity-70"}`}>
<div className="flex items-center gap-4">
<Switch
value={Boolean(parseInt(disableWorkspaceCreation))}
onChange={() => {
if (Boolean(parseInt(disableWorkspaceCreation)) === true) {
updateConfig("DISABLE_WORKSPACE_CREATION", "0");
} else {
updateConfig("DISABLE_WORKSPACE_CREATION", "1");
}
}}
disabled={isSubmitting}
/>
</div>
</div>
</div>
) : (
<Loader>
<Loader.Item height="50px" width="100%" />
</Loader>
)}
{workspaceLoader !== "init-loader" ? (
<>
<div className="pt-6 flex items-center justify-between gap-2">
<div className="flex flex-col items-start gap-x-2">
<div className="flex items-center gap-2 text-16 font-medium">
All workspaces on this instance <span className="text-tertiary"> {workspaceIds.length}</span>
{workspaceLoader && ["mutation", "pagination"].includes(workspaceLoader) && (
<LoaderIcon className="w-4 h-4 animate-spin" />
)}
</div>
<div className={cn("font-regular leading-5 text-tertiary text-11")}>
You can&apos;t yet delete workspaces and you can only go to the workspace if you are an Admin or a
Member.
</div>
</div>
<div className="flex items-center gap-2">
<Link href="/workspace/create" className={getButtonStyling("primary", "base")}>
Create workspace
</Link>
</div>
</div>
<div className="flex flex-col gap-4 py-2">
{workspaceIds.map((workspaceId) => (
<WorkspaceListItem key={workspaceId} workspaceId={workspaceId} />
))}
</div>
{hasNextPage && (
<div className="flex justify-center">
<Button
variant="link"
size="lg"
onClick={() => fetchNextWorkspaces()}
disabled={workspaceLoader === "pagination"}
>
Load more
{workspaceLoader === "pagination" && <LoaderIcon className="w-3 h-3 animate-spin" />}
</Button>
</div>
)}
</>
) : (
<Loader className="space-y-10 py-8">
<Loader.Item height="24px" width="20%" />
<Loader.Item height="92px" width="100%" />
<Loader.Item height="92px" width="100%" />
<Loader.Item height="92px" width="100%" />
</Loader>
)}
</div>
</PageWrapper>
);
});
export const meta: Route.MetaFunction = () => [{ title: "Workspace Management - God Mode" }];
export default WorkspaceManagementPage;
@@ -1,29 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { observer } from "mobx-react";
import { Outlet } from "react-router";
// local components
import type { Route } from "./+types/layout";
import { InstanceAdminAuthWrapper } from "../auth-wrapper";
function AdminLayoutWithoutSidebar(_props: Route.ComponentProps) {
return (
<InstanceAdminAuthWrapper>
<div className="h-screen w-screen overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md pt-6 pb-10 px-8 bg-surface-1">
<Outlet />
</div>
</InstanceAdminAuthWrapper>
);
}
export default observer(AdminLayoutWithoutSidebar);
@@ -1,22 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
// local
import type { Route } from "./+types/page";
import { ResetPasswordRoot } from "@/plane-admin/components/authentication/reset-password/root";
function ResetPasswordPage(_props: Route.ComponentProps) {
return <ResetPasswordRoot />;
}
export default ResetPasswordPage;
@@ -0,0 +1,140 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useForm } from "react-hook-form";
import { Lightbulb } from "lucide-react";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@plane/types";
// components
import type { TControllerInputFormField } from "@/components/common/controller-input";
import { ControllerInput } from "@/components/common/controller-input";
// hooks
import { useInstance } from "@/hooks/store";
type IInstanceAIForm = {
config: IFormattedInstanceConfiguration;
};
type AIFormValues = Record<TInstanceAIConfigurationKeys, string>;
export function InstanceAIForm(props: IInstanceAIForm) {
const { config } = props;
// store
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
formState: { errors, isSubmitting },
} = useForm<AIFormValues>({
defaultValues: {
LLM_API_KEY: config["LLM_API_KEY"],
LLM_MODEL: config["LLM_MODEL"],
},
});
const aiFormFields: TControllerInputFormField[] = [
{
key: "LLM_MODEL",
type: "text",
label: "LLM Model",
description: (
<>
Choose an OpenAI engine.{" "}
<a
href="https://platform.openai.com/docs/models/overview"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
Learn more
</a>
</>
),
placeholder: "gpt-4o-mini",
error: Boolean(errors.LLM_MODEL),
required: false,
},
{
key: "LLM_API_KEY",
type: "password",
label: "API key",
description: (
<>
You will find your API key{" "}
<a
href="https://platform.openai.com/api-keys"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
here.
</a>
</>
),
placeholder: "sk-asddassdfasdefqsdfasd23das3dasdcasd",
error: Boolean(errors.LLM_API_KEY),
required: false,
},
];
const onSubmit = async (formData: AIFormValues) => {
const payload: Partial<AIFormValues> = { ...formData };
await updateInstanceConfigurations(payload)
.then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success",
message: "AI Settings updated successfully",
})
)
.catch((err) => console.error(err));
};
return (
<div className="space-y-8">
<div className="space-y-3">
<div>
<div className="pb-1 text-18 font-medium text-primary">OpenAI</div>
<div className="text-13 font-regular text-tertiary">If you use ChatGPT, this is for you.</div>
</div>
<div className="grid-col grid w-full grid-cols-1 items-center justify-between gap-x-12 gap-y-8 lg:grid-cols-3">
{aiFormFields.map((field) => (
<ControllerInput
key={field.key}
control={control}
type={field.type}
name={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
error={field.error}
required={field.required}
/>
))}
</div>
</div>
<div className="flex flex-col items-start gap-4">
<Button variant="primary" size="lg" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving" : "Save changes"}
</Button>
<div className="relative inline-flex items-center gap-1.5 rounded-sm border border-accent-subtle bg-accent-subtle px-4 py-2 text-caption-sm-regular text-accent-secondary">
<Lightbulb className="size-4" />
<div>
If you have a preferred AI models vendor, please get in{" "}
<a className="font-medium underline" href="https://plane.so/contact">
touch with us.
</a>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,50 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import useSWR from "swr";
import { Loader } from "@plane/ui";
// components
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { useInstance } from "@/hooks/store";
// types
import type { Route } from "./+types/page";
// local
import { InstanceAIForm } from "./form";
const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentProps) {
// store
const { fetchInstanceConfigurations, formattedConfig } = useInstance();
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
return (
<PageWrapper
header={{
title: "AI features for all your workspaces",
description: "Configure your AI API credentials so Plane AI features are turned on for all your workspaces.",
}}
>
{formattedConfig ? (
<InstanceAIForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="40%" />
<div className="grid w-2/3 grid-cols-2 gap-x-8 gap-y-4">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</div>
<Loader.Item height="50px" width="20%" />
</Loader>
)}
</PageWrapper>
);
});
export const meta: Route.MetaFunction = () => [{ title: "Artificial Intelligence Settings - God Mode" }];
export default InstanceAIPage;
@@ -1,58 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { useRouter } from "@/app/compat/next/navigation";
import { LogoSpinner } from "@/components/common/logo-spinner";
import { useUser } from "@/hooks/store";
import { useInstanceFeatureFlags } from "@/plane-admin/hooks/store/use-instance-feature-flag";
import { observer } from "mobx-react";
import { useEffect } from "react";
import type { FC } from "react";
import useSWR from "swr";
type Props = {
children: React.ReactNode;
};
export const InstanceAdminAuthWrapper: FC<Props> = observer(function InstanceAdminAuthWrapper(props: Props) {
// props
const { children } = props;
// router
const { replace } = useRouter();
// store hooks
const { isUserLoggedIn } = useUser();
// plane admin hooks
const { fetchInstanceFeatureFlags } = useInstanceFeatureFlags();
// fetching instance feature flags
const { isLoading: flagsLoader, error: flagsError } = useSWR(
`INSTANCE_FEATURE_FLAGS`,
() => fetchInstanceFeatureFlags(),
{ revalidateOnFocus: false, revalidateIfStale: false, errorRetryCount: 1 }
);
useEffect(() => {
if (isUserLoggedIn === false) replace("/");
}, [replace, isUserLoggedIn]);
if ((flagsLoader && !flagsError) || isUserLoggedIn === undefined) {
return (
<div className="relative flex h-screen w-full items-center justify-center">
<LogoSpinner />
</div>
);
}
if (isUserLoggedIn) return <>{children}</>;
return <></>;
});
@@ -0,0 +1,225 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { isEmpty } from "lodash-es";
import Link from "next/link";
import { useForm } from "react-hook-form";
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceGiteaAuthenticationConfigurationKeys } from "@plane/types";
// components
import { CodeBlock } from "@/components/common/code-block";
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
import type { TControllerInputFormField } from "@/components/common/controller-input";
import { ControllerInput } from "@/components/common/controller-input";
import type { TControllerSwitchFormField } from "@/components/common/controller-switch";
import { ControllerSwitch } from "@/components/common/controller-switch";
import type { TCopyField } from "@/components/common/copy-field";
import { CopyField } from "@/components/common/copy-field";
// hooks
import { useInstance } from "@/hooks/store";
type Props = {
config: IFormattedInstanceConfiguration;
};
type GiteaConfigFormValues = Record<TInstanceGiteaAuthenticationConfigurationKeys, string>;
export function InstanceGiteaConfigForm(props: Props) {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
// store hooks
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
reset,
formState: { errors, isDirty, isSubmitting },
} = useForm<GiteaConfigFormValues>({
defaultValues: {
GITEA_HOST: config["GITEA_HOST"] || "https://gitea.com",
GITEA_CLIENT_ID: config["GITEA_CLIENT_ID"],
GITEA_CLIENT_SECRET: config["GITEA_CLIENT_SECRET"],
ENABLE_GITEA_SYNC: config["ENABLE_GITEA_SYNC"] || "0",
},
});
const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : "";
const GITEA_FORM_FIELDS: TControllerInputFormField[] = [
{
key: "GITEA_HOST",
type: "text",
label: "Gitea Host",
description: (
<>Use the URL of your Gitea instance. For the official Gitea instance, use &quot;https://gitea.com&quot;.</>
),
placeholder: "https://gitea.com",
error: Boolean(errors.GITEA_HOST),
required: true,
},
{
key: "GITEA_CLIENT_ID",
type: "text",
label: "Client ID",
description: (
<>
You will get this from your{" "}
<a
tabIndex={-1}
href="https://gitea.com/user/settings/applications"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
Gitea OAuth application settings.
</a>
</>
),
placeholder: "70a44354520df8bd9bcd",
error: Boolean(errors.GITEA_CLIENT_ID),
required: true,
},
{
key: "GITEA_CLIENT_SECRET",
type: "password",
label: "Client secret",
description: (
<>
Your client secret is also found in your{" "}
<a
tabIndex={-1}
href="https://gitea.com/user/settings/applications"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
Gitea OAuth application settings.
</a>
</>
),
placeholder: "9b0050f94ec1b744e32ce79ea4ffacd40d4119cb",
error: Boolean(errors.GITEA_CLIENT_SECRET),
required: true,
},
];
const GITEA_FORM_SWITCH_FIELD: TControllerSwitchFormField<GiteaConfigFormValues> = {
name: "ENABLE_GITEA_SYNC",
label: "Gitea",
};
const GITEA_SERVICE_FIELD: TCopyField[] = [
{
key: "Callback_URI",
label: "Callback URI",
url: `${originURL}/auth/gitea/callback/`,
description: (
<>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Callback URI</CodeBlock>{" "}
field{" "}
<a
tabIndex={-1}
href={`${control._formValues.GITEA_HOST || "https://gitea.com"}/user/settings/applications`}
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
here.
</a>
</>
),
},
];
const onSubmit = async (formData: GiteaConfigFormValues) => {
const payload: Partial<GiteaConfigFormValues> = { ...formData };
try {
const response = await updateInstanceConfigurations(payload);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Done!",
message: "Your Gitea authentication is configured. You should test it now.",
});
reset({
GITEA_HOST: response.find((item) => item.key === "GITEA_HOST")?.value,
GITEA_CLIENT_ID: response.find((item) => item.key === "GITEA_CLIENT_ID")?.value,
GITEA_CLIENT_SECRET: response.find((item) => item.key === "GITEA_CLIENT_SECRET")?.value,
ENABLE_GITEA_SYNC: response.find((item) => item.key === "ENABLE_GITEA_SYNC")?.value,
});
} catch (err) {
console.error(err);
}
};
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
if (isDirty) {
e.preventDefault();
setIsDiscardChangesModalOpen(true);
}
};
return (
<>
<ConfirmDiscardModal
isOpen={isDiscardChangesModalOpen}
onDiscardHref="/authentication"
handleClose={() => setIsDiscardChangesModalOpen(false)}
/>
<div className="flex flex-col gap-8">
<div className="grid w-full grid-cols-2 gap-x-12 gap-y-8">
<div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1">
<div className="pt-2.5 text-18 font-medium">Gitea-provided details for Plane</div>
{GITEA_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
control={control}
type={field.type}
name={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
error={field.error}
required={field.required}
/>
))}
<ControllerSwitch control={control} field={GITEA_FORM_SWITCH_FIELD} />
<div className="flex flex-col gap-1 pt-4">
<div className="flex items-center gap-4">
<Button
variant="primary"
size="lg"
onClick={(e) => void handleSubmit(onSubmit)(e)}
loading={isSubmitting}
disabled={!isDirty}
>
{isSubmitting ? "Saving" : "Save changes"}
</Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
Go back
</Link>
</div>
</div>
</div>
<div className="col-span-2 md:col-span-1">
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-1 px-6 pt-1.5 pb-4">
<div className="pt-2 text-18 font-medium">Plane-provided details for Gitea</div>
{GITEA_SERVICE_FIELD.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,105 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// plane internal packages
import { setPromiseToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } from "@plane/ui";
// assets
import giteaLogo from "@/app/assets/logos/gitea-logo.svg?url";
// components
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { useInstance } from "@/hooks/store";
// types
import type { Route } from "./+types/page";
// local
import { InstanceGiteaConfigForm } from "./form";
const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthenticationPage() {
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// state
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// config
const enableGiteaConfig = formattedConfig?.IS_GITEA_ENABLED ?? "";
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
const updateConfig = async (key: "IS_GITEA_ENABLED", value: string) => {
setIsSubmitting(true);
const payload = {
[key]: value,
};
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration",
success: {
title: "Configuration saved",
message: () => `Gitea authentication is now ${value === "1" ? "active" : "disabled"}.`,
},
error: {
title: "Error",
message: () => "Failed to save configuration",
},
});
await updateConfigPromise
.then(() => {
setIsSubmitting(false);
})
.catch((err) => {
console.error(err);
setIsSubmitting(false);
});
};
const isGiteaEnabled = enableGiteaConfig === "1";
return (
<PageWrapper
customHeader={
<AuthenticationMethodCard
name="Gitea"
description="Allow members to login or sign up to plane with their Gitea accounts."
icon={<img src={giteaLogo} height={24} width={24} alt="Gitea Logo" />}
config={
<ToggleSwitch
value={isGiteaEnabled}
onChange={() => {
updateConfig("IS_GITEA_ENABLED", isGiteaEnabled ? "0" : "1");
}}
size="sm"
disabled={isSubmitting || !formattedConfig}
/>
}
disabled={isSubmitting || !formattedConfig}
withBorder={false}
/>
}
>
{formattedConfig ? (
<InstanceGiteaConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="25%" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" width="50%" />
</Loader>
)}
</PageWrapper>
);
});
export const meta: Route.MetaFunction = () => [{ title: "Gitea Authentication - God Mode" }];
export default InstanceGiteaAuthenticationPage;
@@ -0,0 +1,263 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { isEmpty } from "lodash-es";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { Monitor } from "lucide-react";
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types";
// components
import { CodeBlock } from "@/components/common/code-block";
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
import type { TControllerInputFormField } from "@/components/common/controller-input";
import type { TControllerSwitchFormField } from "@/components/common/controller-switch";
import { ControllerSwitch } from "@/components/common/controller-switch";
import { ControllerInput } from "@/components/common/controller-input";
import type { TCopyField } from "@/components/common/copy-field";
import { CopyField } from "@/components/common/copy-field";
// hooks
import { useInstance } from "@/hooks/store";
type Props = {
config: IFormattedInstanceConfiguration;
};
type GithubConfigFormValues = Record<TInstanceGithubAuthenticationConfigurationKeys, string>;
export function InstanceGithubConfigForm(props: Props) {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
// store hooks
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
reset,
formState: { errors, isDirty, isSubmitting },
} = useForm<GithubConfigFormValues>({
defaultValues: {
GITHUB_CLIENT_ID: config["GITHUB_CLIENT_ID"],
GITHUB_CLIENT_SECRET: config["GITHUB_CLIENT_SECRET"],
GITHUB_ORGANIZATION_ID: config["GITHUB_ORGANIZATION_ID"],
ENABLE_GITHUB_SYNC: config["ENABLE_GITHUB_SYNC"] || "0",
},
});
const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : "";
const GITHUB_FORM_FIELDS: TControllerInputFormField[] = [
{
key: "GITHUB_CLIENT_ID",
type: "text",
label: "Client ID",
description: (
<>
You will get this from your{" "}
<a
tabIndex={-1}
href="https://github.com/settings/applications/new"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
GitHub OAuth application settings.
</a>
</>
),
placeholder: "70a44354520df8bd9bcd",
error: Boolean(errors.GITHUB_CLIENT_ID),
required: true,
},
{
key: "GITHUB_CLIENT_SECRET",
type: "password",
label: "Client secret",
description: (
<>
Your client secret is also found in your{" "}
<a
tabIndex={-1}
href="https://github.com/settings/applications/new"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
GitHub OAuth application settings.
</a>
</>
),
placeholder: "9b0050f94ec1b744e32ce79ea4ffacd40d4119cb",
error: Boolean(errors.GITHUB_CLIENT_SECRET),
required: true,
},
{
key: "GITHUB_ORGANIZATION_ID",
type: "text",
label: "Organization ID",
description: <>The organization github ID.</>,
placeholder: "123456789",
error: Boolean(errors.GITHUB_ORGANIZATION_ID),
required: false,
},
];
const GITHUB_FORM_SWITCH_FIELD: TControllerSwitchFormField<GithubConfigFormValues> = {
name: "ENABLE_GITHUB_SYNC",
label: "GitHub",
};
const GITHUB_COMMON_SERVICE_DETAILS: TCopyField[] = [
{
key: "Origin_URL",
label: "Origin URL",
url: originURL,
description: (
<>
We will auto-generate this. Paste this into the <CodeBlock darkerShade>Authorized origin URL</CodeBlock> field{" "}
<a
tabIndex={-1}
href="https://github.com/settings/applications/new"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
here.
</a>
</>
),
},
];
const GITHUB_SERVICE_DETAILS: TCopyField[] = [
{
key: "Callback_URI",
label: "Callback URI",
url: `${originURL}/auth/github/callback/`,
description: (
<>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Callback URI</CodeBlock>{" "}
field{" "}
<a
tabIndex={-1}
href="https://github.com/settings/applications/new"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
here.
</a>
</>
),
},
];
const onSubmit = async (formData: GithubConfigFormValues) => {
const payload: Partial<GithubConfigFormValues> = { ...formData };
try {
const response = await updateInstanceConfigurations(payload);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Done!",
message: "Your GitHub authentication is configured. You should test it now.",
});
reset({
GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value,
GITHUB_CLIENT_SECRET: response.find((item) => item.key === "GITHUB_CLIENT_SECRET")?.value,
GITHUB_ORGANIZATION_ID: response.find((item) => item.key === "GITHUB_ORGANIZATION_ID")?.value,
ENABLE_GITHUB_SYNC: response.find((item) => item.key === "ENABLE_GITHUB_SYNC")?.value,
});
} catch (err) {
console.error(err);
}
};
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
if (isDirty) {
e.preventDefault();
setIsDiscardChangesModalOpen(true);
}
};
return (
<>
<ConfirmDiscardModal
isOpen={isDiscardChangesModalOpen}
onDiscardHref="/authentication"
handleClose={() => setIsDiscardChangesModalOpen(false)}
/>
<div className="flex flex-col gap-8">
<div className="grid w-full grid-cols-2 gap-x-12 gap-y-8">
<div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1">
<div className="pt-2.5 text-18 font-medium">GitHub-provided details for Plane</div>
{GITHUB_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
control={control}
type={field.type}
name={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
error={field.error}
required={field.required}
/>
))}
<ControllerSwitch control={control} field={GITHUB_FORM_SWITCH_FIELD} />
<div className="flex flex-col gap-1 pt-4">
<div className="flex items-center gap-4">
<Button
variant="primary"
size="lg"
onClick={(e) => void handleSubmit(onSubmit)(e)}
loading={isSubmitting}
disabled={!isDirty}
>
{isSubmitting ? "Saving" : "Save changes"}
</Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
Go back
</Link>
</div>
</div>
</div>
<div className="col-span-2 flex flex-col gap-y-6 md:col-span-1">
<div className="pt-2 text-18 font-medium">Plane-provided details for GitHub</div>
<div className="flex flex-col gap-y-4">
{/* common service details */}
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-1 px-6 py-4">
{GITHUB_COMMON_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
{/* web service details */}
<div className="flex flex-col overflow-hidden rounded-lg">
<div className="flex items-center gap-x-3 bg-layer-3 px-6 py-3 text-11 font-medium text-secondary uppercase">
<Monitor className="h-3 w-3" />
Web
</div>
<div className="flex flex-col gap-y-4 bg-layer-1 px-6 py-4">
{GITHUB_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
</div>
</div>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,121 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import { useTheme } from "next-themes";
import useSWR from "swr";
// plane internal packages
import { setPromiseToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } from "@plane/ui";
import { resolveGeneralTheme } from "@plane/utils";
// assets
import githubLightModeImage from "@/app/assets/logos/github-black.png?url";
import githubDarkModeImage from "@/app/assets/logos/github-white.png?url";
// components
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { useInstance } from "@/hooks/store";
// types
import type { Route } from "./+types/page";
// local
import { InstanceGithubConfigForm } from "./form";
const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthenticationPage(
_props: Route.ComponentProps
) {
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// state
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// theme
const { resolvedTheme } = useTheme();
// config
const enableGithubConfig = formattedConfig?.IS_GITHUB_ENABLED ?? "";
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
const updateConfig = async (key: "IS_GITHUB_ENABLED", value: string) => {
setIsSubmitting(true);
const payload = {
[key]: value,
};
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration",
success: {
title: "Configuration saved",
message: () => `GitHub authentication is now ${value === "1" ? "active" : "disabled"}.`,
},
error: {
title: "Error",
message: () => "Failed to save configuration",
},
});
await updateConfigPromise
.then(() => {
setIsSubmitting(false);
})
.catch((err) => {
console.error(err);
setIsSubmitting(false);
});
};
const isGithubEnabled = enableGithubConfig === "1";
return (
<PageWrapper
customHeader={
<AuthenticationMethodCard
name="GitHub"
description="Allow members to login or sign up to plane with their GitHub accounts."
icon={
<img
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
height={24}
width={24}
alt="GitHub Logo"
/>
}
config={
<ToggleSwitch
value={isGithubEnabled}
onChange={() => {
updateConfig("IS_GITHUB_ENABLED", isGithubEnabled ? "0" : "1");
}}
size="sm"
disabled={isSubmitting || !formattedConfig}
/>
}
disabled={isSubmitting || !formattedConfig}
withBorder={false}
/>
}
>
{formattedConfig ? (
<InstanceGithubConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="25%" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" width="50%" />
</Loader>
)}
</PageWrapper>
);
});
export const meta: Route.MetaFunction = () => [{ title: "GitHub Authentication - God Mode" }];
export default InstanceGithubAuthenticationPage;
@@ -0,0 +1,229 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { isEmpty } from "lodash-es";
import Link from "next/link";
import { useForm } from "react-hook-form";
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types";
// components
import { CodeBlock } from "@/components/common/code-block";
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
import type { TControllerInputFormField } from "@/components/common/controller-input";
import type { TControllerSwitchFormField } from "@/components/common/controller-switch";
import { ControllerSwitch } from "@/components/common/controller-switch";
import { ControllerInput } from "@/components/common/controller-input";
import type { TCopyField } from "@/components/common/copy-field";
import { CopyField } from "@/components/common/copy-field";
// hooks
import { useInstance } from "@/hooks/store";
type Props = {
config: IFormattedInstanceConfiguration;
};
type GitlabConfigFormValues = Record<TInstanceGitlabAuthenticationConfigurationKeys, string>;
export function InstanceGitlabConfigForm(props: Props) {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
// store hooks
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
reset,
formState: { errors, isDirty, isSubmitting },
} = useForm<GitlabConfigFormValues>({
defaultValues: {
GITLAB_HOST: config["GITLAB_HOST"],
GITLAB_CLIENT_ID: config["GITLAB_CLIENT_ID"],
GITLAB_CLIENT_SECRET: config["GITLAB_CLIENT_SECRET"],
ENABLE_GITLAB_SYNC: config["ENABLE_GITLAB_SYNC"] || "0",
},
});
const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : "";
const GITLAB_FORM_FIELDS: TControllerInputFormField[] = [
{
key: "GITLAB_HOST",
type: "text",
label: "Host",
description: (
<>
This is either https://gitlab.com or the <CodeBlock>domain.tld</CodeBlock> where you host GitLab.
</>
),
placeholder: "https://gitlab.com",
error: Boolean(errors.GITLAB_HOST),
required: true,
},
{
key: "GITLAB_CLIENT_ID",
type: "text",
label: "Application ID",
description: (
<>
Get this from your{" "}
<a
tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
GitLab OAuth application settings
</a>
.
</>
),
placeholder: "c2ef2e7fc4e9d15aa7630f5637d59e8e4a27ff01dceebdb26b0d267b9adcf3c3",
error: Boolean(errors.GITLAB_CLIENT_ID),
required: true,
},
{
key: "GITLAB_CLIENT_SECRET",
type: "password",
label: "Secret",
description: (
<>
The client secret is also found in your{" "}
<a
tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
GitLab OAuth application settings
</a>
.
</>
),
placeholder: "gloas-f79cfa9a03c97f6ffab303177a5a6778a53c61e3914ba093412f68a9298a1b28",
error: Boolean(errors.GITLAB_CLIENT_SECRET),
required: true,
},
];
const GITLAB_FORM_SWITCH_FIELD: TControllerSwitchFormField<GitlabConfigFormValues> = {
name: "ENABLE_GITLAB_SYNC",
label: "GitLab",
};
const GITLAB_SERVICE_FIELD: TCopyField[] = [
{
key: "Callback_URL",
label: "Callback URL",
url: `${originURL}/auth/gitlab/callback/`,
description: (
<>
We will auto-generate this. Paste this into the <CodeBlock darkerShade>Redirect URI</CodeBlock> field of your{" "}
<a
tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
GitLab OAuth application
</a>
.
</>
),
},
];
const onSubmit = async (formData: GitlabConfigFormValues) => {
const payload: Partial<GitlabConfigFormValues> = { ...formData };
try {
const response = await updateInstanceConfigurations(payload);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Done!",
message: "Your GitLab authentication is configured. You should test it now.",
});
reset({
GITLAB_HOST: response.find((item) => item.key === "GITLAB_HOST")?.value,
GITLAB_CLIENT_ID: response.find((item) => item.key === "GITLAB_CLIENT_ID")?.value,
GITLAB_CLIENT_SECRET: response.find((item) => item.key === "GITLAB_CLIENT_SECRET")?.value,
ENABLE_GITLAB_SYNC: response.find((item) => item.key === "ENABLE_GITLAB_SYNC")?.value,
});
} catch (err) {
console.error(err);
}
};
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
if (isDirty) {
e.preventDefault();
setIsDiscardChangesModalOpen(true);
}
};
return (
<>
<ConfirmDiscardModal
isOpen={isDiscardChangesModalOpen}
onDiscardHref="/authentication"
handleClose={() => setIsDiscardChangesModalOpen(false)}
/>
<div className="flex flex-col gap-8">
<div className="grid w-full grid-cols-2 gap-x-12 gap-y-8">
<div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1">
<div className="pt-2.5 text-18 font-medium">GitLab-provided details for Plane</div>
{GITLAB_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
control={control}
type={field.type}
name={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
error={field.error}
required={field.required}
/>
))}
<ControllerSwitch control={control} field={GITLAB_FORM_SWITCH_FIELD} />
<div className="flex flex-col gap-1 pt-4">
<div className="flex items-center gap-4">
<Button
variant="primary"
size="lg"
onClick={(e) => void handleSubmit(onSubmit)(e)}
loading={isSubmitting}
disabled={!isDirty}
>
{isSubmitting ? "Saving" : "Save changes"}
</Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
Go back
</Link>
</div>
</div>
</div>
<div className="col-span-2 md:col-span-1">
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-3 px-6 pt-1.5 pb-4">
<div className="pt-2 text-18 font-medium">Plane-provided details for GitLab</div>
{GITLAB_SERVICE_FIELD.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,109 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { setPromiseToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } from "@plane/ui";
// assets
import GitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url";
// components
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { useInstance } from "@/hooks/store";
// types
import type { Route } from "./+types/page";
// local
import { InstanceGitlabConfigForm } from "./form";
const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthenticationPage(
_props: Route.ComponentProps
) {
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// state
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// config
const enableGitlabConfig = formattedConfig?.IS_GITLAB_ENABLED ?? "";
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
const updateConfig = async (key: "IS_GITLAB_ENABLED", value: string) => {
setIsSubmitting(true);
const payload = {
[key]: value,
};
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration",
success: {
title: "Configuration saved",
message: () => `GitLab authentication is now ${value === "1" ? "active" : "disabled"}.`,
},
error: {
title: "Error",
message: () => "Failed to save configuration",
},
});
await updateConfigPromise
.then(() => {
setIsSubmitting(false);
})
.catch((err) => {
console.error(err);
setIsSubmitting(false);
});
};
return (
<PageWrapper
customHeader={
<AuthenticationMethodCard
name="GitLab"
description="Allow members to login or sign up to plane with their GitLab accounts."
icon={<img src={GitlabLogo} height={24} width={24} alt="GitLab Logo" />}
config={
<ToggleSwitch
value={Boolean(parseInt(enableGitlabConfig))}
onChange={() => {
if (Boolean(parseInt(enableGitlabConfig)) === true) {
updateConfig("IS_GITLAB_ENABLED", "0");
} else {
updateConfig("IS_GITLAB_ENABLED", "1");
}
}}
size="sm"
disabled={isSubmitting || !formattedConfig}
/>
}
disabled={isSubmitting || !formattedConfig}
withBorder={false}
/>
}
>
{formattedConfig ? (
<InstanceGitlabConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="25%" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" width="50%" />
</Loader>
)}
</PageWrapper>
);
});
export const meta: Route.MetaFunction = () => [{ title: "GitLab Authentication - God Mode" }];
export default InstanceGitlabAuthenticationPage;
@@ -0,0 +1,251 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { isEmpty } from "lodash-es";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { Monitor } from "lucide-react";
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types";
// components
import { CodeBlock } from "@/components/common/code-block";
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
import type { TControllerInputFormField } from "@/components/common/controller-input";
import type { TControllerSwitchFormField } from "@/components/common/controller-switch";
import { ControllerSwitch } from "@/components/common/controller-switch";
import { ControllerInput } from "@/components/common/controller-input";
import type { TCopyField } from "@/components/common/copy-field";
import { CopyField } from "@/components/common/copy-field";
// hooks
import { useInstance } from "@/hooks/store";
type Props = {
config: IFormattedInstanceConfiguration;
};
type GoogleConfigFormValues = Record<TInstanceGoogleAuthenticationConfigurationKeys, string>;
export function InstanceGoogleConfigForm(props: Props) {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
// store hooks
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
reset,
formState: { errors, isDirty, isSubmitting },
} = useForm<GoogleConfigFormValues>({
defaultValues: {
GOOGLE_CLIENT_ID: config["GOOGLE_CLIENT_ID"],
GOOGLE_CLIENT_SECRET: config["GOOGLE_CLIENT_SECRET"],
ENABLE_GOOGLE_SYNC: config["ENABLE_GOOGLE_SYNC"] || "0",
},
});
const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : "";
const GOOGLE_FORM_FIELDS: TControllerInputFormField[] = [
{
key: "GOOGLE_CLIENT_ID",
type: "text",
label: "Client ID",
description: (
<>
Your client ID lives in your Google API Console.{" "}
<a
tabIndex={-1}
href="https://developers.google.com/identity/protocols/oauth2/javascript-implicit-flow#creatingcred"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
Learn more
</a>
</>
),
placeholder: "840195096245-0p2tstej9j5nc4l8o1ah2dqondscqc1g.apps.googleusercontent.com",
error: Boolean(errors.GOOGLE_CLIENT_ID),
required: true,
},
{
key: "GOOGLE_CLIENT_SECRET",
type: "password",
label: "Client secret",
description: (
<>
Your client secret should also be in your Google API Console.{" "}
<a
tabIndex={-1}
href="https://developers.google.com/identity/oauth2/web/guides/get-google-api-clientid"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
Learn more
</a>
</>
),
placeholder: "GOCShX-ADp4cI0kPqav1gGCBg5bE02E",
error: Boolean(errors.GOOGLE_CLIENT_SECRET),
required: true,
},
];
const GOOGLE_FORM_SWITCH_FIELD: TControllerSwitchFormField<GoogleConfigFormValues> = {
name: "ENABLE_GOOGLE_SYNC",
label: "Google",
};
const GOOGLE_COMMON_SERVICE_DETAILS: TCopyField[] = [
{
key: "Origin_URL",
label: "Origin URL",
url: originURL,
description: (
<p>
We will auto-generate this. Paste this into your{" "}
<CodeBlock darkerShade>Authorized JavaScript origins</CodeBlock> field. For this OAuth client{" "}
<a
href="https://console.cloud.google.com/apis/credentials/oauthclient"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
here.
</a>
</p>
),
},
];
const GOOGLE_SERVICE_DETAILS: TCopyField[] = [
{
key: "Callback_URI",
label: "Callback URI",
url: `${originURL}/auth/google/callback/`,
description: (
<p>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Redirect URI</CodeBlock>{" "}
field. For this OAuth client{" "}
<a
href="https://console.cloud.google.com/apis/credentials/oauthclient"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
here.
</a>
</p>
),
},
];
const onSubmit = async (formData: GoogleConfigFormValues) => {
const payload: Partial<GoogleConfigFormValues> = { ...formData };
try {
const response = await updateInstanceConfigurations(payload);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Done!",
message: "Your Google authentication is configured. You should test it now.",
});
reset({
GOOGLE_CLIENT_ID: response.find((item) => item.key === "GOOGLE_CLIENT_ID")?.value,
GOOGLE_CLIENT_SECRET: response.find((item) => item.key === "GOOGLE_CLIENT_SECRET")?.value,
ENABLE_GOOGLE_SYNC: response.find((item) => item.key === "ENABLE_GOOGLE_SYNC")?.value,
});
} catch (err) {
console.error(err);
}
};
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
if (isDirty) {
e.preventDefault();
setIsDiscardChangesModalOpen(true);
}
};
return (
<>
<ConfirmDiscardModal
isOpen={isDiscardChangesModalOpen}
onDiscardHref="/authentication"
handleClose={() => setIsDiscardChangesModalOpen(false)}
/>
<div className="flex flex-col gap-8">
<div className="grid w-full grid-cols-2 gap-x-12 gap-y-8">
<div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1">
<div className="pt-2.5 text-18 font-medium">Google-provided details for Plane</div>
{GOOGLE_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
control={control}
type={field.type}
name={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
error={field.error}
required={field.required}
/>
))}
<ControllerSwitch control={control} field={GOOGLE_FORM_SWITCH_FIELD} />
<div className="flex flex-col gap-1 pt-4">
<div className="flex items-center gap-4">
<Button
variant="primary"
size="lg"
onClick={(e) => void handleSubmit(onSubmit)(e)}
loading={isSubmitting}
disabled={!isDirty}
>
{isSubmitting ? "Saving" : "Save changes"}
</Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
Go back
</Link>
</div>
</div>
</div>
<div className="col-span-2 flex flex-col gap-y-6 md:col-span-1">
<div className="pt-2 text-18 font-medium">Plane-provided details for Google</div>
<div className="flex flex-col gap-y-4">
{/* common service details */}
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-1 px-6 py-4">
{GOOGLE_COMMON_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
{/* web service details */}
<div className="flex flex-col overflow-hidden rounded-lg">
<div className="flex items-center gap-x-3 bg-layer-3 px-6 py-3 text-11 font-medium text-secondary uppercase">
<Monitor className="h-3 w-3" />
Web
</div>
<div className="flex flex-col gap-y-4 bg-layer-1 px-6 py-4">
{GOOGLE_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
</div>
</div>
</div>
</div>
</div>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More