diff --git a/.beads/.gitignore b/.beads/.gitignore new file mode 100644 index 00000000000..3f11d44bb09 --- /dev/null +++ b/.beads/.gitignore @@ -0,0 +1,40 @@ +# Database +*.db +*.db-journal +*.db-shm +*.db-wal + +# Lock files +*.lock + +# Temporary +last-touched +*.tmp + +# Local history backups +.br_history/ + +# Sync state (local-only, per-machine) +.sync.lock +sync_base.jsonl + +# Merge artifacts (temporary files from 3-way merge) +beads.base.jsonl +beads.base.meta.json +beads.left.jsonl +beads.left.meta.json +beads.right.jsonl +beads.right.meta.json + +# Daemon runtime files +daemon.lock +daemon.log +daemon.pid +bd.sock +sync-state.json + +# Worktree redirect file +redirect + +# bv (beads viewer) lock file +.bv.lock diff --git a/.beads/config.yaml b/.beads/config.yaml new file mode 100644 index 00000000000..5a93a194dca --- /dev/null +++ b/.beads/config.yaml @@ -0,0 +1,4 @@ +# Beads Project Configuration +# issue_prefix: xplane +# default_priority: 2 +# default_type: task diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl new file mode 100644 index 00000000000..4702472713d --- /dev/null +++ b/.beads/issues.jsonl @@ -0,0 +1,17 @@ +{"id":"xplane-4ks","title":"Keycloak OIDC Integration","description":"## Objective\nAdd Keycloak as an OAuth/OIDC identity provider to Plane, enabling enterprise SSO via OpenID Connect Authorization Code Flow.\n\n## Scope\n- Backend: KeycloakOAuthProvider, views (app + space), URL routes, error codes, instance config, Account model update, migration\n- Frontend: TypeScript types, OAuth button in web + space apps, admin configuration UI\n- Config: Instance-level Keycloak settings (host, realm, client_id, client_secret, enable flag, sync flag)\n\n## Non-Goals\n- Generic OIDC provider support (Keycloak-specific only)\n- SAML support\n- Keycloak admin API integration\n- Multi-realm support\n- Group/role mapping\n- Logout propagation\n\n## Key Decisions\n- D1: Use userinfo endpoint instead of id_token decoding (consistent with other providers)\n- D2: Keycloak-specific provider, not generic OIDC\n- D3: Single realm per instance\n- D4: Callback URLs: `/auth/keycloak/callback/` (app), `/auth/spaces/keycloak/callback/` (space)\n- D5: Error codes: 5113 (KEYCLOAK_NOT_CONFIGURED), 5124 (KEYCLOAK_OAUTH_PROVIDER_ERROR)\n- D6: Config: IS_KEYCLOAK_ENABLED, ENABLE_KEYCLOAK_SYNC naming pattern\n- D7: Ignore email_verified claim (consistent with other providers)\n\n## Success Criteria\n- End-to-end login flow works: click \"Sign in with Keycloak\" → Keycloak redirect → authenticate → redirect back with active session\n- Admin can enable/disable Keycloak and configure credentials from admin UI\n- Existing OAuth providers remain unaffected\n- No new dependencies introduced\n- `pnpm check` and Django server start without errors","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-04-20T10:16:42.471102153Z","created_by":"cuongnt3","updated_at":"2026-04-21T07:08:17.067254Z","closed_at":"2026-04-21T07:08:17.067105Z","close_reason":"All children closed: Keycloak OIDC integration complete","source_repo":".","compaction_level":0,"original_size":0} +{"id":"xplane-4ks.1","title":"Foundation","description":"## Objective\nLay all groundwork that backend and frontend workstreams depend on: error codes, instance configuration, database model update, and TypeScript types.\n\n## Scope\n- Error codes and OAuth adapter mapping for Keycloak\n- Instance configuration variables (6 keys) and InstanceEndpoint API update\n- Account model PROVIDER_CHOICES update + Django migration\n- Frontend TypeScript type updates (auth.ts, base.ts)\n\n## Acceptance Criteria\n- All error codes registered and adapter mapping updated\n- Config variables defined and exposed via instance API\n- Migration generated cleanly\n- TypeScript types compile (`pnpm check:types`)\n- No impact on existing providers","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-04-20T10:16:50.677218524Z","created_by":"cuongnt3","updated_at":"2026-04-21T07:08:16.869995Z","closed_at":"2026-04-21T07:08:16.869794Z","close_reason":"All children closed: types, error codes, account model","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"xplane-4ks.1","depends_on_id":"xplane-4ks","type":"parent-child","created_at":"2026-04-20T10:16:50.677218524Z","created_by":"cuongnt3","metadata":"{}","thread_id":""}]} +{"id":"xplane-4ks.1.1","title":"Error Codes & OAuth Adapter Update","description":"## Objective\nRegister two Keycloak-specific error codes and update the OAuth adapter's error mapping so Keycloak auth errors return the correct error code instead of the generic fallback.\n\n## Context\nPlane's OAuth flow uses provider-specific error codes. Without this, Keycloak errors would fall through to the default `OAUTH_NOT_CONFIGURED` (5104) which is misleading — it's an instance-level code, not a provider-level one.\n\n## Scope\n**In scope:**\n- Add 2 error codes to `apps/api/plane/authentication/adapter/error.py`\n- Add `\"keycloak\"` case to `authentication_error_code()` in `apps/api/plane/authentication/adapter/oauth.py`\n\n**Out of scope:**\n- Any changes to other providers' error codes\n- Frontend error handling (separate bead)\n\n## Components Touched\n- `apps/api/plane/authentication/adapter/error.py` — add error code definitions\n- `apps/api/plane/authentication/adapter/oauth.py` — add case to `authentication_error_code()` method\n\n## Dependencies / Prerequisites\nNone — this is foundation work.\n\n## Important Assumptions and Constraints\n- Error code 5113 for `KEYCLOAK_NOT_CONFIGURED` (fits in \"not configured\" range 5104-5112)\n- Error code 5124 for `KEYCLOAK_OAUTH_PROVIDER_ERROR` (extends \"provider error\" range 5115-5123)\n- These exact values must be used — they are referenced by other beads\n- Follow the exact pattern of existing error code definitions (e.g., GITEA codes)\n\n## Risks / Gotchas\n- Error code values must be unique. Verify current highest codes before adding.\n- The `authentication_error_code()` mapping is critical — missing it causes all Keycloak token/userinfo errors to report code 5104 instead of 5124.\n\n## Acceptance Criteria\n- `KEYCLOAK_NOT_CONFIGURED = 5113` exists in `error.py`\n- `KEYCLOAK_OAUTH_PROVIDER_ERROR = 5124` exists in `error.py`\n- `authentication_error_code()` in `oauth.py` returns `\"KEYCLOAK_OAUTH_PROVIDER_ERROR\"` when `self.provider == \"keycloak\"`\n- Python syntax valid (no import errors)\n- Existing error codes unchanged","status":"closed","priority":2,"issue_type":"task","created_at":"2026-04-20T10:17:18.084248120Z","created_by":"cuongnt3","updated_at":"2026-04-21T07:00:20.166383Z","closed_at":"2026-04-21T07:00:20.166186Z","close_reason":"Implemented: Added KEYCLOAK_NOT_CONFIGURED=5113 and KEYCLOAK_OAUTH_PROVIDER_ERROR=5124 error codes; added keycloak case to authentication_error_code() method","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"xplane-4ks.1.1","depends_on_id":"xplane-4ks.1","type":"parent-child","created_at":"2026-04-20T10:17:18.084248120Z","created_by":"cuongnt3","metadata":"{}","thread_id":""}]} +{"id":"xplane-4ks.1.3","title":"Account Model & Migration","description":"## Objective\nUpdate the Account model's `PROVIDER_CHOICES` to include Keycloak (and fix pre-existing missing Gitea entry), then generate the Django migration.\n\n## Context\nThe Account model uses a CharField with choices for the `provider` field. Keycloak must be registered as a valid choice. Additionally, `(\"gitea\", \"Gitea\")` is currently missing from `PROVIDER_CHOICES` despite Gitea being fully implemented — include it in the same update.\n\n## Scope\n**In scope:**\n- Add `(\"gitea\", \"Gitea\")` and `(\"keycloak\", \"Keycloak\")` to `PROVIDER_CHOICES` in `apps/api/plane/db/models/user.py`\n- Generate Django migration via `python manage.py makemigrations`\n\n**Out of scope:**\n- Schema changes (this is just a choices/validation update)\n- Any model field additions\n\n## Components Touched\n- `apps/api/plane/db/models/user.py` — update `PROVIDER_CHOICES` tuple\n- `apps/api/plane/db/migrations/` — new auto-generated migration file\n\n## Dependencies / Prerequisites\nNone — this is foundation work.\n\n## Important Assumptions and Constraints\n- This is a simple CharField choices update — no actual schema change, just validation\n- The migration is auto-generated by `python manage.py makemigrations`\n- Add both entries in a single migration for cleanliness\n- The provider string `\"keycloak\"` must be lowercase (consistent with other providers)\n\n## Acceptance Criteria\n- `PROVIDER_CHOICES` includes both `(\"gitea\", \"Gitea\")` and `(\"keycloak\", \"Keycloak\")`\n- Django migration file generated successfully\n- Migration applies cleanly (`python manage.py migrate`)\n- Existing provider choices unchanged","status":"closed","priority":2,"issue_type":"task","created_at":"2026-04-20T10:17:43.776460387Z","created_by":"cuongnt3","updated_at":"2026-04-21T07:07:19.579401Z","closed_at":"2026-04-21T07:07:19.579242Z","close_reason":"Implemented: Added gitea+keycloak to PROVIDER_CHOICES, created migration 0122","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"xplane-4ks.1.3","depends_on_id":"xplane-4ks.1","type":"parent-child","created_at":"2026-04-20T10:17:43.776460387Z","created_by":"cuongnt3","metadata":"{}","thread_id":""}],"comments":[{"id":13,"issue_id":"xplane-4ks.1.3","author":"cuongnt3","text":"[POLISH] Migration context: Run python manage.py makemigrations db from apps/api/ directory. Latest migration is 0121_alter_estimate_type.py so new one will be 0122. App label is db (plane.db models app).","created_at":"2026-04-20T10:55:16Z"}]} +{"id":"xplane-4ks.1.4","title":"Frontend TypeScript Types","description":"## Objective\nUpdate shared TypeScript types to include Keycloak as an authentication provider, enabling frontend components to reference Keycloak config and auth types.\n\n## Context\nPlane's frontend apps import auth types from `@plane/types`. All three frontend apps (web, space, admin) need Keycloak types before they can reference `is_keycloak_enabled` or Keycloak config keys.\n\n## Scope\n**In scope:**\n- Update union types in `packages/types/src/instance/auth.ts` to include `\"keycloak\"` where other providers appear\n- Update `IInstanceConfig` interface in `packages/types/src/instance/base.ts` to add `is_keycloak_enabled: boolean`\n- Create `TInstanceKeycloakAuthenticationConfigurationKeys` type (union of `\"KEYCLOAK_HOST\" | \"KEYCLOAK_REALM\" | \"KEYCLOAK_CLIENT_ID\" | \"KEYCLOAK_CLIENT_SECRET\" | \"ENABLE_KEYCLOAK_SYNC\"`)\n\n**Out of scope:**\n- Frontend component implementations (separate beads)\n- Backend types\n\n## Components Touched\n- `packages/types/src/instance/auth.ts` — add Keycloak to union types, add new config keys type\n- `packages/types/src/instance/base.ts` — add `is_keycloak_enabled` to `IInstanceConfig`\n\n## Dependencies / Prerequisites\nNone — this is foundation work.\n\n## Important Assumptions and Constraints\n- Follow exact patterns of existing providers (GitLab, Gitea) in these type files\n- The config key names must match backend exactly: `KEYCLOAK_HOST`, `KEYCLOAK_REALM`, `KEYCLOAK_CLIENT_ID`, `KEYCLOAK_CLIENT_SECRET`, `ENABLE_KEYCLOAK_SYNC`\n- The instance config flag is `is_keycloak_enabled` (matches backend API response)\n- Read current files to understand existing union type patterns before modifying\n\n## Acceptance Criteria\n- `\"keycloak\"` included in all relevant auth provider union types in `auth.ts`\n- `TInstanceKeycloakAuthenticationConfigurationKeys` type exported from `auth.ts`\n- `is_keycloak_enabled: boolean` added to `IInstanceConfig` in `base.ts`\n- `pnpm check:types` passes\n- No changes to existing provider types","status":"closed","priority":2,"issue_type":"task","created_at":"2026-04-20T10:17:52.368615917Z","created_by":"cuongnt3","updated_at":"2026-04-21T07:00:18.267042Z","closed_at":"2026-04-21T07:00:18.266840Z","close_reason":"Implemented: Added keycloak to TCoreInstanceAuthenticationModeKeys, TCoreLoginMediums, TInstanceAuthenticationMethodKeys unions; created TInstanceKeycloakAuthenticationConfigurationKeys type; added to TInstanceAuthenticationConfigurationKeys aggregate; added is_keycloak_enabled to IInstanceConfig","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"xplane-4ks.1.4","depends_on_id":"xplane-4ks.1","type":"parent-child","created_at":"2026-04-20T10:17:52.368615917Z","created_by":"cuongnt3","metadata":"{}","thread_id":""}],"comments":[{"id":3,"issue_id":"xplane-4ks.1.4","author":"cuongnt3","text":"[POLISH] Missing specifics — add these details for self-contained implementation:\n\n1. **Union types to update in `auth.ts`:**\n - `TCoreInstanceAuthenticationModeKeys`: add `\"keycloak\"` to the union\n - `TCoreLoginMediums`: add `\"keycloak\"` to the union\n - `TInstanceAuthenticationConfigurationKeys`: add `| TInstanceKeycloakAuthenticationConfigurationKeys` to the aggregate type\n\n2. **New type to create:**\n ```typescript\n export type TInstanceKeycloakAuthenticationConfigurationKeys =\n | \"KEYCLOAK_HOST\"\n | \"KEYCLOAK_REALM\"\n | \"KEYCLOAK_CLIENT_ID\"\n | \"KEYCLOAK_CLIENT_SECRET\"\n | \"ENABLE_KEYCLOAK_SYNC\";\n ```\n\n3. **In `base.ts`:** Add `is_keycloak_enabled: boolean;` to `IInstanceConfig` (after `is_gitea_enabled`).\n","created_at":"2026-04-20T10:35:14Z"}]} +{"id":"xplane-4ks.2","title":"Backend Provider & Auth Flow","description":"## Objective\nImplement the complete server-side Keycloak OIDC authentication flow: provider class, app views, space views, and URL routing.\n\n## Scope\n- KeycloakOAuthProvider class (following Gitea provider pattern)\n- App views: initiate + callback (following GitLab view pattern)\n- Space views: initiate + callback (following Gitea space view pattern)\n- URL patterns and view exports\n\n## Acceptance Criteria\n- Django server starts without errors\n- Visiting `/auth/keycloak/` redirects to Keycloak (when configured)\n- Callback processes auth code, exchanges for tokens, creates/updates user\n- Space views correctly use `is_space=True` and omit post-auth workflow\n- All views properly handle error cases","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-04-20T10:16:55.578079158Z","created_by":"cuongnt3","updated_at":"2026-04-21T07:08:16.949619Z","closed_at":"2026-04-21T07:08:16.949431Z","close_reason":"All children closed: provider, views, routing","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"xplane-4ks.2","depends_on_id":"xplane-4ks","type":"parent-child","created_at":"2026-04-20T10:16:55.578079158Z","created_by":"cuongnt3","metadata":"{}","thread_id":""}]} +{"id":"xplane-4ks.2.1","title":"KeycloakOAuthProvider Class","description":"## Objective\nCreate the KeycloakOAuthProvider class that handles Keycloak OIDC token exchange and user info retrieval.\n\n## Context\nEach OAuth provider in Plane has a provider class extending OauthAdapter. The provider is instantiated by views with a request + code, then `authenticate()` is called (inherited from OauthAdapter). The provider class handles: config fetching, token exchange, and user data retrieval.\n\n## Scope\n**In scope:**\n- Create `apps/api/plane/authentication/provider/oauth/keycloak.py`\n- Implement `KeycloakOAuthProvider` class extending `OauthAdapter`\n\n**Out of scope:**\n- Views that use this provider (separate beads 2.2, 2.5)\n- Error code definitions (separate bead 1.1)\n\n## Components Touched\n- `apps/api/plane/authentication/provider/oauth/keycloak.py` (NEW)\n\n## Dependencies / Prerequisites\n- Error Codes (xplane-4ks.1.1) — uses KEYCLOAK_NOT_CONFIGURED\n- Instance Config (xplane-epi) — reads config variables\n\n## Important Assumptions and Constraints\n- **Primary template:** Follow `apps/api/plane/authentication/provider/oauth/gitea.py` exactly\n- **Class structure:**\n - `__init__(self, request, code=None, callback=None)`: Fetch config via `get_configuration_value`, validate host+realm, build `token_url` and `userinfo_url`, call `super().__init__()`\n - `set_token_data(self)`: Call `self.get_user_token(data=..., headers=...)`, extract access_token and compute expiry\n - `set_user_data(self)`: Call userinfo endpoint with Bearer token, extract email/name/avatar\n - `create_update_account(self)`: Inherited from OauthAdapter (no override needed)\n- **Keycloak URLs:**\n - Token URL: `{host}/realms/{realm}/protocol/openid-connect/token`\n - Userinfo URL: `{host}/realms/{realm}/protocol/openid-connect/userinfo`\n - Auth URL: `{host}/realms/{realm}/protocol/openid-connect/auth`\n- **Config keys read:** IS_KEYCLOAK_ENABLED, KEYCLOAK_HOST, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET\n- **Super init args:** `provider='keycloak', request=request, self.token_url, self.userinfo_url, client_id, client_secret, redirect_uri, code, callback`\n- **Redirect URI:** Built from `base_host(request=request, is_app=True)` + `/auth/keycloak/callback/` (or passed in by views)\n- **Scope:** `openid email profile`\n- **Error on missing config:** Raise `AuthenticationException` with `KEYCLOAK_NOT_CONFIGURED` (error code 5113) and status 400\n\n## Acceptance Criteria\n- Class inherits from OauthAdapter\n- Config fetched and validated in __init__\n- Token exchange works via standard OAuth2 code flow\n- User data extracted from userinfo endpoint (email, first_name, last_name)\n- AuthenticationException raised if config missing\n- Python syntax valid\n- No new pip dependencies","status":"closed","priority":2,"issue_type":"task","owner":"cuongnt3","created_at":"2026-04-20T10:47:00.091527005Z","created_by":"cuongnt3","updated_at":"2026-04-21T07:03:37.431001Z","closed_at":"2026-04-21T07:03:37.430799Z","close_reason":"Implemented: see conversation","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"xplane-4ks.2.1","depends_on_id":"xplane-4ks.1.1","type":"blocks","created_at":"2026-04-20T10:47:54Z","created_by":"cuongnt3","metadata":"{}","thread_id":""},{"issue_id":"xplane-4ks.2.1","depends_on_id":"xplane-4ks.2","type":"parent-child","created_at":"2026-04-20T10:47:54Z","created_by":"cuongnt3","metadata":"{}","thread_id":""},{"issue_id":"xplane-4ks.2.1","depends_on_id":"xplane-epi","type":"blocks","created_at":"2026-04-20T10:47:54Z","created_by":"cuongnt3","metadata":"{}","thread_id":""}],"comments":[{"id":18,"issue_id":"xplane-4ks.2.1","author":"cuongnt3","text":"[POLISH] CORRECTION: The redirect_uri is NOT built from base_host(). Follow the gitea.py pattern exactly: redirect_uri = f\"{'https' if request.is_secure() else 'http'}://{request.get_host()}/auth/keycloak/callback/\". The base_host() function is only used by views for post-auth redirects, not by the provider for OAuth callback URIs.","created_at":"2026-04-20T11:00:53Z"}]} +{"id":"xplane-4ks.2.2","title":"App Views (Initiate + Callback)","description":"## Objective\nCreate Django views for the Keycloak OAuth initiate and callback endpoints for the main web app.\n\n## Context\nPlane's OAuth flow starts with an initiate view (redirects user to provider) and a callback view (processes the redirect back with auth code). App views use `base_host(request=request, is_app=True)` and pass `callback=post_user_auth_workflow` to the provider.\n\n## Scope\n**In scope:**\n- Create `apps/api/plane/authentication/views/app/keycloak.py`\n- Implement `KeycloakOauthInitiateEndpoint` (GET → redirect to Keycloak)\n- Implement `KeycloakCallbackEndpoint` (GET → process callback, create session)\n\n**Out of scope:**\n- Space views (separate bead)\n- URL routing (separate bead)\n- Provider class (separate bead — this bead uses it)\n\n## Components Touched\n- `apps/api/plane/authentication/views/app/keycloak.py` (NEW)\n\n## Dependencies / Prerequisites\n- KeycloakOAuthProvider (xplane-4ks.2.1) — views instantiate this provider\n- Error Codes (xplane-4ks.1.1) — views reference error codes for error handling\n\n## Important Assumptions and Constraints\n- **Primary template:** Follow `apps/api/plane/authentication/views/app/gitlab.py` exactly for structure\n- App views use `base_host(request=request, is_app=True)` (NOT `is_space=True`)\n- Callback view passes `callback=post_user_auth_workflow` to provider constructor\n- Initiate view: generates state token, builds auth URL, redirects\n- Callback view: validates state, creates provider with auth code, calls `authenticate()`, sets session, redirects to app\n- Error handling: catch exceptions, redirect to error page with appropriate error code\n- Use `KEYCLOAK_OAUTH_PROVIDER_ERROR` for auth failures, `KEYCLOAK_NOT_CONFIGURED` for missing config\n\n## Acceptance Criteria\n- Initiate endpoint redirects to correct Keycloak auth URL with `client_id`, `scope=openid email profile`, `state`, `redirect_uri`\n- Callback endpoint exchanges code for tokens and creates session\n- Errors redirect to error page with correct error codes\n- Follows GitLab app view pattern exactly\n- Python syntax valid","status":"closed","priority":2,"issue_type":"task","created_at":"2026-04-20T10:18:30.952714716Z","created_by":"cuongnt3","updated_at":"2026-04-21T07:07:16.027902Z","closed_at":"2026-04-21T07:07:16.027689Z","close_reason":"Implemented: Created keycloak app views (initiate+callback) following GitLab pattern","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"xplane-4ks.2.2","depends_on_id":"xplane-4ks.1.1","type":"blocks","created_at":"2026-04-20T10:21:05.077279592Z","created_by":"cuongnt3","metadata":"{}","thread_id":""},{"issue_id":"xplane-4ks.2.2","depends_on_id":"xplane-4ks.2","type":"parent-child","created_at":"2026-04-20T10:18:30.952714716Z","created_by":"cuongnt3","metadata":"{}","thread_id":""},{"issue_id":"xplane-4ks.2.2","depends_on_id":"xplane-4ks.2.1","type":"blocks","created_at":"2026-04-20T10:21:05.077279592Z","created_by":"cuongnt3","metadata":"{}","thread_id":""},{"issue_id":"xplane-4ks.2.2","depends_on_id":"xplane-epi","type":"blocks","created_at":"2026-04-20T10:35:00Z","created_by":"cuongnt3","metadata":"{}","thread_id":""}],"comments":[{"id":1,"issue_id":"xplane-4ks.2.2","author":"cuongnt3","text":"[POLISH] CRITICAL FIX — App views use `base_host(request=request, is_app=True)`, NOT `base_host(request)`. The bead says \"App views use base_host(request) (NOT is_space=True)\" but the actual GitLab app view pattern is `base_host(request=request, is_app=True)`. Without `is_app=True`, the host resolution will be incorrect. Update the Context section to say: \"App views use `base_host(request=request, is_app=True)` and pass `callback=post_user_auth_workflow` to the provider.\"\n","created_at":"2026-04-20T10:35:03Z"}]} +{"id":"xplane-4ks.2.4","title":"URL Routing & View Exports","description":"## Objective\nWire up URL patterns for all four Keycloak endpoints and add view imports to the views package `__init__.py`.\n\n## Context\nPlane's authentication URL routing is in `apps/api/plane/authentication/urls.py`. Views must be importable from the views package. This bead connects the views (created in prior beads) to the URL dispatcher.\n\n## Scope\n**In scope:**\n- Add 4 URL patterns to `apps/api/plane/authentication/urls.py`:\n 1. `auth/keycloak/` → `KeycloakOauthInitiateEndpoint` (app initiate)\n 2. `auth/keycloak/callback/` → `KeycloakCallbackEndpoint` (app callback)\n 3. `auth/spaces/keycloak/` → `KeycloakOauthSpaceInitiateEndpoint` (space initiate)\n 4. `auth/spaces/keycloak/callback/` → `KeycloakCallbackSpaceEndpoint` (space callback)\n- Update `apps/api/plane/authentication/views/__init__.py` to export new view classes\n\n**Out of scope:**\n- View implementations (separate beads)\n\n## Components Touched\n- `apps/api/plane/authentication/urls.py`\n- `apps/api/plane/authentication/views/__init__.py`\n\n**NOTE:** There are NO `views/app/__init__.py` or `views/space/__init__.py` files. The main `views/__init__.py` imports directly from submodules:\n```python\nfrom .app.keycloak import KeycloakOauthInitiateEndpoint, KeycloakCallbackEndpoint\nfrom .space.keycloak import KeycloakOauthSpaceInitiateEndpoint, KeycloakCallbackSpaceEndpoint\n```\n\n## Dependencies / Prerequisites\n- App Views (xplane-4ks.2.2) — must exist before routing can import them\n- Space Views (xplane-4ks.2.5) — must exist before routing can import them\n\n## Important Assumptions and Constraints\n- URL patterns follow existing convention: `auth//` and `auth//callback/` for app, `auth/spaces//` and `auth/spaces//callback/` for space\n- Follow exact pattern of existing GitLab/Gitea URL entries\n- View class names: `KeycloakOauthInitiateEndpoint`, `KeycloakCallbackEndpoint`, `KeycloakOauthSpaceInitiateEndpoint`, `KeycloakCallbackSpaceEndpoint`\n\n## Acceptance Criteria\n- All 4 URL patterns registered\n- Views importable from `plane.authentication.views`\n- Django URL resolution works (no import errors on server start)\n- URL pattern names follow existing convention\n- Existing URL patterns unchanged\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-04-20T10:18:49Z","created_by":"cuongnt3","updated_at":"2026-04-21T07:08:06.482981Z","closed_at":"2026-04-21T07:08:06.482806Z","close_reason":"Implemented: Added 4 Keycloak URL patterns to urls.py and view exports to views/__init__.py","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"xplane-4ks.2.4","depends_on_id":"xplane-4ks.2","type":"parent-child","created_at":"2026-04-20T10:18:49Z","created_by":"cuongnt3","metadata":"{}","thread_id":""},{"issue_id":"xplane-4ks.2.4","depends_on_id":"xplane-4ks.2.2","type":"blocks","created_at":"2026-04-20T10:18:49Z","created_by":"cuongnt3","metadata":"{}","thread_id":""},{"issue_id":"xplane-4ks.2.4","depends_on_id":"xplane-4ks.2.5","type":"blocks","created_at":"2026-04-20T10:18:49Z","created_by":"cuongnt3","metadata":"{}","thread_id":""}],"comments":[{"id":12,"issue_id":"xplane-4ks.2.4","author":"cuongnt3","text":"[POLISH] The description has been corrected: `views/app/__init__.py` and `views/space/__init__.py` do NOT exist. Only `views/__init__.py` needs updating with direct submodule imports.","created_at":"2026-04-20T10:35:00Z"}]} +{"id":"xplane-4ks.2.5","title":"Space Views (Initiate + Callback)","description":"## Objective\nCreate Django views for the Keycloak OAuth initiate and callback endpoints for the Space app.\n\n## Context\nSpace views differ from app views in three critical ways: (1) use `is_space=True` in `base_host(request, is_space=True)`, (2) do NOT pass `callback=post_user_auth_workflow` to the provider — space callbacks omit the post-auth workflow, (3) use `validate_next_path` and `get_allowed_hosts` for redirect validation.\n\n## Scope\n**In scope:**\n- Create `apps/api/plane/authentication/views/space/keycloak.py`\n- Implement `KeycloakOauthSpaceInitiateEndpoint` (GET → redirect to Keycloak)\n- Implement `KeycloakCallbackSpaceEndpoint` (GET → process callback, create session)\n\n**Out of scope:**\n- App views (separate bead)\n- URL routing (separate bead)\n\n## Components Touched\n- `apps/api/plane/authentication/views/space/keycloak.py` (NEW)\n\n## Dependencies / Prerequisites\n- KeycloakOAuthProvider (xplane-4ks.2.1) — views instantiate this provider\n- Error Codes (xplane-4ks.1.1) — views reference error codes\n\n## Important Assumptions and Constraints\n- **Primary template:** Follow `apps/api/plane/authentication/views/space/gitea.py` (NOT the app views)\n- **Key differences from app views:**\n 1. `base_host(request, is_space=True)` — must pass `is_space=True`\n 2. Do NOT pass `callback=post_user_auth_workflow` — space omits this\n 3. Use `validate_next_path` and `get_allowed_hosts` for redirect validation\n- Error handling same as app views but redirect target may differ\n\n## Risks / Gotchas\n- Easy mistake: copy-pasting from app views and forgetting `is_space=True` or including `post_user_auth_workflow`. This would break space auth flow.\n\n## Acceptance Criteria\n- Space initiate endpoint redirects to correct Keycloak auth URL\n- Space callback endpoint uses `is_space=True` in `base_host()`\n- Space callback does NOT use `post_user_auth_workflow`\n- Space callback uses `validate_next_path` and `get_allowed_hosts`\n- Follows Gitea space view pattern exactly\n- Python syntax valid","status":"closed","priority":2,"issue_type":"task","created_at":"2026-04-20T10:24:15.072040713Z","created_by":"cuongnt3","updated_at":"2026-04-21T07:07:17.427875Z","closed_at":"2026-04-21T07:07:17.427609Z","close_reason":"Implemented: Created keycloak space views (initiate+callback) following Gitea space pattern","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"xplane-4ks.2.5","depends_on_id":"xplane-4ks.1.1","type":"blocks","created_at":"2026-04-20T10:24:15.072040713Z","created_by":"cuongnt3","metadata":"{}","thread_id":""},{"issue_id":"xplane-4ks.2.5","depends_on_id":"xplane-4ks.2","type":"parent-child","created_at":"2026-04-20T10:24:15.072040713Z","created_by":"cuongnt3","metadata":"{}","thread_id":""},{"issue_id":"xplane-4ks.2.5","depends_on_id":"xplane-4ks.2.1","type":"blocks","created_at":"2026-04-20T10:24:15.072040713Z","created_by":"cuongnt3","metadata":"{}","thread_id":""}]} +{"id":"xplane-4ks.3","title":"Frontend Integration","description":"## Objective\nAdd Keycloak login button to web and space apps, and build admin configuration UI for Keycloak settings.\n\n## Scope\n- Web app: update useCoreOAuthConfig hook with Keycloak option\n- Space app: update useCoreOAuthConfig hook with Keycloak option\n- Admin app: config toggle component, config page with form, hooks and routes update\n- Keycloak logo asset\n\n## Acceptance Criteria\n- Keycloak button appears in web/space login when enabled via config\n- Admin can navigate to Keycloak config page, fill in all fields, and save\n- Callback URL displayed for easy copy-paste\n- `pnpm check` passes\n- No regressions in existing OAuth provider UIs","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-04-20T10:16:59.728600967Z","created_by":"cuongnt3","updated_at":"2026-04-21T07:08:17.010950Z","closed_at":"2026-04-21T07:08:17.010755Z","close_reason":"All children closed: web/space/admin hooks, config toggle, config page","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"xplane-4ks.3","depends_on_id":"xplane-4ks","type":"parent-child","created_at":"2026-04-20T10:16:59.728600967Z","created_by":"cuongnt3","metadata":"{}","thread_id":""}]} +{"id":"xplane-4ks.3.1","title":"Web OAuth Hook","description":"## Objective\nUpdate the web app's `useCoreOAuthConfig` hook to include Keycloak as an OAuth login option.\n\n## Context\nThe `useCoreOAuthConfig` hook in the web app builds the list of available OAuth providers shown on the login page. Each provider checks its enable flag from instance config and provides a redirect URL.\n\n## Scope\n**In scope:**\n- Update `apps/web/core/hooks/oauth/core.tsx` to add Keycloak option\n\n**Out of scope:**\n- Space app hook (separate bead)\n- Admin hooks (separate bead)\n- Logo asset creation\n\n## Components Touched\n- `apps/web/core/hooks/oauth/core.tsx`\n\n## Dependencies / Prerequisites\n- Frontend Types (xplane-4ks.1.4) — needs `is_keycloak_enabled` type on config\n\n## Important Assumptions and Constraints\n- Check `config?.is_keycloak_enabled` to conditionally include Keycloak\n- Auth redirect URL: `/auth/keycloak/`\n- Follow exact pattern of existing provider entries (GitLab, Gitea) in the hook\n- Need a Keycloak logo/icon — use the same import pattern as other providers. If no SVG asset exists yet, use a placeholder or create a simple one.\n\n## Acceptance Criteria\n- Keycloak option appears in OAuth config when `is_keycloak_enabled` is true\n- Keycloak option not shown when `is_keycloak_enabled` is false\n- Redirect URL is `/auth/keycloak/`\n- `pnpm check:types` passes\n- Existing OAuth options unchanged","status":"closed","priority":2,"issue_type":"task","created_at":"2026-04-20T10:19:01.547115332Z","created_by":"cuongnt3","updated_at":"2026-04-21T07:03:37.498060Z","closed_at":"2026-04-21T07:03:37.497902Z","close_reason":"Implemented: see conversation","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"xplane-4ks.3.1","depends_on_id":"xplane-4ks.1.4","type":"blocks","created_at":"2026-04-20T10:19:48.390128107Z","created_by":"cuongnt3","metadata":"{}","thread_id":""},{"issue_id":"xplane-4ks.3.1","depends_on_id":"xplane-4ks.3","type":"parent-child","created_at":"2026-04-20T10:19:01.547115332Z","created_by":"cuongnt3","metadata":"{}","thread_id":""}],"comments":[{"id":6,"issue_id":"xplane-4ks.3.1","author":"cuongnt3","text":"[POLISH] Logo asset guidance:\n\n1. **Logo import pattern:** `import keycloakLogo from \"@/app/assets/logos/keycloak-logo.svg?url\";`\n2. **Logo file location:** `apps/web/app/assets/logos/keycloak-logo.svg` — this file does NOT exist yet. The implementing agent must create a simple Keycloak logo SVG (the official Keycloak icon is a key shape). Use a minimal SVG placeholder if unsure of the exact design.\n3. **Hook entry to add** (after the gitea entry in `oAuthOptions`):\n ```tsx\n {\n id: \"keycloak\",\n text: `${oauthActionText} with Keycloak`,\n icon: \"Keycloak,\n onClick: () => {\n window.location.assign(`${API_BASE_URL}/auth/keycloak/${next_path ? `?next_path=${next_path}` : ``}`);\n },\n enabled: config?.is_keycloak_enabled,\n }\n ```\n","created_at":"2026-04-20T10:35:33Z"},{"id":16,"issue_id":"xplane-4ks.3.1","author":"cuongnt3","text":"[POLISH] IMPORTANT: Also update the isOAuthEnabled derived value to include config?.is_keycloak_enabled in the OR chain. Without this, the OAuth section won't render when only Keycloak is enabled. Add config?.is_keycloak_enabled after the config?.is_gitea_enabled line.","created_at":"2026-04-20T10:59:28Z"}]} +{"id":"xplane-4ks.3.2","title":"Space OAuth Hook","description":"## Objective\nUpdate the space app's `useCoreOAuthConfig` hook to include Keycloak as an OAuth login option.\n\n## Context\nThe space app has its own `useCoreOAuthConfig` hook, identical in pattern to the web app's hook but located in a different directory. It must also include Keycloak.\n\n## Scope\n**In scope:**\n- Update `apps/space/hooks/oauth/core.tsx` to add Keycloak option\n\n**Out of scope:**\n- Web app hook (separate bead)\n- Admin hooks (separate bead)\n\n## Components Touched\n- `apps/space/hooks/oauth/core.tsx`\n\n## Dependencies / Prerequisites\n- Frontend Types (xplane-4ks.1.4) — needs `is_keycloak_enabled` type on config\n\n## Important Assumptions and Constraints\n- Same pattern as web hook: check `config?.is_keycloak_enabled`, redirect to `/auth/keycloak/`\n- Follow exact pattern of existing provider entries in this specific file\n- Space app redirect URL is the same: `/auth/keycloak/` (the backend handles space vs app distinction via different callback URLs)\n\n## Acceptance Criteria\n- Keycloak option appears in space OAuth config when enabled\n- Redirect URL is `/auth/keycloak/`\n- `pnpm check:types` passes\n- Existing OAuth options unchanged","status":"closed","priority":2,"issue_type":"task","created_at":"2026-04-20T10:19:06.820524604Z","created_by":"cuongnt3","updated_at":"2026-04-21T07:03:37.558185Z","closed_at":"2026-04-21T07:03:37.558034Z","close_reason":"Implemented: see conversation","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"xplane-4ks.3.2","depends_on_id":"xplane-4ks.1.4","type":"blocks","created_at":"2026-04-20T10:19:48.485935981Z","created_by":"cuongnt3","metadata":"{}","thread_id":""},{"issue_id":"xplane-4ks.3.2","depends_on_id":"xplane-4ks.3","type":"parent-child","created_at":"2026-04-20T10:19:06.820524604Z","created_by":"cuongnt3","metadata":"{}","thread_id":""}],"comments":[{"id":7,"issue_id":"xplane-4ks.3.2","author":"cuongnt3","text":"[POLISH] Logo asset guidance:\n\n1. **Logo import pattern:** `import keycloakLogo from \"@/app/assets/logos/keycloak-logo.svg?url\";`\n2. **Logo file location:** `apps/space/app/assets/logos/keycloak-logo.svg` — does NOT exist yet. Must create (reuse same SVG from web app bead 3.1, but place in space app assets directory too).\n3. **Hook entry:** Same pattern as web hook (see bead 3.1 polish comment for exact code).\n","created_at":"2026-04-20T10:35:36Z"},{"id":17,"issue_id":"xplane-4ks.3.2","author":"cuongnt3","text":"[POLISH] IMPORTANT: Also update the isOAuthEnabled derived value to include config?.is_keycloak_enabled in the OR chain. Without this, the OAuth section won't render when only Keycloak is enabled. Add config?.is_keycloak_enabled after the config?.is_gitea_enabled line.","created_at":"2026-04-20T10:59:29Z"}]} +{"id":"xplane-4ks.3.3","title":"Admin Hooks & Routes","description":"## Objective\nUpdate admin app hooks and routes to include Keycloak in the authentication modes map and navigation.\n\n## Context\nThe admin app has a `getCoreAuthenticationModesMap` hook that maps auth providers to their config pages, and a routes file that defines admin page routes. Keycloak must be added to both.\n\n## Scope\n**In scope:**\n- Update `apps/admin/hooks/oauth/core.tsx` — add Keycloak to authentication modes map\n- Update `apps/admin/app/routes.ts` — add keycloak route\n\n**Out of scope:**\n- Config page implementation (separate bead)\n- Config toggle component (separate bead)\n\n## Components Touched\n- `apps/admin/hooks/oauth/core.tsx`\n- `apps/admin/app/routes.ts`\n\n## Dependencies / Prerequisites\n- Frontend Types (xplane-4ks.1.4) — needs Keycloak types\n\n## Important Assumptions and Constraints\n- Follow exact pattern of existing provider entries (GitLab, Gitea)\n- Route path should match the convention of other auth config pages\n- The hook maps provider key to config component/page info\n\n## Acceptance Criteria\n- Keycloak appears in `getCoreAuthenticationModesMap` hook\n- Keycloak route registered in admin routes\n- `pnpm check:types` passes\n- Existing admin hooks and routes unchanged","status":"closed","priority":2,"issue_type":"task","created_at":"2026-04-20T10:19:12.555163093Z","created_by":"cuongnt3","updated_at":"2026-04-21T07:03:37.614903Z","closed_at":"2026-04-21T07:03:37.614744Z","close_reason":"Implemented: see conversation","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"xplane-4ks.3.3","depends_on_id":"xplane-4ks.1.4","type":"blocks","created_at":"2026-04-20T10:19:48.584468713Z","created_by":"cuongnt3","metadata":"{}","thread_id":""},{"issue_id":"xplane-4ks.3.3","depends_on_id":"xplane-4ks.3","type":"parent-child","created_at":"2026-04-20T10:19:12.555163093Z","created_by":"cuongnt3","metadata":"{}","thread_id":""}],"comments":[{"id":4,"issue_id":"xplane-4ks.3.3","author":"cuongnt3","text":"[POLISH] Missing implementation details for self-contained implementation:\n\n1. **File: `apps/admin/hooks/oauth/core.tsx`** — Add Keycloak entry to `getCoreAuthenticationModesMap` following this exact pattern:\n ```tsx\n keycloak: {\n key: \"keycloak\",\n name: \"Keycloak\",\n description: \"Allow members to log in or sign up to plane with their Keycloak accounts.\",\n icon: \"Keycloak,\n config: ,\n enabledConfigKey: \"IS_KEYCLOAK_ENABLED\",\n }\n ```\n Import: `import keycloakLogo from \"@/app/assets/logos/keycloak-logo.svg?url\"` and `import { KeycloakConfiguration } from \"@/components/authentication/keycloak-config\";`\n\n2. **File: `apps/admin/app/routes.ts`** — Add: `route(\"authentication/keycloak\", \"./(all)/(dashboard)/authentication/keycloak/page.tsx\")`\n\n3. **DEPENDENCY NOTE:** This bead imports `KeycloakConfiguration` from bead 3.4, but 3.4 depends on 3.3. The implementing agent should create a minimal stub `KeycloakConfiguration` component inline or as a placeholder if 3.4 is not yet done, then replace it when 3.4 is implemented.\n","created_at":"2026-04-20T10:35:23Z"},{"id":15,"issue_id":"xplane-4ks.3.3","author":"cuongnt3","text":"[POLISH] Circular dependency resolution: This bead imports KeycloakConfiguration from bead 3.4 (apps/admin/components/authentication/keycloak-config.tsx), but 3.4 depends on 3.3. When implementing 3.3, create a minimal placeholder stub at apps/admin/components/authentication/keycloak-config.tsx that exports KeycloakConfiguration as a simple div. Bead 3.4 will replace it with the real implementation.","created_at":"2026-04-20T10:55:20Z"}]} +{"id":"xplane-4ks.3.4","title":"Admin Config Toggle Component","description":"## Objective\nCreate the Keycloak authentication list item component with enable/disable toggle for the admin authentication page.\n\n## Context\nThe admin authentication page shows a list of auth providers, each with a toggle to enable/disable. This bead creates the Keycloak entry in that list.\n\n## Scope\n**In scope:**\n- Create `keycloak-config.tsx` component (list item with toggle)\n- Wire it into the authentication list page\n\n**Out of scope:**\n- The full config form page (separate bead)\n- Admin hooks and routes (separate bead)\n\n## Components Touched\n- New component file for Keycloak config toggle (follow the directory pattern of existing providers like GitLab/Gitea)\n\n## Dependencies / Prerequisites\n- Frontend Types (xplane-4ks.1.4) — needs Keycloak types\n- Admin Hooks & Routes (xplane-4ks.3.3) — route must exist for navigation\n\n## Important Assumptions and Constraints\n- Follow exact pattern of existing provider toggle components (e.g., GitLab's `gitlab-config.tsx` or Gitea's `gitea-config.tsx`)\n- Component shows: provider name, description, enable/disable toggle\n- Toggle calls `updateInstanceConfigurations` to set `IS_KEYCLOAK_ENABLED`\n- Links to the Keycloak config page for detailed settings\n\n## Acceptance Criteria\n- Toggle component renders with Keycloak branding\n- Toggle enables/disables `IS_KEYCLOAK_ENABLED` via API\n- Links to Keycloak detail config page\n- `pnpm check:types` passes\n- Matches visual pattern of existing provider toggles","status":"closed","priority":2,"issue_type":"task","created_at":"2026-04-20T10:19:20.550424131Z","created_by":"cuongnt3","updated_at":"2026-04-21T07:07:17.970238Z","closed_at":"2026-04-21T07:07:17.970009Z","close_reason":"Implemented: Full keycloak config toggle component with realm check","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"xplane-4ks.3.4","depends_on_id":"xplane-4ks.1.4","type":"blocks","created_at":"2026-04-20T10:19:48.690644581Z","created_by":"cuongnt3","metadata":"{}","thread_id":""},{"issue_id":"xplane-4ks.3.4","depends_on_id":"xplane-4ks.3","type":"parent-child","created_at":"2026-04-20T10:19:20.550424131Z","created_by":"cuongnt3","metadata":"{}","thread_id":""},{"issue_id":"xplane-4ks.3.4","depends_on_id":"xplane-4ks.3.3","type":"blocks","created_at":"2026-04-20T10:19:49.275548562Z","created_by":"cuongnt3","metadata":"{}","thread_id":""}],"comments":[{"id":5,"issue_id":"xplane-4ks.3.4","author":"cuongnt3","text":"[POLISH] Missing file path and implementation details:\n\n1. **Exact file path:** `apps/admin/components/authentication/keycloak-config.tsx`\n2. **Reference template:** Clone `apps/admin/components/authentication/gitea-config.tsx`\n3. **Component name:** `KeycloakConfiguration` (exported, used by admin hook in 3.3)\n4. **Props pattern:** `{ disabled: boolean; updateConfig: (key: string, value: string) => void }` — match existing provider toggle components\n5. **Config key:** Toggle sets `IS_KEYCLOAK_ENABLED`\n6. **Navigation:** Links to `/authentication/keycloak/` for detailed config\n","created_at":"2026-04-20T10:35:27Z"}]} +{"id":"xplane-4ks.3.5","title":"Admin Config Page & Form","description":"## Objective\nBuild the Keycloak configuration page and form for the admin app, allowing admins to enter Keycloak connection details.\n\n## Context\nEach OAuth provider has a dedicated admin config page with a form. The Keycloak form has one extra field compared to GitLab: \"Realm\". The form saves via the existing generic `InstanceConfigurationEndpoint`.\n\n## Scope\n**In scope:**\n- Create `keycloak/page.tsx` — config page wrapper\n- Create `keycloak/form.tsx` — configuration form component\n- Form fields: Host, Realm, Client ID, Client Secret, Sync toggle, Callback URL display\n\n**Out of scope:**\n- Toggle component (separate bead)\n- Backend config endpoint (already exists as generic endpoint)\n\n## Components Touched\n- New page and form files in admin app auth directory (follow existing provider page patterns)\n\n## Dependencies / Prerequisites\n- Frontend Types (xplane-4ks.1.4) — needs `TInstanceKeycloakAuthenticationConfigurationKeys`\n- Admin Hooks & Routes (xplane-4ks.3.3) — route must exist to navigate to this page\n\n## Important Assumptions and Constraints\n- **Primary template:** Follow `apps/admin/app/(all)/(dashboard)/authentication/gitea/form.tsx` pattern exactly\n- Uses `react-hook-form` for form state\n- Imports `IFormattedInstanceConfiguration` from `@plane/types`\n- Saves via `useInstance().updateInstanceConfigurations(payload)` which calls `PATCH /api/instances/configurations/`\n- No custom backend handler needed — uses existing generic `InstanceConfigurationEndpoint`\n- Form fields and their config keys:\n - Host → `KEYCLOAK_HOST`\n - Realm → `KEYCLOAK_REALM` (unique to Keycloak — extra field vs GitLab)\n - Client ID → `KEYCLOAK_CLIENT_ID`\n - Client Secret → `KEYCLOAK_CLIENT_SECRET`\n - Sync toggle → `ENABLE_KEYCLOAK_SYNC`\n- Callback URL display: show `{instance_url}/auth/keycloak/callback/` as read-only for admin to copy-paste into Keycloak client config\n- Layout should match GitLab form pattern with the Realm field added\n\n## Risks / Gotchas\n- The form is the largest single frontend component (~230 lines). It's a clone-and-modify from GitLab form, so complexity is manageable.\n- Must handle `KEYCLOAK_REALM` field which no other provider has — needs an appropriate label and help text.\n\n## Acceptance Criteria\n- Page renders at the admin keycloak auth route\n- Form displays all 5 config fields (Host, Realm, Client ID, Client Secret, Sync)\n- Callback URL is displayed read-only\n- Save persists all values via `updateInstanceConfigurations`\n- Client Secret field is masked/password type\n- `pnpm check:types` passes\n- Form validates required fields before save\n- Layout matches existing provider config forms","status":"closed","priority":2,"issue_type":"task","created_at":"2026-04-20T10:19:37.311498346Z","created_by":"cuongnt3","updated_at":"2026-04-21T07:07:19.338294Z","closed_at":"2026-04-21T07:07:19.338079Z","close_reason":"Implemented: Keycloak admin config page and form with realm field","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"xplane-4ks.3.5","depends_on_id":"xplane-4ks.1.4","type":"blocks","created_at":"2026-04-20T10:19:48.790528399Z","created_by":"cuongnt3","metadata":"{}","thread_id":""},{"issue_id":"xplane-4ks.3.5","depends_on_id":"xplane-4ks.3","type":"parent-child","created_at":"2026-04-20T10:19:37.311498346Z","created_by":"cuongnt3","metadata":"{}","thread_id":""},{"issue_id":"xplane-4ks.3.5","depends_on_id":"xplane-4ks.3.3","type":"blocks","created_at":"2026-04-20T10:19:49.372339063Z","created_by":"cuongnt3","metadata":"{}","thread_id":""}],"comments":[{"id":9,"issue_id":"xplane-4ks.3.5","author":"cuongnt3","text":"[POLISH] Implementation details for self-containedness:\n\n1. **Exact file paths:**\n - `apps/admin/app/(all)/(dashboard)/authentication/keycloak/page.tsx`\n - `apps/admin/app/(all)/(dashboard)/authentication/keycloak/form.tsx`\n\n2. **Common components to import** (from relative paths within admin auth components):\n - `ControllerInput` — for text inputs (Host, Realm, Client ID, Client Secret)\n - `ControllerSwitch` — for the Sync toggle\n - `CopyField` — for the read-only Callback URL\n\n3. **Form field constant pattern:** Define `KEYCLOAK_FORM_FIELDS` array (like `GITEA_FORM_FIELDS`) with objects `{ label, placeholder, key, type }` for each text field. Define `KEYCLOAK_FORM_SWITCH_FIELD` for the sync toggle.\n\n4. **Page wrapper pattern:** `page.tsx` uses `observer()`, `PageWrapper`, `AuthenticationMethodCard` with a `ToggleSwitch` for `IS_KEYCLOAK_ENABLED`, and renders the form component in the body.\n","created_at":"2026-04-20T10:35:47Z"},{"id":14,"issue_id":"xplane-4ks.3.5","author":"cuongnt3","text":"[POLISH] Exact import paths for form components: import { ControllerInput } from \"@/components/common/controller-input\", import { ControllerSwitch } from \"@/components/common/controller-switch\", import { CopyField } from \"@/components/common/copy-field\". Also import their type variants (TControllerInputFormField, TControllerSwitchFormField, TCopyField). Clone GITEA_FORM_FIELDS, GITEA_FORM_SWITCH_FIELD, and GITEA_SERVICE_FIELD patterns from apps/admin/app/(all)/(dashboard)/authentication/gitea/form.tsx.","created_at":"2026-04-20T10:55:18Z"}]} +{"id":"xplane-epi","title":"Instance Configuration Variables (Keycloak)","description":"## Objective\nDefine Keycloak config variables and expose the `is_keycloak_enabled` flag via the instance API endpoint.\n\n## Context\nPlane stores OAuth provider configuration in `InstanceConfiguration` (database). Each provider has config variables seeded on startup and an enable flag exposed via the instance API. The admin UI and frontend apps read `is_keycloak_enabled` to decide whether to show the Keycloak login button.\n\n## Scope\n**In scope:**\n- Add `keycloak_config_variables` list to `apps/api/plane/utils/instance_config_variables/core.py`\n- Append `keycloak_config_variables` to `core_config_variables`\n- Update `InstanceEndpoint.get()` in `apps/api/plane/license/api/views/instance.py` (3 changes)\n\n**Out of scope:**\n- Admin UI for editing these config values (separate bead)\n- Provider class that reads these values (separate bead)\n\n## Components Touched\n- `apps/api/plane/utils/instance_config_variables/core.py` — add config variable definitions\n- `apps/api/plane/license/api/views/instance.py` — expose `is_keycloak_enabled` in API response\n\n## Dependencies / Prerequisites\nNone — this is foundation work.\n\n## Important Assumptions and Constraints\n- 6 config keys: `IS_KEYCLOAK_ENABLED`, `KEYCLOAK_HOST`, `KEYCLOAK_REALM`, `KEYCLOAK_CLIENT_ID`, `KEYCLOAK_CLIENT_SECRET`, `ENABLE_KEYCLOAK_SYNC`\n- `KEYCLOAK_CLIENT_SECRET` must have `is_encrypted: True`\n- `IS_KEYCLOAK_ENABLED` is seeded (following Gitea's pattern, not Google/GitHub/GitLab which don't seed their enable flags)\n- Initial values come from environment variables via `os.environ.get()`\n- Follow exact pattern of `gitea_config_variables` in `core.py`\n\n### InstanceEndpoint.get() changes (3 places):\n1. Add `{\"key\": \"IS_KEYCLOAK_ENABLED\", \"default\": os.environ.get(\"IS_KEYCLOAK_ENABLED\", \"0\")}` to the `get_configuration_value()` list\n2. Add `IS_KEYCLOAK_ENABLED` to the positional tuple destructuring (append as last position — **order must match the list of dicts**)\n3. Add `data[\"is_keycloak_enabled\"] = IS_KEYCLOAK_ENABLED == \"1\"` to the data dict\n\n**Critical:** Read the current `get_configuration_value()` call before editing. Count existing entries and append to both the list and tuple in the same position. A mismatch silently assigns wrong values.\n\n## Risks / Gotchas\n- The tuple destructuring in `InstanceEndpoint.get()` is position-sensitive. Adding to the list without updating the tuple (or vice versa) silently assigns wrong config values to wrong variables.\n- Must verify the current count of entries before appending.\n\n## Acceptance Criteria\n- `keycloak_config_variables` list defined with all 6 keys\n- `KEYCLOAK_CLIENT_SECRET` has `is_encrypted: True`\n- `keycloak_config_variables` included in `core_config_variables`\n- `InstanceEndpoint.get()` returns `is_keycloak_enabled` boolean in response\n- Python syntax valid\n- Existing config variables unchanged","status":"closed","priority":2,"issue_type":"task","created_at":"2026-04-20T10:39:43.694726302Z","created_by":"cuongnt3","updated_at":"2026-04-21T07:00:22.349563Z","closed_at":"2026-04-21T07:00:22.349365Z","close_reason":"Implemented: Added keycloak_config_variables (6 keys) to core.py; appended to core_config_variables; added IS_KEYCLOAK_ENABLED as 16th entry in InstanceEndpoint.get() tuple and list; added is_keycloak_enabled to API response","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"xplane-epi","depends_on_id":"xplane-4ks.1","type":"parent-child","created_at":"2026-04-20T10:39:52.384296805Z","created_by":"cuongnt3","metadata":"{}","thread_id":""}],"comments":[{"id":11,"issue_id":"xplane-epi","author":"cuongnt3","text":"[POLISH] Minor clarification:\n\n1. **Current tuple count:** The `get_configuration_value()` call in `InstanceEndpoint.get()` currently has **15** entries in the tuple destructuring. Add `IS_KEYCLOAK_ENABLED` as the 16th entry. Both the dict list and the tuple must have matching count and order.\n2. **Config variable count:** Gitea has 5 keys (IS_GITEA_ENABLED, GITEA_HOST, GITEA_CLIENT_ID, GITEA_CLIENT_SECRET, ENABLE_GITEA_SYNC). Keycloak has 6: same pattern + KEYCLOAK_REALM. The bead correctly lists 6 keys.\n","created_at":"2026-04-20T10:39:52Z"}]} diff --git a/.beads/metadata.json b/.beads/metadata.json new file mode 100644 index 00000000000..f581edc0de5 --- /dev/null +++ b/.beads/metadata.json @@ -0,0 +1,4 @@ +{ + "database": "beads.db", + "jsonl_export": "issues.jsonl" +} diff --git a/.gitignore b/.gitignore index e2e6441ba3c..51634dc6a26 100644 --- a/.gitignore +++ b/.gitignore @@ -110,3 +110,6 @@ build/ .react-router/ temp/ scripts/ + +# bv (beads viewer) local config and caches +.bv/ diff --git a/apps/admin/app/(all)/(dashboard)/authentication/keycloak/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/keycloak/form.tsx new file mode 100644 index 00000000000..42d4a9bf0c7 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/keycloak/form.tsx @@ -0,0 +1,198 @@ +/** + * 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, TInstanceKeycloakAuthenticationConfigurationKeys } 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 KeycloakConfigFormValues = Record; + +export function InstanceKeycloakConfigForm(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({ + defaultValues: { + KEYCLOAK_HOST: config["KEYCLOAK_HOST"] || "", + KEYCLOAK_REALM: config["KEYCLOAK_REALM"] || "", + KEYCLOAK_CLIENT_ID: config["KEYCLOAK_CLIENT_ID"], + KEYCLOAK_CLIENT_SECRET: config["KEYCLOAK_CLIENT_SECRET"], + ENABLE_KEYCLOAK_SYNC: config["ENABLE_KEYCLOAK_SYNC"] || "0", + }, + }); + + const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : ""; + + const KEYCLOAK_FORM_FIELDS: TControllerInputFormField[] = [ + { + key: "KEYCLOAK_HOST", + type: "text", + label: "Keycloak Host", + placeholder: "https://keycloak.example.com", + error: Boolean(errors.KEYCLOAK_HOST), + required: true, + }, + { + key: "KEYCLOAK_REALM", + type: "text", + label: "Realm", + description: <>The Keycloak realm to authenticate against., + placeholder: "master", + error: Boolean(errors.KEYCLOAK_REALM), + required: true, + }, + { + key: "KEYCLOAK_CLIENT_ID", + type: "text", + label: "Client ID", + description: <>You will get this from your Keycloak admin console., + placeholder: "plane", + error: Boolean(errors.KEYCLOAK_CLIENT_ID), + required: true, + }, + { + key: "KEYCLOAK_CLIENT_SECRET", + type: "password", + label: "Client Secret", + description: <>Your client secret is found in your Keycloak admin console., + placeholder: "••••••••••••••••••••••••••••••••", + error: Boolean(errors.KEYCLOAK_CLIENT_SECRET), + required: true, + }, + ]; + + const KEYCLOAK_FORM_SWITCH_FIELD: TControllerSwitchFormField = { + name: "ENABLE_KEYCLOAK_SYNC", + label: "Keycloak", + }; + + const KEYCLOAK_SERVICE_FIELD: TCopyField[] = [ + { + key: "Callback_URI", + label: "Callback URI", + url: `${originURL}/auth/keycloak/callback/`, + description: ( + <> + We will auto-generate this. Paste this into your Valid Redirect URIs field + in your Keycloak client settings. + + ), + }, + ]; + + const onSubmit = async (formData: KeycloakConfigFormValues) => { + const payload: Partial = { ...formData }; + + try { + const response = await updateInstanceConfigurations(payload); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Done!", + message: "Your Keycloak authentication is configured. You should test it now.", + }); + reset({ + KEYCLOAK_HOST: response.find((item) => item.key === "KEYCLOAK_HOST")?.value, + KEYCLOAK_REALM: response.find((item) => item.key === "KEYCLOAK_REALM")?.value, + KEYCLOAK_CLIENT_ID: response.find((item) => item.key === "KEYCLOAK_CLIENT_ID")?.value, + KEYCLOAK_CLIENT_SECRET: response.find((item) => item.key === "KEYCLOAK_CLIENT_SECRET")?.value, + ENABLE_KEYCLOAK_SYNC: response.find((item) => item.key === "ENABLE_KEYCLOAK_SYNC")?.value, + }); + } catch (err) { + console.error(err); + } + }; + + const handleGoBack = (e: React.MouseEvent) => { + if (isDirty) { + e.preventDefault(); + setIsDiscardChangesModalOpen(true); + } + }; + + return ( + <> + setIsDiscardChangesModalOpen(false)} + /> +
+
+
+
Keycloak-provided details for Plane
+ {KEYCLOAK_FORM_FIELDS.map((field) => ( + + ))} + +
+
+ + + Go back + +
+
+
+
+
+
Plane-provided details for Keycloak
+ {KEYCLOAK_SERVICE_FIELD.map((field) => ( + + ))} +
+
+
+
+ + ); +} diff --git a/apps/admin/app/(all)/(dashboard)/authentication/keycloak/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/keycloak/page.tsx new file mode 100644 index 00000000000..c40d982bede --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/keycloak/page.tsx @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useState } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +// plane internal packages +import { setPromiseToast } from "@plane/propel/toast"; +import { Loader, ToggleSwitch } from "@plane/ui"; +// assets +import keycloakLogo from "@/app/assets/logos/keycloak-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 { InstanceKeycloakConfigForm } from "./form"; + +const InstanceKeycloakAuthenticationPage = observer(function InstanceKeycloakAuthenticationPage() { + // store + const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); + // state + const [isSubmitting, setIsSubmitting] = useState(false); + // config + const enableKeycloakConfig = formattedConfig?.IS_KEYCLOAK_ENABLED ?? ""; + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + const updateConfig = async (key: "IS_KEYCLOAK_ENABLED", value: string) => { + setIsSubmitting(true); + + const payload = { + [key]: value, + }; + + const updateConfigPromise = updateInstanceConfigurations(payload); + + setPromiseToast(updateConfigPromise, { + loading: "Saving Configuration", + success: { + title: "Configuration saved", + message: () => `Keycloak 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 isKeycloakEnabled = enableKeycloakConfig === "1"; + + return ( + } + config={ + { + updateConfig("IS_KEYCLOAK_ENABLED", isKeycloakEnabled ? "0" : "1"); + }} + size="sm" + disabled={isSubmitting || !formattedConfig} + /> + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> + } + > + {formattedConfig ? ( + + ) : ( + + + + + + + + )} + + ); +}); +export const meta: Route.MetaFunction = () => [{ title: "Keycloak Authentication - God Mode" }]; + +export default InstanceKeycloakAuthenticationPage; diff --git a/apps/admin/app/assets/logos/keycloak-logo.svg b/apps/admin/app/assets/logos/keycloak-logo.svg new file mode 100644 index 00000000000..28922598809 --- /dev/null +++ b/apps/admin/app/assets/logos/keycloak-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/admin/app/routes.ts b/apps/admin/app/routes.ts index 184bed205a7..47703558bda 100644 --- a/apps/admin/app/routes.ts +++ b/apps/admin/app/routes.ts @@ -19,6 +19,7 @@ export default [ route("authentication/gitlab", "./(all)/(dashboard)/authentication/gitlab/page.tsx"), route("authentication/google", "./(all)/(dashboard)/authentication/google/page.tsx"), route("authentication/gitea", "./(all)/(dashboard)/authentication/gitea/page.tsx"), + route("authentication/keycloak", "./(all)/(dashboard)/authentication/keycloak/page.tsx"), route("ai", "./(all)/(dashboard)/ai/page.tsx"), route("image", "./(all)/(dashboard)/image/page.tsx"), ]), diff --git a/apps/admin/components/authentication/keycloak-config.tsx b/apps/admin/components/authentication/keycloak-config.tsx new file mode 100644 index 00000000000..23b8ae7e99c --- /dev/null +++ b/apps/admin/components/authentication/keycloak-config.tsx @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import Link from "next/link"; +import { Settings2 } from "lucide-react"; +import { getButtonStyling } from "@plane/propel/button"; +import type { TInstanceAuthenticationMethodKeys } from "@plane/types"; +import { ToggleSwitch } from "@plane/ui"; +import { cn } from "@plane/utils"; +import { useInstance } from "@/hooks/store"; + +type Props = { + disabled: boolean; + updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; +}; + +export const KeycloakConfiguration = observer(function KeycloakConfiguration(props: Props) { + const { disabled, updateConfig } = props; + const { formattedConfig } = useInstance(); + const KeycloakConfig = formattedConfig?.IS_KEYCLOAK_ENABLED ?? ""; + const KeycloakConfigured = + !!formattedConfig?.KEYCLOAK_HOST && + !!formattedConfig?.KEYCLOAK_CLIENT_ID && + !!formattedConfig?.KEYCLOAK_CLIENT_SECRET && + !!formattedConfig?.KEYCLOAK_REALM; + + return ( + <> + {KeycloakConfigured ? ( +
+ + Edit + + { + Boolean(parseInt(KeycloakConfig)) === true + ? updateConfig("IS_KEYCLOAK_ENABLED", "0") + : updateConfig("IS_KEYCLOAK_ENABLED", "1"); + }} + size="sm" + disabled={disabled} + /> +
+ ) : ( + + + Configure + + )} + + ); +}); diff --git a/apps/admin/hooks/oauth/core.tsx b/apps/admin/hooks/oauth/core.tsx index 9e6914e41cc..7f4c0e8dc4f 100644 --- a/apps/admin/hooks/oauth/core.tsx +++ b/apps/admin/hooks/oauth/core.tsx @@ -17,12 +17,14 @@ import githubLightModeImage from "@/app/assets/logos/github-black.png?url"; import githubDarkModeImage from "@/app/assets/logos/github-white.png?url"; import gitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url"; import googleLogo from "@/app/assets/logos/google-logo.svg?url"; +import keycloakLogo from "@/app/assets/logos/keycloak-logo.svg?url"; // components import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch"; import { GiteaConfiguration } from "@/components/authentication/gitea-config"; import { GithubConfiguration } from "@/components/authentication/github-config"; import { GitlabConfiguration } from "@/components/authentication/gitlab-config"; import { GoogleConfiguration } from "@/components/authentication/google-config"; +import { KeycloakConfiguration } from "@/components/authentication/keycloak-config"; import { PasswordLoginConfiguration } from "@/components/authentication/password-config-switch"; // Authentication methods @@ -89,4 +91,12 @@ export const getCoreAuthenticationModesMap: ( config: , enabledConfigKey: "IS_GITEA_ENABLED", }, + keycloak: { + key: "keycloak", + name: "Keycloak", + description: "Allow members to log in or sign up to plane with their Keycloak accounts.", + icon: Keycloak Logo, + config: , + enabledConfigKey: "IS_KEYCLOAK_ENABLED", + }, }); diff --git a/apps/admin/hooks/oauth/index.ts b/apps/admin/hooks/oauth/index.ts index 74c11e33fcd..8731d08bce8 100644 --- a/apps/admin/hooks/oauth/index.ts +++ b/apps/admin/hooks/oauth/index.ts @@ -19,6 +19,7 @@ export const useAuthenticationModes = (props: TGetAuthenticationModeProps): TIns authenticationModes["github"], authenticationModes["gitlab"], authenticationModes["gitea"], + authenticationModes["keycloak"], ]; return availableAuthenticationModes; diff --git a/apps/api/plane/authentication/adapter/error.py b/apps/api/plane/authentication/adapter/error.py index f91565df2e8..4e98ff977a5 100644 --- a/apps/api/plane/authentication/adapter/error.py +++ b/apps/api/plane/authentication/adapter/error.py @@ -44,10 +44,12 @@ "GITHUB_USER_NOT_IN_ORG": 5122, "GITLAB_NOT_CONFIGURED": 5111, "GITEA_NOT_CONFIGURED": 5112, + "KEYCLOAK_NOT_CONFIGURED": 5113, "GOOGLE_OAUTH_PROVIDER_ERROR": 5115, "GITHUB_OAUTH_PROVIDER_ERROR": 5120, "GITLAB_OAUTH_PROVIDER_ERROR": 5121, "GITEA_OAUTH_PROVIDER_ERROR": 5123, + "KEYCLOAK_OAUTH_PROVIDER_ERROR": 5124, # Reset Password "INVALID_PASSWORD_TOKEN": 5125, "EXPIRED_PASSWORD_TOKEN": 5130, diff --git a/apps/api/plane/authentication/adapter/oauth.py b/apps/api/plane/authentication/adapter/oauth.py index 0bef76b2487..6810715a99e 100644 --- a/apps/api/plane/authentication/adapter/oauth.py +++ b/apps/api/plane/authentication/adapter/oauth.py @@ -55,6 +55,8 @@ def authentication_error_code(self): return "GITLAB_OAUTH_PROVIDER_ERROR" elif self.provider == "gitea": return "GITEA_OAUTH_PROVIDER_ERROR" + elif self.provider == "keycloak": + return "KEYCLOAK_OAUTH_PROVIDER_ERROR" else: return "OAUTH_NOT_CONFIGURED" diff --git a/apps/api/plane/authentication/provider/oauth/keycloak.py b/apps/api/plane/authentication/provider/oauth/keycloak.py new file mode 100644 index 00000000000..62849ed40c6 --- /dev/null +++ b/apps/api/plane/authentication/provider/oauth/keycloak.py @@ -0,0 +1,149 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +import os +from datetime import datetime, timedelta +from urllib.parse import urlencode, urlparse + +import pytz + +# Module imports +from plane.authentication.adapter.oauth import OauthAdapter +from plane.license.utils.instance_value import get_configuration_value +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) + + +class KeycloakOAuthProvider(OauthAdapter): + provider = "keycloak" + scope = "openid email profile" + + def __init__(self, request, code=None, state=None, callback=None): + ( + IS_KEYCLOAK_ENABLED, + KEYCLOAK_HOST, + KEYCLOAK_REALM, + KEYCLOAK_CLIENT_ID, + KEYCLOAK_CLIENT_SECRET, + ) = get_configuration_value( + [ + { + "key": "IS_KEYCLOAK_ENABLED", + "default": os.environ.get("IS_KEYCLOAK_ENABLED"), + }, + { + "key": "KEYCLOAK_HOST", + "default": os.environ.get("KEYCLOAK_HOST"), + }, + { + "key": "KEYCLOAK_REALM", + "default": os.environ.get("KEYCLOAK_REALM"), + }, + { + "key": "KEYCLOAK_CLIENT_ID", + "default": os.environ.get("KEYCLOAK_CLIENT_ID"), + }, + { + "key": "KEYCLOAK_CLIENT_SECRET", + "default": os.environ.get("KEYCLOAK_CLIENT_SECRET"), + }, + ] + ) + + if not (IS_KEYCLOAK_ENABLED == "1" and KEYCLOAK_HOST and KEYCLOAK_REALM and KEYCLOAK_CLIENT_ID and KEYCLOAK_CLIENT_SECRET): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["KEYCLOAK_NOT_CONFIGURED"], + error_message="KEYCLOAK_NOT_CONFIGURED", + ) + + # Enforce scheme and normalize trailing slash(es) + parsed = urlparse(KEYCLOAK_HOST) + if not parsed.scheme or parsed.scheme not in ("https", "http"): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["KEYCLOAK_NOT_CONFIGURED"], + error_message="KEYCLOAK_NOT_CONFIGURED", + ) + KEYCLOAK_HOST = KEYCLOAK_HOST.rstrip("/") + # Set URLs based on the host and realm + self.token_url = f"{KEYCLOAK_HOST}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/token" + self.userinfo_url = f"{KEYCLOAK_HOST}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/userinfo" + + client_id = KEYCLOAK_CLIENT_ID + client_secret = KEYCLOAK_CLIENT_SECRET + + redirect_uri = f"{'https' if request.is_secure() else 'http'}://{request.get_host()}/auth/keycloak/callback/" + url_params = { + "client_id": client_id, + "scope": self.scope, + "redirect_uri": redirect_uri, + "response_type": "code", + "state": state, + } + auth_url = f"{KEYCLOAK_HOST}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/auth?{urlencode(url_params)}" + + super().__init__( + request, + self.provider, + client_id, + self.scope, + redirect_uri, + auth_url, + self.token_url, + self.userinfo_url, + client_secret, + code, + callback=callback, + ) + + def set_token_data(self): + data = { + "code": self.code, + "client_id": self.client_id, + "client_secret": self.client_secret, + "redirect_uri": self.redirect_uri, + "grant_type": "authorization_code", + } + headers = {"Accept": "application/json"} + token_response = self.get_user_token(data=data, headers=headers) + super().set_token_data( + { + "access_token": token_response.get("access_token"), + "refresh_token": token_response.get("refresh_token", None), + "access_token_expired_at": ( + datetime.now(tz=pytz.utc) + timedelta(seconds=token_response.get("expires_in")) + if token_response.get("expires_in") + else None + ), + "refresh_token_expired_at": ( + datetime.now(tz=pytz.utc) + timedelta(seconds=token_response.get("refresh_expires_in")) + if token_response.get("refresh_expires_in") + else None + ), + "id_token": token_response.get("id_token", ""), + } + ) + + def set_user_data(self): + user_info_response = self.get_user_response() + + email = user_info_response.get("email") + first_name = user_info_response.get("given_name") or user_info_response.get("name", "") + last_name = user_info_response.get("family_name", "") + avatar = user_info_response.get("picture", None) + + super().set_user_data( + { + "email": email, + "user": { + "provider_id": str(user_info_response.get("sub")), + "email": email, + "avatar": avatar, + "first_name": first_name, + "last_name": last_name, + "is_password_autoset": True, + }, + } + ) diff --git a/apps/api/plane/authentication/urls.py b/apps/api/plane/authentication/urls.py index 4bec07db00b..ee4d862e251 100644 --- a/apps/api/plane/authentication/urls.py +++ b/apps/api/plane/authentication/urls.py @@ -44,6 +44,10 @@ GiteaOauthInitiateEndpoint, GiteaCallbackSpaceEndpoint, GiteaOauthInitiateSpaceEndpoint, + KeycloakCallbackEndpoint, + KeycloakOauthInitiateEndpoint, + KeycloakCallbackSpaceEndpoint, + KeycloakOauthInitiateSpaceEndpoint, ) urlpatterns = [ @@ -150,4 +154,17 @@ GiteaCallbackSpaceEndpoint.as_view(), name="space-gitea-callback", ), + ## Keycloak Oauth + path("keycloak/", KeycloakOauthInitiateEndpoint.as_view(), name="keycloak-initiate"), + path("keycloak/callback/", KeycloakCallbackEndpoint.as_view(), name="keycloak-callback"), + path( + "spaces/keycloak/", + KeycloakOauthInitiateSpaceEndpoint.as_view(), + name="space-keycloak-initiate", + ), + path( + "spaces/keycloak/callback/", + KeycloakCallbackSpaceEndpoint.as_view(), + name="space-keycloak-callback", + ), ] diff --git a/apps/api/plane/authentication/views/__init__.py b/apps/api/plane/authentication/views/__init__.py index a9c816ae9ea..3b59d971838 100644 --- a/apps/api/plane/authentication/views/__init__.py +++ b/apps/api/plane/authentication/views/__init__.py @@ -10,6 +10,7 @@ from .app.github import GitHubCallbackEndpoint, GitHubOauthInitiateEndpoint from .app.gitlab import GitLabCallbackEndpoint, GitLabOauthInitiateEndpoint from .app.gitea import GiteaCallbackEndpoint, GiteaOauthInitiateEndpoint +from .app.keycloak import KeycloakCallbackEndpoint, KeycloakOauthInitiateEndpoint from .app.google import GoogleCallbackEndpoint, GoogleOauthInitiateEndpoint from .app.magic import MagicGenerateEndpoint, MagicSignInEndpoint, MagicSignUpEndpoint @@ -24,6 +25,8 @@ from .space.gitea import GiteaCallbackSpaceEndpoint, GiteaOauthInitiateSpaceEndpoint +from .space.keycloak import KeycloakCallbackSpaceEndpoint, KeycloakOauthInitiateSpaceEndpoint + from .space.google import GoogleCallbackSpaceEndpoint, GoogleOauthInitiateSpaceEndpoint from .space.magic import ( diff --git a/apps/api/plane/authentication/views/app/keycloak.py b/apps/api/plane/authentication/views/app/keycloak.py new file mode 100644 index 00000000000..d59b25520fe --- /dev/null +++ b/apps/api/plane/authentication/views/app/keycloak.py @@ -0,0 +1,87 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# Python imports +import uuid + +# Django import +from django.http import HttpResponseRedirect +from django.views import View + +# Module imports +from plane.authentication.provider.oauth.keycloak import KeycloakOAuthProvider +from plane.authentication.utils.login import user_login +from plane.authentication.utils.redirection_path import get_redirection_path +from plane.authentication.utils.user_auth_workflow import post_user_auth_workflow +from plane.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) +from plane.utils.path_validator import get_safe_redirect_url + + +class KeycloakOauthInitiateEndpoint(View): + def get(self, request): + request.session["host"] = base_host(request=request, is_app=True) + next_path = request.GET.get("next_path") + if next_path: + request.session["next_path"] = str(next_path) + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url(base_url=base_host(request=request, is_app=True), next_path=next_path, params=params) + return HttpResponseRedirect(url) + try: + state = uuid.uuid4().hex + provider = KeycloakOAuthProvider(request=request, state=state) + request.session["state"] = state + auth_url = provider.get_auth_url() + return HttpResponseRedirect(auth_url) + except AuthenticationException as e: + params = e.get_error_dict() + url = get_safe_redirect_url(base_url=base_host(request=request, is_app=True), next_path=next_path, params=params) + return HttpResponseRedirect(url) + + +class KeycloakCallbackEndpoint(View): + def get(self, request): + code = request.GET.get("code") + state = request.GET.get("state") + next_path = request.session.get("next_path") + if state != request.session.get("state", ""): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["KEYCLOAK_OAUTH_PROVIDER_ERROR"], + error_message="KEYCLOAK_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url(base_url=base_host(request=request, is_app=True), next_path=next_path, params=params) + return HttpResponseRedirect(url) + if not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["KEYCLOAK_OAUTH_PROVIDER_ERROR"], + error_message="KEYCLOAK_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url(base_url=base_host(request=request, is_app=True), next_path=next_path, params=params) + return HttpResponseRedirect(url) + try: + provider = KeycloakOAuthProvider(request=request, code=code, callback=post_user_auth_workflow) + user = provider.authenticate() + user_login(request=request, user=user, is_app=True) + if next_path: + path = next_path + else: + path = get_redirection_path(user=user) + url = get_safe_redirect_url(base_url=base_host(request=request, is_app=True), next_path=path, params={}) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + url = get_safe_redirect_url(base_url=base_host(request=request, is_app=True), next_path=next_path, params=params) + return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/keycloak.py b/apps/api/plane/authentication/views/space/keycloak.py new file mode 100644 index 00000000000..7f4a0461416 --- /dev/null +++ b/apps/api/plane/authentication/views/space/keycloak.py @@ -0,0 +1,91 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +import uuid +from urllib.parse import urlencode + +from django.http import HttpResponseRedirect +from django.views import View + +from plane.authentication.provider.oauth.keycloak import KeycloakOAuthProvider +from plane.authentication.utils.login import user_login +from plane.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) +from plane.utils.path_validator import validate_next_path + + +class KeycloakOauthInitiateSpaceEndpoint(View): + def get(self, request): + request.session["host"] = base_host(request=request, is_space=True) + next_path = request.GET.get("next_path") + if next_path: + request.session["next_path"] = str(validate_next_path(next_path)) + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(validate_next_path(next_path)) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + try: + state = uuid.uuid4().hex + provider = KeycloakOAuthProvider(request=request, state=state) + request.session["state"] = state + auth_url = provider.get_auth_url() + return HttpResponseRedirect(auth_url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + +class KeycloakCallbackSpaceEndpoint(View): + def get(self, request): + code = request.GET.get("code") + state = request.GET.get("state") + next_path = request.session.get("next_path") + if state != request.session.get("state", ""): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["KEYCLOAK_OAUTH_PROVIDER_ERROR"], + error_message="KEYCLOAK_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(validate_next_path(next_path)) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + if not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["KEYCLOAK_OAUTH_PROVIDER_ERROR"], + error_message="KEYCLOAK_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(validate_next_path(next_path)) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + try: + provider = KeycloakOAuthProvider(request=request, code=code) + user = provider.authenticate() + user_login(request=request, user=user, is_space=True) + url = ( + f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}" + ) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(validate_next_path(next_path)) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) diff --git a/apps/api/plane/db/migrations/0122_alter_account_provider.py b/apps/api/plane/db/migrations/0122_alter_account_provider.py new file mode 100644 index 00000000000..4272172e2d2 --- /dev/null +++ b/apps/api/plane/db/migrations/0122_alter_account_provider.py @@ -0,0 +1,24 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0121_alter_estimate_type"), + ] + + operations = [ + migrations.AlterField( + model_name="account", + name="provider", + field=models.CharField( + choices=[ + ("google", "Google"), + ("github", "Github"), + ("gitlab", "GitLab"), + ("gitea", "Gitea"), + ("keycloak", "Keycloak"), + ] + ), + ), + ] diff --git a/apps/api/plane/db/models/user.py b/apps/api/plane/db/models/user.py index 7f1ab162dab..321869b27a0 100644 --- a/apps/api/plane/db/models/user.py +++ b/apps/api/plane/db/models/user.py @@ -273,6 +273,8 @@ class Account(TimeAuditModel): ("google", "Google"), ("github", "Github"), ("gitlab", "GitLab"), + ("gitea", "Gitea"), + ("keycloak", "Keycloak"), ) id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True) diff --git a/apps/api/plane/license/api/views/instance.py b/apps/api/plane/license/api/views/instance.py index 29c2521abd8..b98bea367b8 100644 --- a/apps/api/plane/license/api/views/instance.py +++ b/apps/api/plane/license/api/views/instance.py @@ -63,6 +63,7 @@ def get(self, request): POSTHOG_HOST, UNSPLASH_ACCESS_KEY, LLM_API_KEY, + IS_KEYCLOAK_ENABLED, ) = get_configuration_value( [ { @@ -122,6 +123,10 @@ def get(self, request): "key": "LLM_API_KEY", "default": os.environ.get("LLM_API_KEY", ""), }, + { + "key": "IS_KEYCLOAK_ENABLED", + "default": os.environ.get("IS_KEYCLOAK_ENABLED", "0"), + }, ] ) @@ -151,6 +156,7 @@ def get(self, request): # Open AI settings data["has_llm_configured"] = bool(LLM_API_KEY) + data["is_keycloak_enabled"] = IS_KEYCLOAK_ENABLED == "1" # File size settings data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880)) diff --git a/apps/api/plane/tests/unit/authentication/__init__.py b/apps/api/plane/tests/unit/authentication/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/api/plane/tests/unit/authentication/test_keycloak_provider.py b/apps/api/plane/tests/unit/authentication/test_keycloak_provider.py new file mode 100644 index 00000000000..213f6636247 --- /dev/null +++ b/apps/api/plane/tests/unit/authentication/test_keycloak_provider.py @@ -0,0 +1,260 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from datetime import datetime, timedelta +from unittest.mock import MagicMock, patch + +import pytest +import pytz + +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) +from plane.authentication.provider.oauth.keycloak import KeycloakOAuthProvider + + +# Default valid config values for mocking get_configuration_value +VALID_CONFIG = ("1", "https://keycloak.example.com", "my-realm", "client-id", "client-secret") + + +def _mock_request(host="app.plane.so", secure=True): + """Create a mock Django request.""" + request = MagicMock() + request.get_host.return_value = host + request.is_secure.return_value = secure + return request + + +@pytest.mark.unit +class TestKeycloakProviderInit: + """Test KeycloakOAuthProvider initialization and configuration validation.""" + + @patch("plane.authentication.provider.oauth.keycloak.get_configuration_value") + @patch.object(KeycloakOAuthProvider, "__init__", wraps=KeycloakOAuthProvider.__init__) + def test_successful_init(self, mock_init, mock_get_config): + """Provider initializes correctly with valid configuration.""" + mock_get_config.return_value = VALID_CONFIG + request = _mock_request() + + provider = KeycloakOAuthProvider.__new__(KeycloakOAuthProvider) + # We need to mock super().__init__ since OauthAdapter depends on Django + with patch("plane.authentication.provider.oauth.keycloak.OauthAdapter.__init__"): + KeycloakOAuthProvider.__init__(provider, request, code="test-code", state="test-state") + + assert provider.token_url == "https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token" + assert provider.userinfo_url == "https://keycloak.example.com/realms/my-realm/protocol/openid-connect/userinfo" + + @patch("plane.authentication.provider.oauth.keycloak.get_configuration_value") + def test_raises_when_not_enabled(self, mock_get_config): + """Provider raises KEYCLOAK_NOT_CONFIGURED when IS_KEYCLOAK_ENABLED is not '1'.""" + mock_get_config.return_value = ("0", "https://keycloak.example.com", "realm", "id", "secret") + + with pytest.raises(AuthenticationException) as exc_info: + KeycloakOAuthProvider(_mock_request()) + + assert exc_info.value.error_code == AUTHENTICATION_ERROR_CODES["KEYCLOAK_NOT_CONFIGURED"] + + @patch("plane.authentication.provider.oauth.keycloak.get_configuration_value") + def test_raises_when_enabled_is_empty(self, mock_get_config): + """Provider raises when IS_KEYCLOAK_ENABLED is empty/None.""" + mock_get_config.return_value = (None, "https://keycloak.example.com", "realm", "id", "secret") + + with pytest.raises(AuthenticationException): + KeycloakOAuthProvider(_mock_request()) + + @patch("plane.authentication.provider.oauth.keycloak.get_configuration_value") + def test_raises_when_host_missing(self, mock_get_config): + """Provider raises when KEYCLOAK_HOST is empty.""" + mock_get_config.return_value = ("1", "", "realm", "id", "secret") + + with pytest.raises(AuthenticationException): + KeycloakOAuthProvider(_mock_request()) + + @patch("plane.authentication.provider.oauth.keycloak.get_configuration_value") + def test_raises_when_realm_missing(self, mock_get_config): + """Provider raises when KEYCLOAK_REALM is empty.""" + mock_get_config.return_value = ("1", "https://keycloak.example.com", "", "id", "secret") + + with pytest.raises(AuthenticationException): + KeycloakOAuthProvider(_mock_request()) + + @patch("plane.authentication.provider.oauth.keycloak.get_configuration_value") + def test_raises_when_client_id_missing(self, mock_get_config): + """Provider raises when KEYCLOAK_CLIENT_ID is empty.""" + mock_get_config.return_value = ("1", "https://keycloak.example.com", "realm", "", "secret") + + with pytest.raises(AuthenticationException): + KeycloakOAuthProvider(_mock_request()) + + @patch("plane.authentication.provider.oauth.keycloak.get_configuration_value") + def test_raises_when_client_secret_missing(self, mock_get_config): + """Provider raises when KEYCLOAK_CLIENT_SECRET is empty.""" + mock_get_config.return_value = ("1", "https://keycloak.example.com", "realm", "id", "") + + with pytest.raises(AuthenticationException): + KeycloakOAuthProvider(_mock_request()) + + @patch("plane.authentication.provider.oauth.keycloak.get_configuration_value") + def test_raises_when_host_has_no_scheme(self, mock_get_config): + """Provider raises when KEYCLOAK_HOST has no URL scheme.""" + mock_get_config.return_value = ("1", "keycloak.example.com", "realm", "id", "secret") + + with pytest.raises(AuthenticationException): + KeycloakOAuthProvider(_mock_request()) + + @patch("plane.authentication.provider.oauth.keycloak.get_configuration_value") + def test_raises_when_host_has_invalid_scheme(self, mock_get_config): + """Provider raises when KEYCLOAK_HOST has non-http(s) scheme.""" + mock_get_config.return_value = ("1", "ftp://keycloak.example.com", "realm", "id", "secret") + + with pytest.raises(AuthenticationException): + KeycloakOAuthProvider(_mock_request()) + + @patch("plane.authentication.provider.oauth.keycloak.OauthAdapter.__init__") + @patch("plane.authentication.provider.oauth.keycloak.get_configuration_value") + def test_trailing_slash_stripped(self, mock_get_config, mock_super_init): + """Provider strips trailing slashes from host.""" + mock_get_config.return_value = ("1", "https://keycloak.example.com///", "realm", "id", "secret") + mock_super_init.return_value = None + + provider = KeycloakOAuthProvider(_mock_request()) + assert "keycloak.example.com///" not in provider.token_url + assert provider.token_url.startswith("https://keycloak.example.com/realms/") + + @patch("plane.authentication.provider.oauth.keycloak.OauthAdapter.__init__") + @patch("plane.authentication.provider.oauth.keycloak.get_configuration_value") + def test_redirect_uri_uses_request_scheme(self, mock_get_config, mock_super_init): + """Provider builds redirect_uri using request's scheme and host.""" + mock_get_config.return_value = VALID_CONFIG + mock_super_init.return_value = None + + # Check the args passed to super().__init__ + provider = KeycloakOAuthProvider(_mock_request(host="my.plane.app", secure=True)) + call_args = mock_super_init.call_args + redirect_uri = call_args[0][4] # 5th positional arg + assert redirect_uri == "https://my.plane.app/auth/keycloak/callback/" + + @patch("plane.authentication.provider.oauth.keycloak.OauthAdapter.__init__") + @patch("plane.authentication.provider.oauth.keycloak.get_configuration_value") + def test_redirect_uri_http_when_not_secure(self, mock_get_config, mock_super_init): + """Provider uses http scheme when request is not secure.""" + mock_get_config.return_value = VALID_CONFIG + mock_super_init.return_value = None + + provider = KeycloakOAuthProvider(_mock_request(host="localhost:8000", secure=False)) + call_args = mock_super_init.call_args + redirect_uri = call_args[0][4] + assert redirect_uri == "http://localhost:8000/auth/keycloak/callback/" + + +@pytest.mark.unit +class TestKeycloakProviderTokenData: + """Test set_token_data parsing.""" + + @patch("plane.authentication.provider.oauth.keycloak.OauthAdapter.__init__", return_value=None) + @patch("plane.authentication.provider.oauth.keycloak.get_configuration_value", return_value=VALID_CONFIG) + def _create_provider(self, mock_get_config, mock_super_init): + return KeycloakOAuthProvider(_mock_request()) + + def test_set_token_data_parses_response(self): + """set_token_data extracts access_token, refresh_token, and expiry times.""" + provider = self._create_provider() + provider.code = "test-code" + provider.client_id = "client-id" + provider.client_secret = "client-secret" + provider.redirect_uri = "https://app.plane.so/auth/keycloak/callback/" + + token_response = { + "access_token": "access-123", + "refresh_token": "refresh-456", + "expires_in": 300, + "refresh_expires_in": 1800, + "id_token": "id-token-789", + } + + with patch.object(provider, "get_user_token", return_value=token_response): + with patch("plane.authentication.adapter.oauth.OauthAdapter.set_token_data") as mock_set: + KeycloakOAuthProvider.set_token_data(provider) + + call_data = mock_set.call_args[0][0] + assert call_data["access_token"] == "access-123" + assert call_data["refresh_token"] == "refresh-456" + assert call_data["id_token"] == "id-token-789" + assert call_data["access_token_expired_at"] is not None + assert call_data["refresh_token_expired_at"] is not None + + def test_set_token_data_handles_missing_expiry(self): + """set_token_data returns None for expiry when not provided.""" + provider = self._create_provider() + provider.code = "test-code" + provider.client_id = "client-id" + provider.client_secret = "client-secret" + provider.redirect_uri = "https://app.plane.so/auth/keycloak/callback/" + + token_response = { + "access_token": "access-123", + } + + with patch.object(provider, "get_user_token", return_value=token_response): + with patch("plane.authentication.adapter.oauth.OauthAdapter.set_token_data") as mock_set: + KeycloakOAuthProvider.set_token_data(provider) + + call_data = mock_set.call_args[0][0] + assert call_data["access_token"] == "access-123" + assert call_data["access_token_expired_at"] is None + assert call_data["refresh_token_expired_at"] is None + + +@pytest.mark.unit +class TestKeycloakProviderUserData: + """Test set_user_data OIDC claim mapping.""" + + @patch("plane.authentication.provider.oauth.keycloak.OauthAdapter.__init__", return_value=None) + @patch("plane.authentication.provider.oauth.keycloak.get_configuration_value", return_value=VALID_CONFIG) + def _create_provider(self, mock_get_config, mock_super_init): + return KeycloakOAuthProvider(_mock_request()) + + def test_set_user_data_maps_oidc_claims(self): + """set_user_data maps Keycloak OIDC claims correctly.""" + provider = self._create_provider() + + userinfo = { + "sub": "kc-user-123", + "email": "user@example.com", + "given_name": "John", + "family_name": "Doe", + "picture": "https://example.com/avatar.jpg", + } + + with patch.object(provider, "get_user_response", return_value=userinfo): + with patch("plane.authentication.adapter.oauth.OauthAdapter.set_user_data") as mock_set: + KeycloakOAuthProvider.set_user_data(provider) + + call_data = mock_set.call_args[0][0] + assert call_data["email"] == "user@example.com" + assert call_data["user"]["provider_id"] == "kc-user-123" + assert call_data["user"]["first_name"] == "John" + assert call_data["user"]["last_name"] == "Doe" + assert call_data["user"]["avatar"] == "https://example.com/avatar.jpg" + assert call_data["user"]["is_password_autoset"] is True + + def test_set_user_data_falls_back_to_name(self): + """set_user_data uses 'name' when 'given_name' is missing.""" + provider = self._create_provider() + + userinfo = { + "sub": "kc-user-456", + "email": "user2@example.com", + "name": "Jane Smith", + } + + with patch.object(provider, "get_user_response", return_value=userinfo): + with patch("plane.authentication.adapter.oauth.OauthAdapter.set_user_data") as mock_set: + KeycloakOAuthProvider.set_user_data(provider) + + call_data = mock_set.call_args[0][0] + assert call_data["user"]["first_name"] == "Jane Smith" + assert call_data["user"]["last_name"] == "" + assert call_data["user"]["avatar"] is None diff --git a/apps/api/plane/utils/instance_config_variables/core.py b/apps/api/plane/utils/instance_config_variables/core.py index 6eebf0b3adb..dd9b11c3f03 100644 --- a/apps/api/plane/utils/instance_config_variables/core.py +++ b/apps/api/plane/utils/instance_config_variables/core.py @@ -144,6 +144,45 @@ }, ] +keycloak_config_variables = [ + { + "key": "IS_KEYCLOAK_ENABLED", + "value": os.environ.get("IS_KEYCLOAK_ENABLED", "0"), + "category": "KEYCLOAK", + "is_encrypted": False, + }, + { + "key": "KEYCLOAK_HOST", + "value": os.environ.get("KEYCLOAK_HOST"), + "category": "KEYCLOAK", + "is_encrypted": False, + }, + { + "key": "KEYCLOAK_REALM", + "value": os.environ.get("KEYCLOAK_REALM"), + "category": "KEYCLOAK", + "is_encrypted": False, + }, + { + "key": "KEYCLOAK_CLIENT_ID", + "value": os.environ.get("KEYCLOAK_CLIENT_ID"), + "category": "KEYCLOAK", + "is_encrypted": False, + }, + { + "key": "KEYCLOAK_CLIENT_SECRET", + "value": os.environ.get("KEYCLOAK_CLIENT_SECRET"), + "category": "KEYCLOAK", + "is_encrypted": True, + }, + { + "key": "ENABLE_KEYCLOAK_SYNC", + "value": os.environ.get("ENABLE_KEYCLOAK_SYNC", "0"), + "category": "KEYCLOAK", + "is_encrypted": False, + }, +] + smtp_config_variables = [ { "key": "ENABLE_SMTP", @@ -239,6 +278,7 @@ *github_config_variables, *gitlab_config_variables, *gitea_config_variables, + *keycloak_config_variables, *smtp_config_variables, *llm_config_variables, *unsplash_config_variables, diff --git a/apps/space/app/assets/logos/keycloak-logo.svg b/apps/space/app/assets/logos/keycloak-logo.svg new file mode 100644 index 00000000000..87b0926cbff --- /dev/null +++ b/apps/space/app/assets/logos/keycloak-logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/space/cmc-local/components/account/auth-forms/auth-root.tsx b/apps/space/cmc-local/components/account/auth-forms/auth-root.tsx new file mode 100644 index 00000000000..6f6e23c98cd --- /dev/null +++ b/apps/space/cmc-local/components/account/auth-forms/auth-root.tsx @@ -0,0 +1,192 @@ +/** + * 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 } from "react"; +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +// plane imports +import { SitesAuthService } from "@plane/services"; +import type { IEmailCheckData } from "@plane/types"; +// components +import { TermsAndConditions } from "@/components/account/terms-and-conditions"; +import { AuthBanner } from "@/components/account/auth-forms/auth-banner"; +import { AuthHeader } from "@/components/account/auth-forms/auth-header"; +import { CmcOAuthOptions } from "@/components/account/auth-forms/cmc-oauth-options"; +import { AuthEmailForm } from "@/components/account/auth-forms/email"; +import { AuthPasswordForm } from "@/components/account/auth-forms/password"; +import { AuthUniqueCodeForm } from "@/components/account/auth-forms/unique-code"; +// helpers +import { EErrorAlertType, authErrorHandler, EAuthenticationErrorCodes } from "@/helpers/authentication.helper"; +import type { TAuthErrorInfo } from "@/helpers/authentication.helper"; +// hooks +import { useOAuthConfig } from "@/hooks/oauth"; +import { useInstance } from "@/hooks/store/use-instance"; +// types +import { EAuthModes, EAuthSteps } from "@/types/auth"; + +const authService = new SitesAuthService(); + +export const AuthRoot = observer(function AuthRoot() { + // router params + const searchParams = useSearchParams(); + const emailParam = searchParams.get("email") || undefined; + const error_code = searchParams.get("error_code") || undefined; + const nextPath = searchParams.get("next_path") || undefined; + // states + const [authMode, setAuthMode] = useState(EAuthModes.SIGN_UP); + const [authStep, setAuthStep] = useState(EAuthSteps.EMAIL); + const [email, setEmail] = useState(emailParam ? emailParam.toString() : ""); + const [errorInfo, setErrorInfo] = useState(undefined); + const [isPasswordAutoset, setIsPasswordAutoset] = useState(true); + // hooks + const { config } = useInstance(); + + useEffect(() => { + if (error_code) { + const errorhandler = authErrorHandler(error_code?.toString() as EAuthenticationErrorCodes); + if (errorhandler) { + if (errorhandler.code === EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN) { + setAuthMode(EAuthModes.SIGN_IN); + setAuthStep(EAuthSteps.PASSWORD); + } + if (errorhandler.code === EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP) { + setAuthMode(EAuthModes.SIGN_UP); + setAuthStep(EAuthSteps.PASSWORD); + } + if ( + [ + EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_IN, + EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_IN, + EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN, + ].includes(errorhandler.code) + ) { + setAuthMode(EAuthModes.SIGN_IN); + setAuthStep(EAuthSteps.UNIQUE_CODE); + } + if ( + [ + EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_UP, + EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_UP, + EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP, + ].includes(errorhandler.code) + ) { + setAuthMode(EAuthModes.SIGN_UP); + setAuthStep(EAuthSteps.UNIQUE_CODE); + } + setErrorInfo(errorhandler); + } + } + }, [error_code]); + + const isSMTPConfigured = config?.is_smtp_configured || false; + const isMagicLoginEnabled = config?.is_magic_login_enabled || false; + const isEmailPasswordEnabled = config?.is_email_password_enabled || false; + const oAuthActionText = authMode === EAuthModes.SIGN_UP ? "Sign up" : "Sign in"; + const { isOAuthEnabled, oAuthOptions } = useOAuthConfig(oAuthActionText); + + // submit handler- email verification + const handleEmailVerification = async (data: IEmailCheckData) => { + setEmail(data.email); + + await authService + .emailCheck(data) + .then(async (response) => { + let currentAuthMode: EAuthModes = response.existing ? EAuthModes.SIGN_IN : EAuthModes.SIGN_UP; + if (response.existing) { + currentAuthMode = EAuthModes.SIGN_IN; + setAuthMode(() => EAuthModes.SIGN_IN); + } else { + currentAuthMode = EAuthModes.SIGN_UP; + setAuthMode(() => EAuthModes.SIGN_UP); + } + + if (currentAuthMode === EAuthModes.SIGN_IN) { + if (isSMTPConfigured && isMagicLoginEnabled && response.status === "MAGIC_CODE") { + setAuthStep(EAuthSteps.UNIQUE_CODE); + generateEmailUniqueCode(data.email); + } else if (isEmailPasswordEnabled) { + setIsPasswordAutoset(false); + setAuthStep(EAuthSteps.PASSWORD); + } else { + const errorhandler = authErrorHandler("5005" as EAuthenticationErrorCodes); + setErrorInfo(errorhandler); + } + } else { + if (isSMTPConfigured && isMagicLoginEnabled && response.status === "MAGIC_CODE") { + setAuthStep(EAuthSteps.UNIQUE_CODE); + generateEmailUniqueCode(data.email); + } else if (isEmailPasswordEnabled) { + setAuthStep(EAuthSteps.PASSWORD); + } else { + const errorhandler = authErrorHandler("5006" as EAuthenticationErrorCodes); + setErrorInfo(errorhandler); + } + } + return; + }) + .catch((error) => { + const errorhandler = authErrorHandler(error?.error_code?.toString(), data?.email || undefined); + if (errorhandler?.type) setErrorInfo(errorhandler); + }); + }; + + // generating the unique code + const generateEmailUniqueCode = async (emailAddress: string): Promise<{ code: string } | undefined> => { + const payload = { email: emailAddress }; + return await authService + .generateUniqueCode(payload) + .then(() => ({ code: "" })) + .catch((error) => { + const errorhandler = authErrorHandler(error?.error_code.toString()); + if (errorhandler?.type) setErrorInfo(errorhandler); + throw error; + }); + }; + + return ( +
+
+ {errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && ( + setErrorInfo(value)} /> + )} + + {isOAuthEnabled && } + + {authStep === EAuthSteps.EMAIL && } + {authStep === EAuthSteps.UNIQUE_CODE && ( + { + setEmail(""); + setAuthStep(EAuthSteps.EMAIL); + }} + generateEmailUniqueCode={generateEmailUniqueCode} + /> + )} + {authStep === EAuthSteps.PASSWORD && ( + { + setEmail(""); + setAuthStep(EAuthSteps.EMAIL); + }} + handleAuthStep={(step: EAuthSteps) => { + if (step === EAuthSteps.UNIQUE_CODE) generateEmailUniqueCode(email); + setAuthStep(step); + }} + /> + )} + +
+
+ ); +}); diff --git a/apps/space/cmc-local/components/account/auth-forms/cmc-oauth-options.tsx b/apps/space/cmc-local/components/account/auth-forms/cmc-oauth-options.tsx new file mode 100644 index 00000000000..6601f045f92 --- /dev/null +++ b/apps/space/cmc-local/components/account/auth-forms/cmc-oauth-options.tsx @@ -0,0 +1,94 @@ +/** + * 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 type { TOAuthOption } from "@plane/types"; +import { cn } from "@plane/ui"; +// constants +import { CMC_AUTH_BRANDING, CMC_AUTH_KEYCLOAK_PROVIDER_ID } from "@/constants/cmc-auth"; + +type CmcOAuthOptionsProps = { + options: TOAuthOption[]; + compact?: boolean; + showDivider?: boolean; + className?: string; + containerClassName?: string; +}; + +type CmcOAuthButtonProps = { + text: string; + icon: ReactNode; + onClick: () => void; + compact: boolean; + className?: string; +}; + +function CmcOAuthButton(props: CmcOAuthButtonProps) { + const { text, icon, onClick, compact, className = "" } = props; + const showText = !compact || !icon; + + return ( + + ); +} + +export function CmcOAuthOptions(props: CmcOAuthOptionsProps) { + const { options, compact = false, showDivider = true, className = "", containerClassName = "" } = props; + + const enabledOptions = options.filter((option) => option.enabled !== false); + + if (enabledOptions.length === 0) return null; + + return ( +
+
+ {enabledOptions.map((option) => { + const isCmcSso = option.id === CMC_AUTH_KEYCLOAK_PROVIDER_ID; + + return ( + + ); + })} +
+ + {showDivider && ( +
+
+

or

+
+
+ )} +
+ ); +} diff --git a/apps/space/cmc-local/components/account/auth-forms/index.ts b/apps/space/cmc-local/components/account/auth-forms/index.ts new file mode 100644 index 00000000000..125f6699c4c --- /dev/null +++ b/apps/space/cmc-local/components/account/auth-forms/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./auth-root"; diff --git a/apps/space/cmc-local/constants/cmc-auth.ts b/apps/space/cmc-local/constants/cmc-auth.ts new file mode 100644 index 00000000000..fc34eba55b3 --- /dev/null +++ b/apps/space/cmc-local/constants/cmc-auth.ts @@ -0,0 +1,13 @@ +/** + * CMC Telecom login branding overlay. + */ + +export const CMC_AUTH_BRANDING = { + logoUrl: "https://auth.cmctelecom.vn/resources/izwga/login/cmc-hub/img/cmc_logo.png", + title: "CMC Telecom Work Platform", + subtitle: "Project management for all teams", + ssoButtonText: "Đăng nhập bằng CMC SSO", + logoClassName: "h-16 w-fit object-contain", +} as const; + +export const CMC_AUTH_KEYCLOAK_PROVIDER_ID = "keycloak"; diff --git a/apps/space/hooks/oauth/core.tsx b/apps/space/hooks/oauth/core.tsx index 63a18cc2e59..93ee3187d52 100644 --- a/apps/space/hooks/oauth/core.tsx +++ b/apps/space/hooks/oauth/core.tsx @@ -11,6 +11,7 @@ import { API_BASE_URL } from "@plane/constants"; import type { TOAuthConfigs, TOAuthOption } from "@plane/types"; // assets import giteaLogo from "@/app/assets/logos/gitea-logo.svg?url"; +import keycloakLogo from "@/app/assets/logos/keycloak-logo.svg?url"; import githubLightLogo from "@/app/assets/logos/github-black.png?url"; import githubDarkLogo from "@/app/assets/logos/github-dark.svg?url"; import gitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url"; @@ -33,7 +34,8 @@ export const useCoreOAuthConfig = (oauthActionText: string): TOAuthConfigs => { (config?.is_google_enabled || config?.is_github_enabled || config?.is_gitlab_enabled || - config?.is_gitea_enabled)) || + config?.is_gitea_enabled || + config?.is_keycloak_enabled)) || false; const oAuthOptions: TOAuthOption[] = [ { @@ -79,6 +81,15 @@ export const useCoreOAuthConfig = (oauthActionText: string): TOAuthConfigs => { }, enabled: config?.is_gitea_enabled, }, + { + id: "keycloak", + text: `${oauthActionText} with Keycloak`, + icon: Keycloak Logo, + onClick: () => { + window.location.assign(`${API_BASE_URL}/auth/keycloak/${next_path ? `?next_path=${next_path}` : ``}`); + }, + enabled: config?.is_keycloak_enabled, + }, ]; return { diff --git a/apps/space/tsconfig.json b/apps/space/tsconfig.json index a340381bbf2..160d67d62e8 100644 --- a/apps/space/tsconfig.json +++ b/apps/space/tsconfig.json @@ -4,7 +4,7 @@ "rootDirs": [".", "./.react-router/types"], "types": ["vite/client"], "paths": { - "@/*": ["./*"] + "@/*": ["./cmc-local/*", "./*"] }, "strictNullChecks": true, "noImplicitOverride": false, diff --git a/apps/web/app/assets/logos/keycloak-logo.svg b/apps/web/app/assets/logos/keycloak-logo.svg new file mode 100644 index 00000000000..7c96722083a --- /dev/null +++ b/apps/web/app/assets/logos/keycloak-logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/web/cmc-local/components/account/auth-forms/auth-header.tsx b/apps/web/cmc-local/components/account/auth-forms/auth-header.tsx new file mode 100644 index 00000000000..4cd11439ab8 --- /dev/null +++ b/apps/web/cmc-local/components/account/auth-forms/auth-header.tsx @@ -0,0 +1,130 @@ +/** + * 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 { observer } from "mobx-react"; +import useSWR from "swr"; +import { useTranslation } from "@plane/i18n"; +import type { IWorkspaceMemberInvitation } from "@plane/types"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { WorkspaceLogo } from "@/components/workspace/logo"; +// constants +import { CMC_AUTH_BRANDING } from "@/constants/cmc-auth"; +// helpers +import { EAuthModes, EAuthSteps } from "@/helpers/authentication.helper"; +// services +import { WorkspaceService } from "@/services/workspace.service"; + +type TAuthHeader = { + workspaceSlug: string | undefined; + invitationId: string | undefined; + invitationEmail: string | undefined; + authMode: EAuthModes; + currentAuthStep: EAuthSteps; +}; + +const Titles = { + [EAuthModes.SIGN_IN]: { + [EAuthSteps.EMAIL]: { + header: CMC_AUTH_BRANDING.title, + subHeader: CMC_AUTH_BRANDING.subtitle, + }, + [EAuthSteps.PASSWORD]: { + header: CMC_AUTH_BRANDING.title, + subHeader: CMC_AUTH_BRANDING.subtitle, + }, + [EAuthSteps.UNIQUE_CODE]: { + header: CMC_AUTH_BRANDING.title, + subHeader: CMC_AUTH_BRANDING.subtitle, + }, + }, + [EAuthModes.SIGN_UP]: { + [EAuthSteps.EMAIL]: { + header: CMC_AUTH_BRANDING.title, + subHeader: CMC_AUTH_BRANDING.subtitle, + }, + [EAuthSteps.PASSWORD]: { + header: CMC_AUTH_BRANDING.title, + subHeader: CMC_AUTH_BRANDING.subtitle, + }, + [EAuthSteps.UNIQUE_CODE]: { + header: CMC_AUTH_BRANDING.title, + subHeader: CMC_AUTH_BRANDING.subtitle, + }, + }, +}; + +const workSpaceService = new WorkspaceService(); + +export const AuthHeader = observer(function AuthHeader(props: TAuthHeader) { + const { workspaceSlug, invitationId, invitationEmail, authMode, currentAuthStep } = props; + // plane imports + const { t } = useTranslation(); + + const { data: invitation, isLoading } = useSWR( + workspaceSlug && invitationId ? `WORKSPACE_INVITATION_${workspaceSlug}_${invitationId}` : null, + async () => workspaceSlug && invitationId && workSpaceService.getWorkspaceInvitation(workspaceSlug, invitationId), + { + revalidateOnFocus: false, + shouldRetryOnError: false, + } + ); + + const getHeaderSubHeader = ( + step: EAuthSteps, + mode: EAuthModes, + workspaceInvitation: IWorkspaceMemberInvitation | undefined, + email: string | undefined + ) => { + if (workspaceInvitation && email && workspaceInvitation.email === email && workspaceInvitation.workspace) { + const workspace = workspaceInvitation.workspace; + return { + header: ( +
+ {t("common.join")}{" "} + {" "} + {workspace.name} +
+ ), + subHeader: + mode == EAuthModes.SIGN_UP + ? "Create an account to start managing work with your team." + : "Log in to start managing work with your team.", + }; + } + + return Titles[mode][step]; + }; + + const { header, subHeader } = getHeaderSubHeader(currentAuthStep, authMode, invitation || undefined, invitationEmail); + + if (isLoading) + return ( +
+ +
+ ); + + return ; +}); + +type TAuthHeaderBase = { + header: ReactNode; + subHeader: string; +}; + +export function AuthHeaderBase(props: TAuthHeaderBase) { + return ( +
+ CMC Telecom +
+ {props.header} + {props.subHeader} +
+
+ ); +} diff --git a/apps/web/cmc-local/components/account/auth-forms/auth-root.tsx b/apps/web/cmc-local/components/account/auth-forms/auth-root.tsx new file mode 100644 index 00000000000..1f308fd93f5 --- /dev/null +++ b/apps/web/cmc-local/components/account/auth-forms/auth-root.tsx @@ -0,0 +1,157 @@ +/** + * 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 } from "react"; +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +// components +import { TermsAndConditions } from "@/components/account/terms-and-conditions"; +import { AuthBanner } from "@/components/account/auth-forms/auth-banner"; +import { AuthHeader, AuthHeaderBase } from "@/components/account/auth-forms/auth-header"; +import { CmcOAuthOptions } from "@/components/account/auth-forms/cmc-oauth-options"; +import { AuthFormRoot } from "@/components/account/auth-forms/form-root"; +// helpers +import type { TAuthErrorInfo } from "@/helpers/authentication.helper"; +import { + EAuthModes, + EAuthSteps, + EAuthenticationErrorCodes, + EErrorAlertType, + authErrorHandler, +} from "@/helpers/authentication.helper"; +// hooks +import { useOAuthConfig } from "@/hooks/oauth"; +import { useInstance } from "@/hooks/store/use-instance"; + +type TAuthRoot = { + authMode: EAuthModes; +}; + +export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) { + //router + const searchParams = useSearchParams(); + // query params + const emailParam = searchParams.get("email"); + const invitation_id = searchParams.get("invitation_id"); + const workspaceSlug = searchParams.get("slug"); + const error_code = searchParams.get("error_code"); + // props + const { authMode: currentAuthMode } = props; + // states + const [authMode, setAuthMode] = useState(undefined); + const [authStep, setAuthStep] = useState(EAuthSteps.EMAIL); + const [email, setEmail] = useState(emailParam ? emailParam.toString() : ""); + const [errorInfo, setErrorInfo] = useState(undefined); + // store hooks + const { config } = useInstance(); + // derived values + const oAuthActionText = authMode === EAuthModes.SIGN_UP ? "Sign up" : "Sign in"; + const { isOAuthEnabled, oAuthOptions } = useOAuthConfig(oAuthActionText); + const isEmailBasedAuthEnabled = config?.is_email_password_enabled || config?.is_magic_login_enabled; + const noAuthMethodsAvailable = !isOAuthEnabled && !isEmailBasedAuthEnabled; + + useEffect(() => { + if (!authMode && currentAuthMode) setAuthMode(currentAuthMode); + }, [currentAuthMode, authMode]); + + useEffect(() => { + if (error_code && authMode) { + const errorhandler = authErrorHandler(error_code?.toString() as EAuthenticationErrorCodes); + if (errorhandler) { + // password error handler + if ([EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP].includes(errorhandler.code)) { + setAuthMode(EAuthModes.SIGN_UP); + setAuthStep(EAuthSteps.PASSWORD); + } + if ([EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN].includes(errorhandler.code)) { + setAuthMode(EAuthModes.SIGN_IN); + setAuthStep(EAuthSteps.PASSWORD); + } + // magic_code error handler + if ( + [ + EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_UP, + EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP, + EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_UP, + EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP, + ].includes(errorhandler.code) + ) { + setAuthMode(EAuthModes.SIGN_UP); + setAuthStep(EAuthSteps.UNIQUE_CODE); + } + if ( + [ + EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_IN, + EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN, + EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_IN, + EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN, + ].includes(errorhandler.code) + ) { + setAuthMode(EAuthModes.SIGN_IN); + setAuthStep(EAuthSteps.UNIQUE_CODE); + } + + setErrorInfo(errorhandler); + } + } + }, [error_code, authMode]); + + if (!authMode) return <>; + + if (noAuthMethodsAvailable) { + return ( + + + + ); + } + + return ( + + {errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && ( + setErrorInfo(value)} /> + )} + + {isOAuthEnabled && ( + + )} + {isEmailBasedAuthEnabled && ( + setEmail(nextEmail)} + setAuthMode={(nextAuthMode) => setAuthMode(nextAuthMode)} + setAuthStep={(nextAuthStep) => setAuthStep(nextAuthStep)} + setErrorInfo={(nextErrorInfo) => setErrorInfo(nextErrorInfo)} + currentAuthMode={currentAuthMode} + /> + )} + + + ); +}); + +function AuthContainer({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ); +} diff --git a/apps/web/cmc-local/components/account/auth-forms/cmc-oauth-options.tsx b/apps/web/cmc-local/components/account/auth-forms/cmc-oauth-options.tsx new file mode 100644 index 00000000000..6601f045f92 --- /dev/null +++ b/apps/web/cmc-local/components/account/auth-forms/cmc-oauth-options.tsx @@ -0,0 +1,94 @@ +/** + * 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 type { TOAuthOption } from "@plane/types"; +import { cn } from "@plane/ui"; +// constants +import { CMC_AUTH_BRANDING, CMC_AUTH_KEYCLOAK_PROVIDER_ID } from "@/constants/cmc-auth"; + +type CmcOAuthOptionsProps = { + options: TOAuthOption[]; + compact?: boolean; + showDivider?: boolean; + className?: string; + containerClassName?: string; +}; + +type CmcOAuthButtonProps = { + text: string; + icon: ReactNode; + onClick: () => void; + compact: boolean; + className?: string; +}; + +function CmcOAuthButton(props: CmcOAuthButtonProps) { + const { text, icon, onClick, compact, className = "" } = props; + const showText = !compact || !icon; + + return ( + + ); +} + +export function CmcOAuthOptions(props: CmcOAuthOptionsProps) { + const { options, compact = false, showDivider = true, className = "", containerClassName = "" } = props; + + const enabledOptions = options.filter((option) => option.enabled !== false); + + if (enabledOptions.length === 0) return null; + + return ( +
+
+ {enabledOptions.map((option) => { + const isCmcSso = option.id === CMC_AUTH_KEYCLOAK_PROVIDER_ID; + + return ( + + ); + })} +
+ + {showDivider && ( +
+
+

or

+
+
+ )} +
+ ); +} diff --git a/apps/web/cmc-local/components/account/auth-forms/index.ts b/apps/web/cmc-local/components/account/auth-forms/index.ts new file mode 100644 index 00000000000..125f6699c4c --- /dev/null +++ b/apps/web/cmc-local/components/account/auth-forms/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./auth-root"; diff --git a/apps/web/cmc-local/constants/cmc-auth.ts b/apps/web/cmc-local/constants/cmc-auth.ts new file mode 100644 index 00000000000..fc34eba55b3 --- /dev/null +++ b/apps/web/cmc-local/constants/cmc-auth.ts @@ -0,0 +1,13 @@ +/** + * CMC Telecom login branding overlay. + */ + +export const CMC_AUTH_BRANDING = { + logoUrl: "https://auth.cmctelecom.vn/resources/izwga/login/cmc-hub/img/cmc_logo.png", + title: "CMC Telecom Work Platform", + subtitle: "Project management for all teams", + ssoButtonText: "Đăng nhập bằng CMC SSO", + logoClassName: "h-16 w-fit object-contain", +} as const; + +export const CMC_AUTH_KEYCLOAK_PROVIDER_ID = "keycloak"; diff --git a/apps/web/core/hooks/oauth/core.tsx b/apps/web/core/hooks/oauth/core.tsx index 1614883fe86..bc9fc95193b 100644 --- a/apps/web/core/hooks/oauth/core.tsx +++ b/apps/web/core/hooks/oauth/core.tsx @@ -11,6 +11,7 @@ import { API_BASE_URL } from "@plane/constants"; import type { TOAuthConfigs, TOAuthOption } from "@plane/types"; // assets import giteaLogo from "@/app/assets/logos/gitea-logo.svg?url"; +import keycloakLogo from "@/app/assets/logos/keycloak-logo.svg?url"; import GithubLightLogo from "@/app/assets/logos/github-black.png?url"; import GithubDarkLogo from "@/app/assets/logos/github-dark.svg?url"; import gitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url"; @@ -33,7 +34,8 @@ export const useCoreOAuthConfig = (oauthActionText: string): TOAuthConfigs => { (config?.is_google_enabled || config?.is_github_enabled || config?.is_gitlab_enabled || - config?.is_gitea_enabled)) || + config?.is_gitea_enabled || + config?.is_keycloak_enabled)) || false; const oAuthOptions: TOAuthOption[] = [ { @@ -79,6 +81,15 @@ export const useCoreOAuthConfig = (oauthActionText: string): TOAuthConfigs => { }, enabled: config?.is_gitea_enabled, }, + { + id: "keycloak", + text: `${oauthActionText} with Keycloak`, + icon: Keycloak Logo, + onClick: () => { + window.location.assign(`${API_BASE_URL}/auth/keycloak/${next_path ? `?next_path=${next_path}` : ``}`); + }, + enabled: config?.is_keycloak_enabled, + }, ]; return { diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 2646a6599f6..457d5fcc4ed 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "rootDirs": [".", "./.react-router/types"], "paths": { - "@/*": ["./core/*"], + "@/*": ["./cmc-local/*", "./core/*"], "@/app/*": ["./app/*"], "@/helpers/*": ["./helpers/*"], "@/styles/*": ["./styles/*"], diff --git a/build-xplane.yml b/build-xplane.yml new file mode 100644 index 00000000000..0f0c5f827ff --- /dev/null +++ b/build-xplane.yml @@ -0,0 +1,48 @@ +services: + web: + image: ${IMAGE_PREFIX:-xplane}/xplane-web:${APP_RELEASE:-latest} + build: + context: . + dockerfile: apps/web/Dockerfile.web + + admin: + image: ${IMAGE_PREFIX:-xplane}/xplane-admin:${APP_RELEASE:-latest} + build: + context: . + dockerfile: apps/admin/Dockerfile.admin + + space: + image: ${IMAGE_PREFIX:-xplane}/xplane-space:${APP_RELEASE:-latest} + build: + context: . + dockerfile: apps/space/Dockerfile.space + + live: + image: ${IMAGE_PREFIX:-xplane}/xplane-live:${APP_RELEASE:-latest} + build: + context: . + dockerfile: apps/live/Dockerfile.live + + api: + image: ${IMAGE_PREFIX:-xplane}/xplane-api:${APP_RELEASE:-latest} + build: &backend-build + context: ./apps/api + dockerfile: Dockerfile.api + + worker: + image: ${IMAGE_PREFIX:-xplane}/xplane-worker:${APP_RELEASE:-latest} + build: *backend-build + + beat-worker: + image: ${IMAGE_PREFIX:-xplane}/xplane-beat-worker:${APP_RELEASE:-latest} + build: *backend-build + + migrator: + image: ${IMAGE_PREFIX:-xplane}/xplane-migrator:${APP_RELEASE:-latest} + build: *backend-build + + proxy: + image: ${IMAGE_PREFIX:-xplane}/xplane-proxy:${APP_RELEASE:-latest} + build: + context: ./apps/proxy + dockerfile: Dockerfile.ce diff --git a/docker-compose-local.yml b/docker-compose-local.yml index 6bc5ee309f7..17514566ced 100644 --- a/docker-compose-local.yml +++ b/docker-compose-local.yml @@ -22,6 +22,8 @@ services: RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER} RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD} RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST} + ports: + - "5672:5672" plane-minio: image: minio/minio @@ -45,8 +47,8 @@ services: MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID} MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY} ports: - - "9000:9000" - - "9090:9090" + - "9002:9000" + - "9092:9090" plane-db: image: postgres:15.7-alpine @@ -140,6 +142,18 @@ services: - plane-db - plane-redis + keycloak: + image: quay.io/keycloak/keycloak:26.2 + command: start-dev + restart: unless-stopped + networks: + - dev_env + ports: + - "8080:8080" + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + volumes: redisdata: uploads: diff --git a/docs/ce-feature-unlock-spec.md b/docs/ce-feature-unlock-spec.md new file mode 100644 index 00000000000..30516a5581f --- /dev/null +++ b/docs/ce-feature-unlock-spec.md @@ -0,0 +1,393 @@ +# Spec: Unlock tính năng Community Edition + +> **Mục tiêu:** Mở khóa các tính năng premium có sẵn code trong bản CE mà vẫn giữ khả năng pull update từ upstream Plane. + +## 1. Kiến trúc hiện tại + +### Path alias + +``` +// apps/web/tsconfig.json +"@/plane-web/*": ["./ce/*"] +``` + +Toàn bộ code premium được import qua `@/plane-web/...` → resolve về `apps/web/ce/` → trả về stub `<>` hoặc upgrade banner. + +### Build toolchain + +- **Vite** + `vite-tsconfig-paths` plugin đọc paths từ `tsconfig.json` +- **React Router** (không phải Next.js) +- TypeScript paths hỗ trợ mảng fallback: `["./path-1/*", "./path-2/*"]` + +### Mô hình CE stub + +``` +apps/web/ +├── ce/ ← Community Edition stubs (upstream) +├── core/ ← Shared core code (upstream) +├── app/ ← Route pages (upstream) +└── ee-local/ ← [MỚI] Local overrides +``` + +### Backend + +Backend **KHÔNG** enforce bất kỳ edition check nào. Tất cả API endpoint hoạt động cho mọi edition. Kiểm tra đã xác nhận: + +- `IssueRelationViewSet` — CRUD đầy đủ +- `CycleViewSet` — bao gồm transfer issues +- `EstimatePointEndpoint`, `BulkEstimatePointEndpoint` +- `PageViewSet`, `PageVersionEndpoint` +- `ProjectPublishEndpoint` (deploy boards) +- `KeycloakOauthInitiateEndpoint` + `KeycloakCallbackEndpoint` + +--- + +## 2. Chiến lược kỹ thuật: Sparse overlay + +### Nguyên tắc + +1. **Không sửa file trong `ce/`** — giữ nguyên upstream, tránh merge conflict +2. **Tạo thư mục `ee-local/`** — chỉ chứa file override cho tính năng cần unlock +3. **Một thay đổi duy nhất trong upstream**: sửa tsconfig paths để thêm fallback +4. **Re-export từ CE** khi chỉ cần override một phần barrel export + +### Thay đổi tsconfig + +```diff +// apps/web/tsconfig.json +- "@/plane-web/*": ["./ce/*"] ++ "@/plane-web/*": ["./ee-local/*", "./ce/*"] +``` + +Khi `ee-local/components/active-cycles/index.ts` tồn tại → resolve về đó. +Khi không tồn tại → fallback về `ce/` như bình thường. + +### Quy tắc overlay + +| Quy tắc | Mô tả | +| ------------------------------------------ | ---------------------------------------------------------------------------- | +| **Chỉ override file cần thiết** | Không copy toàn bộ CE tree | +| **Giữ nguyên interface** | Export cùng tên, cùng props type với CE stub | +| **Re-export CE cho phần không đổi** | `export * from "../../ce/components/xxx"` rồi override symbol cụ thể | +| **Không sửa app import sites** | Giữ nguyên `@/plane-web/...` pattern | +| **TypeScript là interface drift detector** | Sau mỗi upstream pull, chạy `pnpm check:types` để phát hiện breaking changes | + +### Merge conflict profile + +| File | Conflict risk | +| ------------------------ | ----------------------------------------------------------- | +| `apps/web/tsconfig.json` | **Rất thấp** — 1 dòng thay đổi, hiếm khi upstream sửa paths | +| `apps/web/ee-local/**` | **Không** — file mới, không tồn tại trong upstream | +| `apps/web/ce/**` | **Không** — không sửa | +| `apps/web/core/**` | **Không** — không sửa | + +### Rủi ro duy nhất + +Upstream thay đổi **interface** của CE stub (đổi tên props, đổi tên export). Khi đó: + +- `pnpm check:types` sẽ báo lỗi ngay +- Sửa file tương ứng trong `ee-local/` cho khớp interface mới + +--- + +## 3. Phân loại tính năng + +### Tier 0: Đã hoạt động — Chỉ cần config + +| Tính năng | Cách bật | Effort | +| --------------------------- | ------------------------------------------------------------------------------------ | -------------- | +| **OIDC/Keycloak SSO** | Set env vars hoặc config qua God Mode (`/admin/authentication/keycloak`) | Không cần code | +| **Pages / Wiki** | Đã hoạt động sẵn | Không cần code | +| **Real-time Collaboration** | Đã hoạt động sẵn (apps/live) | Không cần code | +| **Page Versions** | Đã hoạt động sẵn — `PageVersionEndpoint` + UI trong `core/components/pages/version/` | Không cần code | + +**Env vars cho OIDC:** + +```env +IS_KEYCLOAK_ENABLED=1 +KEYCLOAK_HOST=https://your-keycloak.example.com +KEYCLOAK_REALM=your-realm +KEYCLOAK_CLIENT_ID=plane +KEYCLOAK_CLIENT_SECRET=your-secret +``` + +### Tier 1: Unlock bằng overlay — Backend + Frontend core có sẵn + +#### 1.1 Active Cycles (Workspace-level) + +**Hiện trạng:** + +- CE stub: `ce/components/active-cycles/root.tsx` → hiện trang upgrade +- Core có sẵn: `core/components/cycles/active-cycle/` gồm `cycle-stats.tsx`, `productivity.tsx`, `progress.tsx` +- CE đã có: `ce/components/cycles/active-cycle/root.tsx` — component `ActiveCycleRoot` cho **project-level** đầy đủ +- Backend: Cycle API đầy đủ, store `activeCycleIds` có sẵn +- Thiếu: Component wrapper cho workspace-level (aggregate nhiều project) + +**Overlay cần tạo:** + +``` +ee-local/ + components/ + active-cycles/ + index.ts → export { WorkspaceActiveCyclesRoot } + root.tsx → Fetch all projects, render ActiveCycleRoot per project +``` + +**Effort:** Trung bình — cần viết component aggregate cycle từ nhiều project. `ActiveCycleRoot` đã sẵn sàng cho từng project, chỉ cần wrapper loop qua projects. + +**Risk:** Thấp — interface đơn giản (`WorkspaceActiveCyclesRoot` không nhận props). + +--- + +#### 1.2 Project Publish (Deploy Boards) + +**Hiện trạng:** + +- Core có sẵn: `core/components/project/publish-project/modal.tsx` — modal đầy đủ +- Store: `core/store/project/project-publish.store.ts` — `IProjectPublishStore` đầy đủ +- Service: `ProjectPublishService` có sẵn +- Backend: API `project-deploy-boards` có sẵn +- Space app: `apps/space/` render published projects + +**Overlay cần tạo:** Không cần — tính năng này đã được wire trực tiếp trong `core/`, không đi qua CE stub. Cần xác nhận lại nếu có menu item nào bị ẩn. + +**Effort:** Thấp — kiểm tra và verify. + +**Risk:** Rất thấp. + +--- + +#### 1.3 Estimates (Nút Edit/Delete) + +**Hiện trạng:** + +- Core: `core/components/estimates/` có đầy đủ list, create modal, radio select +- CE stubs: + - `ce/components/estimates/estimate-list-item-buttons.tsx` — **có code thật** (nút delete), chỉ ẩn nút edit + - `ce/components/estimates/update/modal.tsx` → `<>` + - `ce/components/estimates/inputs/time-input.tsx` → `<>` + - `ce/components/estimates/points/delete.tsx` → `<>` + +**Overlay cần tạo:** + +``` +ee-local/ + components/ + estimates/ + index.ts + update/ + index.ts + modal.tsx → Implement update estimate modal + inputs/ + index.ts + time-input.tsx → Implement time-based estimate input + points/ + index.ts + delete.tsx → Implement delete estimate point confirm +``` + +**Effort:** Trung bình — cần implement 3 component UI nhỏ. Có thể tham khảo create modal cho pattern. + +**Risk:** Trung bình — nhiều file overlay, interface có thể drift. + +--- + +#### 1.4 Issue Relations (CRUD đầy đủ) + +**Hiện trạng:** + +- Backend: `IssueRelationViewSet` — full CRUD cho `blocked_by`, `blocking`, `relates_to`, `duplicate` +- Core: Issue detail đã wire relation section, nút add relation có sẵn +- CE stubs liên quan: Một số stub trong `ce/components/issues/issue-details/` nhưng relation CRUD cơ bản đã hoạt động + +**Overlay cần tạo:** Không cần — Issue Relations CRUD đã hoạt động trong CE. Chỉ cần verify. + +**Effort:** Không cần code. + +**Risk:** Không có. + +--- + +### Tier 2: Unlock cần viết thêm code — Backend có sẵn, frontend cần bổ sung + +#### 2.1 Gantt Dependency Paths (Visual) + +**Hiện trạng:** + +- Backend: `IssueRelation` hỗ trợ `start_before`, `finish_before` relation types +- CE stubs: + - `ce/components/gantt-chart/dependency/dependency-paths.tsx` → `<>` + - `ce/components/gantt-chart/dependency/draggable-dependency-path.tsx` → `<>` + - `ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx` → `<>` + - `ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx` → `<>` + +**Cần implement:** + +- SVG path rendering giữa các block trên Gantt chart +- Drag handles cho dependency creation +- Tính toán tọa độ dựa trên Gantt layout context + +**Effort:** Lớn — cần hiểu sâu Gantt chart rendering, SVG coordinate math, drag & drop. + +**Risk:** Cao — phụ thuộc vào internal API của Gantt chart component, dễ break khi upstream refactor. + +**Khuyến nghị:** Trì hoãn trừ khi thực sự cần. + +--- + +#### 2.2 Bulk Operations UI + +**Hiện trạng:** + +- Backend: API hỗ trợ bulk update một số field +- CE stub: `IssueBulkOperationsRoot` → upgrade banner +- Core: Có `BulkOperationsUpgradeBanner` nhưng **không có** bulk edit panel thật + +**Cần implement:** + +- UI panel cho bulk edit (state, priority, assignee, labels, cycle, module) +- Logic xử lý multi-select và batch API call + +**Effort:** Lớn. + +**Khuyến nghị:** Trì hoãn. + +--- + +#### 2.3 View Publish + +**Hiện trạng:** + +- CE stub: `ce/components/views/publish/modal.tsx` → `<>` +- CE hook stub: `ce/components/views/publish/use-view-publish.tsx` → trả về dummy values +- Không có core implementation cho view publish (khác với project publish) + +**Cần implement:** + +- Modal publish view +- Backend endpoint cho view anchors (nếu chưa có) +- Hook `useViewPublish` + +**Effort:** Trung bình–Lớn. + +**Khuyến nghị:** Trì hoãn. + +--- + +### Tier 3: Không có code — Cần build từ đầu + +Các tính năng sau **không tồn tại** trong repo CE. Unlock chúng đồng nghĩa với việc tự phát triển tính năng mới, không còn là "unlock" nữa. + +| Tính năng | Backend | Frontend | Ghi chú | +| ----------------------------------- | ------------------------ | --------------- | -------------------------------- | +| Time Tracking / Worklogs | ❌ Không có model | ❌ Stub `<>` | Cần model, API, UI hoàn toàn mới | +| Epics | ⚠️ Chỉ có `is_epic` flag | ❌ Stub `<>` | Cần epic management UI | +| Work Item Types / Custom Properties | ❌ Không có model | ❌ Stub | Cần schema system | +| Automations engine | ❌ Chỉ có auto-archive | ❌ Stub | Cần workflow engine | +| Custom Dashboards & Widgets | ⚠️ Basic home widgets | ❌ | Cần dashboard builder | +| RBAC / GAC | ❌ 4 role cố định | ❌ | Cần permission system | +| SAML | ❌ | ❌ | Cần SAML provider | +| LDAP | ❌ | ❌ | Cần LDAP integration | +| PQL | ❌ | ❌ | Cần query parser | +| Cycle Reports | ❌ | ❌ | Cần report generator | +| Project Templates | ❌ | ❌ | Cần template system | +| SLA | ❌ | ❌ | Cần SLA engine | +| Intake Forms | ❌ | ❌ | Cần form builder | + +**Khuyến nghị:** Không nên thực hiện. Đây là các tính năng thương mại của Plane, effort quá lớn và không bền vững. + +--- + +## 4. Kế hoạch thực hiện + +### Phase 1: Foundation (1 ngày) + +- [ ] Tạo thư mục `apps/web/ee-local/` +- [ ] Sửa `apps/web/tsconfig.json`: thêm fallback path +- [ ] Thêm `apps/web/ee-local/` vào `.gitignore` upstream (hoặc maintain trên branch riêng) +- [ ] Verify build hoạt động bình thường khi `ee-local/` trống +- [ ] Config OIDC/Keycloak env vars (Tier 0) + +### Phase 2: Low-risk unlocks (2–3 ngày) + +- [ ] **Verify** Project Publish — xác nhận modal đã wire đúng +- [ ] **Verify** Issue Relations — xác nhận CRUD hoạt động +- [ ] **Verify** Page Versions — xác nhận UI hiển thị +- [ ] **Implement** Active Cycles workspace overlay + +### Phase 3: Medium-risk unlocks (3–5 ngày) + +- [ ] **Implement** Estimates overlay (update modal, time input, delete) +- [ ] Viết test cho các overlay components + +### Deferred + +- Gantt Dependency Paths — chỉ làm khi có nhu cầu thực tế +- Bulk Operations — chỉ làm khi có nhu cầu thực tế +- View Publish — chờ upstream bổ sung + +--- + +## 5. Quy trình pull upstream + +```bash +# 1. Fetch upstream +git fetch upstream main + +# 2. Rebase/merge +git rebase upstream/main +# Conflict chỉ có thể xảy ra ở tsconfig.json (1 dòng) + +# 3. Verify +pnpm check:types # Phát hiện interface drift +pnpm build # Verify build +pnpm dev # Smoke test + +# 4. Fix nếu cần +# Nếu TypeScript báo lỗi ở ee-local/ → sửa cho khớp interface mới +``` + +### Checklist sau mỗi upstream pull + +- [ ] `pnpm check:types` pass +- [ ] `pnpm build` pass +- [ ] Các trang unlock vẫn render đúng +- [ ] Login OIDC vẫn hoạt động + +--- + +## 6. Cấu trúc thư mục overlay dự kiến + +``` +apps/web/ee-local/ +├── README.md ← Giải thích overlay pattern +├── components/ +│ ├── active-cycles/ +│ │ ├── index.ts ← export { WorkspaceActiveCyclesRoot } +│ │ └── root.tsx ← Workspace aggregate component +│ └── estimates/ +│ ├── index.ts ← Re-export CE + override +│ ├── update/ +│ │ ├── index.ts +│ │ └── modal.tsx +│ ├── inputs/ +│ │ ├── index.ts +│ │ └── time-input.tsx +│ └── points/ +│ ├── index.ts +│ └── delete.tsx +``` + +--- + +## 7. Tổng kết + +| Metric | Giá trị | +| --------------------------------- | -------------------------------------------------------- | +| **Tổng tính năng premium** | ~30+ | +| **Đã hoạt động sẵn (Tier 0)** | 4 (OIDC, Wiki, Collab, Page Versions) | +| **Unlock được an toàn (Tier 1)** | 4 (Active Cycles, Project Publish, Estimates, Relations) | +| **Unlock cần viết code (Tier 2)** | 3 (Gantt Deps, Bulk Ops, View Publish) | +| **Không có code (Tier 3)** | ~15+ | +| **Upstream conflict risk** | 1 dòng trong tsconfig.json | +| **Effort tổng Phase 1+2+3** | ~6–9 ngày | diff --git a/docs/cmc-login-branding-overlay.md b/docs/cmc-login-branding-overlay.md new file mode 100644 index 00000000000..a005252bd52 --- /dev/null +++ b/docs/cmc-login-branding-overlay.md @@ -0,0 +1,113 @@ +# CMC Login Branding Overlay + +Tài liệu này mô tả cách giữ các tùy biến branding đăng nhập CMC Telecom trong sparse overlay `cmc-local`, thay vì sửa trực tiếp file upstream của Plane. + +## Mục tiêu + +- Giữ diff với upstream nhỏ, dễ rebase/merge. +- Gom branding CMC login vào thư mục app-local: `apps/web/cmc-local/**` và `apps/space/cmc-local/**`. +- Không sửa trực tiếp các file upstream/core/UI để đổi logo, text hoặc icon đăng nhập. + +## Cơ chế overlay + +TypeScript path alias được cấu hình để ưu tiên file trong `cmc-local` trước, sau đó fallback về source gốc. + +```jsonc +// apps/web/tsconfig.json +"@/*": ["./cmc-local/*", "./core/*"] + +// apps/space/tsconfig.json +"@/*": ["./cmc-local/*", "./*"] +``` + +Khi import `@/components/account/auth-forms`, TypeScript resolve theo thứ tự: + +1. Tìm file tương ứng trong `cmc-local`. +2. Nếu không có, fallback về source gốc (`core` với Web, app root với Space). + +Các alias cụ thể hơn như `@/app/*`, `@/helpers/*`, `@/styles/*` của Web không bị overlay bởi rule `@/*`. + +## File overlay hiện tại + +### Web + +| File | Vai trò | +| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | +| `apps/web/cmc-local/constants/cmc-auth.ts` | Branding constants: logo, title, subtitle, SSO button text, Keycloak provider id. | +| `apps/web/cmc-local/components/account/auth-forms/index.ts` | Entry point overlay cho barrel import `@/components/account/auth-forms`. | +| `apps/web/cmc-local/components/account/auth-forms/auth-root.tsx` | Giữ flow auth gốc, thay OAuth renderer bằng `CmcOAuthOptions`. | +| `apps/web/cmc-local/components/account/auth-forms/auth-header.tsx` | Render CMC logo/title/subtitle cho login header; vẫn giữ workspace invitation logic. | +| `apps/web/cmc-local/components/account/auth-forms/cmc-oauth-options.tsx` | Render OAuth buttons local; riêng provider `keycloak` đổi text và bỏ icon. | + +### Space + +| File | Vai trò | +| -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `apps/space/cmc-local/constants/cmc-auth.ts` | Branding constants dùng cho Space overlay. | +| `apps/space/cmc-local/components/account/auth-forms/index.ts` | Entry point overlay cho barrel import `@/components/account/auth-forms`. | +| `apps/space/cmc-local/components/account/auth-forms/auth-root.tsx` | Giữ flow auth gốc, thay OAuth renderer bằng `CmcOAuthOptions`. | +| `apps/space/cmc-local/components/account/auth-forms/cmc-oauth-options.tsx` | Render OAuth buttons local; riêng provider `keycloak` đổi text và bỏ icon. | + +Space chưa overlay `auth-header.tsx`; file này vẫn fallback về `apps/space/components/account/auth-forms/auth-header.tsx`. + +## Branding values + +Giá trị branding được đặt trong cả hai file constants: + +- `apps/web/cmc-local/constants/cmc-auth.ts` +- `apps/space/cmc-local/constants/cmc-auth.ts` + +```ts +export const CMC_AUTH_BRANDING = { + logoUrl: "https://auth.cmctelecom.vn/resources/izwga/login/cmc-hub/img/cmc_logo.png", + title: "CMC Telecom Work Platform", + subtitle: "Project management for all teams", + ssoButtonText: "Đăng nhập bằng CMC SSO", + logoClassName: "h-16 w-fit object-contain", +} as const; + +export const CMC_AUTH_KEYCLOAK_PROVIDER_ID = "keycloak"; +``` + +Khi đổi wording/logo, cập nhật đồng bộ hai file constants để Web và Space không lệch nhau. + +## Quy tắc bảo trì + +- Không sửa các file sau chỉ để thay branding: + - `apps/web/core/components/account/auth-forms/auth-header.tsx` + - `apps/web/core/hooks/oauth/core.tsx` + - `apps/space/hooks/oauth/core.tsx` + - `packages/ui/src/oauth/oauth-button.tsx` +- Chỉ copy file upstream vào `cmc-local` khi cần override behavior thực sự. +- Overlay phải giữ cùng export name và props contract với file gốc để import sites không đổi. +- Nếu chỉ cần override một phần barrel export, re-export phần cần thiết thay vì copy cả thư mục. +- Sau mỗi lần pull upstream, so sánh các overlay copy với file gốc tương ứng để bắt interface drift. + +## Khi upstream thay đổi + +Các overlay copy có thể drift nếu upstream đổi props, hooks, auth flow hoặc export name. Cách xử lý: + +1. Chạy typecheck để phát hiện lỗi compile. +2. Diff overlay với file upstream tương ứng. +3. Port phần thay đổi upstream cần thiết vào overlay. +4. Giữ CMC-specific logic nhỏ nhất có thể: constants + `CmcOAuthOptions` + header branding. + +## Verification + +Chạy các lệnh sau sau khi sửa overlay: + +```bash +pnpm exec oxfmt --check docs/cmc-login-branding-overlay.md apps/web/cmc-local apps/space/cmc-local apps/web/tsconfig.json apps/space/tsconfig.json +git diff --check +pnpm --filter=web check:types +pnpm --filter=space check:types +pnpm --filter=web check:lint +pnpm --filter=space check:lint +``` + +`check:lint` hiện có thể báo warnings sẵn từ upstream; yêu cầu tối thiểu là không phát sinh errors và không thêm warnings trong `cmc-local`. + +## Liên quan + +- [Keycloak OIDC Local Development Guide](./keycloak-local-dev.md) +- [CE Feature Unlock Spec](./ce-feature-unlock-spec.md) — mô tả sparse overlay pattern tương tự cho `ee-local`. diff --git a/docs/keycloak-local-dev.md b/docs/keycloak-local-dev.md new file mode 100644 index 00000000000..ee937b457ad --- /dev/null +++ b/docs/keycloak-local-dev.md @@ -0,0 +1,287 @@ +# Keycloak OIDC Local Development Guide + +This guide walks you through setting up a local Keycloak server and configuring Plane to use it for OIDC authentication. + +## Prerequisites + +- Docker & Docker Compose +- Node.js 18+ with corepack/pnpm +- Python 3.12+ with uv (or pip) + +## 1. Initial Project Setup + +If this is your first time setting up the project, run the setup script from the project root: + +```bash +chmod +x setup.sh +./setup.sh +``` + +This script: + +- Copies `.env.example` → `.env` for all apps (root, web, api, space, admin, live) +- Generates a Django `SECRET_KEY` and appends it to `apps/api/.env` +- Runs `corepack enable` and `pnpm install` + +> **Already set up?** Skip to step 2. Just make sure your `.env` files exist. + +## 2. Start Infrastructure + +Start Plane's local dependencies (Postgres, Redis, RabbitMQ, MinIO): + +```bash +docker compose -f docker-compose-local.yml up -d plane-db plane-redis plane-mq plane-minio +``` + +## 3. Start Keycloak Server + +Add a Keycloak container. You can either add it to `docker-compose-local.yml` or run it standalone: + +### Option A: Standalone Docker (recommended for dev) + +```bash +docker run -d \ + --name keycloak-dev \ + -p 8080:8080 \ + -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \ + -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \ + quay.io/keycloak/keycloak:26.2 start-dev +``` + +### Option B: Add to docker-compose-local.yml + +```yaml +# Add under services: +keycloak: + image: quay.io/keycloak/keycloak:26.2 + command: start-dev + restart: unless-stopped + networks: + - dev_env + ports: + - "8080:8080" + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin +``` + +Then: `docker compose -f docker-compose-local.yml up -d keycloak` + +Wait ~30s for Keycloak to start, then verify: http://localhost:8080 + +## 4. Configure Keycloak Realm & Client + +### 3.1 Create a Realm + +1. Go to http://localhost:8080/admin (login: `admin` / `admin`) +2. Click the realm dropdown (top-left, says "master") → **Create realm** +3. Set **Realm name** = `plane` → **Create** + +### 3.2 Create a Client + +1. In the `plane` realm → **Clients** → **Create client** +2. **Client ID** = `plane-app` → **Next** +3. Enable **Client authentication** (this makes it a confidential client) → **Next** +4. Set **Valid redirect URIs**: + - `http://localhost:3000/auth/keycloak/callback/` (web app) + - `http://localhost:3002/auth/keycloak/callback/` (space app) + - `http://localhost:8000/auth/keycloak/callback/` (API direct) +5. Set **Valid post logout redirect URIs**: `http://localhost:3000/*` +6. Set **Web origins**: `http://localhost:3000` +7. Click **Save** + +### 3.3 Get Client Secret + +1. Go to **Clients** → `plane-app` → **Credentials** tab +2. Copy the **Client secret** value + +### 3.4 Create a Test User + +1. Go to **Users** → **Add user** +2. Fill in: + - **Username**: `testuser` + - **Email**: `testuser@example.com` + - **Email verified**: ON + - **First name**: `Test` + - **Last name**: `User` +3. Click **Create** +4. Go to **Credentials** tab → **Set password**: + - **Password**: `testpass` + - **Temporary**: OFF +5. Click **Save** + +## 5. Configure Plane API + +Add Keycloak environment variables to the **end** of `apps/api/.env` (which was created by `setup.sh`): + +```bash +# Keycloak OIDC Configuration +IS_KEYCLOAK_ENABLED=1 +KEYCLOAK_HOST=http://localhost:8080 +KEYCLOAK_REALM=plane +KEYCLOAK_CLIENT_ID=plane-app +KEYCLOAK_CLIENT_SECRET= +ENABLE_KEYCLOAK_SYNC=0 +``` + +> **Tip:** If running Keycloak inside `docker-compose-local.yml` (Option B in step 3), use `KEYCLOAK_HOST=http://keycloak:8080` for API running in Docker, or `http://localhost:8080` for API running locally. + +## 6. Start Plane + +### Option A: Docker (all backend services) + +```bash +docker compose -f docker-compose-local.yml up -d api worker beat-worker migrator +``` + +This starts the API server, Celery worker (background jobs), beat worker (scheduled tasks), and runs migrations automatically. + +### Option B: Local Python + +```bash +cd apps/api +source .venv/bin/activate # or your virtualenv + +# 1. Run migrations +python manage.py migrate --settings=plane.settings.local + +# 2. Start API server +python manage.py runserver 8000 --settings=plane.settings.local + +# 3. Start Celery worker (in a separate terminal) +cd apps/api && source .venv/bin/activate +DJANGO_SETTINGS_MODULE=plane.settings.local celery -A plane worker -l info + +# 4. Start Celery beat (in a separate terminal, optional — for scheduled tasks) +cd apps/api && source .venv/bin/activate +DJANGO_SETTINGS_MODULE=plane.settings.local celery -A plane beat -l info +``` + +> **Note:** Without the Celery worker, background tasks (activation emails, workspace invitation processing) will queue but not execute. The OAuth login flow itself works without it, but post-login workflows won't complete. + +### Start Frontend + +```bash +# From project root (skip if setup.sh already ran these) +corepack enable +pnpm install +pnpm dev +``` + +This starts: + +- Web app: http://localhost:3000 +- Admin panel: http://localhost:3001 + +### Start Live Service (optional — for real-time collaboration) + +```bash +pnpm --filter=live dev +``` + +This starts the Hocuspocus/Yjs collaboration server on http://localhost:3004. Not required for authentication testing, but needed for real-time document editing. + +## 7. Enable Keycloak in Admin Panel + +1. Go to http://localhost:3001 (Admin / God Mode) +2. Navigate to **Authentication** +3. Find the **Keycloak** card → click **Configure** +4. Fill in: + - **Host**: `http://localhost:8080` + - **Realm**: `plane` + - **Client ID**: `plane-app` + - **Client Secret**: `` +5. Toggle **Enable Keycloak** ON +6. Click **Save** + +> **Note:** You can configure either via env vars (step 5) or via Admin UI (step 7). The Admin UI values take precedence over env vars when the instance config store has values. + +## 8. Test the Login Flow + +1. Go to http://localhost:3000 (Web app) +2. On the login page, you should see a **Keycloak** button +3. Click it → redirects to Keycloak login page at `localhost:8080` +4. Enter `testuser@example.com` / `testpass` +5. After successful auth → redirected back to Plane, logged in + +### Expected OAuth Flow + +``` +Browser → GET /auth/keycloak/ (Plane API) + → 302 redirect to Keycloak authorize endpoint + → User logs in on Keycloak + → 302 callback to /auth/keycloak/callback/ (Plane API) + → Plane exchanges code for token, fetches userinfo + → Creates/updates user account + → 302 redirect to Plane web app (logged in) +``` + +## 9. Run Unit Tests + +```bash +cd apps/api +source .venv/bin/activate +uv pip install -r requirements/test.txt # if not already installed + +REDIS_URL="redis://localhost:6379" \ +SECRET_KEY="test-secret" \ +DJANGO_SETTINGS_MODULE="plane.settings.test" \ +pytest plane/tests/unit/authentication/test_keycloak_provider.py -v -m unit +``` + +All 16 tests should pass. These tests mock all external dependencies and don't need a running Keycloak server. + +## Troubleshooting + +### "Keycloak is not configured" error + +- Check that `IS_KEYCLOAK_ENABLED=1` is set (not `0` or empty) +- Verify `KEYCLOAK_HOST` includes the scheme: `http://localhost:8080` (not `localhost:8080`) +- Ensure all 5 config keys are set: host, realm, client_id, client_secret, is_enabled + +### Redirect URI mismatch + +- Keycloak's client config must have the exact redirect URI including trailing slash +- For local dev with web app: `http://localhost:3000/auth/keycloak/callback/` +- Check if your app uses http vs https (local dev = http) + +### "Invalid grant" or token errors + +- Ensure the Keycloak client has **Client authentication** enabled (confidential client) +- Verify the client secret matches between Plane config and Keycloak +- Check Keycloak server logs: `docker logs keycloak-dev` + +### User created but no email + +- Keycloak must return the `email` claim. Ensure: + - User has email set and verified in Keycloak + - Client scopes include `email` (default in Keycloak 26+) + +### Docker networking issues + +If running API in Docker and Keycloak standalone (or vice versa), use the host machine IP or Docker network: + +```bash +# From Docker container, access host services: +KEYCLOAK_HOST=http://host.docker.internal:8080 +``` + +## File Reference + +| Component | Files | +| ------------ | -------------------------------------------------------------------- | +| Provider | `apps/api/plane/authentication/provider/oauth/keycloak.py` | +| App Views | `apps/api/plane/authentication/views/app/keycloak.py` | +| Space Views | `apps/api/plane/authentication/views/space/keycloak.py` | +| URL Routes | `apps/api/plane/authentication/urls.py` | +| Error Codes | `apps/api/plane/authentication/adapter/error.py` | +| Config Vars | `apps/api/plane/utils/instance_config_variables/core.py` | +| Instance API | `apps/api/plane/license/api/views/instance.py` | +| DB Migration | `apps/api/plane/db/migrations/0122_alter_account_provider.py` | +| Types | `packages/types/src/instance/auth.ts`, `base.ts` | +| Web Hook | `apps/web/core/hooks/oauth/core.tsx` | +| Space Hook | `apps/space/hooks/oauth/core.tsx` | +| Admin Hook | `apps/admin/hooks/oauth/core.tsx` | +| Admin Config | `apps/admin/components/authentication/keycloak-config.tsx` | +| Admin Page | `apps/admin/app/(all)/(dashboard)/authentication/keycloak/` | +| Unit Tests | `apps/api/plane/tests/unit/authentication/test_keycloak_provider.py` | diff --git a/docs/plans/keycloak-oidc-integration.md b/docs/plans/keycloak-oidc-integration.md new file mode 100644 index 00000000000..62afb6d6a4a --- /dev/null +++ b/docs/plans/keycloak-oidc-integration.md @@ -0,0 +1,557 @@ +# Keycloak OIDC Integration — Execution Plan + +## 1. Executive Intent + +### Problem + +Plane currently supports four OAuth providers (Google, GitHub, GitLab, Gitea) but lacks support for enterprise identity providers using the OpenID Connect (OIDC) standard. Organizations running Keycloak as their central IdP cannot use single sign-on with Plane, forcing users to maintain separate credentials. + +### Why it matters + +Enterprise adoption requires integration with corporate identity infrastructure. Keycloak is the most widely deployed open-source IAM solution and supports OIDC — the industry standard for modern authentication. Adding Keycloak support makes Plane viable for organizations that mandate centralized identity management. + +### Core outcomes + +1. Users can sign in to Plane (web, space) using their Keycloak credentials via OIDC Authorization Code Flow. +2. Instance admins can configure Keycloak (host, realm, client ID, client secret) through the admin dashboard. +3. User profile data (name, email, avatar) syncs from Keycloak on login when sync is enabled. +4. The implementation follows Plane's existing OAuth provider pattern exactly — no architectural novelty. + +### Non-goals + +- **Generic OIDC provider support**: This plan targets Keycloak specifically. A generic "custom OIDC" provider would require dynamic URL configuration UI and is out of scope. +- **SAML support**: Keycloak supports SAML, but this plan uses OIDC only. +- **Keycloak admin API integration**: No automated client registration, realm management, or role mapping. +- **Multi-realm support**: One realm per Plane instance. Multiple realms require separate Plane instances. +- **Group/role mapping**: Keycloak groups/roles will not be mapped to Plane workspace roles. +- **Logout propagation**: Keycloak backchannel/frontchannel logout is out of scope. + +### Success criteria + +- End-to-end login flow works: click "Sign in with Keycloak" → redirect to Keycloak → authenticate → redirect back to Plane with active session. +- Admin can enable/disable Keycloak and configure credentials from the admin UI. +- Existing OAuth providers remain unaffected. +- No new dependencies introduced (uses existing `requests` library and Django infrastructure). + +--- + +## 2. Scope Framing + +### MVP scope + +- Backend: Keycloak OIDC provider, views (app + space), URL routes, error codes, instance config variables, Account model update, migration +- Frontend: Types update, OAuth button in web + space apps, admin configuration page with form +- Config: Instance-level Keycloak settings (host, realm, client_id, client_secret, enable flag, sync flag) + +### Explicitly deferred + +- Token refresh / offline access +- Keycloak logout endpoint integration +- PKCE (Proof Key for Code Exchange) — Keycloak supports it, but Plane's OAuth pattern uses confidential clients with client_secret +- JWK validation of id_token — we use the userinfo endpoint for user data, consistent with other providers +- Automated testing against a real Keycloak instance (would require Docker test infrastructure) +- i18n for Keycloak-related admin UI strings + +### Assumptions + +1. Keycloak instance is accessible from the Plane API server (network-level). +2. Keycloak client is configured as "confidential" with client_secret (not public client). +3. The Keycloak realm has standard OIDC endpoints at well-known paths (`/realms/{realm}/protocol/openid-connect/*`). +4. Users have `email` claim in their Keycloak profile (Plane requires email for user identity). +5. Plane's existing OAuth adapter pattern (token exchange via POST, userinfo via GET with Bearer token) is compatible with Keycloak's OIDC implementation. + +--- + +## 3. Delivery-Relevant System Understanding + +### Authentication architecture + +Plane uses a layered OAuth architecture: + +``` +View (Django View) + └── Provider (e.g., GitLabOAuthProvider) + └── OauthAdapter (base class) + └── Adapter (base class — user creation, login completion) +``` + +**Flow**: View receives redirect → creates Provider with auth code → calls `provider.authenticate()` → Provider exchanges code for tokens (`set_token_data`) → fetches user info (`set_user_data`) → Adapter creates/updates User + Account + Session → redirects to app. + +### Key integration points + +| Component | File | What to touch | +| ------------------- | -------------------------------------------------------- | ---------------------------------------------------- | +| Provider base class | `apps/api/plane/authentication/adapter/oauth.py` | Add `keycloak` case in `authentication_error_code()` | +| Error codes | `apps/api/plane/authentication/adapter/error.py` | Add 2 new error codes | +| Account model | `apps/api/plane/db/models/user.py` | Add to `PROVIDER_CHOICES` | +| Config variables | `apps/api/plane/utils/instance_config_variables/core.py` | Add keycloak config block | +| Instance API | `apps/api/plane/license/api/views/instance.py` | Add `is_keycloak_enabled` boolean transform | +| Auth URL routing | `apps/api/plane/authentication/urls.py` | Add 4 URL patterns | +| View exports | `apps/api/plane/authentication/views/__init__.py` | Add imports | +| Frontend types | `packages/types/src/instance/auth.ts`, `base.ts` | Add Keycloak types | +| Web OAuth hook | `apps/web/core/hooks/oauth/core.tsx` | Add Keycloak option | +| Space OAuth hook | `apps/space/hooks/oauth/core.tsx` | Add Keycloak option | +| Admin auth hook | `apps/admin/hooks/oauth/core.tsx` | Add Keycloak mapping | +| Admin routes | `apps/admin/app/routes.ts` | Add keycloak route | + +### Trust boundaries + +- Plane API ↔ Keycloak: HTTPS required in production. Token exchange uses client_secret (server-side only, never exposed to browser). +- Browser ↔ Plane API: Session cookie (HttpOnly, same-site). CSRF protection via token. +- Browser ↔ Keycloak: Standard OIDC redirect. State parameter prevents CSRF. + +### Data flow + +``` +Browser → Plane API /auth/keycloak/ (GET) + → Redirect to Keycloak /realms/{realm}/protocol/openid-connect/auth + → User authenticates at Keycloak + → Redirect to Plane API /auth/keycloak/callback/?code=...&state=... + → Plane API POSTs to Keycloak /realms/{realm}/protocol/openid-connect/token + → Receives access_token, id_token, refresh_token + → Plane API GETs Keycloak /realms/{realm}/protocol/openid-connect/userinfo + → Receives email, name, sub, picture + → Creates/updates User + Account + → Sets session cookie + → Redirects to app +``` + +### State ownership + +- **User identity**: Keycloak is the source of truth; Plane stores a copy. +- **Session**: Plane owns the session. Keycloak session is independent. +- **Configuration**: Stored in `InstanceConfiguration` (database), configurable via admin UI. + +--- + +## 4. Workstream Decomposition + +### WS1: Backend Provider & Auth Flow + +**Purpose**: Implement the core Keycloak OIDC authentication logic. + +**Produces**: + +- `KeycloakOAuthProvider` class (provider) +- App views (initiate + callback) +- Space views (initiate + callback) +- URL routing + +**Key implementation considerations**: + +- Follow **Gitea provider** pattern for the provider class (`apps/api/plane/authentication/provider/oauth/gitea.py`) — it uses `"openid email profile"` scope (same as Keycloak), has URL scheme validation, and has `IS_GITEA_ENABLED` in the config seed. Follow **GitLab view** pattern for the initiate/callback endpoints (`apps/api/plane/authentication/views/app/gitlab.py`) since the structure is identical. +- **Space views differ from app views** in three ways: (1) use `is_space=True` in `base_host(request, is_space=True)`, (2) do NOT pass `callback=post_user_auth_workflow` to the provider — space callbacks omit the post-auth workflow, (3) use `validate_next_path` and `get_allowed_hosts` for redirect validation. Reference `apps/api/plane/authentication/views/space/gitea.py` as the template for space views, NOT the app views. +- Keycloak adds `KEYCLOAK_REALM` as an additional config dimension (GitLab/Gitea only have host). +- OIDC userinfo response uses `sub` (not `id`), `given_name`/`family_name` (not `name`/`family_name`), `picture` (not `avatar_url`). +- Scope must be `"openid email profile"` (same as Gitea, not GitLab's `"read_user"`). +- Validate `KEYCLOAK_HOST`: parse with `urlparse()`, reject if scheme is not `http` or `https` (raise `KEYCLOAK_NOT_CONFIGURED`), strip trailing slashes (`KEYCLOAK_HOST.rstrip("/")`). +- Validate `KEYCLOAK_REALM`: must be non-empty, must not contain `/`, `?`, `#`, or whitespace (these would break URL construction). Raise `KEYCLOAK_NOT_CONFIGURED` if invalid. + +**Risks**: + +- Keycloak may return different claim names depending on realm/client configuration. Mitigation: use standard OIDC claims and document required Keycloak client settings. + +**Interfaces**: WS2 (error codes), WS3 (config variables), WS4 (Account model). + +### WS2: Error Codes & OAuth Adapter Update + +**Purpose**: Register Keycloak-specific error codes and update the adapter's error mapping. + +**Produces**: + +- Two new error codes in `error.py` +- Updated `authentication_error_code()` mapping in `oauth.py` + +**Key considerations**: + +- Error codes must be unique. Current highest OAuth code is 5123 (GITEA_OAUTH_PROVIDER_ERROR). Use 5113 (KEYCLOAK_NOT_CONFIGURED) and 5124 (KEYCLOAK_OAUTH_PROVIDER_ERROR). +- The `authentication_error_code()` method in `OauthAdapter` maps provider name → error code string. **Must** add `"keycloak"` → `"KEYCLOAK_OAUTH_PROVIDER_ERROR"` case. This is mandatory — the default fallback returns `"OAUTH_NOT_CONFIGURED"` which is a different error (instance-level code 5104, not provider-level). Missing this case would cause all Keycloak token/userinfo errors to report code 5104 instead of 5124. + +### WS3: Instance Configuration + +**Purpose**: Define Keycloak config variables and expose them via API. + +**Produces**: + +- Config variable definitions in `core.py` (`keycloak_config_variables` list, added to `core_config_variables`) +- Updated `InstanceEndpoint.get()` in `apps/api/plane/license/api/views/instance.py` — requires changes in **three places**: + 1. Add `{"key": "IS_KEYCLOAK_ENABLED", "default": os.environ.get("IS_KEYCLOAK_ENABLED", "0")}` to the `get_configuration_value()` list of dicts + 2. Add `IS_KEYCLOAK_ENABLED` to the positional tuple destructuring. Current order (15 vars): `ENABLE_SIGNUP, ENABLE_EMAIL_PASSWORD, ... IS_GITEA_ENABLED`. Append `IS_KEYCLOAK_ENABLED` as the 16th position. **Order must exactly match the list of dicts above — a mismatch silently assigns wrong values.** + 3. Add `data["is_keycloak_enabled"] = IS_KEYCLOAK_ENABLED == "1"` to the data dict alongside other auth flags. + + **Implementation note**: Read the current `get_configuration_value()` call in `apps/api/plane/license/api/views/instance.py` before editing. Count existing entries and append to both the list and the tuple in the same position. + +**Key considerations**: + +- 6 config keys: `IS_KEYCLOAK_ENABLED`, `KEYCLOAK_HOST`, `KEYCLOAK_REALM`, `KEYCLOAK_CLIENT_ID`, `KEYCLOAK_CLIENT_SECRET`, `ENABLE_KEYCLOAK_SYNC` +- `KEYCLOAK_CLIENT_SECRET` must be `is_encrypted: True` +- Initial values come from environment variables (same pattern as other providers) +- `IS_KEYCLOAK_ENABLED` is included in `keycloak_config_variables` (following Gitea's pattern where `IS_GITEA_ENABLED` is seeded). Google/GitHub/GitLab do NOT seed their `IS_*_ENABLED` flags — their flags are only set by the admin UI toggle. Gitea/Keycloak seed them to default `"0"` for explicit visibility. + +### WS4: Database Model & Migration + +**Purpose**: Update Account model to support Keycloak as a provider. + +**Produces**: + +- Updated `PROVIDER_CHOICES` in Account model — add both `("gitea", "Gitea")` (fixing pre-existing gap) and `("keycloak", "Keycloak")` +- Django migration file + +**Key considerations**: + +- This is a simple CharField choices update — no schema change, just validation. Note: `("gitea", "Gitea")` is currently missing from `PROVIDER_CHOICES` despite Gitea being fully implemented. Include it in the same migration. +- Migration is auto-generated by `python manage.py makemigrations`. + +### WS5: Frontend Types + +**Purpose**: Update shared TypeScript types to include Keycloak. + +**Produces**: + +- Updated union types in `auth.ts` +- Updated `IInstanceConfig` in `base.ts` +- New `TInstanceKeycloakAuthenticationConfigurationKeys` type + +**Interfaces**: All frontend workstreams depend on this. + +### WS6: Frontend OAuth Integration (Web + Space) + +**Purpose**: Add Keycloak login button to web and space apps. + +**Produces**: + +- Updated `useCoreOAuthConfig` hook in web app +- Updated `useCoreOAuthConfig` hook in space app +- Keycloak logo asset + +**Key considerations**: + +- Both hooks follow identical pattern — check `config?.is_keycloak_enabled`, add option with redirect to `/auth/keycloak/`. +- Need a Keycloak logo (SVG preferred, or PNG). Can use the official Keycloak logo or a generic key/shield icon. + +### WS7: Admin Configuration UI + +**Purpose**: Build the admin interface for configuring Keycloak. + +**Produces**: + +- `keycloak-config.tsx` component (list item with toggle) +- `keycloak/page.tsx` (config page) +- `keycloak/form.tsx` (configuration form with Host, Realm, Client ID, Client Secret, Sync toggle, Callback URL display) +- Updated `getCoreAuthenticationModesMap` hook +- Updated admin routes + +**Key considerations**: + +- Form has one extra field compared to GitLab: "Realm". Layout should match GitLab form pattern. +- Callback URL display should show `{instance_url}/auth/keycloak/callback/` for easy copy-paste into Keycloak client config. +- **Save mechanism**: The form saves configuration via `useInstance().updateInstanceConfigurations(payload)` which calls `PATCH /api/instances/configurations/`. No custom backend handler is needed — the existing generic `InstanceConfigurationEndpoint` handles all providers. Reference `apps/admin/app/(all)/(dashboard)/authentication/gitea/form.tsx` for the exact pattern (uses `react-hook-form`, imports `IFormattedInstanceConfiguration` from `@plane/types`). +- The form must handle the `KEYCLOAK_REALM` field which is unique to this provider. + +--- + +## 5. Dependency and Sequencing Model + +### Dependency graph + +``` +WS2 (Error Codes) ──┐ +WS3 (Instance Config) ──┤ +WS4 (DB Migration) ──┼──→ WS1 (Backend Provider) ──→ Integration Test + │ +WS5 (Frontend Types) ──┼──→ WS6 (Web + Space OAuth) ──→ E2E Test + │ + └──→ WS7 (Admin Config UI) ──→ E2E Test +``` + +### Hard blockers + +- WS1 depends on WS2 (error codes), WS3 (config vars), WS4 (Account model) — provider imports/uses these. +- WS6 and WS7 depend on WS5 (types must exist before frontend code). + +### Soft sequencing + +- WS2, WS3, WS4 can all proceed in parallel (independent files). +- WS6 and WS7 can proceed in parallel (different apps, different files). +- WS5 should land before WS6/WS7 but can be done quickly. + +### Recommended sequence + +1. **Phase 1** (parallel): WS2 + WS3 + WS4 + WS5 — all foundation work +2. **Phase 2** (parallel): WS1 + WS6 + WS7 — provider + frontend, after foundation +3. **Phase 3**: Integration testing — end-to-end verification + +### Why this sequence + +- Foundation work (error codes, config, types, migration) is small and independent — doing it first eliminates all blockers. +- Provider and frontend can then be built in parallel since they touch different codebases (Python vs TypeScript). +- Testing last ensures all pieces are in place. + +--- + +## 6. Key Design and Delivery Decisions + +### D1: Use userinfo endpoint instead of id_token decoding + +**Decision**: Fetch user data from Keycloak's userinfo endpoint (GET with Bearer token) rather than decoding the id_token JWT. +**Reasoning**: Consistent with how all other Plane OAuth providers work. Avoids adding JWT/JWK verification dependencies. The id_token is still stored in the Account record. +**Consequence**: One extra HTTP call per login. Negligible performance impact for an auth flow. + +### D2: Keycloak-specific provider (not generic OIDC) + +**Decision**: Create a `KeycloakOAuthProvider` specifically, not a generic OIDC provider. +**Reasoning**: Plane's architecture uses named providers with hardcoded config keys. A generic OIDC provider would require a fundamentally different config model (dynamic URL fields, custom claim mapping). This can be built later on top of the Keycloak implementation if needed. +**Consequence**: Adding another OIDC provider (e.g., Okta, Auth0) would require similar but separate implementation. However, this keeps the codebase consistent and simple. + +### D3: Single realm per instance + +**Decision**: One `KEYCLOAK_REALM` setting per Plane instance. +**Reasoning**: Plane's instance config is flat key-value. Multi-realm would require array/object config which the current model doesn't support. +**Consequence**: Organizations with multiple realms need multiple Plane instances or a Keycloak-side solution (federation/brokering between realms). + +### D4: Callback URL pattern + +**Decision**: Use `/auth/keycloak/callback/` (app) and `/auth/spaces/keycloak/callback/` (space). +**Reasoning**: Follows existing convention (gitlab, github, google, gitea all use `//callback/`). +**Consequence**: Must be registered in Keycloak client as valid redirect URIs. + +### D5: Error code allocation + +**Decision**: Use codes 5113 (`KEYCLOAK_NOT_CONFIGURED`) and 5124 (`KEYCLOAK_OAUTH_PROVIDER_ERROR`). +**Reasoning**: 5113 fits in the "not configured" range (5104-5112), 5124 extends the "provider error" range (5115-5123). Follows the pattern gap. + +### D6: Config variable naming + +**Decision**: Use `IS_KEYCLOAK_ENABLED` (not `ENABLE_KEYCLOAK`) for the enable flag, and `ENABLE_KEYCLOAK_SYNC` for the sync flag. +**Reasoning**: Follows Gitea's pattern (`IS_GITEA_ENABLED`). The `IS_*` prefix is used for the main toggle, `ENABLE_*_SYNC` for sync. + +### D7: Ignore `email_verified` claim + +**Decision**: Do not check the `email_verified` claim from Keycloak's userinfo response. +**Reasoning**: No existing Plane OAuth provider checks email verification status. Adding this check only for Keycloak would be inconsistent and could block users in development/test Keycloak realms where email verification is disabled. +**Consequence**: Users with unverified emails in Keycloak can log in to Plane. This matches the behavior of all other providers. Can be hardened later as a cross-provider improvement. + +--- + +## 7. Risks, Ambiguities, and Assumptions + +### Risks + +| Risk | Severity | Mitigation | +| ---------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| Keycloak userinfo response has non-standard claims | Medium | Use standard OIDC claims (`sub`, `email`, `given_name`, `family_name`, `picture`). Document required Keycloak client scope configuration. | +| Network connectivity between Plane API and Keycloak | Medium | Document network requirements. Provide clear error messages when Keycloak is unreachable. | +| CORS issues if Keycloak and Plane are on different domains | Low | Not applicable — token exchange is server-to-server (no browser CORS). Redirect flow handles cross-domain via HTTP redirects. | +| Keycloak email not verified | Low | Ignore `email_verified` claim — no existing provider checks this (see D7). Can be hardened later as a cross-provider improvement. | + +### Ambiguities + +1. **Logo asset**: No official Keycloak logo in the repo. Need to source one. Could use official Keycloak logo or a generic shield icon. +2. **Admin UI description text**: Need appropriate copy for the Keycloak config page (e.g., "Connect your Keycloak identity provider to enable single sign-on"). + +### Assumptions + +1. The `OauthAdapter.get_user_token()` method works with Keycloak's token endpoint (standard OAuth2 POST with form-encoded body). +2. The `OauthAdapter.get_user_response()` method works with Keycloak's userinfo endpoint (standard Bearer token auth). +3. Keycloak returns `email` in the userinfo response (most Keycloak setups include the `email` scope by default). +4. The existing `Account` model's `access_token`, `refresh_token`, `id_token` fields are sufficient for Keycloak tokens. + +--- + +## 8. Execution Slices / Phases + +### Phase 1: Foundation (can be parallelized, ~2 hours) + +**Objective**: Lay all groundwork that other workstreams depend on. + +**Included**: + +- WS2: Add error codes to `error.py`, update `authentication_error_code()` in `oauth.py` +- WS3: Add `keycloak_config_variables` to `core.py`, update `InstanceEndpoint` in `instance.py` +- WS4: Add `("gitea", "Gitea")` and `("keycloak", "Keycloak")` to `PROVIDER_CHOICES`, generate migration +- WS5: Update all TypeScript types in `auth.ts` and `base.ts` + +**Dependencies**: None (this is the starting phase). + +**Validation**: Type check passes (`pnpm check:types`), Python syntax valid, migration generates cleanly. + +**After this phase**: All backend and frontend foundational types are in place. + +### Phase 2: Backend Provider (depends on Phase 1, ~3 hours) + +**Objective**: Implement the complete server-side Keycloak authentication flow. + +**Included**: + +- WS1: Create `KeycloakOAuthProvider`, app views, space views +- Wire up URL routes +- Update view exports in `__init__.py` + +**Dependencies**: Phase 1 (error codes, config vars, Account model). + +**Validation**: Django server starts without errors. Manual test: visiting `/auth/keycloak/` redirects to Keycloak (if configured). + +**After this phase**: The backend auth flow is complete. A user could authenticate via direct URL manipulation. + +### Phase 3: Frontend Integration (depends on Phase 1, parallel with Phase 2, ~3 hours) + +**Objective**: Add Keycloak to all three frontend apps. + +**Included**: + +- WS6: Update web + space OAuth hooks, add logo +- WS7: Create admin config page + form, update admin hooks + routes + +**Dependencies**: Phase 1 (types). Does NOT depend on Phase 2 (frontend just generates URLs; backend handles the actual flow). + +**Validation**: `pnpm check` passes. Admin config page renders. Keycloak button appears when enabled. + +**After this phase**: Full end-to-end integration is visually complete. + +### Phase 4: Integration Testing (~1 hour) + +**Objective**: Verify the complete flow works end-to-end. + +**Included**: + +- Set up a test Keycloak instance (Docker: `docker run -p 8080:8080 quay.io/keycloak/keycloak:latest start-dev`) +- Create realm, client, test user +- Configure Plane with Keycloak credentials via admin UI +- Test: sign-in, sign-up (new user), profile sync, error handling + +**Validation**: User can log in via Keycloak button, user record is created, session is active. + +--- + +## 9. Validation and Acceptance Framing + +### Functional validation + +- [ ] Clicking "Sign in with Keycloak" redirects to correct Keycloak authorization URL with proper query params (client_id, scope, state, redirect_uri) +- [ ] After Keycloak authentication, callback correctly exchanges code for tokens +- [ ] New user: account is created with correct email, name, avatar from Keycloak claims +- [ ] Existing user: account is linked, session is created, no duplicate user +- [ ] Profile sync: when enabled, user name/avatar updates from Keycloak on next login +- [ ] Admin UI: can enable/disable Keycloak, configure all fields, see callback URL + +### Integration validation + +- [ ] Other OAuth providers (Google, GitHub, GitLab, Gitea) still work after changes +- [ ] Email/password login still works +- [ ] Magic link login still works +- [ ] Space app login with Keycloak works +- [ ] Admin app is not affected (admin uses separate auth flow) + +### Security validation + +- [ ] State parameter is validated on callback (CSRF protection) +- [ ] Client secret is stored encrypted in InstanceConfiguration +- [ ] Client secret is never exposed to the browser +- [ ] Token exchange happens server-to-server +- [ ] Invalid/expired code returns proper error, not 500 + +### Failure mode validation + +- [ ] Keycloak unreachable: clear error message, no crash +- [ ] Keycloak not configured: appropriate error code returned +- [ ] Invalid client credentials: proper error handling +- [ ] User denies authorization at Keycloak: handled gracefully +- [ ] Missing email claim: login rejected with clear error +- [ ] State mismatch (CSRF attempt): rejected + +### Regression expectations + +- All existing tests pass unchanged +- No changes to Django settings or DRF configuration +- No changes to session handling or CSRF middleware +- No new Python/Node.js dependencies + +--- + +## 10. Task Graph Mapping + +### Top-level tasks (workstreams → beads) + +``` +keycloak-oidc-integration/ +├── foundation/ +│ ├── error-codes (WS2 — add error codes + update oauth adapter) +│ ├── instance-config (WS3 — config variables + instance API) +│ ├── account-model (WS4 — PROVIDER_CHOICES + migration) +│ └── frontend-types (WS5 — TypeScript type updates) +├── backend/ +│ ├── provider (WS1 — KeycloakOAuthProvider class) +│ ├── app-views (WS1 — initiate + callback views for web) +│ ├── space-views (WS1 — initiate + callback views for space) +│ └── url-routing (WS1 — URL patterns + view exports) +├── frontend/ +│ ├── web-oauth-hook (WS6 — update useCoreOAuthConfig in web) +│ ├── space-oauth-hook (WS6 — update useCoreOAuthConfig in space) +│ ├── admin-config-page (WS7 — page.tsx + form.tsx) +│ ├── admin-config-toggle (WS7 — keycloak-config.tsx list component) +│ └── admin-hooks-routes (WS7 — update hooks + routes) +``` + +### Dependency encoding + +- `backend/*` depends on `foundation/*` (all four) +- `frontend/*` depends on `foundation/frontend-types` +- `backend/app-views` and `backend/space-views` depend on `backend/provider` +- `backend/url-routing` depends on `backend/app-views` + `backend/space-views` +- `frontend/admin-config-page` depends on `frontend/admin-hooks-routes` (route must exist) + +### Context each leaf task must carry + +**All tasks**: "We are adding Keycloak as a new OAuth/OIDC provider to Plane. Follow the Gitea provider pattern for the provider class and the GitLab pattern for view classes. Keycloak adds one extra config: `KEYCLOAK_REALM`." + +**Backend tasks**: Must reference `apps/api/plane/authentication/provider/oauth/gitea.py` as the primary template for the provider class (same OIDC scope pattern, URL validation), and `apps/api/plane/authentication/views/app/gitlab.py` for the view classes. Must know Keycloak OIDC endpoint URL patterns: + +- Auth: `{HOST}/realms/{REALM}/protocol/openid-connect/auth` +- Token: `{HOST}/realms/{REALM}/protocol/openid-connect/token` +- UserInfo: `{HOST}/realms/{REALM}/protocol/openid-connect/userinfo` +- Scope: `"openid email profile"` +- UserInfo claims: `sub`, `email`, `given_name`, `family_name`, `picture` +- Must validate `KEYCLOAK_HOST` (URL scheme + trailing slash) and `KEYCLOAK_REALM` (non-empty, no `/`, `?`, `#`, whitespace) +- Unlike Gitea (OAuth2), Keycloak is OIDC and returns `id_token` in the token response. `set_token_data()` must include `id_token` in the dict passed to `super().set_token_data()` so it is stored in the Account record (see D1). +- No email fallback method is needed (unlike Gitea's `__get_email()`). Keycloak with `email` scope always returns email in the standard userinfo response. +- Claim mapping to Plane fields: `sub` → `provider_id` (string, not int — unlike Gitea's `id`), `email` → `email`, `given_name` → `first_name`, `family_name` → `last_name`, `picture` → `avatar` + +**Frontend tasks**: Must reference corresponding GitLab/Gitea files as templates. Must know the config key is `IS_KEYCLOAK_ENABLED` and the auth URL is `/auth/keycloak/`. + +**Admin tasks**: Must know the form fields: `KEYCLOAK_HOST`, `KEYCLOAK_REALM`, `KEYCLOAK_CLIENT_ID`, `KEYCLOAK_CLIENT_SECRET`, `ENABLE_KEYCLOAK_SYNC`. Callback URL to display: `{instance_url}/auth/keycloak/callback/`. + +### Splitting guidance + +- `foundation/*` tasks are small (each touches 1-2 files) — keep as leaf tasks +- `backend/provider` is the meatiest task (~120 lines) — keep as one task, it's a single file +- `frontend/admin-config-page` could be large — form.tsx alone is ~230 lines. Keep as one task since it's a clone-and-modify job from GitLab form. + +### Cross-cutting concerns to keep explicit + +- Error code values (5113, 5124) must be consistent between `error.py` and `oauth.py` +- Config key names must be consistent across `core.py`, `instance.py`, `auth.ts`, `base.ts`, admin form +- The provider name string `"keycloak"` must be consistent everywhere (Python provider class, Account model, frontend types, URL routes) + +--- + +## Appendix: Keycloak Client Configuration Guide + +For the Plane admin to configure Keycloak, they need to: + +1. **Create a new client** in Keycloak admin console: + - Client ID: e.g., `plane` + - Client Protocol: `openid-connect` + - Access Type: `confidential` + - Valid Redirect URIs: `https:///auth/keycloak/callback/` + +2. **Configure client scopes**: Ensure `email` and `profile` scopes are included in the default client scopes. + +3. **Get credentials**: Copy the Client ID and Client Secret from the Keycloak client's "Credentials" tab. + +4. **Configure Plane**: In the Plane admin dashboard, navigate to Authentication → Keycloak, and enter: + - Host: `https://` (no trailing slash) + - Realm: e.g., `master` or your custom realm name + - Client ID: from step 3 + - Client Secret: from step 3 + +5. **Enable**: Toggle Keycloak authentication on. diff --git a/packages/types/src/instance/auth.ts b/packages/types/src/instance/auth.ts index f3566b291f7..38bed14b0d8 100644 --- a/packages/types/src/instance/auth.ts +++ b/packages/types/src/instance/auth.ts @@ -10,7 +10,8 @@ export type TCoreInstanceAuthenticationModeKeys = | "google" | "github" | "gitlab" - | "gitea"; + | "gitea" + | "keycloak"; export type TInstanceAuthenticationModeKeys = TCoreInstanceAuthenticationModeKeys; @@ -31,7 +32,8 @@ export type TInstanceAuthenticationMethodKeys = | "IS_GOOGLE_ENABLED" | "IS_GITHUB_ENABLED" | "IS_GITLAB_ENABLED" - | "IS_GITEA_ENABLED"; + | "IS_GITEA_ENABLED" + | "IS_KEYCLOAK_ENABLED"; export type TInstanceGoogleAuthenticationConfigurationKeys = | "GOOGLE_CLIENT_ID" @@ -56,11 +58,19 @@ export type TInstanceGiteaAuthenticationConfigurationKeys = | "GITEA_CLIENT_SECRET" | "ENABLE_GITEA_SYNC"; +export type TInstanceKeycloakAuthenticationConfigurationKeys = + | "KEYCLOAK_HOST" + | "KEYCLOAK_REALM" + | "KEYCLOAK_CLIENT_ID" + | "KEYCLOAK_CLIENT_SECRET" + | "ENABLE_KEYCLOAK_SYNC"; + export type TInstanceAuthenticationConfigurationKeys = | TInstanceGoogleAuthenticationConfigurationKeys | TInstanceGithubAuthenticationConfigurationKeys | TInstanceGitlabAuthenticationConfigurationKeys - | TInstanceGiteaAuthenticationConfigurationKeys; + | TInstanceGiteaAuthenticationConfigurationKeys + | TInstanceKeycloakAuthenticationConfigurationKeys; export type TInstanceAuthenticationKeys = TInstanceAuthenticationMethodKeys | TInstanceAuthenticationConfigurationKeys; @@ -83,4 +93,4 @@ export type TOAuthConfigs = { oAuthOptions: TOAuthOption[]; }; -export type TCoreLoginMediums = "email" | "magic-code" | "github" | "gitlab" | "google" | "gitea"; +export type TCoreLoginMediums = "email" | "magic-code" | "github" | "gitlab" | "google" | "gitea" | "keycloak"; diff --git a/packages/types/src/instance/base.ts b/packages/types/src/instance/base.ts index a93baef524f..9f977ded2ec 100644 --- a/packages/types/src/instance/base.ts +++ b/packages/types/src/instance/base.ts @@ -51,6 +51,7 @@ export interface IInstanceConfig { is_github_enabled: boolean; is_gitlab_enabled: boolean; is_gitea_enabled: boolean; + is_keycloak_enabled: boolean; is_magic_login_enabled: boolean; is_email_password_enabled: boolean; github_app_name: string | undefined;