Compare commits

..

1 Commits

5711 changed files with 129253 additions and 248775 deletions
-67
View File
@@ -1,67 +0,0 @@
---
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)
@@ -1,65 +0,0 @@
---
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`
-200
View File
@@ -1,200 +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 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
@@ -1,608 +0,0 @@
---
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
+7
View File
@@ -0,0 +1,7 @@
[codespell]
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
skip = .git*,*.svg,i18n,*-lock.yaml,*.css,.codespellrc,migrations,*.js,*.map,*.mjs
check-hidden = true
# ignore all CamelCase and camelCase
ignore-regex = \b[A-Za-z][a-z]+[A-Z][a-zA-Z]+\b
ignore-words-list = tread
+1 -1
View File
@@ -66,4 +66,4 @@ temp/
.react-router/
build/
node_modules/
README.md
README.md
+1 -1
View File
@@ -1,7 +1,7 @@
name: Bug report
description: Create a bug report to help us improve Plane
title: "[bug]: "
labels: [🐛bug, plane]
labels: [🐛bug]
assignees: [vihar, pushya22]
body:
- type: markdown
@@ -1,7 +1,7 @@
name: Feature request
description: Suggest a feature to improve Plane
title: "[feature]: "
labels: [✨feature, plane]
labels: [✨feature]
assignees: [vihar, pushya22]
body:
- type: markdown
+1 -1
View File
@@ -1,6 +1,6 @@
contact_links:
- name: Help and support
about: Reach out to us on our Forum or GitHub discussions.
about: Reach out to us on our Discord server or GitHub discussions.
- name: Dedicated support
url: mailto:support@plane.so
about: Write to us if you'd like dedicated support using Plane
-48
View File
@@ -1,48 +0,0 @@
---
description: Guidelines for bash commands and tooling in the monorepo
applyTo: "**/*.sh"
---
# Bash & Tooling Instructions
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>`
## Monorepo Tooling
We use **Turbo** for build system orchestration.
- Configuration: `turbo.json`
## Project Structure
- `apps/`: Contains application services (admin, api, live, proxy, space, web).
- `packages/`: Contains shared packages and libraries.
- `deployments/`: Deployment configurations.
## Running Tests
- To run tests in a specific package (e.g., codemods):
```bash
cd packages/codemods
pnpm run test
```
- Or from root:
```bash
pnpm --filter @plane/codemods run test
```
## Docker
- Local development uses `docker-compose-local.yml`.
- Production/Staging uses `docker-compose.yml`.
@@ -1,129 +0,0 @@
---
description: Guidelines for using modern TypeScript features (v5.0-v5.8)
applyTo: "**/*.{ts,tsx,mts,cts}"
---
# TypeScript Coding Guidelines & Modern Features (v5.0 - v5.8)
When writing TypeScript code, prioritize using modern features and best practices introduced in recent versions (up to 5.8).
## Global Themes Across 5.x
1. **Standard decorators are here; legacy decorators are legacy.**
New TC39-compliant decorators landed in 5.0 and were extended in 5.2 (metadata). Old `experimentalDecorators`-style behavior is still supported but should be treated as legacy.
2. **Type system is more precise and less noisy.**
Major work went into narrowing, control flow analysis, error messages, and new helpers like `NoInfer`, inferred predicates, and better `undefined`/`never`/uninitialized checks.
3. **Module / runtime interop has been modernized.**
Options like `--moduleResolution bundler`, `--module nodenext`/`node18`, `--rewriteRelativeImportExtensions`, `--erasableSyntaxOnly`, and `--verbatimModuleSyntax` are about playing nicely with ESM, Node 18+/22+, direct TypeScript execution, and bundlers.
4. **The standard library keeps tracking modern JS.**
Support for new ES features (iterator helpers, `Object.groupBy`/`Map.groupBy`, new Set/ES2024 APIs) shows up as type declarations and sometimes extra checks (regex syntax checking, etc.).
When generating or refactoring code, prefer these newer idioms, and avoid patterns that conflict with updated checks.
## Modern Features to Utilize
### Type System & Inference
- **`const` Type Parameters (5.0)**: Use `const` type parameters for more precise literal inference.
```typescript
declare function names<const T extends string[]>(...names: T): void;
```
- **`@satisfies` Operator (5.0)**: Use `satisfies` to validate types without widening them.
- **Inferred Type Predicates (5.5)**: Allow TypeScript to infer type predicates for functions that filter arrays or check types, reducing the need for explicit `is` return types.
- **`NoInfer` Utility (5.4)**: Use `NoInfer<T>` to block inference for specific type arguments when you want them to be determined by other arguments.
- **Narrowing**:
- **Switch(true) (5.3)**: Utilize narrowing in `switch(true)` blocks.
- **Boolean Comparisons (5.3)**: Rely on narrowing from direct boolean comparisons.
- **Closures (5.4)**: Trust preserved narrowing in closures when variables aren't modified after the check.
- **Constant Indexed Access (5.5)**: Use constant indices to narrow object/array properties.
### Syntax & Control Flow
- **Decorators (5.0)**: Use standard ECMAScript decorators (Stage 3).
- **`using` Declarations (5.2)**: Use `using` for explicit resource management (Disposable pattern) instead of manual cleanup.
```typescript
using resource = new Resource();
```
- **Import Attributes (5.3/5.8)**: Use `with { type: "json" }` for import attributes. Avoid the deprecated `assert` syntax.
- **`switch` Exhaustiveness**: Rely on TypeScript's exhaustiveness checking in switch statements.
### Modules & Imports
- **`verbatimModuleSyntax` (5.0)**: Respect this flag by using `import type` explicitly when importing types to ensure they are erased during compilation.
- **Type-Only Imports with Extensions (5.2)**: You can use `.ts`, `.mts`, `.cts` extensions in `import type` statements.
- **`resolution-mode` (5.3)**: Use `import type { Type } from "mod" with { "resolution-mode": "import" }` if needed for specific module resolution contexts.
- **JSDoc `@import` (5.5)**: Use `@import` tags in JSDoc for cleaner type imports in JS files if working in a mixed codebase.
### Standard Library & Built-ins
- **Iterator Helpers (5.6)**: Use new iterator methods (map, filter, etc.) if targeting modern environments.
- **Set Methods (5.5)**: Utilize new `Set` methods like `union`, `intersection`, etc., when available.
- **`Object.groupBy` / `Map.groupBy` (5.4)**: Use these standard methods for grouping instead of external libraries like Lodash when appropriate.
- **`Promise.withResolvers` (5.7)**: Use `Promise.withResolvers()` for creating promises with exposed resolve/reject functions.
### Configuration & Tooling
- **`--moduleResolution bundler` (5.0)**: Assume this resolution strategy for modern web projects (Vite, Next.js, etc.).
- **`--erasableSyntaxOnly` (5.8)**: Be aware of this flag; avoid TypeScript-specific syntax that cannot be simply erased (like `enum`s or `namespaces`) if the project aims for maximum compatibility with tools like Node.js's `--strip-types`. Prefer `const` objects or unions over `enum`s if requested.
## Specific Coding Patterns
### Arrays & Collections
- Use **Copying Array Methods (5.2)** (`toSorted`, `toSpliced`, `with`) for immutable array operations.
- **TypedArrays (5.7)**: Be aware that TypedArrays are now generic over `ArrayBufferLike`.
### Classes
- **Parameter Decorators (5.0/5.2)**: Use modern standard decorators.
- **`super` Property Access (5.3)**: Avoid accessing instance fields via `super`.
### Error Handling
- **Checks for Never-Initialized Variables (5.7)**: Ensure variables are initialized before use to avoid new errors.
## Deprecations to Avoid
- Avoid `import ... assert` (use `with`).
- Avoid implicit `any` returns in `undefined`-returning functions (though 5.1 makes this easier, explicit is better).
- Avoid `enum`s if the project prefers erasable syntax (5.8).
## Version-Specific Highlights
### TypeScript 5.0
- **Decorators**: Use standard decorators unless `experimentalDecorators` is explicitly enabled.
- **`const` Type Parameters**: Use for literal inference.
- **Enums**: All enums are union enums.
- **Modules**: `--moduleResolution bundler` and `--verbatimModuleSyntax` are key for modern bundlers.
### TypeScript 5.1
- **Returns**: `undefined`-returning functions don't need explicit returns.
- **Getters/Setters**: Can have unrelated types with explicit annotations.
### TypeScript 5.2
- **Resource Management**: `using` declarations for `Symbol.dispose`.
- **Decorator Metadata**: Use `context.metadata` for design-time metadata.
### TypeScript 5.3
- **Import Attributes**: Use `with { type: "json" }`.
- **Switch(true)**: Narrowing works in `switch(true)`.
### TypeScript 5.4
- **Closures**: Narrowing preserved in closures if last assignment is before creation.
- **`NoInfer`**: Block inference for specific arguments.
- **Grouping**: `Object.groupBy` / `Map.groupBy`.
### TypeScript 5.5
- **Inferred Predicates**: Functions checking types often don't need explicit `is` return types.
- **Constant Index Access**: Better narrowing for constant keys.
- **Regex**: Syntax checking for regex literals.
### TypeScript 5.6
- **Truthiness Checks**: Errors on always-truthy/falsy conditions (e.g., `if (/regex/)`).
- **Iterator Helpers**: `.map`, `.filter` on iterators.
### TypeScript 5.7
- **Uninitialized Variables**: Stricter checks for never-initialized variables.
- **Relative Imports**: `--rewriteRelativeImportExtensions` for `.ts` imports in output.
- **ES2024**: Support for `Promise.withResolvers`, `Atomics.waitAsync`.
### TypeScript 5.8
- **Return Checks**: Granular checks for conditional returns.
- **Node Modules**: `--module node18` stable; `require()` of ESM allowed in `nodenext`.
- **Erasable Syntax**: `--erasableSyntaxOnly` forbids enums, namespaces, etc.
When generating code, always prefer the most modern, standard, and type-safe approach available in TypeScript 5.8.
+14 -15
View File
@@ -134,7 +134,7 @@ jobs:
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v6
uses: actions/checkout@v4
branch_build_push_admin:
name: Build-Push Admin Docker Image
@@ -142,7 +142,7 @@ jobs:
needs: [branch_build_setup]
steps:
- name: Admin Build and Push
uses: makeplane/actions/build-push@v1.4.0
uses: makeplane/actions/build-push@v1.0.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -164,7 +164,7 @@ jobs:
needs: [branch_build_setup]
steps:
- name: Web Build and Push
uses: makeplane/actions/build-push@v1.4.0
uses: makeplane/actions/build-push@v1.0.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -186,7 +186,7 @@ jobs:
needs: [branch_build_setup]
steps:
- name: Space Build and Push
uses: makeplane/actions/build-push@v1.4.0
uses: makeplane/actions/build-push@v1.0.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -208,7 +208,7 @@ jobs:
needs: [branch_build_setup]
steps:
- name: Live Build and Push
uses: makeplane/actions/build-push@v1.4.0
uses: makeplane/actions/build-push@v1.0.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -230,7 +230,7 @@ jobs:
needs: [branch_build_setup]
steps:
- name: Backend Build and Push
uses: makeplane/actions/build-push@v1.4.0
uses: makeplane/actions/build-push@v1.0.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -252,7 +252,7 @@ jobs:
needs: [branch_build_setup]
steps:
- name: Proxy Build and Push
uses: makeplane/actions/build-push@v1.4.0
uses: makeplane/actions/build-push@v1.0.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -282,7 +282,7 @@ jobs:
- branch_build_push_proxy
steps:
- name: Checkout Files
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Prepare AIO Assets
id: prepare_aio_assets
@@ -298,13 +298,13 @@ jobs:
echo "AIO_BUILD_VERSION=${aio_version}" >> $GITHUB_OUTPUT
- name: Upload AIO Assets
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
path: ./deployments/aio/community/dist
name: aio-assets-dist
- name: AIO Build and Push
uses: makeplane/actions/build-push@v1.4.0
uses: makeplane/actions/build-push@v1.1.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -337,7 +337,7 @@ jobs:
- branch_build_push_proxy
steps:
- name: Checkout Files
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Update Assets
run: |
@@ -352,7 +352,7 @@ jobs:
# sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deployments/cli/community/variables.env
- name: Upload Assets
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: community-assets
path: |
@@ -381,7 +381,7 @@ jobs:
REL_VERSION: ${{ needs.branch_build_setup.outputs.release_version }}
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Update Assets
run: |
@@ -391,13 +391,12 @@ jobs:
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v2.6.1
uses: softprops/action-gh-release@v2.1.0
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.sha }}
draft: false
prerelease: ${{ env.IS_PRERELEASE }}
generate_release_notes: true
+2 -2
View File
@@ -10,13 +10,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
- name: Get PR Branch version
run: echo "PR_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
+27 -7
View File
@@ -16,27 +16,47 @@ 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
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@v3
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
uses: github/codeql-action/autobuild@v3
# ️ 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
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"
+25
View File
@@ -0,0 +1,25 @@
# Codespell configuration is within .codespellrc
---
name: Codespell
on:
push:
branches: [preview]
pull_request:
branches: [preview]
permissions:
contents: read
jobs:
codespell:
name: Check for spelling errors
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Annotate locations with typos
uses: codespell-project/codespell-problem-matcher@v1
- name: Codespell
uses: codespell-project/actions-codespell@v2
-45
View File
@@ -1,45 +0,0 @@
name: Copy Right Check
on:
workflow_dispatch:
pull_request:
branches:
- "preview"
types:
- "opened"
- "synchronize"
- "ready_for_review"
- "review_requested"
- "reopened"
jobs:
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: 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 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."
+6 -6
View File
@@ -48,7 +48,7 @@ jobs:
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v6
uses: actions/checkout@v4
full_build_push:
runs-on: ubuntu-22.04
@@ -63,23 +63,23 @@ jobs:
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
steps:
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
with:
driver: ${{ env.BUILDX_DRIVER }}
version: ${{ env.BUILDX_VERSION }}
endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Build and Push to Docker Hub
uses: docker/build-push-action@v7.0.0
uses: docker/build-push-action@v6.9.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@v4
uses: tailscale/github-action@v2
with:
oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }}
-51
View File
@@ -1,51 +0,0 @@
name: i18n sync check
on:
workflow_dispatch:
pull_request:
branches:
- "preview"
types:
- "opened"
- "synchronize"
- "reopened"
- "ready_for_review"
paths:
- "packages/i18n/**"
- ".github/workflows/i18n-sync-check.yml"
push:
branches:
- "preview"
paths:
- "packages/i18n/**"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
sync-check:
name: check:sync
runs-on: ubuntu-latest
timeout-minutes: 5
if: github.event_name == 'push' || github.event.pull_request.draft == false
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 1
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: Run sync check
run: pnpm dlx tsx packages/i18n/scripts/sync-check.ts --ci
@@ -27,13 +27,11 @@ jobs:
github.event.pull_request.draft == false &&
github.event.pull_request.requested_reviewers != null
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.12.x"
cache: 'pip'
cache-dependency-path: 'apps/api/requirements.txt'
python-version: "3.x"
- name: Install Pylint
run: python -m pip install ruff
- name: Install API Dependencies
@@ -8,6 +8,8 @@ on:
types:
- "opened"
- "synchronize"
- "ready_for_review"
- "review_requested"
- "reopened"
concurrency:
@@ -15,11 +17,10 @@ concurrency:
cancel-in-progress: true
jobs:
# Format check has no build dependencies - run immediately in parallel
check-format:
name: check:format
build-and-lint:
name: Build and lint web apps
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 25
if: |
github.event.pull_request.draft == false &&
github.event.pull_request.requested_reviewers != null
@@ -28,178 +29,25 @@ jobs:
TURBO_SCM_HEAD: ${{ github.sha }}
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 50
filter: blob:none
- name: Set up Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
- 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: Check formatting
run: pnpm turbo run check:format --affected
# Build packages - required for lint and type checks
build:
name: Build packages
runs-on: ubuntu-latest
timeout-minutes: 15
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=4096"
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: Restore Turbo cache
uses: actions/cache/restore@v5
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-
turbo-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build packages
run: pnpm turbo run build --affected
- name: Save Turbo cache
uses: actions/cache/save@v5
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
# Lint check - no build dependency, OxLint is a standalone Rust binary
check-lint:
name: check:lint
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
- name: Lint Affected
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:
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }}
TURBO_SCM_HEAD: ${{ github.sha }}
NODE_OPTIONS: "--max-old-space-size=4096"
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 50
filter: blob:none
- name: Check Affected format
run: pnpm turbo run check:format --affected
- 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: Restore Turbo cache
uses: actions/cache/restore@v5
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run check:types
run: pnpm turbo run check:types --affected
- name: Build Affected
run: pnpm turbo run build --affected
+52
View File
@@ -0,0 +1,52 @@
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@v4
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: |
# 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 "${{ vars.SYNC_PR_TITLE }}" --body "")
echo "Pull Request created: $PR_URL"
fi
+44
View File
@@ -0,0 +1,44 @@
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@v4
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 }}"
TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}"
SOURCE_BRANCH="${{ env.SOURCE_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
-15
View File
@@ -102,18 +102,3 @@ dev-editor
storybook-static
CLAUDE.md
build/
.react-router/
build/
.react-router/
temp/
scripts/
!packages/i18n/scripts/
# i18n auto-generated types (regenerated on every build)
packages/i18n/src/types/keys.generated.ts
# Local security notes (not for version control)
/security/
-1
View File
@@ -1 +0,0 @@
pnpm lint-staged
+25 -45
View File
@@ -1,54 +1,34 @@
# ------------------------------
# Core Workspace Behavior
# ------------------------------
# Enforce pnpm workspace behavior and allow Turbo's lifecycle hooks if scripts are disabled
# This repo uses pnpm with workspaces.
# Always prefer using local workspace packages when available
prefer-workspace-packages = true
# Prefer linking local workspace packages when available
prefer-workspace-packages=true
link-workspace-packages=true
shared-workspace-lockfile=true
# Symlink workspace packages instead of duplicating them
link-workspace-packages = true
# Make peer installs smoother across the monorepo
auto-install-peers=true
strict-peer-dependencies=false
# Use a single lockfile across the whole monorepo
shared-workspace-lockfile = true
# If scripts are disabled (e.g., CI with --ignore-scripts), allowlisted packages can still run their hooks
# Turbo occasionally performs postinstall tasks for optimal performance
# moved to pnpm-workspace.yaml: onlyBuiltDependencies (e.g., allow turbo)
# Ensure packages added from workspace save using workspace: protocol
save-workspace-protocol = true
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=prettier
public-hoist-pattern[]=typescript
# Reproducible installs across CI and dev
prefer-frozen-lockfile=true
# ------------------------------
# Dependency Resolution
# ------------------------------
# Prefer resolving to highest versions in monorepo to reduce duplication
resolution-mode=highest
# Choose the highest compatible version across the workspace
# → reduces fragmentation & node_modules bloat
resolution-mode = highest
# Speed up native module builds by caching side effects
side-effects-cache=true
# Automatically install peer dependencies instead of forcing every package to declare them
auto-install-peers = true
# Speed up local dev by reusing local store when possible
prefer-offline=true
# Don't break the install if peers are missing
strict-peer-dependencies = false
# ------------------------------
# Performance Optimizations
# ------------------------------
# Use cached artifacts for native modules (sharp, esbuild, etc.)
side-effects-cache = true
# Prefer local cached packages rather than hitting network
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
# Ensure workspace protocol is used when adding internal deps
save-workspace-protocol=true
-17
View File
@@ -1,17 +0,0 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5",
"sortTailwindcss": {
"stylesheet": "packages/tailwind-config/index.css",
"functions": ["cn", "clsx", "cva"]
},
"overrides": [
{
"files": ["packages/codemods/**/*"],
"options": {
"printWidth": 80
}
}
]
}
-53
View File
@@ -1,53 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "jsx-a11y", "import", "promise", "unicorn", "oxc"],
"categories": {
"correctness": "warn",
"suspicious": "warn",
"perf": "warn"
},
"env": {
"browser": true,
"node": true,
"es2024": true
},
"settings": {
"react": {
"version": "18.3"
},
"jsx-a11y": {
"polymorphicPropName": "as"
}
},
"ignorePatterns": [
".cache/**",
".next/**",
".react-router/**",
".storybook/**",
".turbo/**",
".vite/**",
"*.config.{js,mjs,cjs,ts}",
"build/**",
"coverage/**",
"dist/**",
"**/public/**",
"storybook-static/**"
],
"rules": {
"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",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
"destructuredArrayIgnorePattern": "^_",
"ignoreRestSiblings": true
}
]
}
}
-10
View File
@@ -1,10 +0,0 @@
.next/
.react-router/
.turbo/
.vite/
build/
dist/
node_modules/
out/
pnpm-lock.yaml
storybook-static/
-10
View File
@@ -1,10 +0,0 @@
# 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
-36
View File
@@ -1,36 +0,0 @@
# Agent Development Guide
## Commands
- `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
- **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
- **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
## Backend tests (Docker)
The Django/pytest suite for `apps/api` runs in an isolated stack defined by `docker-compose-test.yml` at the repo root.
Prereq (once): `./setup.sh` — generates `apps/api/.env` from `.env.example`.
- 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`
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.
-8
View File
@@ -1,8 +0,0 @@
.oxlintrc.json @sriramveeraghanta @lifeiscontent
.oxfmtrc.json @sriramveeraghanta @lifeiscontent
apps/api/ @dheeru0198 @pablohashescobar
apps/web/ @sriramveeraghanta
apps/space/ @sriramveeraghanta
apps/admin/ @sriramveeraghanta
apps/live/ @Palanikannan1437
deployments/ @mguptahub
+9 -11
View File
@@ -91,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 [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`.
- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier.
## Ways to contribute
@@ -187,19 +187,18 @@ Adding a new language involves several steps to ensure it integrates seamlessly
Add the new language to the TLanguage type in the language definitions file:
```ts
// packages/i18n/src/types/language.ts
export type TLanguage = "en" | "fr" | "your-lang";
// packages/i18n/src/types/language.ts
export type TLanguage = "en" | "fr" | "your-lang";
```
1. **Add language configuration**
Include the new language in the list of supported languages:
```ts
// packages/i18n/src/constants/language.ts
export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
{ label: "English", value: "en" },
{ label: "Your Language", value: "your-lang" },
];
// packages/i18n/src/constants/language.ts
export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
{ label: "English", value: "en" },
{ label: "Your Language", value: "your-lang" }
];
```
2. **Create translation files**
@@ -211,7 +210,6 @@ export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
3. **Update import logic**
Modify the language import logic to include your new language:
```ts
private importLanguageFile(language: TLanguage): Promise<any> {
switch (language) {
@@ -244,4 +242,4 @@ Happy translating! 🌍✨
## Need help? Questions and suggestions
Questions, suggestions, and thoughts are most welcome. We can also be reached in our [Forum](https://forum.plane.so).
Questions, suggestions, and thoughts are most welcome. We can also be reached in our [Discord Server](https://discord.com/invite/A92xrEGCge).
-3
View File
@@ -1,3 +0,0 @@
Copyright (c) 2023-present Plane Software, Inc. and contributors
SPDX-License-Identifier: AGPL-3.0-only
See the LICENSE file for details.
-34
View File
@@ -1,34 +0,0 @@
## Copyright check
To verify that all tracked Python files contain the correct copyright header for **Plane Software Inc.** for the year **2023**, run this command from the repository root:
```bash
addlicense --check -f COPYRIGHT.txt -ignore "**/migrations/**" $(git ls-files '*.py')
```
#### To Apply Changes
python files
```bash
addlicense -v -f COPYRIGHT.txt -ignore "**/migrations/**" $(git ls-files '*.py')
```
ts and tsx files in a specific app
```bash
addlicense -v -f COPYRIGHT.txt \
-ignore "**/*.config.ts" \
-ignore "**/*.d.ts" \
$(git ls-files 'packages/*.ts')
```
Note: Please make sure ts command is running on specific folder, running it for the whole mono repo is crashing os processes.
#### Other Options
- **`addlicense -check`**: runs in check-only mode and fails if any file is missing or has an incorrect header.
- **`-c "Plane Software Inc."`**: sets the copyright holder.
- **`-f LICENSE.txt`**: uses the contents and format defined in `LICENSE.txt` as the header template.
- **`-y 2023`**: sets the year in the header.
- **`$(git ls-files '*.py')`**: restricts the check to Python files tracked in git.
+16 -7
View File
@@ -7,10 +7,17 @@
</p>
<p align="center"><b>Modern project management for all teams</b></p>
<p align="center">
<a href="https://discord.com/invite/A92xrEGCge">
<img alt="Discord online members" src="https://img.shields.io/discord/1031547764020084846?color=5865F2&label=Discord&style=for-the-badge" />
</a>
<img alt="Commit activity per month" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
</p>
<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://x.com/planepowers"><b>X</b></a> •
<a href="https://github.com/makeplane/plane/releases"><b>Releases</b></a> •
<a href="https://twitter.com/planepowers"><b>Twitter</b></a> •
<a href="https://docs.plane.so/"><b>Documentation</b></a>
</p>
@@ -26,7 +33,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 [Forum](https://forum.plane.so) 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 [Discord](https://discord.com/invite/A92xrEGCge) or raise a GitHub issue. We read everything and respond to most.
## 🚀 Installation
@@ -47,7 +54,7 @@ Getting started with Plane is simple. Choose the setup that works best for you:
## 🌟 Features
- **Work Items**
- **Issues**
Efficiently create and manage tasks with a robust rich text editor that supports file uploads. Enhance organization and tracking by adding sub-properties and referencing related issues.
- **Cycles**
@@ -65,13 +72,15 @@ Getting started with Plane is simple. Choose the setup that works best for you:
- **Analytics**
Access real-time insights across all your Plane data. Visualize trends, remove blockers, and keep your projects moving forward.
- **Drive** (_coming soon_): The drive helps you share documents, images, videos, or any other files that make sense to you or your team and align on the problem/solution.
## 🛠️ Local development
See [CONTRIBUTING](./CONTRIBUTING.md)
## ⚙️ Built with
[![React Router](https://img.shields.io/badge/-React%20Router-CA4245?logo=react-router&style=for-the-badge&logoColor=white)](https://reactrouter.com/)
[![Next JS](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white)](https://nextjs.org/)
[![Django](https://img.shields.io/badge/Django-092E20?style=for-the-badge&logo=django&logoColor=green)](https://www.djangoproject.com/)
[![Node JS](https://img.shields.io/badge/node.js-339933?style=for-the-badge&logo=Node.js&logoColor=white)](https://nodejs.org/en)
@@ -129,7 +138,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 [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.
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.
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 +154,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://forum.plane.so)!
- Talk or write about Plane or any other ecosystem integration and [let us know](https://discord.com/invite/A92xrEGCge)!
- 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.
-5
View File
@@ -1,5 +0,0 @@
# React Router - https://github.com/remix-run/react-router-templates/blob/dc79b1a065f59f3bfd840d4ef75cc27689b611e6/default/.dockerignore
.react-router/
build/
node_modules/
README.md
+8 -8
View File
@@ -1,12 +1,12 @@
VITE_API_BASE_URL="http://localhost:8000"
NEXT_PUBLIC_API_BASE_URL="http://localhost:8000"
VITE_WEB_BASE_URL="http://localhost:3000"
NEXT_PUBLIC_WEB_BASE_URL="http://localhost:3000"
VITE_ADMIN_BASE_URL="http://localhost:3001"
VITE_ADMIN_BASE_PATH="/god-mode"
NEXT_PUBLIC_ADMIN_BASE_URL="http://localhost:3001"
NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
VITE_SPACE_BASE_URL="http://localhost:3002"
VITE_SPACE_BASE_PATH="/spaces"
NEXT_PUBLIC_SPACE_BASE_URL="http://localhost:3002"
NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
VITE_LIVE_BASE_URL="http://localhost:3100"
VITE_LIVE_BASE_PATH="/live"
NEXT_PUBLIC_LIVE_BASE_URL="http://localhost:3100"
NEXT_PUBLIC_LIVE_BASE_PATH="/live"
+12
View File
@@ -0,0 +1,12 @@
.next/*
out/*
public/*
dist/*
node_modules/*
.turbo/*
.env*
.env
.env.local
.env.development
.env.production
.env.test
+18
View File
@@ -0,0 +1,18 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/next.js"],
rules: {
"no-duplicate-imports": "off",
"import/no-duplicates": ["error", { "prefer-inline": false }],
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
"@typescript-eslint/no-import-type-side-effects": "error",
"@typescript-eslint/consistent-type-imports": [
"error",
{
prefer: "type-imports",
fixStyle: "separate-type-imports",
disallowTypeAnnotations: false,
},
],
},
};
+5 -9
View File
@@ -1,10 +1,6 @@
.next/
.react-router/
.turbo/
.vite/
build/
dist/
node_modules/
.next
.vercel
.tubro
out/
pnpm-lock.yaml
storybook-static/
dist/
build/
+5
View File
@@ -0,0 +1,5 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}
+76 -63
View File
@@ -1,90 +1,103 @@
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS base
# Setup pnpm package manager with corepack and configure global bin directory for caching
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
# *****************************************************************************
# STAGE 1: Build the project
# *****************************************************************************
FROM base AS builder
RUN apk add --no-cache libc6-compat
WORKDIR /app
ENV TURBO_TELEMETRY_DISABLED=1
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
ENV CI=1
RUN corepack enable pnpm
# =========================================================================== #
FROM base AS builder
RUN pnpm add -g turbo@2.9.14
ARG TURBO_VERSION=2.5.6
RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION}
COPY . .
# Create a pruned workspace for just the admin app
RUN turbo prune --scope=admin --docker
# =========================================================================== #
# *****************************************************************************
# STAGE 2: Install dependencies & build the project
# *****************************************************************************
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
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN corepack enable pnpm
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
# 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
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store
# 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
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ARG NEXT_PUBLIC_ADMIN_BASE_URL=""
ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
ARG NEXT_PUBLIC_WEB_BASE_URL=""
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
ENV NEXT_TELEMETRY_DISABLED=1
ENV TURBO_TELEMETRY_DISABLED=1
# Build only the admin package
RUN pnpm turbo run build --filter=admin
# =========================================================================== #
# *****************************************************************************
# STAGE 3: Copy the project and start it
# *****************************************************************************
FROM base AS runner
WORKDIR /app
FROM nginx:1.29-alpine AS production
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
RUN apk update && apk upgrade --no-cache && rm -rf /var/cache/apk/*
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer /app/apps/admin/.next/standalone ./
COPY --from=installer /app/apps/admin/.next/static ./apps/admin/.next/static
COPY --from=installer /app/apps/admin/public ./apps/admin/public
COPY apps/admin/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=installer /app/apps/admin/build/client /usr/share/nginx/html/god-mode
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ARG NEXT_PUBLIC_ADMIN_BASE_URL=""
ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
ARG NEXT_PUBLIC_WEB_BASE_URL=""
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
ENV NEXT_TELEMETRY_DISABLED=1
ENV TURBO_TELEMETRY_DISABLED=1
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 ["node", "apps/admin/server.js"]
+1 -1
View File
@@ -8,7 +8,7 @@ COPY . .
RUN corepack enable pnpm && pnpm add -g turbo
RUN pnpm install
ENV VITE_ADMIN_BASE_PATH="/god-mode"
ENV NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
EXPOSE 3000
+14 -18
View File
@@ -1,9 +1,5 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import type { FC } from "react";
import { useForm } from "react-hook-form";
import { Lightbulb } from "lucide-react";
import { Button } from "@plane/propel/button";
@@ -21,7 +17,7 @@ type IInstanceAIForm = {
type AIFormValues = Record<TInstanceAIConfigurationKeys, string>;
export function InstanceAIForm(props: IInstanceAIForm) {
export const InstanceAIForm: FC<IInstanceAIForm> = (props) => {
const { config } = props;
// store
const { updateInstanceConfigurations } = useInstance();
@@ -48,7 +44,7 @@ export function InstanceAIForm(props: IInstanceAIForm) {
<a
href="https://platform.openai.com/docs/models/overview"
target="_blank"
className="text-accent-primary hover:underline"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
Learn more
@@ -69,7 +65,7 @@ export function InstanceAIForm(props: IInstanceAIForm) {
<a
href="https://platform.openai.com/api-keys"
target="_blank"
className="text-accent-primary hover:underline"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
here.
@@ -100,8 +96,8 @@ export function InstanceAIForm(props: IInstanceAIForm) {
<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 className="pb-1 text-xl font-medium text-custom-text-100">OpenAI</div>
<div className="text-sm font-normal text-custom-text-300">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) => (
@@ -120,16 +116,16 @@ export function InstanceAIForm(props: IInstanceAIForm) {
</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"}
<div className="space-y-4">
<Button variant="primary" 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 className="relative inline-flex items-center gap-2 rounded border border-custom-primary-100/20 bg-custom-primary-100/10 px-4 py-2 text-xs text-custom-primary-200">
<Lightbulb height="14" width="14" />
<div>
If you have a preferred AI models vendor, please get in{" "}
<a className="font-medium underline" href="https://plane.so/contact">
<a className="underline font-medium" href="https://plane.so/contact">
touch with us.
</a>
</div>
@@ -137,4 +133,4 @@ export function InstanceAIForm(props: IInstanceAIForm) {
</div>
</div>
);
}
};
@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Artificial Intelligence Settings - God Mode",
};
export default function AILayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}
+26 -31
View File
@@ -1,50 +1,45 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
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
// components
import { InstanceAIForm } from "./form";
const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentProps) {
const InstanceAIPage = observer(() => {
// 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 className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">AI features for all your workspaces</div>
<div className="text-sm font-normal text-custom-text-300">
Configure your AI API credentials so Plane AI features are turned on for all your workspaces.
</div>
<Loader.Item height="50px" width="20%" />
</Loader>
)}
</PageWrapper>
</div>
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{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>
)}
</div>
</div>
</>
);
});
export const meta: Route.MetaFunction = () => [{ title: "Artificial Intelligence Settings - God Mode" }];
export default InstanceAIPage;
@@ -1,225 +0,0 @@
/**
* 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>
</>
);
}
@@ -1,105 +0,0 @@
/**
* 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;
@@ -1,9 +1,6 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import type { FC } from "react";
import { useState } from "react";
import { isEmpty } from "lodash-es";
import Link from "next/link";
@@ -14,12 +11,12 @@ 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";
import { cn } from "@plane/utils";
// 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";
@@ -32,7 +29,7 @@ type Props = {
type GithubConfigFormValues = Record<TInstanceGithubAuthenticationConfigurationKeys, string>;
export function InstanceGithubConfigForm(props: Props) {
export const InstanceGithubConfigForm: FC<Props> = (props) => {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
@@ -49,7 +46,6 @@ export function InstanceGithubConfigForm(props: Props) {
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",
},
});
@@ -67,7 +63,7 @@ export function InstanceGithubConfigForm(props: Props) {
tabIndex={-1}
href="https://github.com/settings/applications/new"
target="_blank"
className="text-accent-primary hover:underline"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
GitHub OAuth application settings.
@@ -89,7 +85,7 @@ export function InstanceGithubConfigForm(props: Props) {
tabIndex={-1}
href="https://github.com/settings/applications/new"
target="_blank"
className="text-accent-primary hover:underline"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
GitHub OAuth application settings.
@@ -111,11 +107,6 @@ export function InstanceGithubConfigForm(props: Props) {
},
];
const GITHUB_FORM_SWITCH_FIELD: TControllerSwitchFormField<GithubConfigFormValues> = {
name: "ENABLE_GITHUB_SYNC",
label: "GitHub",
};
const GITHUB_COMMON_SERVICE_DETAILS: TCopyField[] = [
{
key: "Origin_URL",
@@ -128,7 +119,7 @@ export function InstanceGithubConfigForm(props: Props) {
tabIndex={-1}
href="https://github.com/settings/applications/new"
target="_blank"
className="text-accent-primary hover:underline"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
here.
@@ -151,7 +142,7 @@ export function InstanceGithubConfigForm(props: Props) {
tabIndex={-1}
href="https://github.com/settings/applications/new"
target="_blank"
className="text-accent-primary hover:underline"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
here.
@@ -164,22 +155,20 @@ export function InstanceGithubConfigForm(props: Props) {
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);
}
await updateInstanceConfigurations(payload)
.then((response = []) => {
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,
});
})
.catch((err) => console.error(err));
};
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
@@ -197,9 +186,9 @@ export function InstanceGithubConfigForm(props: Props) {
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>
<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-xl font-medium">GitHub-provided details for Plane</div>
{GITHUB_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
@@ -213,42 +202,39 @@ export function InstanceGithubConfigForm(props: Props) {
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 variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting} disabled={!isDirty}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
<Link
href="/authentication"
className={cn(getButtonStyling("neutral-primary", "md"), "font-medium")}
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="col-span-2 md:col-span-1 flex flex-col gap-y-6">
<div className="pt-2 text-xl 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">
<div className="flex flex-col gap-y-4 px-6 py-4 bg-custom-background-80 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 */}
<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" />
<div className="flex flex-col rounded-lg overflow-hidden">
<div className="px-6 py-3 bg-custom-background-80/60 font-medium text-xs uppercase flex items-center gap-x-3 text-custom-text-200">
<Monitor className="w-3 h-3" />
Web
</div>
<div className="flex flex-col gap-y-4 bg-layer-1 px-6 py-4">
<div className="px-6 py-4 flex flex-col gap-y-4 bg-custom-background-80">
{GITHUB_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
@@ -260,4 +246,4 @@ export function InstanceGithubConfigForm(props: Props) {
</div>
</>
);
}
};
@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "GitHub Authentication - God Mode",
};
export default function GitHubAuthenticationLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}
@@ -1,33 +1,25 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
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
// icons
import githubLightModeImage from "@/public/logos/github-black.png";
import githubDarkModeImage from "@/public/logos/github-white.png";
// local components
import { InstanceGithubConfigForm } from "./form";
const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthenticationPage(
_props: Route.ComponentProps
) {
const InstanceGithubAuthenticationPage = observer(() => {
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// state
@@ -49,10 +41,10 @@ const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthent
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration",
loading: "Saving Configuration...",
success: {
title: "Configuration saved",
message: () => `GitHub authentication is now ${value === "1" ? "active" : "disabled"}.`,
message: () => `GitHub authentication is now ${value ? "active" : "disabled"}.`,
},
error: {
title: "Error",
@@ -73,49 +65,50 @@ const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthent
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>
<>
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<AuthenticationMethodCard
name="GitHub"
description="Allow members to login or sign up to plane with their GitHub accounts."
icon={
<Image
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}
/>
</div>
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{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>
)}
</div>
</div>
</>
);
});
export const meta: Route.MetaFunction = () => [{ title: "GitHub Authentication - God Mode" }];
export default InstanceGithubAuthenticationPage;
@@ -1,9 +1,4 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { FC } from "react";
import { useState } from "react";
import { isEmpty } from "lodash-es";
import Link from "next/link";
@@ -13,12 +8,11 @@ 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";
import { cn } from "@plane/utils";
// 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";
@@ -31,7 +25,7 @@ type Props = {
type GitlabConfigFormValues = Record<TInstanceGitlabAuthenticationConfigurationKeys, string>;
export function InstanceGitlabConfigForm(props: Props) {
export const InstanceGitlabConfigForm: FC<Props> = (props) => {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
@@ -48,7 +42,6 @@ export function InstanceGitlabConfigForm(props: Props) {
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",
},
});
@@ -79,7 +72,7 @@ export function InstanceGitlabConfigForm(props: Props) {
tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
target="_blank"
className="text-accent-primary hover:underline"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
GitLab OAuth application settings
@@ -102,7 +95,7 @@ export function InstanceGitlabConfigForm(props: Props) {
tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
target="_blank"
className="text-accent-primary hover:underline"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
GitLab OAuth application settings
@@ -116,11 +109,6 @@ export function InstanceGitlabConfigForm(props: Props) {
},
];
const GITLAB_FORM_SWITCH_FIELD: TControllerSwitchFormField<GitlabConfigFormValues> = {
name: "ENABLE_GITLAB_SYNC",
label: "GitLab",
};
const GITLAB_SERVICE_FIELD: TCopyField[] = [
{
key: "Callback_URL",
@@ -133,7 +121,7 @@ export function InstanceGitlabConfigForm(props: Props) {
tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
target="_blank"
className="text-accent-primary hover:underline"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
GitLab OAuth application
@@ -147,22 +135,20 @@ export function InstanceGitlabConfigForm(props: Props) {
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);
}
await updateInstanceConfigurations(payload)
.then((response = []) => {
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,
});
})
.catch((err) => console.error(err));
};
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
@@ -180,9 +166,9 @@ export function InstanceGitlabConfigForm(props: Props) {
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>
<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-xl font-medium">GitLab-provided details for Plane</div>
{GITLAB_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
@@ -196,27 +182,24 @@ export function InstanceGitlabConfigForm(props: Props) {
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 variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting} disabled={!isDirty}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
<Link
href="/authentication"
className={cn(getButtonStyling("neutral-primary", "md"), "font-medium")}
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>
<div className="flex flex-col gap-y-4 px-6 pt-1.5 pb-4 bg-custom-background-80/60 rounded-lg">
<div className="pt-2 text-xl 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} />
))}
@@ -226,4 +209,4 @@ export function InstanceGitlabConfigForm(props: Props) {
</div>
</>
);
}
};
@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "GitLab Authentication - God Mode",
};
export default function GitlabAuthenticationLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}
@@ -1,29 +1,21 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
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
// icons
import GitlabLogo from "@/public/logos/gitlab-logo.svg";
// local components
import { InstanceGitlabConfigForm } from "./form";
const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthenticationPage(
_props: Route.ComponentProps
) {
const InstanceGitlabAuthenticationPage = observer(() => {
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// state
@@ -43,10 +35,10 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration",
loading: "Saving Configuration...",
success: {
title: "Configuration saved",
message: () => `GitLab authentication is now ${value === "1" ? "active" : "disabled"}.`,
message: () => `GitLab authentication is now ${value ? "active" : "disabled"}.`,
},
error: {
title: "Error",
@@ -64,46 +56,47 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent
});
};
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>
<>
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<AuthenticationMethodCard
name="GitLab"
description="Allow members to login or sign up to plane with their GitLab accounts."
icon={<Image 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}
/>
</div>
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{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>
)}
</div>
</div>
</>
);
});
export const meta: Route.MetaFunction = () => [{ title: "GitLab Authentication - God Mode" }];
export default InstanceGitlabAuthenticationPage;
@@ -1,9 +1,5 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import type { FC } from "react";
import { useState } from "react";
import { isEmpty } from "lodash-es";
import Link from "next/link";
@@ -14,12 +10,11 @@ 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";
import { cn } from "@plane/utils";
// 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";
@@ -32,7 +27,7 @@ type Props = {
type GoogleConfigFormValues = Record<TInstanceGoogleAuthenticationConfigurationKeys, string>;
export function InstanceGoogleConfigForm(props: Props) {
export const InstanceGoogleConfigForm: FC<Props> = (props) => {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
@@ -48,7 +43,6 @@ export function InstanceGoogleConfigForm(props: Props) {
defaultValues: {
GOOGLE_CLIENT_ID: config["GOOGLE_CLIENT_ID"],
GOOGLE_CLIENT_SECRET: config["GOOGLE_CLIENT_SECRET"],
ENABLE_GOOGLE_SYNC: config["ENABLE_GOOGLE_SYNC"] || "0",
},
});
@@ -66,7 +60,7 @@ export function InstanceGoogleConfigForm(props: Props) {
tabIndex={-1}
href="https://developers.google.com/identity/protocols/oauth2/javascript-implicit-flow#creatingcred"
target="_blank"
className="text-accent-primary hover:underline"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
Learn more
@@ -88,7 +82,7 @@ export function InstanceGoogleConfigForm(props: Props) {
tabIndex={-1}
href="https://developers.google.com/identity/oauth2/web/guides/get-google-api-clientid"
target="_blank"
className="text-accent-primary hover:underline"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
Learn more
@@ -101,11 +95,6 @@ export function InstanceGoogleConfigForm(props: Props) {
},
];
const GOOGLE_FORM_SWITCH_FIELD: TControllerSwitchFormField<GoogleConfigFormValues> = {
name: "ENABLE_GOOGLE_SYNC",
label: "Google",
};
const GOOGLE_COMMON_SERVICE_DETAILS: TCopyField[] = [
{
key: "Origin_URL",
@@ -118,7 +107,7 @@ export function InstanceGoogleConfigForm(props: Props) {
<a
href="https://console.cloud.google.com/apis/credentials/oauthclient"
target="_blank"
className="text-accent-primary hover:underline"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
here.
@@ -140,7 +129,7 @@ export function InstanceGoogleConfigForm(props: Props) {
<a
href="https://console.cloud.google.com/apis/credentials/oauthclient"
target="_blank"
className="text-accent-primary hover:underline"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
here.
@@ -153,21 +142,19 @@ export function InstanceGoogleConfigForm(props: Props) {
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);
}
await updateInstanceConfigurations(payload)
.then((response = []) => {
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,
});
})
.catch((err) => console.error(err));
};
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
@@ -185,9 +172,9 @@ export function InstanceGoogleConfigForm(props: Props) {
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>
<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-xl font-medium">Google-provided details for Plane</div>
{GOOGLE_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
@@ -201,42 +188,39 @@ export function InstanceGoogleConfigForm(props: Props) {
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 variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting} disabled={!isDirty}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
<Link
href="/authentication"
className={cn(getButtonStyling("neutral-primary", "md"), "font-medium")}
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="col-span-2 md:col-span-1 flex flex-col gap-y-6">
<div className="pt-2 text-xl 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">
<div className="flex flex-col gap-y-4 px-6 py-4 bg-custom-background-80 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 */}
<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" />
<div className="flex flex-col rounded-lg overflow-hidden">
<div className="px-6 py-3 bg-custom-background-80/60 font-medium text-xs uppercase flex items-center gap-x-3 text-custom-text-200">
<Monitor className="w-3 h-3" />
Web
</div>
<div className="flex flex-col gap-y-4 bg-layer-1 px-6 py-4">
<div className="px-6 py-4 flex flex-col gap-y-4 bg-custom-background-80">
{GOOGLE_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
@@ -248,4 +232,4 @@ export function InstanceGoogleConfigForm(props: Props) {
</div>
</>
);
}
};
@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Google Authentication - God Mode",
};
export default function GoogleAuthenticationLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}
@@ -1,29 +1,21 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import useSWR from "swr";
import { setPromiseToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } 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
// icons
import GoogleLogo from "@/public/logos/google-logo.svg";
// local components
import { InstanceGoogleConfigForm } from "./form";
const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthenticationPage(
_props: Route.ComponentProps
) {
const InstanceGoogleAuthenticationPage = observer(() => {
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// state
@@ -43,10 +35,10 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration",
loading: "Saving Configuration...",
success: {
title: "Configuration saved",
message: () => `Google authentication is now ${value === "1" ? "active" : "disabled"}.`,
message: () => `Google authentication is now ${value ? "active" : "disabled"}.`,
},
error: {
title: "Error",
@@ -64,47 +56,48 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent
});
};
return (
<PageWrapper
customHeader={
<AuthenticationMethodCard
name="Google"
description="Allow members to login or sign up to plane with their Google
<>
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<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={
<ToggleSwitch
value={Boolean(parseInt(enableGoogleConfig))}
onChange={() => {
if (Boolean(parseInt(enableGoogleConfig)) === true) {
updateConfig("IS_GOOGLE_ENABLED", "0");
} else {
updateConfig("IS_GOOGLE_ENABLED", "1");
}
}}
size="sm"
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>
icon={<Image src={GoogleLogo} height={24} width={24} alt="Google Logo" />}
config={
<ToggleSwitch
value={Boolean(parseInt(enableGoogleConfig))}
onChange={() => {
if (Boolean(parseInt(enableGoogleConfig)) === true) {
updateConfig("IS_GOOGLE_ENABLED", "0");
} else {
updateConfig("IS_GOOGLE_ENABLED", "1");
}
}}
size="sm"
disabled={isSubmitting || !formattedConfig}
/>
}
disabled={isSubmitting || !formattedConfig}
withBorder={false}
/>
</div>
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{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>
)}
</div>
</div>
</>
);
});
export const meta: Route.MetaFunction = () => [{ title: "Google Authentication - God Mode" }];
export default InstanceGoogleAuthenticationPage;
@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Authentication Settings - Plane Web",
};
export default function AuthenticationLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}
@@ -1,174 +1,114 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import { useCallback, useRef, useState } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import { useTheme } from "next-themes";
import useSWR from "swr";
// plane internal packages
import { setPromiseToast, setToast, TOAST_TYPE } from "@plane/propel/toast";
import type { TInstanceConfigurationKeys, TInstanceAuthenticationModes } from "@plane/types";
import { setPromiseToast } from "@plane/propel/toast";
import type { TInstanceConfigurationKeys } from "@plane/types";
import { Loader, ToggleSwitch } 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";
import { cn } from "@plane/utils";
// hooks
import { useAuthenticationModes } from "@/hooks/oauth";
import { useInstance } from "@/hooks/store";
// types
import type { Route } from "./+types/page";
// plane admin components
import { AuthenticationModes } from "@/plane-admin/components/authentication";
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 InstanceAuthenticationPage = observer(() => {
// store
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);
// state
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// derived values
const enableSignUpConfig = formattedConfig?.ENABLE_SIGNUP ?? "";
// Only validate if this is an authentication method key
if (isAuthMethodKey) {
const canDisable = canDisableAuthMethod(key, currentAuthModes, formattedConfig);
const updateConfig = async (key: TInstanceConfigurationKeys, value: string) => {
setIsSubmitting(true);
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;
}
}
}
const payload = {
[key]: value,
};
// Proceed with the update
setIsSubmitting(true);
const updateConfigPromise = updateInstanceConfigurations(payload);
const payload = {
[key]: value,
};
setPromiseToast(updateConfigPromise, {
loading: "Saving configuration",
success: {
title: "Success",
message: () => "Configuration saved successfully",
},
error: {
title: "Error",
message: () => "Failed to save configuration",
},
});
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);
});
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("flex w-full items-center gap-14 rounded-sm")}>
<div className="flex grow items-center gap-4">
<div className="grow">
<div className="pb-1 text-16 font-medium">Allow anyone to sign up even without an invite</div>
<div className={cn("text-11 leading-5 font-regular text-tertiary")}>
Toggling this off will only let users sign up when they are invited.
<>
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">Manage authentication modes for your instance</div>
<div className="text-sm font-normal text-custom-text-300">
Configure authentication modes for your team and restrict sign-ups to be invite only.
</div>
</div>
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{formattedConfig ? (
<div className="space-y-3">
<div className={cn("w-full flex items-center gap-14 rounded")}>
<div className="flex grow items-center gap-4">
<div className="grow">
<div className="text-lg font-medium pb-1">Allow anyone to sign up even without an invite</div>
<div className={cn("font-normal leading-5 text-custom-text-300 text-xs")}>
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">
<ToggleSwitch
value={Boolean(parseInt(enableSignUpConfig))}
onChange={() => {
if (Boolean(parseInt(enableSignUpConfig)) === true) {
updateConfig("ENABLE_SIGNUP", "0");
} else {
updateConfig("ENABLE_SIGNUP", "1");
}
}}
size="sm"
disabled={isSubmitting}
/>
</div>
</div>
</div>
<div className="text-lg font-medium pt-6">Available authentication modes</div>
<AuthenticationModes disabled={isSubmitting} updateConfig={updateConfig} />
</div>
<div className={`shrink-0 pr-4 ${isSubmitting && "opacity-70"}`}>
<div className="flex items-center gap-4">
<ToggleSwitch
value={Boolean(parseInt(enableSignUpConfig))}
onChange={() => {
if (Boolean(parseInt(enableSignUpConfig)) === true) {
updateConfig("ENABLE_SIGNUP", "0");
} else {
updateConfig("ENABLE_SIGNUP", "1");
}
}}
size="sm"
disabled={isSubmitting}
/>
</div>
</div>
</div>
<div className="text-lg pt-6 font-medium">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}
/>
))}
) : (
<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>
)}
</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>
</div>
</>
);
});
export const meta: Route.MetaFunction = () => [{ title: "Authentication Settings - Plane Web" }];
export default InstanceAuthenticationPage;
@@ -1,10 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import { useMemo, useState } from "react";
import type { FC } from "react";
import React, { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
// types
import { Button } from "@plane/propel/button";
@@ -34,7 +31,7 @@ const EMAIL_SECURITY_OPTIONS: { [key in TEmailSecurityKeys]: string } = {
NONE: "No email security",
};
export function InstanceEmailForm(props: IInstanceEmailForm) {
export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
const { config } = props;
// states
const [isSendTestEmailModalOpen, setIsSendTestEmailModalOpen] = useState(false);
@@ -163,12 +160,13 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
/>
))}
<div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">Email security</h4>
<h4 className="text-sm text-custom-text-300">Email security</h4>
<CustomSelect
value={emailSecurityKey}
label={EMAIL_SECURITY_OPTIONS[emailSecurityKey]}
onChange={handleEmailSecurityChange}
buttonClassName="rounded-md border-subtle"
buttonClassName="rounded-md border-custom-border-200"
optionsClassName="w-full"
input
>
{Object.entries(EMAIL_SECURITY_OPTIONS).map(([key, value]) => (
@@ -179,12 +177,12 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
</CustomSelect>
</div>
</div>
<div className="my-6 flex flex-col gap-6 border-t border-subtle pt-4">
<div className="flex flex-col gap-6 my-6 pt-4 border-t border-custom-border-100">
<div className="flex w-full max-w-xl flex-col gap-y-10 px-1">
<div className="mr-8 flex items-center gap-10 pt-4">
<div className="grow">
<div className="text-13 font-medium text-primary">Authentication</div>
<div className="text-11 font-regular text-tertiary">
<div className="text-sm font-medium text-custom-text-100">Authentication</div>
<div className="text-xs font-normal text-custom-text-300">
This is optional, but we recommend setting up a username and a password for your SMTP server.
</div>
</div>
@@ -207,19 +205,17 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
</div>
</div>
</div>
<div className="flex max-w-4xl items-center gap-4 py-1">
<div className="flex max-w-4xl items-center py-1 gap-4">
<Button
variant="primary"
size="lg"
onClick={handleSubmit(onSubmit)}
loading={isSubmitting}
disabled={!isValid || !isDirty}
>
{isSubmitting ? "Saving" : "Save changes"}
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
<Button
variant="secondary"
size="lg"
variant="outline-primary"
onClick={() => setIsSendTestEmailModalOpen(true)}
loading={isSubmitting}
disabled={!isValid}
@@ -229,4 +225,4 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
</div>
</div>
);
}
};
@@ -0,0 +1,14 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
interface EmailLayoutProps {
children: ReactNode;
}
export const metadata: Metadata = {
title: "Email Settings - God Mode",
};
export default function EmailLayout({ children }: EmailLayoutProps) {
return <>{children}</>;
}
+38 -47
View File
@@ -1,24 +1,16 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } 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
// components
import { InstanceEmailForm } from "./email-config-form";
const InstanceEmailPage = observer(function InstanceEmailPage(_props: Route.ComponentProps) {
const InstanceEmailPage: React.FC = observer(() => {
// store
const { fetchInstanceConfigurations, formattedConfig, disableEmail } = useInstance();
@@ -58,46 +50,45 @@ const InstanceEmailPage = observer(function InstanceEmailPage(_props: Route.Comp
}, [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 className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="flex items-center justify-between gap-4 border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<div className="py-4 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">Secure emails from your own instance</div>
<div className="text-sm font-normal text-custom-text-300">
Plane can send useful emails to you and your users from your own instance without talking to the Internet.
<div className="text-sm font-normal text-custom-text-300">
Set it up below and please test your settings before you save them.&nbsp;
<span className="text-red-400">Misconfigs can lead to email bounces and errors.</span>
</div>
</div>
</>
),
actions: isLoading ? (
<Loader>
<Loader.Item width="24px" height="16px" className="rounded-full" />
</Loader>
) : (
<ToggleSwitch value={isSMTPEnabled} onChange={handleToggle} size="sm" 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%" />
</div>
{isLoading ? (
<Loader>
<Loader.Item width="24px" height="16px" className="rounded-full" />
</Loader>
) : (
<ToggleSwitch value={isSMTPEnabled} onChange={handleToggle} size="sm" disabled={isSubmitting} />
)}
</>
)}
</PageWrapper>
</div>
{isSMTPEnabled && !isLoading && (
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{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>
)}
</div>
)}
</div>
</>
);
});
export const meta: Route.MetaFunction = () => [{ title: "Email Settings - God Mode" }];
export default InstanceEmailPage;
@@ -1,10 +1,5 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useState, Fragment } from "react";
import type { FC } from "react";
import React, { useEffect, useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
// plane imports
import { Button } from "@plane/propel/button";
@@ -25,7 +20,7 @@ enum ESendEmailSteps {
const instanceService = new InstanceService();
export function SendTestEmailModal(props: Props) {
export const SendTestEmailModal: FC<Props> = (props) => {
const { isOpen, handleClose } = props;
// state
@@ -67,10 +62,10 @@ export function SendTestEmailModal(props: Props) {
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={Fragment}
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
@@ -78,12 +73,12 @@ export function SendTestEmailModal(props: Props) {
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-backdrop transition-opacity" />
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="my-10 flex justify-center p-4 text-center sm:p-0 md:my-20">
<Transition.Child
as={Fragment}
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
@@ -91,8 +86,8 @@ export function SendTestEmailModal(props: Props) {
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative w-full transform rounded-lg bg-surface-1 p-5 px-4 text-left shadow-raised-200 transition-all sm:max-w-xl">
<h3 className="text-16 leading-6 font-medium text-primary">
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 p-5 px-4 text-left shadow-custom-shadow-md transition-all w-full sm:max-w-xl">
<h3 className="text-lg font-medium leading-6 text-custom-text-100">
{sendEmailStep === ESendEmailSteps.SEND_EMAIL
? "Send test email"
: sendEmailStep === ESendEmailSteps.SUCCESS
@@ -107,12 +102,12 @@ export function SendTestEmailModal(props: Props) {
value={receiverEmail}
onChange={(e) => setReceiverEmail(e.target.value)}
placeholder="Receiver email"
className="w-full resize-none text-16"
className="w-full resize-none text-lg"
tabIndex={1}
/>
)}
{sendEmailStep === ESendEmailSteps.SUCCESS && (
<div className="flex flex-col gap-y-4 text-13">
<div className="flex flex-col gap-y-4 text-sm">
<p>
We have sent the test email to {receiverEmail}. Please check your spam folder if you cannot find
it.
@@ -120,14 +115,14 @@ export function SendTestEmailModal(props: Props) {
<p>If you still cannot find it, recheck your SMTP configuration and trigger a new test email.</p>
</div>
)}
{sendEmailStep === ESendEmailSteps.FAILED && <div className="text-13">{error}</div>}
<div className="mt-5 flex items-center justify-end gap-2">
<Button variant="secondary" size="lg" onClick={handleClose} tabIndex={2}>
{sendEmailStep === ESendEmailSteps.FAILED && <div className="text-sm">{error}</div>}
<div className="flex items-center gap-2 justify-end mt-5">
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={2}>
{sendEmailStep === ESendEmailSteps.SEND_EMAIL ? "Cancel" : "Close"}
</Button>
{sendEmailStep === ESendEmailSteps.SEND_EMAIL && (
<Button variant="primary" size="lg" loading={isLoading} onClick={handleSubmit} tabIndex={3}>
{isLoading ? "Sending email" : "Send email"}
<Button variant="primary" size="sm" loading={isLoading} onClick={handleSubmit} tabIndex={3}>
{isLoading ? "Sending email..." : "Send email"}
</Button>
)}
</div>
@@ -139,4 +134,4 @@ export function SendTestEmailModal(props: Props) {
</Dialog>
</Transition.Root>
);
}
};
@@ -1,36 +1,35 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { Telescope } from "lucide-react";
// plane imports
// types
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IInstance, IInstanceAdmin } from "@plane/types";
// ui
import { Input, ToggleSwitch } from "@plane/ui";
// components
import { ControllerInput } from "@/components/common/controller-input";
// hooks
import { useInstance } from "@/hooks/store";
import { IntercomConfig } from "./intercom";
// hooks
export interface IGeneralConfigurationForm {
instance: IInstance;
instanceAdmins: IInstanceAdmin[];
}
export const GeneralConfigurationForm = observer(function GeneralConfigurationForm(props: IGeneralConfigurationForm) {
export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer((props) => {
const { instance, instanceAdmins } = props;
// hooks
const { updateInstanceInfo } = useInstance();
const { instanceConfigurations, updateInstanceInfo, updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
watch,
formState: { errors, isSubmitting },
} = useForm<Partial<IInstance>>({
defaultValues: {
@@ -42,6 +41,17 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
const onSubmit = async (formData: Partial<IInstance>) => {
const payload: Partial<IInstance> = { ...formData };
// update the intercom configuration
const isIntercomEnabled =
instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1";
if (!payload.is_telemetry_enabled && isIntercomEnabled) {
try {
await updateInstanceConfigurations({ IS_INTERCOM_ENABLED: "0" });
} catch (error) {
console.error(error);
}
}
await updateInstanceInfo(payload)
.then(() =>
setToast({
@@ -55,8 +65,8 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
return (
<div className="space-y-8">
<div className="space-y-4">
<div className="text-16 font-medium text-primary">Instance details</div>
<div className="space-y-3">
<div className="text-lg font-medium">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"
@@ -70,51 +80,54 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
/>
<div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">Email</h4>
<h4 className="text-sm text-custom-text-300">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"
className="w-full cursor-not-allowed !text-custom-text-400"
autoComplete="on"
disabled
/>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">Instance ID</h4>
<h4 className="text-sm text-custom-text-300">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"
className="w-full cursor-not-allowed rounded-md font-medium !text-custom-text-400"
disabled
/>
</div>
</div>
</div>
<div className="space-y-6">
<div className="border-b border-subtle pb-1.5 text-16 font-medium text-primary">Telemetry</div>
<div className="flex items-center gap-14">
<div className="flex grow items-center gap-4">
<div className="space-y-3">
<div className="text-lg font-medium">Chat + telemetry</div>
<IntercomConfig isTelemetryEnabled={watch("is_telemetry_enabled") ?? false} />
<div className="flex items-center gap-14 px-4 py-3 border border-custom-border-200 rounded">
<div className="grow flex items-center gap-4">
<div className="shrink-0">
<div className="flex size-11 items-center justify-center rounded-lg bg-layer-1">
<Telescope className="size-5 text-tertiary" />
<div className="flex items-center justify-center w-10 h-10 bg-custom-background-80 rounded-full">
<Telescope className="w-6 h-6 text-custom-text-300/80 p-0.5" />
</div>
</div>
<div className="grow">
<div className="text-13 leading-5 font-medium text-primary">Let Plane collect anonymous usage data</div>
<div className="text-11 leading-5 font-regular text-tertiary">
<div className="text-sm font-medium text-custom-text-100 leading-5">
Let Plane collect anonymous usage data
</div>
<div className="text-xs font-normal text-custom-text-300 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"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
our Telemetry Policy.
@@ -135,15 +148,8 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
</div>
<div>
<Button
variant="primary"
size="lg"
onClick={() => {
void handleSubmit(onSubmit)();
}}
loading={isSubmitting}
>
{isSubmitting ? "Saving" : "Save changes"}
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
</div>
</div>
@@ -0,0 +1,83 @@
"use client";
import type { FC } from "react";
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 { ToggleSwitch } from "@plane/ui";
// hooks
import { useInstance } from "@/hooks/store";
type TIntercomConfig = {
isTelemetryEnabled: boolean;
};
export const IntercomConfig: FC<TIntercomConfig> = observer((props) => {
const { isTelemetryEnabled } = props;
// hooks
const { instanceConfigurations, updateInstanceConfigurations, fetchInstanceConfigurations } = useInstance();
// states
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// derived values
const isIntercomEnabled = isTelemetryEnabled
? instanceConfigurations
? instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1"
? true
: false
: undefined
: false;
const { isLoading } = useSWR(isTelemetryEnabled ? "INSTANCE_CONFIGURATIONS" : null, () =>
isTelemetryEnabled ? fetchInstanceConfigurations() : null
);
const initialLoader = isLoading && isIntercomEnabled === undefined;
const submitInstanceConfigurations = async (payload: Partial<IFormattedInstanceConfiguration>) => {
try {
await updateInstanceConfigurations(payload);
} catch (error) {
console.error(error);
} finally {
setIsSubmitting(false);
}
};
const enableIntercomConfig = () => {
submitInstanceConfigurations({ IS_INTERCOM_ENABLED: isIntercomEnabled ? "0" : "1" });
};
return (
<>
<div className="flex items-center gap-14 px-4 py-3 border border-custom-border-200 rounded">
<div className="grow flex items-center gap-4">
<div className="shrink-0">
<div className="flex items-center justify-center w-10 h-10 bg-custom-background-80 rounded-full">
<MessageSquare className="w-6 h-6 text-custom-text-300/80 p-0.5" />
</div>
</div>
<div className="grow">
<div className="text-sm font-medium text-custom-text-100 leading-5">Chat with us</div>
<div className="text-xs font-normal text-custom-text-300 leading-5">
Let your users chat with us via Intercom or another service. Toggling Telemetry off turns this off
automatically.
</div>
</div>
<div className="ml-auto">
<ToggleSwitch
value={isIntercomEnabled ? true : false}
onChange={enableIntercomConfig}
size="sm"
disabled={!isTelemetryEnabled || isSubmitting || initialLoader}
/>
</div>
</div>
</div>
</>
);
});
@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "General Settings - God Mode",
};
export default function GeneralLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}
@@ -1,35 +1,31 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import { observer } from "mobx-react";
// components
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { useInstance } from "@/hooks/store";
// local imports
// components
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>
<>
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">General settings</div>
<div className="text-sm font-normal text-custom-text-300">
Change the name of your instance and instance admin e-mail addresses. Enable or disable telemetry in your
instance.
</div>
</div>
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{instance && instanceAdmins && (
<GeneralConfigurationForm instance={instance} instanceAdmins={instanceAdmins} />
)}
</div>
</div>
</>
);
}
export const meta: Route.MetaFunction = () => [{ title: "General Settings - God Mode" }];
export default observer(GeneralPage);
@@ -1,42 +1,58 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { Menu, Settings } from "lucide-react";
// icons
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "../breadcrumb-link";
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
// hooks
import { useTheme } from "@/hooks/store";
// local imports
import { CORE_HEADER_SEGMENT_LABELS } from "./core";
import { EXTENDED_HEADER_SEGMENT_LABELS } from "./extended";
export const HamburgerToggle = observer(function HamburgerToggle() {
export const HamburgerToggle: FC = observer(() => {
const { isSidebarCollapsed, toggleSidebar } = useTheme();
return (
<button
className="group flex size-7 cursor-pointer items-center justify-center rounded-sm bg-layer-1 transition-all hover:bg-layer-1-hover md:hidden"
<div
className="w-7 h-7 rounded flex justify-center items-center bg-custom-background-80 transition-all hover:bg-custom-background-90 cursor-pointer group md:hidden"
onClick={() => toggleSidebar(!isSidebarCollapsed)}
>
<Menu size={14} className="text-secondary transition-all group-hover:text-primary" />
</button>
<Menu size={14} className="text-custom-text-200 group-hover:text-custom-text-100 transition-all" />
</div>
);
});
const HEADER_SEGMENT_LABELS = {
...CORE_HEADER_SEGMENT_LABELS,
...EXTENDED_HEADER_SEGMENT_LABELS,
};
export const AdminHeader = observer(function AdminHeader() {
export const AdminHeader: FC = observer(() => {
const pathName = usePathname();
const getHeaderTitle = (pathName: string) => {
switch (pathName) {
case "general":
return "General";
case "ai":
return "Artificial Intelligence";
case "email":
return "Email";
case "authentication":
return "Authentication";
case "image":
return "Image";
case "google":
return "Google";
case "github":
return "GitHub";
case "gitlab":
return "GitLab";
case "workspace":
return "Workspace";
case "create":
return "Create";
default:
return pathName.toUpperCase();
}
};
// Function to dynamically generate breadcrumb items based on pathname
const generateBreadcrumbItems = (pathname: string) => {
const pathSegments = pathname.split("/").slice(1); // removing the first empty string.
@@ -46,17 +62,17 @@ export const AdminHeader = observer(function AdminHeader() {
const breadcrumbItems = pathSegments.map((segment) => {
currentUrl += "/" + segment;
return {
title: HEADER_SEGMENT_LABELS[segment] ?? segment.toUpperCase(),
title: getHeaderTitle(segment),
href: currentUrl,
};
});
return breadcrumbItems;
};
const breadcrumbItems = generateBreadcrumbItems(pathName || "");
const breadcrumbItems = generateBreadcrumbItems(pathName);
return (
<div className="relative z-10 flex h-header w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-subtle bg-surface-1 p-4">
<div className="relative z-10 flex h-header w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-sidebar-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<HamburgerToggle />
{breadcrumbItems.length >= 0 && (
@@ -67,7 +83,7 @@ export const AdminHeader = observer(function AdminHeader() {
<BreadcrumbLink
href="/general/"
label="Settings"
icon={<Settings className="h-4 w-4 text-tertiary" />}
icon={<Settings className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
@@ -1,9 +1,5 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import type { FC } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
@@ -19,7 +15,7 @@ type IInstanceImageConfigForm = {
type ImageConfigFormValues = Record<TInstanceImageConfigurationKeys, string>;
export function InstanceImageConfigForm(props: IInstanceImageConfigForm) {
export const InstanceImageConfigForm: FC<IInstanceImageConfigForm> = (props) => {
const { config } = props;
// store hooks
const { updateInstanceConfigurations } = useInstance();
@@ -62,7 +58,7 @@ export function InstanceImageConfigForm(props: IInstanceImageConfigForm) {
<a
href="https://unsplash.com/documentation#creating-a-developer-account"
target="_blank"
className="text-accent-primary hover:underline"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
Learn more.
@@ -76,10 +72,10 @@ export function InstanceImageConfigForm(props: IInstanceImageConfigForm) {
</div>
<div>
<Button variant="primary" size="lg" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving" : "Save changes"}
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
</div>
</div>
);
}
};
@@ -0,0 +1,14 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
interface ImageLayoutProps {
children: ReactNode;
}
export const metadata: Metadata = {
title: "Images Settings - God Mode",
};
export default function ImageLayout({ children }: ImageLayoutProps) {
return <>{children}</>;
}
+22 -27
View File
@@ -1,46 +1,41 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
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) {
const InstanceImagePage = observer(() => {
// 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>
<>
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">Third-party image libraries</div>
<div className="text-sm font-normal text-custom-text-300">
Let your users search and choose images from third-party libraries
</div>
</div>
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{formattedConfig ? (
<InstanceImageConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="50%" />
<Loader.Item height="50px" width="20%" />
</Loader>
)}
</div>
</div>
</>
);
});
export const meta: Route.MetaFunction = () => [{ title: "Images Settings - God Mode" }];
export default InstanceImagePage;
+18 -18
View File
@@ -1,32 +1,34 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import type { FC, ReactNode } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
import { Outlet } from "react-router";
// components
import { AdminHeader } from "@/components/common/header";
import { LogoSpinner } from "@/components/common/logo-spinner";
import { NewUserPopup } from "@/components/common/new-user-popup";
import { NewUserPopup } from "@/components/new-user-popup";
// hooks
import { useUser } from "@/hooks/store";
// local components
import type { Route } from "./+types/layout";
import { AdminHeader } from "./header";
import { AdminSidebar } from "./sidebar";
function AdminLayout(_props: Route.ComponentProps) {
type TAdminLayout = {
children: ReactNode;
};
const AdminLayout: FC<TAdminLayout> = (props) => {
const { children } = props;
// router
const { replace } = useRouter();
const router = useRouter();
// store hooks
const { isUserLoggedIn } = useUser();
useEffect(() => {
if (isUserLoggedIn === false) replace("/");
}, [replace, isUserLoggedIn]);
if (isUserLoggedIn === false) {
router.push("/");
}
}, [router, isUserLoggedIn]);
if (isUserLoggedIn === undefined) {
return (
@@ -40,11 +42,9 @@ function AdminLayout(_props: Route.ComponentProps) {
return (
<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">
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
<AdminHeader />
<div className="vertical-scrollbar scrollbar-md h-full w-full overflow-hidden overflow-y-scroll">
<Outlet />
</div>
<div className="h-full w-full overflow-hidden">{children}</div>
</main>
<NewUserPopup />
</div>
@@ -52,6 +52,6 @@ function AdminLayout(_props: Route.ComponentProps) {
}
return <></>;
}
};
export default observer(AdminLayout);
@@ -1,8 +1,4 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import { Fragment, useEffect, useState } from "react";
import { observer } from "mobx-react";
@@ -20,7 +16,7 @@ import { useTheme, useUser } from "@/hooks/store";
// service initialization
const authService = new AuthService();
export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
export const AdminSidebarDropdown = observer(() => {
// store hooks
const { isSidebarCollapsed } = useTheme();
const { currentUser, signOut } = useUser();
@@ -39,20 +35,20 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
const getSidebarMenuItems = () => (
<Menu.Items
className={cn(
"shadow-lg absolute left-0 z-20 mt-1.5 flex w-52 flex-col divide-y divide-subtle rounded-md border border-subtle bg-surface-1 px-1 py-2 text-11 outline-none",
"absolute left-0 z-20 mt-1.5 flex w-52 flex-col divide-y divide-custom-sidebar-border-100 rounded-md border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-1 py-2 text-xs shadow-lg outline-none",
{
"left-4": isSidebarCollapsed,
}
)}
>
<div className="flex flex-col gap-2.5 pb-2">
<span className="truncate px-2 text-secondary">{currentUser?.email}</span>
<span className="px-2 text-custom-sidebar-text-200 truncate">{currentUser?.email}</span>
</div>
<div className="py-2">
<Menu.Item
as="button"
type="button"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1 hover:bg-layer-1-hover"
className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80"
onClick={handleThemeSwitch}
>
<Palette className="h-4 w-4 stroke-[1.5]" />
@@ -65,7 +61,7 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
<Menu.Item
as="button"
type="submit"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1 hover:bg-layer-1-hover"
className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80"
>
<LogOut className="h-4 w-4 stroke-[1.5]" />
Sign out
@@ -77,14 +73,14 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
useEffect(() => {
if (csrfToken === undefined)
void authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
}, [csrfToken]);
return (
<div className="flex max-h-header items-center gap-x-5 gap-y-2 border-b border-subtle px-4 py-2.5">
<div className="flex max-h-header items-center gap-x-5 gap-y-2 border-b border-custom-sidebar-border-200 px-4 py-3.5">
<div className="h-full w-full truncate">
<div
className={`flex flex-grow items-center gap-x-2 truncate rounded-sm ${
className={`flex flex-grow items-center gap-x-2 truncate rounded py-1 ${
isSidebarCollapsed ? "justify-center" : ""
}`}
>
@@ -94,8 +90,8 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
"cursor-default": !isSidebarCollapsed,
})}
>
<div className="flex size-8 flex-shrink-0 items-center justify-center rounded-sm bg-layer-1">
<UserCog2 className="size-5 text-primary" />
<div className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded bg-custom-sidebar-background-80">
<UserCog2 className="h-5 w-5 text-custom-text-200" />
</div>
</Menu.Button>
{isSidebarCollapsed && (
@@ -115,7 +111,7 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
{!isSidebarCollapsed && (
<div className="flex w-full gap-2">
<h4 className="grow truncate text-body-md-medium text-primary">Instance admin</h4>
<h4 className="grow truncate text-base font-medium text-custom-text-200">Instance admin</h4>
</div>
)}
</div>
@@ -129,7 +125,7 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
src={getFileURL(currentUser.avatar_url)}
size={24}
shape="square"
className="!text-body-sm-medium"
className="!text-base"
/>
</Menu.Button>
@@ -1,22 +1,21 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import type { FC } from "react";
import { useState, useRef } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { HelpCircle, MessageSquare, MoveLeft } from "lucide-react";
import { ExternalLink, HelpCircle, MoveLeft } from "lucide-react";
import { Transition } from "@headlessui/react";
import { WEB_BASE_URL } from "@plane/constants";
// plane internal packages
import { GithubIcon, NewTabIcon, PageIcon } from "@plane/propel/icons";
import { WEB_BASE_URL } from "@plane/constants";
import { DiscordIcon, GithubIcon, PageIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils";
// hooks
import { useInstance, useTheme } from "@/hooks/store";
import { useTheme } from "@/hooks/store";
// assets
// eslint-disable-next-line import/order
import packageJson from "package.json";
const helpOptions = [
{
@@ -25,9 +24,9 @@ const helpOptions = [
Icon: PageIcon,
},
{
name: "Join our Forum",
href: "https://forum.plane.so",
Icon: MessageSquare,
name: "Join our Discord",
href: "https://discord.com/invite/A92xrEGCge",
Icon: DiscordIcon,
},
{
name: "Report a bug",
@@ -36,11 +35,10 @@ const helpOptions = [
},
];
export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection() {
export const AdminSidebarHelpSection: FC = observer(() => {
// states
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
// store
const { instance } = useInstance();
const { isSidebarCollapsed, toggleSidebar } = useTheme();
// refs
const helpOptionsRef = useRef<HTMLDivElement | null>(null);
@@ -50,9 +48,9 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
return (
<div
className={cn(
"flex h-14 w-full flex-shrink-0 items-center justify-between gap-1 self-baseline border-t border-subtle bg-surface-1 px-4",
"flex w-full items-center justify-between gap-1 self-baseline border-t border-custom-border-200 bg-custom-sidebar-background-100 px-4 h-14 flex-shrink-0",
{
"h-auto flex-col py-1.5": isSidebarCollapsed,
"flex-col h-auto py-1.5": isSidebarCollapsed,
}
)}
>
@@ -60,32 +58,32 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
<Tooltip tooltipContent="Redirect to Plane" position="right" className="ml-4" disabled={!isSidebarCollapsed}>
<a
href={redirectionLink}
className={`relative flex items-center gap-1 rounded-sm bg-layer-1 px-2 py-1 text-body-xs-medium whitespace-nowrap text-secondary`}
className={`relative px-2 py-1.5 flex items-center gap-2 font-medium rounded border border-custom-primary-100/20 bg-custom-primary-100/10 text-xs text-custom-primary-200 whitespace-nowrap`}
>
<NewTabIcon width={14} height={14} />
<ExternalLink size={14} />
{!isSidebarCollapsed && "Redirect to Plane"}
</a>
</Tooltip>
<Tooltip tooltipContent="Help" position={isSidebarCollapsed ? "right" : "top"} className="ml-4">
<button
type="button"
className={`ml-auto grid place-items-center rounded-md p-1.5 text-secondary outline-none hover:bg-layer-1-hover hover:text-primary ${
className={`ml-auto grid place-items-center rounded-md p-1.5 text-custom-text-200 outline-none hover:bg-custom-background-90 hover:text-custom-text-100 ${
isSidebarCollapsed ? "w-full" : ""
}`}
onClick={() => setIsNeedHelpOpen((prev) => !prev)}
>
<HelpCircle className="size-4" />
<HelpCircle className="h-3.5 w-3.5" />
</button>
</Tooltip>
<Tooltip tooltipContent="Toggle sidebar" position={isSidebarCollapsed ? "right" : "top"} className="ml-4">
<button
type="button"
className={`grid place-items-center rounded-md p-1.5 text-secondary outline-none hover:bg-layer-1-hover hover:text-primary ${
className={`grid place-items-center rounded-md p-1.5 text-custom-text-200 outline-none hover:bg-custom-background-90 hover:text-custom-text-100 ${
isSidebarCollapsed ? "w-full" : ""
}`}
onClick={() => toggleSidebar(!isSidebarCollapsed)}
>
<MoveLeft className={`size-4 duration-300 ${isSidebarCollapsed ? "rotate-180" : ""}`} />
<MoveLeft className={`h-3.5 w-3.5 duration-300 ${isSidebarCollapsed ? "rotate-180" : ""}`} />
</button>
</Tooltip>
</div>
@@ -101,9 +99,9 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
leaveTo="transform opacity-0 scale-95"
>
<div
className={`absolute bottom-2 z-[15] min-w-[10rem] ${
className={`absolute bottom-2 min-w-[10rem] z-[15] ${
isSidebarCollapsed ? "left-full" : "-left-[75px]"
} divide-y divide-subtle-1 rounded-sm bg-surface-1 p-1 whitespace-nowrap shadow-raised-100`}
} divide-y divide-custom-border-200 whitespace-nowrap rounded bg-custom-background-100 p-1 shadow-custom-shadow-xs`}
ref={helpOptionsRef}
>
<div className="space-y-1 pb-2">
@@ -111,11 +109,11 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
if (href)
return (
<Link href={href} key={name} target="_blank">
<div className="flex items-center gap-x-2 rounded-sm px-2 py-1 text-11 hover:bg-layer-1-hover">
<div className="flex items-center gap-x-2 rounded px-2 py-1 text-xs hover:bg-custom-background-80">
<div className="grid flex-shrink-0 place-items-center">
<Icon className="h-3.5 w-3.5 text-secondary" />
<Icon className="h-3.5 w-3.5 text-custom-text-200" width={14} height={14} />
</div>
<span className="text-11">{name}</span>
<span className="text-xs">{name}</span>
</div>
</Link>
);
@@ -124,17 +122,17 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
<button
key={name}
type="button"
className="flex w-full items-center gap-x-2 rounded-sm px-2 py-1 text-11 hover:bg-layer-1"
className="flex w-full items-center gap-x-2 rounded px-2 py-1 text-xs hover:bg-custom-background-80"
>
<div className="grid flex-shrink-0 place-items-center">
<Icon className="h-3.5 w-3.5 text-secondary" />
<Icon className="h-3.5 w-3.5 text-custom-text-200" />
</div>
<span className="text-11">{name}</span>
<span className="text-xs">{name}</span>
</button>
);
})}
</div>
<div className="px-2 pt-2 pb-1 text-10">Version: v{instance?.current_version}</div>
<div className="px-2 pb-1 pt-2 text-[10px]">Version: v{packageJson.version}</div>
</div>
</Transition>
</div>
@@ -1,26 +1,60 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import { observer } from "mobx-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react";
// plane internal packages
import { WorkspaceIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils";
// hooks
import { useTheme } from "@/hooks/store";
import { useSidebarMenu } from "@/hooks/use-sidebar-menu";
export const AdminSidebarMenu = observer(function AdminSidebarMenu() {
// router
const pathName = usePathname();
const INSTANCE_ADMIN_LINKS = [
{
Icon: Cog,
name: "General",
description: "Identify your instances and get key details.",
href: `/general/`,
},
{
Icon: WorkspaceIcon,
name: "Workspaces",
description: "Manage all workspaces on this instance.",
href: `/workspace/`,
},
{
Icon: Mail,
name: "Email",
description: "Configure your SMTP controls.",
href: `/email/`,
},
{
Icon: Lock,
name: "Authentication",
description: "Configure authentication modes.",
href: `/authentication/`,
},
{
Icon: BrainCog,
name: "Artificial intelligence",
description: "Configure your OpenAI creds.",
href: `/ai/`,
},
{
Icon: Image,
name: "Images in Plane",
description: "Allow third-party image libraries.",
href: `/image/`,
},
];
export const AdminSidebarMenu = observer(() => {
// store hooks
const { isSidebarCollapsed, toggleSidebar } = useTheme();
// derived values
const sidebarMenu = useSidebarMenu();
// router
const pathName = usePathname();
const handleItemClick = () => {
if (window.innerWidth < 768) {
@@ -29,28 +63,41 @@ export const AdminSidebarMenu = observer(function AdminSidebarMenu() {
};
return (
<div className="vertical-scrollbar flex scrollbar-sm h-full w-full flex-col gap-2.5 overflow-y-scroll px-4 py-4">
{sidebarMenu.map((item, index) => {
const isActive = item.href === pathName || pathName?.includes(item.href);
<div className="flex h-full w-full flex-col gap-2.5 overflow-y-scroll vertical-scrollbar scrollbar-sm px-4 py-4">
{INSTANCE_ADMIN_LINKS.map((item, index) => {
const isActive = item.href === pathName || pathName.includes(item.href);
return (
<Link key={index} href={item.href} onClick={handleItemClick}>
<div>
<Tooltip tooltipContent={item.name} position="right" className="ml-2" disabled={!isSidebarCollapsed}>
<div
className={cn(
"group flex w-full items-center gap-3 rounded-md px-3 py-2 transition-colors outline-none",
{
"!bg-layer-transparent-active text-primary": isActive,
"text-secondary hover:bg-layer-transparent-hover active:bg-layer-transparent-active": !isActive,
},
`group flex w-full items-center gap-3 rounded-md px-3 py-2 outline-none transition-colors`,
isActive
? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80",
isSidebarCollapsed ? "justify-center" : "w-[260px]"
)}
>
{<item.Icon className="h-4 w-4 flex-shrink-0" />}
{!isSidebarCollapsed && (
<div className="w-full">
<div className={cn(`text-body-xs-medium transition-colors`)}>{item.name}</div>
<div className={cn(`text-caption-sm-regular transition-colors`)}>{item.description}</div>
<div className="w-full ">
<div
className={cn(
`text-sm font-medium transition-colors`,
isActive ? "text-custom-primary-100" : "text-custom-sidebar-text-200"
)}
>
{item.name}
</div>
<div
className={cn(
`text-[10px] transition-colors`,
isActive ? "text-custom-primary-90" : "text-custom-sidebar-text-400"
)}
>
{item.description}
</div>
</div>
)}
</div>
+10 -7
View File
@@ -1,9 +1,6 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import type { FC } from "react";
import { useEffect, useRef } from "react";
import { observer } from "mobx-react";
// plane helpers
@@ -15,7 +12,7 @@ import { AdminSidebarDropdown } from "./sidebar-dropdown";
import { AdminSidebarHelpSection } from "./sidebar-help-section";
import { AdminSidebarMenu } from "./sidebar-menu";
export const AdminSidebar = observer(function AdminSidebar() {
export const AdminSidebar: FC = observer(() => {
// store
const { isSidebarCollapsed, toggleSidebar } = useTheme();
@@ -44,7 +41,13 @@ export const AdminSidebar = observer(function AdminSidebar() {
return (
<div
className={`fixed inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-subtle bg-surface-1 duration-300 md:relative ${isSidebarCollapsed ? "-ml-[290px]" : ""} sm:${isSidebarCollapsed ? "-ml-[290px]" : ""} md:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"} lg:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"} `}
className={`inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-custom-sidebar-border-200 bg-custom-sidebar-background-100 duration-300
fixed md:relative
${isSidebarCollapsed ? "-ml-[290px]" : ""}
sm:${isSidebarCollapsed ? "-ml-[290px]" : ""}
md:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"}
lg:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"}
`}
>
<div ref={ref} className="flex h-full w-full flex-1 flex-col">
<AdminSidebarDropdown />
@@ -1,9 +1,3 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -14,7 +8,6 @@ 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
@@ -22,7 +15,7 @@ import { useWorkspace } from "@/hooks/store";
const instanceWorkspaceService = new InstanceWorkspaceService();
export function WorkspaceCreateForm() {
export const WorkspaceCreateForm = () => {
// router
const router = useRouter();
// states
@@ -91,13 +84,20 @@ export function WorkspaceCreateForm() {
<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>
<h4 className="text-sm text-custom-text-300">Name your workspace</h4>
<div className="flex flex-col gap-1">
<Controller
control={control}
name="name"
rules={{
validate: (value) => validateWorkspaceName(value, true),
required: "This is a required field.",
validate: (value) =>
/^[\w\s-]*$/.test(value) ||
`Workspaces names can contain only (" "), ( - ), ( _ ) and alphanumeric characters.`,
maxLength: {
value: 80,
message: "Limit your name to 80 characters.",
},
}}
render={({ field: { value, ref, onChange } }) => (
<Input
@@ -118,18 +118,22 @@ export function WorkspaceCreateForm() {
/>
)}
/>
<span className="text-11 text-danger-primary">{errors?.name?.message}</span>
<span className="text-xs text-red-500">{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 w-full items-center gap-0.5 rounded-md border-[0.5px] border-subtle px-3">
<span className="text-13 whitespace-nowrap text-secondary">{workspaceBaseURL}</span>
<h4 className="text-sm text-custom-text-300">Set your workspace&apos;s URL</h4>
<div className="flex gap-0.5 w-full items-center rounded-md border-[0.5px] border-custom-border-200 px-3">
<span className="whitespace-nowrap text-sm text-custom-text-200">{workspaceBaseURL}</span>
<Controller
control={control}
name="slug"
rules={{
validate: (value) => validateSlug(value),
required: "The URL is a required field.",
maxLength: {
value: 48,
message: "Limit your URL to 48 characters.",
},
}}
render={({ field: { onChange, value, ref } }) => (
<Input
@@ -144,19 +148,19 @@ export function WorkspaceCreateForm() {
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"
className="block w-full rounded-md border-none bg-transparent !px-0 py-2 text-sm"
/>
)}
/>
</div>
{slugError && <p className="text-13 text-danger-primary">This URL is taken. Try something else.</p>}
{slugError && <p className="text-sm text-red-500">This URL is taken. Try something else.</p>}
{invalidSlug && (
<p className="text-13 text-danger-primary">{`URLs can contain only ( - ), ( _ ) and alphanumeric characters.`}</p>
<p className="text-sm text-red-500">{`URLs can contain only ( - ), ( _ ) and alphanumeric characters.`}</p>
)}
{errors.slug && <span className="text-11 text-danger-primary">{errors.slug.message}</span>}
{errors.slug && <span className="text-xs text-red-500">{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>
<h4 className="text-sm text-custom-text-300">How many people will use this workspace?</h4>
<div className="w-full">
<Controller
name="organization_size"
@@ -168,11 +172,12 @@ export function WorkspaceCreateForm() {
onChange={onChange}
label={
ORGANIZATION_SIZE.find((c) => c === value) ?? (
<span className="text-placeholder">Select a range</span>
<span className="text-custom-text-400">Select a range</span>
)
}
buttonClassName="!border-[0.5px] !border-subtle !shadow-none"
buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none"
input
optionsClassName="w-full"
>
{ORGANIZATION_SIZE.map((item) => (
<CustomSelect.Option key={item} value={item}>
@@ -183,25 +188,25 @@ export function WorkspaceCreateForm() {
)}
/>
{errors.organization_size && (
<span className="text-13 text-danger-primary">{errors.organization_size.message}</span>
<span className="text-sm text-red-500">{errors.organization_size.message}</span>
)}
</div>
</div>
</div>
<div className="flex max-w-4xl items-center gap-4 py-1">
<div className="flex max-w-4xl items-center py-1 gap-4">
<Button
variant="primary"
size="lg"
size="sm"
onClick={handleSubmit(handleCreateWorkspace)}
disabled={!isValid}
loading={isSubmitting}
>
{isSubmitting ? "Creating workspace" : "Create workspace"}
</Button>
<Link className={getButtonStyling("secondary", "lg")} href="/workspace">
<Link className={getButtonStyling("neutral-primary", "sm")} href="/workspace">
Go back
</Link>
</div>
</div>
);
}
};
@@ -1,30 +1,21 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
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.",
}}
>
const WorkspaceCreatePage = observer(() => (
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">Create a new workspace on this instance.</div>
<div className="text-sm font-normal text-custom-text-300">
You will need to invite users from Workspace Settings after you create this workspace.
</div>
</div>
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
<WorkspaceCreateForm />
</PageWrapper>
);
});
export const meta: Route.MetaFunction = () => [{ title: "Create Workspace - God Mode" }];
</div>
</div>
));
export default WorkspaceCreatePage;
@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Workspace Management - God Mode",
};
export default function WorkspaceManagementLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}
@@ -1,8 +1,4 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
@@ -14,16 +10,14 @@ import { Button, getButtonStyling } from "@plane/propel/button";
import { setPromiseToast } from "@plane/propel/toast";
import type { TInstanceConfigurationKeys } from "@plane/types";
import { Loader, ToggleSwitch } 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) {
const WorkspaceManagementPage = observer(() => {
// states
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// store
@@ -75,98 +69,102 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
};
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("flex w-full items-center gap-14 rounded-sm")}>
<div className="flex grow items-center gap-4">
<div className="grow">
<div className="pb-1 text-16 font-medium">Prevent anyone else from creating a workspace.</div>
<div className={cn("text-11 leading-5 font-regular text-tertiary")}>
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">
<ToggleSwitch
value={Boolean(parseInt(disableWorkspaceCreation))}
onChange={() => {
if (Boolean(parseInt(disableWorkspaceCreation)) === true) {
updateConfig("DISABLE_WORKSPACE_CREATION", "0");
} else {
updateConfig("DISABLE_WORKSPACE_CREATION", "1");
}
}}
size="sm"
disabled={isSubmitting}
/>
</div>
</div>
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="flex items-center justify-between gap-4 border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<div className="flex flex-col gap-1">
<div className="text-xl font-medium text-custom-text-100">Workspaces on this instance</div>
<div className="text-sm font-normal text-custom-text-300">
See all workspaces and control who can create them.
</div>
) : (
<Loader>
<Loader.Item height="50px" width="100%" />
</Loader>
)}
{workspaceLoader !== "init-loader" ? (
<>
<div className="flex items-center justify-between gap-2 pt-6">
<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="h-4 w-4 animate-spin" />
)}
</div>
<div className={cn("text-11 leading-5 font-regular text-tertiary")}>
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="h-3 w-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>
</div>
</PageWrapper>
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
<div className="space-y-3">
{formattedConfig ? (
<div className={cn("w-full flex items-center gap-14 rounded")}>
<div className="flex grow items-center gap-4">
<div className="grow">
<div className="text-lg font-medium pb-1">Prevent anyone else from creating a workspace.</div>
<div className={cn("font-normal leading-5 text-custom-text-300 text-xs")}>
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">
<ToggleSwitch
value={Boolean(parseInt(disableWorkspaceCreation))}
onChange={() => {
if (Boolean(parseInt(disableWorkspaceCreation)) === true) {
updateConfig("DISABLE_WORKSPACE_CREATION", "0");
} else {
updateConfig("DISABLE_WORKSPACE_CREATION", "1");
}
}}
size="sm"
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-lg font-medium">
All workspaces on this instance{" "}
<span className="text-custom-text-300"> {workspaceIds.length}</span>
{workspaceLoader && ["mutation", "pagination"].includes(workspaceLoader) && (
<LoaderIcon className="w-4 h-4 animate-spin" />
)}
</div>
<div className={cn("font-normal leading-5 text-custom-text-300 text-xs")}>
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", "sm")}>
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-primary"
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>
</div>
</div>
);
});
export const meta: Route.MetaFunction = () => [{ title: "Workspace Management - God Mode" }];
export default WorkspaceManagementPage;
+10 -17
View File
@@ -1,36 +1,29 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Info } from "lucide-react";
import type { FC } from "react";
import { Info, X } from "lucide-react";
// plane constants
import type { TAdminAuthErrorInfo } from "@plane/constants";
// icons
import { CloseIcon } from "@plane/propel/icons";
type TAuthBanner = {
bannerData: TAdminAuthErrorInfo | undefined;
handleBannerData?: (bannerData: TAdminAuthErrorInfo | undefined) => void;
};
export function AuthBanner(props: TAuthBanner) {
export const AuthBanner: FC<TAuthBanner> = (props) => {
const { bannerData, handleBannerData } = props;
if (!bannerData) return <></>;
return (
<div className="relative flex items-center gap-2 rounded-md border border-accent-strong/50 bg-accent-primary/10 p-2">
<div className="relative flex h-4 w-4 flex-shrink-0 items-center justify-center">
<Info size={16} className="text-accent-primary" />
<div className="relative flex items-center p-2 rounded-md gap-2 border border-custom-primary-100/50 bg-custom-primary-100/10">
<div className="w-4 h-4 flex-shrink-0 relative flex justify-center items-center">
<Info size={16} className="text-custom-primary-100" />
</div>
<div className="w-full text-13 font-medium text-accent-primary">{bannerData?.message}</div>
<div className="w-full text-sm font-medium text-custom-primary-100">{bannerData?.message}</div>
<div
className="relative ml-auto flex h-6 w-6 cursor-pointer items-center justify-center rounded-xs text-accent-primary transition-all hover:bg-accent-primary/20"
className="relative ml-auto w-6 h-6 rounded-sm flex justify-center items-center transition-all cursor-pointer hover:bg-custom-primary-100/20 text-custom-primary-100/80"
onClick={() => handleBannerData && handleBannerData(undefined)}
>
<CloseIcon className="h-4 w-4 flex-shrink-0" />
<X className="w-4 h-4 flex-shrink-0" />
</div>
</div>
);
}
};
+8 -14
View File
@@ -1,18 +1,12 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import Link from "next/link";
import { PlaneLockup } from "@plane/propel/icons";
export function AuthHeader() {
return (
<div className="sticky top-0 flex w-full flex-shrink-0 items-center justify-between gap-6">
<Link href="/">
<PlaneLockup height={20} width={95} className="text-primary" />
</Link>
</div>
);
}
export const AuthHeader = () => (
<div className="flex items-center justify-between gap-6 w-full flex-shrink-0 sticky top-0">
<Link href="/">
<PlaneLockup height={20} width={95} className="text-custom-text-100" />
</Link>
</div>
);
+74 -11
View File
@@ -1,13 +1,23 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import Image from "next/image";
import Link from "next/link";
import { KeyRound, Mails } from "lucide-react";
// plane packages
import type { TAdminAuthErrorInfo } from "@plane/constants";
import { SUPPORT_EMAIL, EAdminAuthErrorCodes } from "@plane/constants";
import type { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
import { resolveGeneralTheme } from "@plane/utils";
// components
import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch";
import { GithubConfiguration } from "@/components/authentication/github-config";
import { GitlabConfiguration } from "@/components/authentication/gitlab-config";
import { GoogleConfiguration } from "@/components/authentication/google-config";
import { PasswordLoginConfiguration } from "@/components/authentication/password-config-switch";
// images
import githubLightModeImage from "@/public/logos/github-black.png";
import githubDarkModeImage from "@/public/logos/github-white.png";
import GitlabLogo from "@/public/logos/gitlab-logo.svg";
import GoogleLogo from "@/public/logos/google-logo.svg";
export enum EErrorAlertType {
BANNER_ALERT = "BANNER_ALERT",
@@ -18,7 +28,7 @@ export enum EErrorAlertType {
}
const errorCodeMessages: {
[key in EAdminAuthErrorCodes]: { title: string; message: (email?: string) => React.ReactNode };
[key in EAdminAuthErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode };
} = {
// admin
[EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST]: {
@@ -50,7 +60,7 @@ const errorCodeMessages: {
message: () => (
<div>
Admin user already exists.&nbsp;
<Link className="font-medium underline underline-offset-4 transition-all hover:font-bold" href={`/admin`}>
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
Sign In
</Link>
&nbsp;now.
@@ -62,7 +72,7 @@ const errorCodeMessages: {
message: () => (
<div>
Admin user does not exist.&nbsp;
<Link className="font-medium underline underline-offset-4 transition-all hover:font-bold" href={`/admin`}>
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
Sign In
</Link>
&nbsp;now.
@@ -71,11 +81,14 @@ const errorCodeMessages: {
},
[EAdminAuthErrorCodes.ADMIN_USER_DEACTIVATED]: {
title: `User account deactivated`,
message: () => `User account deactivated. Please contact ${SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`,
message: () => `User account deactivated. Please contact ${!!SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`,
},
};
export const authErrorHandler = (errorCode: EAdminAuthErrorCodes, email?: string): TAdminAuthErrorInfo | undefined => {
export const authErrorHandler = (
errorCode: EAdminAuthErrorCodes,
email?: string | undefined
): TAdminAuthErrorInfo | undefined => {
const bannerAlertErrorCodes = [
EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST,
EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
@@ -98,3 +111,53 @@ export const authErrorHandler = (errorCode: EAdminAuthErrorCodes, email?: string
return undefined;
};
export const getBaseAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({
disabled,
updateConfig,
resolvedTheme,
}) => [
{
key: "unique-codes",
name: "Unique codes",
description:
"Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.",
icon: <Mails className="h-6 w-6 p-0.5 text-custom-text-300/80" />,
config: <EmailCodesConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "passwords-login",
name: "Passwords",
description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.",
icon: <KeyRound className="h-6 w-6 p-0.5 text-custom-text-300/80" />,
config: <PasswordLoginConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "google",
name: "Google",
description: "Allow members to log in or sign up for Plane with their Google accounts.",
icon: <Image src={GoogleLogo} height={20} width={20} alt="Google Logo" />,
config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "github",
name: "GitHub",
description: "Allow members to log in or sign up for Plane with their GitHub accounts.",
icon: (
<Image
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
height={20}
width={20}
alt="GitHub Logo"
/>
),
config: <GithubConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "gitlab",
name: "GitLab",
description: "Allow members to log in or sign up to plane with their GitLab accounts.",
icon: <Image src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
];
+4 -26
View File
@@ -1,31 +1,9 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
import { Outlet } from "react-router";
// hooks
import { useUser } from "@/hooks/store/use-user";
function RootLayout() {
// router
const { replace } = useRouter();
// store hooks
const { isUserLoggedIn } = useUser();
useEffect(() => {
if (isUserLoggedIn === true) replace("/general");
}, [replace, isUserLoggedIn]);
"use client";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<div className="relative z-10 flex h-screen w-screen flex-col items-center overflow-hidden overflow-y-auto bg-surface-1 px-8 pt-6 pb-10">
<Outlet />
<div className="relative z-10 flex flex-col items-center w-screen h-screen overflow-hidden overflow-y-auto pt-6 pb-10 px-8">
{children}
</div>
);
}
export default observer(RootLayout);
+4 -14
View File
@@ -1,8 +1,4 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import { observer } from "mobx-react";
// components
@@ -12,17 +8,16 @@ import { InstanceSetupForm } from "@/components/instance/setup-form";
// hooks
import { useInstance } from "@/hooks/store";
// components
import type { Route } from "./+types/page";
import { InstanceSignInForm } from "./sign-in-form";
function HomePage() {
const HomePage = () => {
// store hooks
const { instance, error } = useInstance();
// if instance is not fetched, show loading
if (!instance && !error) {
return (
<div className="flex h-screen w-full items-center justify-center">
<div className="flex items-center justify-center h-screen w-full">
<LogoSpinner />
</div>
);
@@ -40,11 +35,6 @@ function HomePage() {
// if instance is fetched and setup is done, show sign in form
return <InstanceSignInForm />;
}
};
export default observer(HomePage);
export const meta: Route.MetaFunction = () => [
{ title: "Admin Instance Setup & Sign-In" },
{ name: "description", content: "Configure your Plane instance or sign in to the admin portal." },
];
+18 -21
View File
@@ -1,9 +1,6 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import type { FC } from "react";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Eye, EyeOff } from "lucide-react";
@@ -16,7 +13,7 @@ import { Input, Spinner } from "@plane/ui";
// components
import { Banner } from "@/components/common/banner";
// local components
import { FormHeader } from "@/components/instance/form-header";
import { FormHeader } from "../../../core/components/instance/form-header";
import { AuthBanner } from "./auth-banner";
import { AuthHeader } from "./auth-header";
import { authErrorHandler } from "./auth-helpers";
@@ -49,7 +46,7 @@ const defaultFromData: TFormData = {
password: "",
};
export function InstanceSignInForm() {
export const InstanceSignInForm: FC = () => {
// search params
const searchParams = useSearchParams();
const emailParam = searchParams.get("email") || undefined;
@@ -111,8 +108,8 @@ export function InstanceSignInForm() {
return (
<>
<AuthHeader />
<div className="mt-10 flex w-full flex-grow flex-col items-center justify-center py-6">
<div className="relative flex w-full max-w-[22.5rem] flex-col gap-6">
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">
<FormHeader
heading="Manage your Plane instance"
subHeading="Configure instance-wide settings to secure your instance"
@@ -134,11 +131,11 @@ export function InstanceSignInForm() {
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
<div className="w-full space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="email">
Email <span className="text-danger-primary">*</span>
<label className="text-sm text-custom-text-300 font-medium" htmlFor="email">
Email <span className="text-red-500">*</span>
</label>
<Input
className="w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
id="email"
name="email"
type="email"
@@ -146,18 +143,18 @@ export function InstanceSignInForm() {
placeholder="name@company.com"
value={formData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
autoComplete="off"
autoComplete="on"
autoFocus
/>
</div>
<div className="w-full space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="password">
Password <span className="text-danger-primary">*</span>
<label className="text-sm text-custom-text-300 font-medium" htmlFor="password">
Password <span className="text-red-500">*</span>
</label>
<div className="relative">
<Input
className="w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
id="password"
name="password"
type={showPassword ? "text" : "password"}
@@ -165,12 +162,12 @@ export function InstanceSignInForm() {
placeholder="Enter your password"
value={formData.password}
onChange={(e) => handleFormChange("password", e.target.value)}
autoComplete="off"
autoComplete="on"
/>
{showPassword ? (
<button
type="button"
className="absolute top-3.5 right-3 flex items-center justify-center text-placeholder"
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => setShowPassword(false)}
>
<EyeOff className="h-4 w-4" />
@@ -178,7 +175,7 @@ export function InstanceSignInForm() {
) : (
<button
type="button"
className="absolute top-3.5 right-3 flex items-center justify-center text-placeholder"
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => setShowPassword(true)}
>
<Eye className="h-4 w-4" />
@@ -187,7 +184,7 @@ export function InstanceSignInForm() {
</div>
</div>
<div className="py-2">
<Button type="submit" size="xl" className="w-full" disabled={isButtonDisabled}>
<Button type="submit" size="lg" className="w-full" disabled={isButtonDisabled}>
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Sign in"}
</Button>
</div>
@@ -196,4 +193,4 @@ export function InstanceSignInForm() {
</div>
</>
);
}
};
@@ -1,15 +1,14 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { FC, ReactNode } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// hooks
import { useInstance } from "@/hooks/store";
export const InstanceProvider = observer(function InstanceProvider(props: React.PropsWithChildren) {
type InstanceProviderProps = {
children: ReactNode;
};
export const InstanceProvider: FC<InstanceProviderProps> = observer((props) => {
const { children } = props;
// store hooks
const { fetchInstanceInfo } = useInstance();
@@ -1,16 +1,11 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import { ThemeProvider } from "next-themes";
import { SWRConfig } from "swr";
import { AppProgressBar } from "@/lib/b-progress";
// local imports
import { ToastWithTheme } from "./toast";
import { StoreProvider } from "./store.provider";
// providers
import { InstanceProvider } from "./instance.provider";
import { StoreProvider } from "./store.provider";
import { ToastWithTheme } from "./toast";
import { UserProvider } from "./user.provider";
const DEFAULT_SWR_CONFIG = {
@@ -18,14 +13,13 @@ const DEFAULT_SWR_CONFIG = {
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnMount: true,
refreshInterval: 600_000,
refreshInterval: 600000,
errorRetryCount: 3,
};
export function CoreProviders({ children }: { children: React.ReactNode }) {
export default function InstanceLayout({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem>
<AppProgressBar />
<ToastWithTheme />
<SWRConfig value={DEFAULT_SWR_CONFIG}>
<StoreProvider>
@@ -1,12 +1,9 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import type { ReactNode } from "react";
import { createContext } from "react";
// plane admin store
import { RootStore } from "../store/root.store";
import { RootStore } from "@/plane-admin/store/root.store";
let rootStore = new RootStore();
@@ -27,12 +24,12 @@ function initializeStore(initialData = {}) {
}
export type StoreProviderProps = {
children: React.ReactNode;
children: ReactNode;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
initialState?: any;
};
export function StoreProvider({ children, initialState = {} }: StoreProviderProps) {
export const StoreProvider = ({ children, initialState = {} }: StoreProviderProps) => {
const store = initializeStore(initialState);
return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
}
};
@@ -1,14 +1,10 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import { useTheme } from "next-themes";
import { Toast } from "@plane/propel/toast";
import { resolveGeneralTheme } from "@plane/utils";
export function ToastWithTheme() {
export const ToastWithTheme = () => {
const { resolvedTheme } = useTheme();
return <Toast theme={resolveGeneralTheme(resolvedTheme)} />;
}
};
@@ -1,16 +1,17 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
"use client";
import type { FC, ReactNode } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// hooks
import { useInstance, useTheme, useUser } from "@/hooks/store";
export const UserProvider = observer(function UserProvider({ children }: React.PropsWithChildren) {
interface IUserProvider {
children: ReactNode;
}
export const UserProvider: FC<IUserProvider> = observer(({ children }) => {
// hooks
const { isSidebarCollapsed, toggleSidebar } = useTheme();
const { currentUser, fetchCurrentUser } = useUser();
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 640 640" width="32" height="32"><path d="m395.9 484.2-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5 21.2-17.9 33.8-11.8 17.2 8.3 27.1 13 27.1 13l-.1-109.2 16.7-.1.1 117.1s57.4 24.2 83.1 40.1c3.7 2.3 10.2 6.8 12.9 14.4 2.1 6.1 2 13.1-1 19.3l-61 126.9c-6.2 12.7-21.4 18.1-33.9 12" style="fill:#fff"/><path d="M622.7 149.8c-4.1-4.1-9.6-4-9.6-4s-117.2 6.6-177.9 8c-13.3.3-26.5.6-39.6.7v117.2c-5.5-2.6-11.1-5.3-16.6-7.9 0-36.4-.1-109.2-.1-109.2-29 .4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5c-9.8-.6-22.5-2.1-39 1.5-8.7 1.8-33.5 7.4-53.8 26.9C-4.9 212.4 6.6 276.2 8 285.8c1.7 11.7 6.9 44.2 31.7 72.5 45.8 56.1 144.4 54.8 144.4 54.8s12.1 28.9 30.6 55.5c25 33.1 50.7 58.9 75.7 62 63 0 188.9-.1 188.9-.1s12 .1 28.3-10.3c14-8.5 26.5-23.4 26.5-23.4S547 483 565 451.5c5.5-9.7 10.1-19.1 14.1-28 0 0 55.2-117.1 55.2-231.1-1.1-34.5-9.6-40.6-11.6-42.6M125.6 353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6 321.8 60 295.4c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5 38.5-30c13.8-3.7 31-3.1 31-3.1s7.1 59.4 15.7 94.2c7.2 29.2 24.8 77.7 24.8 77.7s-26.1-3.1-43-9.1m300.3 107.6s-6.1 14.5-19.6 15.4c-5.8.4-10.3-1.2-10.3-1.2s-.3-.1-5.3-2.1l-112.9-55s-10.9-5.7-12.8-15.6c-2.2-8.1 2.7-18.1 2.7-18.1L322 273s4.8-9.7 12.2-13c.6-.3 2.3-1 4.5-1.5 8.1-2.1 18 2.8 18 2.8L467.4 315s12.6 5.7 15.3 16.2c1.9 7.4-.5 14-1.8 17.2-6.3 15.4-55 113.1-55 113.1" style="fill:#609926"/><path d="M326.8 380.1c-8.2.1-15.4 5.8-17.3 13.8s2 16.3 9.1 20c7.7 4 17.5 1.8 22.7-5.4 5.1-7.1 4.3-16.9-1.8-23.1l24-49.1c1.5.1 3.7.2 6.2-.5 4.1-.9 7.1-3.6 7.1-3.6 4.2 1.8 8.6 3.8 13.2 6.1 4.8 2.4 9.3 4.9 13.4 7.3.9.5 1.8 1.1 2.8 1.9 1.6 1.3 3.4 3.1 4.7 5.5 1.9 5.5-1.9 14.9-1.9 14.9-2.3 7.6-18.4 40.6-18.4 40.6-8.1-.2-15.3 5-17.7 12.5-2.6 8.1 1.1 17.3 8.9 21.3s17.4 1.7 22.5-5.3c5-6.8 4.6-16.3-1.1-22.6 1.9-3.7 3.7-7.4 5.6-11.3 5-10.4 13.5-30.4 13.5-30.4.9-1.7 5.7-10.3 2.7-21.3-2.5-11.4-12.6-16.7-12.6-16.7-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3 4.7-9.7 9.4-19.3 14.1-29-4.1-2-8.1-4-12.2-6.1-4.8 9.8-9.7 19.7-14.5 29.5-6.7-.1-12.9 3.5-16.1 9.4-3.4 6.3-2.7 14.1 1.9 19.8z" style="fill:#609926"/></svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

-41
View File
@@ -1,41 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
/**
* Ensures that a URL has a trailing slash while preserving query parameters and fragments
* @param url - The URL to process
* @returns The URL with a trailing slash added to the pathname (if not already present)
*/
export function ensureTrailingSlash(url: string): string {
try {
const fallbackBaseUrl =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "http://dummy.com";
// Handle relative URLs by creating a URL object with a fallback base URL
const urlObj = new URL(url, fallbackBaseUrl);
// Don't modify root path
if (urlObj.pathname === "/") {
return url;
}
// Add trailing slash if it doesn't exist
if (!urlObj.pathname.endsWith("/")) {
urlObj.pathname += "/";
}
// For relative URLs, return just the path + search + hash
if (url.startsWith("/")) {
return urlObj.pathname + urlObj.search + urlObj.hash;
}
// For absolute URLs, return the full URL
return urlObj.toString();
} catch (error) {
// If URL parsing fails, return the original URL
console.warn("Failed to parse URL for trailing slash enforcement:", url, error);
return url;
}
}
-20
View File
@@ -1,20 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
// Minimal shim so code using next/image compiles under React Router + Vite
// without changing call sites. It just renders a native img.
type NextImageProps = React.ImgHTMLAttributes<HTMLImageElement> & {
src: string;
};
function Image({ src, alt = "", ...rest }: NextImageProps) {
return <img src={src} alt={alt} {...rest} />;
}
export default Image;
-23
View File
@@ -1,23 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { Link as RRLink } from "react-router";
import { ensureTrailingSlash } from "./helper";
type NextLinkProps = React.ComponentProps<"a"> & {
href: string;
replace?: boolean;
prefetch?: boolean; // next.js prop, ignored
scroll?: boolean; // next.js prop, ignored
shallow?: boolean; // next.js prop, ignored
};
function Link({ href, replace, prefetch: _prefetch, scroll: _scroll, shallow: _shallow, ...rest }: NextLinkProps) {
return <RRLink to={ensureTrailingSlash(href)} replace={replace} {...rest} />;
}
export default Link;
-38
View File
@@ -1,38 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useMemo } from "react";
import { useLocation, useNavigate, useSearchParams as useSearchParamsRR } from "react-router";
import { ensureTrailingSlash } from "./helper";
export function useRouter() {
const navigate = useNavigate();
return useMemo(
() => ({
push: (to: string) => navigate(ensureTrailingSlash(to)),
replace: (to: string) => navigate(ensureTrailingSlash(to), { replace: true }),
back: () => navigate(-1),
forward: () => navigate(1),
refresh: () => {
location.reload();
},
prefetch: async (_to: string) => {
// no-op in this shim
},
}),
[navigate]
);
}
export function usePathname(): string {
const { pathname } = useLocation();
return pathname;
}
export function useSearchParams(): URLSearchParams {
const [searchParams] = useSearchParamsRR();
return searchParams;
}
-42
View File
@@ -1,42 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { Link } from "react-router";
// ui
import { Button } from "@plane/propel/button";
// images
import Image404 from "@/app/assets/images/404.svg?url";
function PageNotFound() {
return (
<div className={`h-screen w-full overflow-hidden bg-surface-1`}>
<div className="grid h-full place-items-center p-4">
<div className="space-y-8 text-center">
<div className="relative mx-auto h-60 w-60 lg:h-80 lg:w-80">
<img src={Image404} alt="404 - Page not found" className="h-full w-full object-contain" />
</div>
<div className="space-y-2">
<h3 className="text-16 font-semibold">Oops! Something went wrong.</h3>
<p className="text-13 text-secondary">
Sorry, the page you are looking for cannot be found. It may have been removed, had its name changed, or is
temporarily unavailable.
</p>
</div>
<Link to="/general/">
<span className="flex justify-center py-4">
<Button variant="secondary" size="lg">
Go to general settings
</Button>
</span>
</Link>
</div>
</div>
</div>
);
}
export default PageNotFound;
-18
View File
@@ -1,18 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>
);
});
+9
View File
@@ -0,0 +1,9 @@
"use client";
export default function RootErrorPage() {
return (
<div>
<p>Something went wrong.</p>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
// plane imports
import { ADMIN_BASE_PATH } from "@plane/constants";
// styles
import "@/styles/globals.css";
export const metadata: Metadata = {
title: "Plane | Simple, extensible, open-source project management tool.",
description:
"Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.",
openGraph: {
title: "Plane | Simple, extensible, open-source project management tool.",
description:
"Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.",
url: "https://plane.so/",
},
keywords:
"software development, customer feedback, software, accelerate, code management, release management, project management, work items tracking, agile, scrum, kanban, collaboration",
twitter: {
site: "@planepowers",
},
};
export default function RootLayout({ children }: { children: ReactNode }) {
const ASSET_PREFIX = ADMIN_BASE_PATH;
return (
<html lang="en">
<head>
<link rel="apple-touch-icon" sizes="180x180" href={`${ASSET_PREFIX}/favicon/apple-touch-icon.png`} />
<link rel="icon" type="image/png" sizes="32x32" href={`${ASSET_PREFIX}/favicon/favicon-32x32.png`} />
<link rel="icon" type="image/png" sizes="16x16" href={`${ASSET_PREFIX}/favicon/favicon-16x16.png`} />
<link rel="manifest" href={`${ASSET_PREFIX}/site.webmanifest.json`} />
<link rel="shortcut icon" href={`${ASSET_PREFIX}/favicon/favicon.ico`} />
</head>
<body className={`antialiased`}>{children}</body>
</html>
);
}

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