Skip to content

feat(auth): Lark/Feishu OAuth provider + workspace directory invite#9063

Draft
JOBYINC wants to merge 20 commits into
makeplane:masterfrom
JOBYINC:pr/lark-integration
Draft

feat(auth): Lark/Feishu OAuth provider + workspace directory invite#9063
JOBYINC wants to merge 20 commits into
makeplane:masterfrom
JOBYINC:pr/lark-integration

Conversation

@JOBYINC
Copy link
Copy Markdown

@JOBYINC JOBYINC commented May 13, 2026

What

Adds end-to-end Lark/Feishu support, on par with the existing Google/GitHub/GitLab/Gitea SSO integrations:

  1. OAuth provider for sign-in via Lark (feishu.cn) and Lark Suite (larksuite.com). Configured under admin → authentication, with the same toggle + client_id / client_secret layout as the other providers.
  2. Invite from Lark modal on the workspace members page. Workspace admins can browse the entire Feishu directory the app is authorised to see, multi-select teammates, pick a role, and add them directly — no email round-trip. Pre-created accounts match the same identifier the OAuth provider hands out on first sign-in, so SSO links seamlessly.
  3. Performance + i18n niceties that came up while making this usable for a 500-person Feishu tenant.

Why

Feishu / Lark is the de-facto chat + identity platform inside Chinese enterprises (50M+ DAU per ByteDance). Today self-hosted Plane deploys in those orgs either ship without SSO or rely on Google Workspace, which is blocked. With this PR they can stand Plane up next to Feishu with zero email round-trips.

Commits

# Commit What
1 feat(auth): add Lark/Feishu OAuth provider Backend OAuth provider + admin config + types
2 feat(auth/web): add "Sign in with Lark" button Lark button on web + space sign-in pages
3 fix(auth/lark): unblock callback by removing LogRecord reserved-key collisions Lark v2 token endpoint returns RFC 6749-style success; fixes KeyError: msg in extra={}
4 ci(docker): expose pnpm global bin so turbo install succeeds pnpm 11 installs globals under $PNPM_HOME/bin, not $PNPM_HOME — unblocks frontend Docker build
5 feat(auth/lark): synthesize stable identifier when directory exposes no email Some Feishu tenants don't surface enterprise_email via API — fall back to <union_id>@lark.local so SSO is still functional
6 feat(workspace/lark): contacts + batch invite endpoints /api/workspaces/<slug>/lark-contacts/ + /lark-invite/
7 feat(web/workspace): Invite-from-Lark modal Multi-select directory picker; gated on config.is_lark_enabled + workspace-admin role
8 perf(lark-contacts): concurrent dept crawl + 10-min Redis cache Cold 50s → Warm <5ms
9 feat(i18n): VITE_DEFAULT_LANGUAGE env + browser auto-detect Priority chain: localStorage > env > navigator.languages > en. Backwards compatible.

Setup notes for reviewers

To exercise the full flow against a real Feishu tenant:

  1. Create an internal app in https://open.feishu.cn → grab App ID + App Secret
  2. Add the OAuth scopes: contact:user.email:readonly contact:user.basic_profile:readonly
  3. For the directory picker, also grant tenant-token scopes: contact:contact.base:readonly, contact:user.base:readonly, contact:user.email:readonly, and set their data range to "All members"
  4. Set LARK_CLIENT_ID, LARK_CLIENT_SECRET, LARK_BASE_DOMAIN=feishu.cn (or larksuite.com) on the API
  5. Enable in admin → authentication

Known caveats

  • The synthetic <union_id>@lark.local email is necessary because Feishu's contact v3 API does not always expose enterprise_email (some tenants source the "Business Email" display from Feishu Mail rather than the contact record). Open to a different fallback shape if maintainers prefer.
  • The contacts crawler runs serially within each top-level department subtree; concurrency is only at the top level. Could be deeper, but the 10-min cache makes cold paths rare in practice.
  • i18n env var is opt-in (empty → browser detection only) so existing deploys see no behaviour change.

Marking this draft to give maintainers room to suggest the right shape before I polish anything else. Happy to split into smaller PRs (auth-only / invite-only / i18n) if that's easier to land.

Marcus Cheung added 9 commits May 13, 2026 02:14
Adds Lark (open.feishu.cn / open.larksuite.com) as a first-class
OAuth provider, mirroring the existing Google/Gitea pattern.

Backend (api):
- New LarkOAuthProvider handling Lark's v2 token endpoint (JSON body)
  and v1 user_info endpoint (which wraps the payload in {code, msg, data}).
- New initiate + callback views for both the App and Space surfaces.
- Wired into urls.py with /auth/lark/, /auth/lark/callback/,
  /auth/spaces/lark/, /auth/spaces/lark/callback/.
- Added LARK_NOT_CONFIGURED (5113) and LARK_OAUTH_PROVIDER_ERROR (5124)
  error codes.
- /api/instances/ now exposes is_lark_enabled, driven by the existing
  get_configuration_value() pattern (IS_LARK_ENABLED env var).
- LARK_BASE_DOMAIN env var (default feishu.cn) toggles between the
  China (feishu.cn) and international (larksuite.com) deployments.

Types:
- Extended TCoreInstanceAuthenticationModeKeys, TInstanceAuthenticationMethodKeys,
  TInstanceAuthenticationConfigurationKeys, and TCoreLoginMediums with Lark.
- New TInstanceLarkAuthenticationConfigurationKeys for LARK_CLIENT_ID /
  LARK_CLIENT_SECRET / LARK_BASE_DOMAIN.
- IInstanceConfig gains is_lark_enabled.

Admin UI (partial):
- New LarkConfiguration toggle component.
- Registered Lark in getCoreAuthenticationModesMap.
- Placeholder Lark logo (to be replaced with brand-approved asset).
- TODO: dedicated /authentication/lark page + form for entering client_id/secret.
  For now, configuration is via env vars (IS_LARK_ENABLED, LARK_CLIENT_ID,
  LARK_CLIENT_SECRET, LARK_BASE_DOMAIN) — backend reads from env when DB has
  no instance configuration row.

Web (TODO):
- Login button for "Sign in with Lark" in apps/web/core/hooks/oauth/core.tsx.

Tested:
- py_compile on all 3 new Python files passes.

Refs: planned upstream PR to makeplane/plane after frontend completion.
Companion to the LarkOAuthProvider backend (d506213). Wires Lark into:
- apps/web/core/hooks/oauth/core.tsx — isOAuthEnabled + oAuthOptions
- apps/space/hooks/oauth/core.tsx — same, for public space sign-in
- apps/web/app/assets/logos/lark-logo.svg — placeholder Lark mark
- apps/space/app/assets/logos/lark-logo.svg — same

When IS_LARK_ENABLED=1 and the API exposes is_lark_enabled in /api/instances/,
the login page now renders a "Sign in with Lark" button that hits /auth/lark/
(or /auth/spaces/lark/ on the space surface), triggering the OAuth handshake
implemented in plane.authentication.provider.oauth.lark.LarkOAuthProvider.

Logo asset is a minimal placeholder using the Lark brand blue (#3370FF); will
be replaced with the brand-approved mark before opening upstream PR.

Refs: #lark-sso continuation of feature/lark-oauth-provider.
…ollisions and simplifying token success check

Two bugs in lark.py surfaced during first end-to-end test on task.vijimgroup.com:

1) logger.warning(..., extra={..."msg": ...}) raised KeyError('Attempt to
   overwrite msg in LogRecord'). Python logging refuses to overwrite any
   LogRecord builtin (msg, args, name, levelname, ...). Rename collisions to
   lark_msg / lark_error / lark_code / lark_keys so the warning actually emits.

2) set_token_data() previously gated success on token_response.get('code', 0) == 0,
   but Lark's v2 /authen/v2/oauth/token endpoint follows RFC 6749 exactly: success
   is a flat payload without any 'code' field; errors return
   {error, error_description, code}. The old check would have rejected every
   successful exchange. Switch to 'access_token presence' as the success signal.

Also added lark_keys/lark_payload_keys diagnostics to the user_info warning so
future divergence between Lark's v1 user_info wrapping and the OAuth flow we expect
is visible at warning level instead of needing DEBUG=1 to recover the traceback.

Tested end-to-end: marcus@joby.com successfully signed in via Lark on
task.vijimgroup.com after this fix.
pnpm 11 installs globals under $PNPM_HOME/bin (not $PNPM_HOME).
Without /pnpm/bin in PATH, "pnpm add -g turbo" aborts with
"configured global bin directory not in PATH" before the install
can complete. Adds /pnpm/bin to PATH in web/admin/space images.
…no email

Lark's v1 user_info and contact v3 endpoints both omit enterprise_email
for tenants whose "Business Email" displays from Feishu Mail rather than
from a directory record. To keep SSO functional, fall back to
"<union_id>@lark.local" so account matching has a stable, tenant-wide
identifier even when no real email is reachable via API.

Also adds the tenant_access_token helper plus richer error logging so the
v1+v3 lookup chain can be diagnosed without DEBUG=1.
…oints

Two endpoints under the workspace scope, both gated by WorkSpaceAdminPermission:

- GET  /api/workspaces/<slug>/lark-contacts/  Walks the tenant directory
  (visible to the Lark app via /open-apis/contact/v3/scopes + /users) and
  returns one row per unique union_id with name/email/avatar.

- POST /api/workspaces/<slug>/lark-invite/    Pre-creates User accounts
  (email = enterprise_email when exposed, else <union_id>@lark.local — the
  same synthetic identifier the OAuth provider hands out on first sign-in)
  and links them as active WorkspaceMember rows. Idempotent.

The lark.py provider already handles the synthetic-id fallback at sign-in
time, so contacts invited here can sign in via SSO and land on their
existing record.
Adds a second invite button on the workspace members page (gated on
config.is_lark_enabled and workspace-admin role) that opens a modal
listing every contact the Feishu app is authorised to see. The modal
supports search, multi-select, role pick (Guest/Member/Admin) and
posts the chosen contacts to the new /lark-invite/ endpoint, then
refreshes the workspace members list on success.

The email-based invite flow is unchanged — both buttons live side by
side so admins can use whichever fits the situation.
The cold path still spends most of its time walking deep department
trees serially per top-level dept, so going wider over the four roots
with a ThreadPoolExecutor only trims a few seconds. The real win is the
cache: subsequent admin opens of the Invite-from-Lark modal return in
~5 ms instead of re-crawling.

Cache is keyed on a hash of LARK_CLIENT_ID so multi-tenant deploys
don't collide, and `?refresh=1` bypasses for the rare case someone
just joined Feishu and an admin wants to invite them straight away.
initializeLanguage() now resolves in priority order:
  1. localStorage (user already picked one)
  2. VITE_DEFAULT_LANGUAGE (build-time pin for self-hosted deploys)
  3. navigator.languages (with prefix fallback so a bare "zh" still
     gets a "zh-*" locale instead of dropping to English)
  4. FALLBACK_LANGUAGE (en)

Backwards-compatible — existing users with a saved locale see no
change. New users on Chinese browsers now land on 简体中文 by default
instead of having to switch manually after each sign-in.

CI workflow pins VITE_DEFAULT_LANGUAGE=zh-CN for vijim's lark-stable
build. Other deploys can override or omit.
@CLAassistant
Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.


Marcus Cheung seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 604d517b-9791-444b-8651-6e27b35dd716

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Marcus Cheung added 11 commits May 13, 2026 13:02
The "work item" → "工作项" mapping is a literal translation but doesn't
read naturally in Chinese product copy — every other Chinese task tool
(Asana 中文版, Tower, 飞书任务) uses 任务. Switches all 250 occurrences
across translations.ts (216) and empty-state.ts (34).

No code, keys, or English strings change — purely a polish to the
user-facing Chinese vocabulary.
For org deployments where every employee should land on the same
workspace, the upstream onboarding ("create your first workspace")
defeats the purpose of SSO — admins end up with one stray workspace
per new hire that they then have to delete or migrate out of.

Adds two env knobs read by the Lark callback view after a successful
sign-in:

  LARK_DEFAULT_WORKSPACE_SLUG  workspace slug to attach users to
  LARK_DEFAULT_WORKSPACE_ROLE  int role, defaults 15 (Member)

Unset slug preserves upstream behaviour exactly. Missing workspace
slug logs a warning rather than failing the login so a typo in the
env can't lock anyone out. Existing memberships are re-activated if
inactive; existing-and-active rows are left alone (don't downgrade
admins back to Member just because they signed in again).
For org deployments where everyone's identity lives in Feishu, the
modal-driven "Invite from Lark" works for opening-day bulk imports
but stops covering new hires once Plane is live. This wires a Celery
beat task that mirrors the entire visible directory into a default
workspace every hour, plus an admin-only POST endpoint for ad-hoc
syncs.

The reusable function is sync_lark_directory(slug, role, force_refresh).
It's idempotent — re-runs find-or-create existing rows. Returns a
counts dict so the manual endpoint can render an immediate stats
toast and the periodic task can log progress.

Opt-in via env:
  LARK_AUTO_SYNC_ENABLED=1            Enables the hourly beat task
  LARK_DEFAULT_WORKSPACE_SLUG=<slug>  Target workspace
  LARK_DEFAULT_WORKSPACE_ROLE=15      Role for newly-imported members

Without LARK_AUTO_SYNC_ENABLED the schedule entry exists but the task
short-circuits — zero impact on deploys that haven't opted in.
Adds workspaceService.larkSync(slug) and a "Sync from Lark" button on
the workspace members page that calls the new /lark-sync/ endpoint
and toasts the resulting counts (new / existing / total). Button is
gated on the same is_lark_enabled + admin-role checks as Invite from
Lark, sits to its left, and disables itself while the request is in
flight (~5-8s for a 500-person tenant).

Use case: an admin who just hired someone an hour ago and doesn't
want to wait for the hourly cron can sync immediately.
Plane derives display_name from the email prefix when it isn't set
explicitly. For our synthetic <union_id>@lark.local emails that
prefix is the raw union_id, which then leaks into the assignee
picker, mention dropdown, and member list as 'on_191957171d05dd9…'
instead of the person's name.

Both the OAuth provider (sign-in path) and the sync task (bulk /
periodic import path) now pass display_name through from the Lark
directory name. Existing users get backfilled when the sync runs:
display_name is overwritten if it doesn't already match the
directory name, so a one-shot sync repairs old accounts without
needing a separate migration.
For org deployments where every employee is already in the workspace
via Lark SSO, creating a new Project leaves them out of its
ProjectMember roster — they become invisible to the assignee picker
and @mention dropdown until manually invited.

A post_save signal on Project + a Celery task close that loop: when
LARK_AUTO_JOIN_NEW_PROJECTS is enabled and the new project lives in
LARK_DEFAULT_WORKSPACE_SLUG, every active workspace member is added
as a ProjectMember at their existing workspace role. transaction.on_commit
defers the dispatch so we never enqueue against a rolled-back project.

The job runs out-of-band so project creation latency stays flat even
on 500-person tenants; members appear in the picker within seconds.
Opt-out is the default — the receiver is a cheap no-op without the
env flag.
Adds a thin, opt-in bridge from Plane's IssueActivity audit log to the
Lark IM API. Whenever Plane writes an activity row (Plane itself is
the source of truth for what counts as a notifiable change), a signal
receiver maps the row to one of three Celery tasks:

  field == 'assignees'    →  notify_issue_assigned_task
  field == 'state'        →  notify_issue_state_changed_task
  issue_comment_id set    →  notify_issue_comment_task

Each task resolves the recipient's Lark union_id via the Account
table (provider='lark') or the synthetic <union_id>@lark.local email
fallback for users who haven't signed in yet, and posts an
interactive card to their Feishu private chat.

utils/lark_notify caches tenant_access_token in Django cache (90 min;
real TTL is 2h) so a burst of notifications doesn't hammer the auth
endpoint. Card builders live alongside the send helpers so all the
copy is one click away from the integration.

Opt-in via env: LARK_NOTIFICATIONS_ENABLED=1 and the app must be
published with `im:message:send_as_bot` scope.

PLANE_PUBLIC_BASE_URL feeds the "View task" button URL.

Signal sender is IssueActivity rather than Issue/IssueAssignee/
IssueComment directly — keeps the dispatch logic in one place and
inherits Plane's exact semantics for what counts as a notifiable
change (it already filters out no-op updates).
Plane writes the IssueActivity audit log via `IssueActivity.objects.bulk_create`,
which Django does NOT fire post_save signals for. The previous
`@receiver(post_save, sender=IssueActivity)` therefore never ran — assignee /
state / comment events queued zero notify tasks despite the integration being
"enabled" in env.

Switch to dispatching directly from `issue_activities_task.issue_activity` right
after the bulk_create. New helper `dispatch_lark_for_activities` walks the
freshly-saved rows and queues the right task per field. Also adds a side-effect
import of `lark_notify_task` in `signals.py` so Celery autodiscovery registers
the three @shared_task functions at worker boot (the lazy import inside
`issue_activities_task` happens too late).
Previously every new Project triggered a fan-out of every active workspace
member as a ProjectMember, ignoring `Project.network`. Private (Secret,
network=0) projects are supposed to stay invitation-only — defeating that is
the whole reason network exists. Skip the autojoin path when network != 2
(Public). Toggling a project public later does NOT backfill — owners can
re-invite manually or via the existing Sync-from-Lark flow.
…b star CTA

Internal package paths (plane.*, @plane/*) and copyright headers are left
untouched — they're not user-visible and changing them would orphan the fork
from upstream merges. Scope of this commit:

- i18n locales (en, zh-CN): ~46 user-visible strings
- Web app: layout meta, page-title fallback, auth screens, integration card
  descriptions, activity-log system actor name, welcome screen, PWA manifests
- Admin app: page titles, sign-in heading, telemetry/AI/auth copy
- Backend: magic-link email subject
- Top nav: remove "Star us on GitHub" CTA (StarUsOnGitHubLink); component file
  retained to avoid noise on future upstream merges

Plans/pricing-card copy in plans.tsx kept verbatim — CE users never see it
and re-applying upstream PR changes there is cheap if needed later.
Earlier rebrand pass missed these — the previous commit's apps/web/app/layout.tsx
edit was a no-op because the React Router 7 SPA actually renders from
apps/web/app/root.tsx (and apps/space/app/root.tsx) with title/og/twitter meta
sourced from @plane/constants SITE_NAME/SPACE_SITE_TITLE constants. Update all
three so the prerendered HTML shell + Open Graph cards reflect "Tick" instead
of "Plane".
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants