feat(auth): Lark/Feishu OAuth provider + workspace directory invite#9063
feat(auth): Lark/Feishu OAuth provider + workspace directory invite#9063JOBYINC wants to merge 20 commits into
Conversation
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.
|
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. |
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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.
Built for teams:
One agent for your entire SDLC. Right inside Slack. 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. Comment |
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".
What
Adds end-to-end Lark/Feishu support, on par with the existing Google/GitHub/GitLab/Gitea SSO integrations:
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
KeyError: msgin extra={}$PNPM_HOME/bin, not$PNPM_HOME— unblocks frontend Docker buildenterprise_emailvia API — fall back to<union_id>@lark.localso SSO is still functional/api/workspaces/<slug>/lark-contacts/+/lark-invite/config.is_lark_enabled+ workspace-admin roleSetup notes for reviewers
To exercise the full flow against a real Feishu tenant:
App ID+App Secretcontact:user.email:readonly contact:user.basic_profile:readonlycontact:contact.base:readonly,contact:user.base:readonly,contact:user.email:readonly, and set their data range to "All members"LARK_CLIENT_ID,LARK_CLIENT_SECRET,LARK_BASE_DOMAIN=feishu.cn(orlarksuite.com) on the APIKnown caveats
<union_id>@lark.localemail is necessary because Feishu's contact v3 API does not always exposeenterprise_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.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.