From 9800d023e395964e98be53cd315a0d83283ac2ec Mon Sep 17 00:00:00 2001 From: Marcus Cheung Date: Mon, 11 May 2026 19:05:29 -0700 Subject: [PATCH 01/20] feat(auth): add Lark/Feishu OAuth provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/admin/app/assets/logos/lark-logo.svg | 4 + .../components/authentication/lark-config.tsx | 57 ++++++ apps/admin/hooks/oauth/core.tsx | 10 + .../api/plane/authentication/adapter/error.py | 2 + .../api/plane/authentication/adapter/oauth.py | 2 + .../authentication/provider/oauth/lark.py | 185 ++++++++++++++++++ apps/api/plane/authentication/urls.py | 17 ++ .../plane/authentication/views/__init__.py | 3 + .../plane/authentication/views/app/lark.py | 104 ++++++++++ .../plane/authentication/views/space/lark.py | 101 ++++++++++ apps/api/plane/license/api/views/instance.py | 6 + packages/types/src/instance/auth.ts | 16 +- packages/types/src/instance/base.ts | 1 + 13 files changed, 504 insertions(+), 4 deletions(-) create mode 100644 apps/admin/app/assets/logos/lark-logo.svg create mode 100644 apps/admin/components/authentication/lark-config.tsx create mode 100644 apps/api/plane/authentication/provider/oauth/lark.py create mode 100644 apps/api/plane/authentication/views/app/lark.py create mode 100644 apps/api/plane/authentication/views/space/lark.py diff --git a/apps/admin/app/assets/logos/lark-logo.svg b/apps/admin/app/assets/logos/lark-logo.svg new file mode 100644 index 00000000000..5e5fcd71876 --- /dev/null +++ b/apps/admin/app/assets/logos/lark-logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/admin/components/authentication/lark-config.tsx b/apps/admin/components/authentication/lark-config.tsx new file mode 100644 index 00000000000..c20f7fd0f37 --- /dev/null +++ b/apps/admin/components/authentication/lark-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"; +// icons +import { Settings2 } from "lucide-react"; +// plane internal packages +import { getButtonStyling } from "@plane/propel/button"; +import type { TInstanceAuthenticationMethodKeys } from "@plane/types"; +import { ToggleSwitch } from "@plane/ui"; +import { cn } from "@plane/utils"; +// hooks +import { useInstance } from "@/hooks/store"; + +type Props = { + disabled: boolean; + updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; +}; + +export const LarkConfiguration = observer(function LarkConfiguration(props: Props) { + const { disabled, updateConfig } = props; + // store + const { formattedConfig } = useInstance(); + // derived values + const enableLarkConfig = formattedConfig?.IS_LARK_ENABLED ?? ""; + const isLarkConfigured = !!formattedConfig?.LARK_CLIENT_ID && !!formattedConfig?.LARK_CLIENT_SECRET; + + return ( + <> + {isLarkConfigured ? ( +
+ + Edit + + { + const newEnableLarkConfig = Boolean(parseInt(enableLarkConfig)) === true ? "0" : "1"; + updateConfig("IS_LARK_ENABLED", newEnableLarkConfig); + }} + size="sm" + disabled={disabled} + /> +
+ ) : ( + + + Configure + + )} + + ); +}); diff --git a/apps/admin/hooks/oauth/core.tsx b/apps/admin/hooks/oauth/core.tsx index 9e6914e41cc..06b924e7a25 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 larkLogo from "@/app/assets/logos/lark-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 { LarkConfiguration } from "@/components/authentication/lark-config"; import { PasswordLoginConfiguration } from "@/components/authentication/password-config-switch"; // Authentication methods @@ -89,4 +91,12 @@ export const getCoreAuthenticationModesMap: ( config: , enabledConfigKey: "IS_GITEA_ENABLED", }, + lark: { + key: "lark", + name: "Lark / Feishu", + description: "Allow members to log in or sign up for Plane with their Lark or Feishu accounts.", + icon: Lark Logo, + config: , + enabledConfigKey: "IS_LARK_ENABLED", + }, }); diff --git a/apps/api/plane/authentication/adapter/error.py b/apps/api/plane/authentication/adapter/error.py index f91565df2e8..235de2a596f 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, + "LARK_NOT_CONFIGURED": 5113, "GOOGLE_OAUTH_PROVIDER_ERROR": 5115, "GITHUB_OAUTH_PROVIDER_ERROR": 5120, "GITLAB_OAUTH_PROVIDER_ERROR": 5121, "GITEA_OAUTH_PROVIDER_ERROR": 5123, + "LARK_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..1eabb879093 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 == "lark": + return "LARK_OAUTH_PROVIDER_ERROR" else: return "OAUTH_NOT_CONFIGURED" diff --git a/apps/api/plane/authentication/provider/oauth/lark.py b/apps/api/plane/authentication/provider/oauth/lark.py new file mode 100644 index 00000000000..1d1bce614a8 --- /dev/null +++ b/apps/api/plane/authentication/provider/oauth/lark.py @@ -0,0 +1,185 @@ +# 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 os +from datetime import datetime +from urllib.parse import urlencode + +import pytz +import requests + +# 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 LarkOAuthProvider(OauthAdapter): + """ + OAuth provider for Lark (feishu.cn) and Lark Suite (larksuite.com). + + Brand is selected via LARK_BASE_DOMAIN config: + - "feishu.cn" -> 飞书 (default, China) + - "larksuite.com" -> Lark (international) + + Lark deviates from typical OAuth2 in two ways: + 1. Token endpoint expects JSON body (not form-encoded). + 2. Userinfo endpoint wraps payload in {code, msg, data: {...}}. + Both are handled by overriding set_token_data and set_user_data. + """ + + scope = "contact:user.email:readonly contact:user.basic_profile:readonly" + provider = "lark" + + def __init__(self, request, code=None, state=None, callback=None): + (LARK_CLIENT_ID, LARK_CLIENT_SECRET, LARK_BASE_DOMAIN) = get_configuration_value( + [ + { + "key": "LARK_CLIENT_ID", + "default": os.environ.get("LARK_CLIENT_ID"), + }, + { + "key": "LARK_CLIENT_SECRET", + "default": os.environ.get("LARK_CLIENT_SECRET"), + }, + { + "key": "LARK_BASE_DOMAIN", + "default": os.environ.get("LARK_BASE_DOMAIN", "feishu.cn"), + }, + ] + ) + + if not (LARK_CLIENT_ID and LARK_CLIENT_SECRET): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["LARK_NOT_CONFIGURED"], + error_message="LARK_NOT_CONFIGURED", + ) + + base_domain = LARK_BASE_DOMAIN or "feishu.cn" + accounts_host = f"https://accounts.{base_domain}" + open_host = f"https://open.{base_domain}" + + self.token_url = f"{open_host}/open-apis/authen/v2/oauth/token" + self.userinfo_url = f"{open_host}/open-apis/authen/v1/user_info" + + client_id = LARK_CLIENT_ID + client_secret = LARK_CLIENT_SECRET + + redirect_uri = ( + f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/lark/callback/""" + ) + url_params = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": self.scope, + "state": state, + } + auth_url = f"{accounts_host}/open-apis/authen/v1/authorize?{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): + # Lark v2 token endpoint expects a JSON body (not form-encoded), so we + # override the base behaviour instead of going through get_user_token. + data = { + "grant_type": "authorization_code", + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": self.code, + "redirect_uri": self.redirect_uri, + } + try: + response = requests.post(self.token_url, json=data, timeout=15) + response.raise_for_status() + token_response = response.json() + except requests.RequestException: + self.logger.warning("Error getting Lark user token") + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["LARK_OAUTH_PROVIDER_ERROR"], + error_message="LARK_OAUTH_PROVIDER_ERROR", + ) + + if token_response.get("code", 0) != 0 or not token_response.get("access_token"): + self.logger.warning( + "Lark token endpoint returned an error", + extra={"response_code": token_response.get("code"), "msg": token_response.get("msg")}, + ) + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["LARK_OAUTH_PROVIDER_ERROR"], + error_message="LARK_OAUTH_PROVIDER_ERROR", + ) + + expires_in = token_response.get("expires_in") + refresh_expires_in = token_response.get("refresh_token_expires_in") + now_ts = datetime.now(tz=pytz.utc).timestamp() + + super().set_token_data( + { + "access_token": token_response.get("access_token"), + "refresh_token": token_response.get("refresh_token"), + "access_token_expired_at": ( + datetime.fromtimestamp(now_ts + expires_in, tz=pytz.utc) if expires_in else None + ), + "refresh_token_expired_at": ( + datetime.fromtimestamp(now_ts + refresh_expires_in, tz=pytz.utc) if refresh_expires_in else None + ), + "id_token": "", + } + ) + + def set_user_data(self): + user_info_response = self.get_user_response() + # Lark wraps the payload in {code, msg, data: {...}}. + payload = user_info_response.get("data") or {} + if user_info_response.get("code", 0) != 0 or not payload.get("email"): + self.logger.warning( + "Lark user_info returned an unexpected payload", + extra={ + "response_code": user_info_response.get("code"), + "msg": user_info_response.get("msg"), + "has_email": bool(payload.get("email")), + }, + ) + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["LARK_OAUTH_PROVIDER_ERROR"], + error_message="LARK_OAUTH_PROVIDER_ERROR", + ) + + # Prefer union_id (stable across all apps in a tenant) for provider_id, + # fall back to open_id (per-app stable). + provider_id = payload.get("union_id") or payload.get("open_id") + + # Lark exposes a single display name. For Plane's first/last split, put + # the full name into first_name and leave last_name empty — this avoids + # mangling CJK names which don't follow a fixed first/last convention. + full_name = payload.get("en_name") or payload.get("name") or "" + + user_data = { + "email": payload.get("email"), + "user": { + "avatar": payload.get("avatar_url"), + "first_name": full_name, + "last_name": "", + "provider_id": provider_id, + "is_password_autoset": True, + }, + } + super().set_user_data(user_data) diff --git a/apps/api/plane/authentication/urls.py b/apps/api/plane/authentication/urls.py index 4bec07db00b..a30248d537e 100644 --- a/apps/api/plane/authentication/urls.py +++ b/apps/api/plane/authentication/urls.py @@ -44,6 +44,10 @@ GiteaOauthInitiateEndpoint, GiteaCallbackSpaceEndpoint, GiteaOauthInitiateSpaceEndpoint, + LarkCallbackEndpoint, + LarkOauthInitiateEndpoint, + LarkCallbackSpaceEndpoint, + LarkOauthInitiateSpaceEndpoint, ) urlpatterns = [ @@ -150,4 +154,17 @@ GiteaCallbackSpaceEndpoint.as_view(), name="space-gitea-callback", ), + ## Lark Oauth + path("lark/", LarkOauthInitiateEndpoint.as_view(), name="lark-initiate"), + path("lark/callback/", LarkCallbackEndpoint.as_view(), name="lark-callback"), + path( + "spaces/lark/", + LarkOauthInitiateSpaceEndpoint.as_view(), + name="space-lark-initiate", + ), + path( + "spaces/lark/callback/", + LarkCallbackSpaceEndpoint.as_view(), + name="space-lark-callback", + ), ] diff --git a/apps/api/plane/authentication/views/__init__.py b/apps/api/plane/authentication/views/__init__.py index a9c816ae9ea..64fb1c57ab3 100644 --- a/apps/api/plane/authentication/views/__init__.py +++ b/apps/api/plane/authentication/views/__init__.py @@ -11,6 +11,7 @@ from .app.gitlab import GitLabCallbackEndpoint, GitLabOauthInitiateEndpoint from .app.gitea import GiteaCallbackEndpoint, GiteaOauthInitiateEndpoint from .app.google import GoogleCallbackEndpoint, GoogleOauthInitiateEndpoint +from .app.lark import LarkCallbackEndpoint, LarkOauthInitiateEndpoint from .app.magic import MagicGenerateEndpoint, MagicSignInEndpoint, MagicSignUpEndpoint from .app.signout import SignOutAuthEndpoint @@ -26,6 +27,8 @@ from .space.google import GoogleCallbackSpaceEndpoint, GoogleOauthInitiateSpaceEndpoint +from .space.lark import LarkCallbackSpaceEndpoint, LarkOauthInitiateSpaceEndpoint + from .space.magic import ( MagicGenerateSpaceEndpoint, MagicSignInSpaceEndpoint, diff --git a/apps/api/plane/authentication/views/app/lark.py b/apps/api/plane/authentication/views/app/lark.py new file mode 100644 index 00000000000..5856c67e7ba --- /dev/null +++ b/apps/api/plane/authentication/views/app/lark.py @@ -0,0 +1,104 @@ +# 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.lark import LarkOAuthProvider +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 LarkOauthInitiateEndpoint(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) + + # Check instance configuration + 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 = LarkOAuthProvider(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 LarkCallbackEndpoint(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["LARK_OAUTH_PROVIDER_ERROR"], + error_message="LARK_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["LARK_OAUTH_PROVIDER_ERROR"], + error_message="LARK_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 = LarkOAuthProvider(request=request, code=code, callback=post_user_auth_workflow) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user, is_app=True) + # Get the redirection path + 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/lark.py b/apps/api/plane/authentication/views/space/lark.py new file mode 100644 index 00000000000..69324b3d974 --- /dev/null +++ b/apps/api/plane/authentication/views/space/lark.py @@ -0,0 +1,101 @@ +# 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 +from django.utils.http import url_has_allowed_host_and_scheme + +# Module imports +from plane.authentication.provider.oauth.lark import LarkOAuthProvider +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 ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) +from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts + + +class LarkOauthInitiateSpaceEndpoint(View): + def get(self, request): + request.session["host"] = base_host(request=request, is_space=True) + next_path = request.GET.get("next_path") + + # Check instance configuration + 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_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + try: + state = uuid.uuid4().hex + provider = LarkOAuthProvider(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_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + + +class LarkCallbackSpaceEndpoint(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["LARK_OAUTH_PROVIDER_ERROR"], + error_message="LARK_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + if not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["LARK_OAUTH_PROVIDER_ERROR"], + error_message="LARK_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) + try: + provider = LarkOAuthProvider(request=request, code=code) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user, is_space=True) + # redirect to referer path + next_path = validate_next_path(next_path=next_path) + + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(base_host(request=request, is_space=True)) + except AuthenticationException as e: + params = e.get_error_dict() + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) + return HttpResponseRedirect(url) diff --git a/apps/api/plane/license/api/views/instance.py b/apps/api/plane/license/api/views/instance.py index a0d52d4912f..068cad78f64 100644 --- a/apps/api/plane/license/api/views/instance.py +++ b/apps/api/plane/license/api/views/instance.py @@ -55,6 +55,7 @@ def get(self, request): GITHUB_APP_NAME, IS_GITLAB_ENABLED, IS_GITEA_ENABLED, + IS_LARK_ENABLED, EMAIL_HOST, ENABLE_MAGIC_LINK_LOGIN, ENABLE_EMAIL_PASSWORD, @@ -95,6 +96,10 @@ def get(self, request): "key": "IS_GITEA_ENABLED", "default": os.environ.get("IS_GITEA_ENABLED", "0"), }, + { + "key": "IS_LARK_ENABLED", + "default": os.environ.get("IS_LARK_ENABLED", "0"), + }, {"key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST", "")}, { "key": "ENABLE_MAGIC_LINK_LOGIN", @@ -144,6 +149,7 @@ def get(self, request): data["is_github_enabled"] = IS_GITHUB_ENABLED == "1" data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1" data["is_gitea_enabled"] = IS_GITEA_ENABLED == "1" + data["is_lark_enabled"] = IS_LARK_ENABLED == "1" data["is_magic_login_enabled"] = ENABLE_MAGIC_LINK_LOGIN == "1" data["is_email_password_enabled"] = ENABLE_EMAIL_PASSWORD == "1" diff --git a/packages/types/src/instance/auth.ts b/packages/types/src/instance/auth.ts index f3566b291f7..74f9c2ddef9 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" + | "lark"; 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_LARK_ENABLED"; export type TInstanceGoogleAuthenticationConfigurationKeys = | "GOOGLE_CLIENT_ID" @@ -56,11 +58,17 @@ export type TInstanceGiteaAuthenticationConfigurationKeys = | "GITEA_CLIENT_SECRET" | "ENABLE_GITEA_SYNC"; +export type TInstanceLarkAuthenticationConfigurationKeys = + | "LARK_CLIENT_ID" + | "LARK_CLIENT_SECRET" + | "LARK_BASE_DOMAIN"; + export type TInstanceAuthenticationConfigurationKeys = | TInstanceGoogleAuthenticationConfigurationKeys | TInstanceGithubAuthenticationConfigurationKeys | TInstanceGitlabAuthenticationConfigurationKeys - | TInstanceGiteaAuthenticationConfigurationKeys; + | TInstanceGiteaAuthenticationConfigurationKeys + | TInstanceLarkAuthenticationConfigurationKeys; export type TInstanceAuthenticationKeys = TInstanceAuthenticationMethodKeys | TInstanceAuthenticationConfigurationKeys; @@ -83,4 +91,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" | "lark"; diff --git a/packages/types/src/instance/base.ts b/packages/types/src/instance/base.ts index 431b09ac0f3..be9eaf0ec1e 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_lark_enabled: boolean; is_magic_login_enabled: boolean; is_email_password_enabled: boolean; github_app_name: string | undefined; From b4edb02d2aede0871dd584c4dc4467cb3610967e Mon Sep 17 00:00:00 2001 From: Marcus Cheung Date: Tue, 12 May 2026 12:49:08 -0700 Subject: [PATCH 02/20] feat(auth/web): add "Sign in with Lark" button to web + space frontends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/space/app/assets/logos/lark-logo.svg | 4 ++++ apps/space/hooks/oauth/core.tsx | 13 ++++++++++++- apps/web/app/assets/logos/lark-logo.svg | 4 ++++ apps/web/core/hooks/oauth/core.tsx | 13 ++++++++++++- 4 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 apps/space/app/assets/logos/lark-logo.svg create mode 100644 apps/web/app/assets/logos/lark-logo.svg diff --git a/apps/space/app/assets/logos/lark-logo.svg b/apps/space/app/assets/logos/lark-logo.svg new file mode 100644 index 00000000000..5e5fcd71876 --- /dev/null +++ b/apps/space/app/assets/logos/lark-logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/space/hooks/oauth/core.tsx b/apps/space/hooks/oauth/core.tsx index 63a18cc2e59..f7684753b86 100644 --- a/apps/space/hooks/oauth/core.tsx +++ b/apps/space/hooks/oauth/core.tsx @@ -15,6 +15,7 @@ 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"; import googleLogo from "@/app/assets/logos/google-logo.svg?url"; +import larkLogo from "@/app/assets/logos/lark-logo.svg?url"; // hooks import { useInstance } from "@/hooks/store/use-instance"; @@ -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_lark_enabled)) || false; const oAuthOptions: TOAuthOption[] = [ { @@ -79,6 +81,15 @@ export const useCoreOAuthConfig = (oauthActionText: string): TOAuthConfigs => { }, enabled: config?.is_gitea_enabled, }, + { + id: "lark", + text: `${oauthActionText} with Lark`, + icon: Lark Logo, + onClick: () => { + window.location.assign(`${API_BASE_URL}/auth/lark/${next_path ? `?next_path=${next_path}` : ``}`); + }, + enabled: config?.is_lark_enabled, + }, ]; return { diff --git a/apps/web/app/assets/logos/lark-logo.svg b/apps/web/app/assets/logos/lark-logo.svg new file mode 100644 index 00000000000..5e5fcd71876 --- /dev/null +++ b/apps/web/app/assets/logos/lark-logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/core/hooks/oauth/core.tsx b/apps/web/core/hooks/oauth/core.tsx index 1614883fe86..6e90cf5f757 100644 --- a/apps/web/core/hooks/oauth/core.tsx +++ b/apps/web/core/hooks/oauth/core.tsx @@ -15,6 +15,7 @@ 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"; import googleLogo from "@/app/assets/logos/google-logo.svg?url"; +import larkLogo from "@/app/assets/logos/lark-logo.svg?url"; // hooks import { useInstance } from "@/hooks/store/use-instance"; @@ -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_lark_enabled)) || false; const oAuthOptions: TOAuthOption[] = [ { @@ -79,6 +81,15 @@ export const useCoreOAuthConfig = (oauthActionText: string): TOAuthConfigs => { }, enabled: config?.is_gitea_enabled, }, + { + id: "lark", + text: `${oauthActionText} with Lark`, + icon: Lark Logo, + onClick: () => { + window.location.assign(`${API_BASE_URL}/auth/lark/${next_path ? `?next_path=${next_path}` : ``}`); + }, + enabled: config?.is_lark_enabled, + }, ]; return { From bf75c60d546c7d4b90e756dca408d12386025678 Mon Sep 17 00:00:00 2001 From: Marcus Cheung Date: Tue, 12 May 2026 14:37:05 -0700 Subject: [PATCH 03/20] fix(auth/lark): unblock callback by removing LogRecord reserved-key collisions 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. --- .../authentication/provider/oauth/lark.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/api/plane/authentication/provider/oauth/lark.py b/apps/api/plane/authentication/provider/oauth/lark.py index 1d1bce614a8..d347910c071 100644 --- a/apps/api/plane/authentication/provider/oauth/lark.py +++ b/apps/api/plane/authentication/provider/oauth/lark.py @@ -117,10 +117,17 @@ def set_token_data(self): error_message="LARK_OAUTH_PROVIDER_ERROR", ) - if token_response.get("code", 0) != 0 or not token_response.get("access_token"): + # Lark v2 token endpoint returns RFC 6749-style success (flat, no `code` field) or + # error (`{error, error_description, code}`). Treat presence of `access_token` + # as the success signal and surface the `error` field on failure. + if not token_response.get("access_token"): self.logger.warning( "Lark token endpoint returned an error", - extra={"response_code": token_response.get("code"), "msg": token_response.get("msg")}, + extra={ + "lark_error": token_response.get("error"), + "lark_error_description": token_response.get("error_description"), + "lark_code": token_response.get("code"), + }, ) raise AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["LARK_OAUTH_PROVIDER_ERROR"], @@ -148,13 +155,15 @@ def set_token_data(self): def set_user_data(self): user_info_response = self.get_user_response() # Lark wraps the payload in {code, msg, data: {...}}. - payload = user_info_response.get("data") or {} + payload = user_info_response.get("data") or user_info_response or {} if user_info_response.get("code", 0) != 0 or not payload.get("email"): self.logger.warning( "Lark user_info returned an unexpected payload", extra={ - "response_code": user_info_response.get("code"), - "msg": user_info_response.get("msg"), + "lark_code": user_info_response.get("code"), + "lark_msg": user_info_response.get("msg"), + "lark_keys": list(user_info_response.keys()) if isinstance(user_info_response, dict) else None, + "lark_payload_keys": list(payload.keys()) if isinstance(payload, dict) else None, "has_email": bool(payload.get("email")), }, ) From 5bc973916cc1e66b01495a989e8268541890738a Mon Sep 17 00:00:00 2001 From: Marcus Cheung Date: Tue, 12 May 2026 17:22:35 -0700 Subject: [PATCH 04/20] ci(docker): expose pnpm global bin so turbo install succeeds 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. --- apps/admin/Dockerfile.admin | 2 +- apps/space/Dockerfile.space | 2 +- apps/web/Dockerfile.web | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/admin/Dockerfile.admin b/apps/admin/Dockerfile.admin index 19ad2c392a1..3ee1d73bf98 100644 --- a/apps/admin/Dockerfile.admin +++ b/apps/admin/Dockerfile.admin @@ -4,7 +4,7 @@ WORKDIR /app ENV TURBO_TELEMETRY_DISABLED=1 ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" +ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH" ENV CI=1 RUN corepack enable pnpm diff --git a/apps/space/Dockerfile.space b/apps/space/Dockerfile.space index 60d4a155aa8..39a05176aeb 100644 --- a/apps/space/Dockerfile.space +++ b/apps/space/Dockerfile.space @@ -4,7 +4,7 @@ WORKDIR /app ENV TURBO_TELEMETRY_DISABLED=1 ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" +ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH" ENV CI=1 RUN corepack enable pnpm diff --git a/apps/web/Dockerfile.web b/apps/web/Dockerfile.web index 38af19e74ba..8da6b1e8346 100644 --- a/apps/web/Dockerfile.web +++ b/apps/web/Dockerfile.web @@ -3,7 +3,7 @@ FROM node:22-alpine AS base # Setup pnpm package manager with corepack and configure global bin directory for caching ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" +ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH" RUN corepack enable # ***************************************************************************** From 939e994dfdc1404be4e8174d9c6526998cb27dd1 Mon Sep 17 00:00:00 2001 From: Marcus Cheung Date: Tue, 12 May 2026 17:22:41 -0700 Subject: [PATCH 05/20] feat(auth/lark): synthesize stable identifier when directory exposes 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 "@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. --- .../authentication/provider/oauth/lark.py | 151 ++++++++++++++++-- 1 file changed, 142 insertions(+), 9 deletions(-) diff --git a/apps/api/plane/authentication/provider/oauth/lark.py b/apps/api/plane/authentication/provider/oauth/lark.py index d347910c071..0d34e27bb56 100644 --- a/apps/api/plane/authentication/provider/oauth/lark.py +++ b/apps/api/plane/authentication/provider/oauth/lark.py @@ -62,10 +62,11 @@ def __init__(self, request, code=None, state=None, callback=None): base_domain = LARK_BASE_DOMAIN or "feishu.cn" accounts_host = f"https://accounts.{base_domain}" - open_host = f"https://open.{base_domain}" + # Stored for the contact-v3 fallback in set_user_data (see _fetch_enterprise_email). + self.open_host = f"https://open.{base_domain}" - self.token_url = f"{open_host}/open-apis/authen/v2/oauth/token" - self.userinfo_url = f"{open_host}/open-apis/authen/v1/user_info" + self.token_url = f"{self.open_host}/open-apis/authen/v2/oauth/token" + self.userinfo_url = f"{self.open_host}/open-apis/authen/v1/user_info" client_id = LARK_CLIENT_ID client_secret = LARK_CLIENT_SECRET @@ -110,8 +111,26 @@ def set_token_data(self): response = requests.post(self.token_url, json=data, timeout=15) response.raise_for_status() token_response = response.json() - except requests.RequestException: - self.logger.warning("Error getting Lark user token") + except requests.RequestException as exc: + # Surface Lark's actual error body so we can distinguish expired code, + # bad redirect_uri, bad credentials, etc. without DEBUG=1. + body_snippet = "" + status_code = None + if exc.response is not None: + status_code = exc.response.status_code + try: + body_snippet = exc.response.text[:500] + except Exception: + body_snippet = "" + self.logger.warning( + "Lark token exchange failed", + extra={ + "lark_token_url": self.token_url, + "lark_http_status": status_code, + "lark_response_body": body_snippet, + "lark_redirect_uri": self.redirect_uri, + }, + ) raise AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["LARK_OAUTH_PROVIDER_ERROR"], error_message="LARK_OAUTH_PROVIDER_ERROR", @@ -152,19 +171,133 @@ def set_token_data(self): } ) + def _get_tenant_access_token(self): + # The contact v3 endpoint requires app-level identity (tenant_access_token), + # not user_access_token — the user-token call returns 400 with + # "scope contact:contact:readonly_as_app required" even when the user has + # delegated their own email scope. + url = f"{self.open_host}/open-apis/auth/v3/tenant_access_token/internal" + try: + response = requests.post( + url, + json={"app_id": self.client_id, "app_secret": self.client_secret}, + timeout=15, + ) + response.raise_for_status() + body = response.json() + except requests.RequestException as exc: + self.logger.warning( + "Lark tenant_access_token request failed", + extra={ + "lark_http_status": exc.response.status_code if exc.response is not None else None, + "lark_response_body": (exc.response.text[:300] if exc.response is not None else ""), + }, + ) + return None + + if body.get("code", 0) != 0: + self.logger.warning( + "Lark tenant_access_token returned a non-zero code", + extra={"lark_code": body.get("code"), "lark_msg": body.get("msg")}, + ) + return None + + return body.get("tenant_access_token") + + def _fetch_enterprise_email(self, open_id): + # The v1 authen/user_info endpoint omits enterprise_email — fall back to + # contact v3 with tenant_access_token. Requires the app to have one of: + # contact:contact.base:readonly, contact:contact:readonly, + # contact:contact:access_as_app, contact:contact:readonly_as_app. + tenant_token = self._get_tenant_access_token() + if not tenant_token: + return None + + url = f"{self.open_host}/open-apis/contact/v3/users/{open_id}" + try: + response = requests.get( + url, + params={"user_id_type": "open_id"}, + headers={"Authorization": f"Bearer {tenant_token}"}, + timeout=15, + ) + response.raise_for_status() + body = response.json() + except requests.RequestException as exc: + self.logger.warning( + "Lark contact v3 lookup failed", + extra={ + "lark_contact_url": url, + "lark_http_status": exc.response.status_code if exc.response is not None else None, + "lark_response_body": (exc.response.text[:300] if exc.response is not None else ""), + }, + ) + return None + + if body.get("code", 0) != 0: + self.logger.warning( + "Lark contact v3 returned a non-zero code", + extra={ + "lark_code": body.get("code"), + "lark_msg": body.get("msg"), + }, + ) + return None + + user_record = (body.get("data") or {}).get("user") or {} + return user_record.get("enterprise_email") or user_record.get("email") + def set_user_data(self): user_info_response = self.get_user_response() # Lark wraps the payload in {code, msg, data: {...}}. payload = user_info_response.get("data") or user_info_response or {} - if user_info_response.get("code", 0) != 0 or not payload.get("email"): + + if user_info_response.get("code", 0) != 0: self.logger.warning( - "Lark user_info returned an unexpected payload", + "Lark user_info returned a non-zero code", extra={ "lark_code": user_info_response.get("code"), "lark_msg": user_info_response.get("msg"), "lark_keys": list(user_info_response.keys()) if isinstance(user_info_response, dict) else None, + }, + ) + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["LARK_OAUTH_PROVIDER_ERROR"], + error_message="LARK_OAUTH_PROVIDER_ERROR", + ) + + # In Lark's data model, `email` is the user's personal email (often blank) + # and `enterprise_email` is the corporate email shown as "Business Email" + # in the directory UI. Prefer enterprise_email so SSO matches the address + # employees actually identify with. + email = payload.get("enterprise_email") or payload.get("email") + + # The v1 user_info endpoint usually omits enterprise_email entirely — fall + # back to contact v3 which exposes the full directory record. + if not email and payload.get("open_id"): + email = self._fetch_enterprise_email(payload["open_id"]) + + # Last resort: some Feishu tenants don't expose any user email via API at + # all — the "Business Email" shown in profile is sourced from Feishu Mail + # and isn't part of the standard contact record. Synthesize a stable + # identifier from union_id (or open_id) so SSO can still match an + # existing user account. + synthetic_email_used = False + if not email: + stable_id = payload.get("union_id") or payload.get("open_id") + if stable_id: + email = f"{stable_id}@lark.local" + synthetic_email_used = True + self.logger.info( + "Lark user has no directory email — synthesizing identifier", + extra={"lark_synthetic_email": True}, + ) + + if not email: + self.logger.warning( + "Lark user has no usable identifier (no email, no union_id, no open_id)", + extra={ "lark_payload_keys": list(payload.keys()) if isinstance(payload, dict) else None, - "has_email": bool(payload.get("email")), }, ) raise AuthenticationException( @@ -182,7 +315,7 @@ def set_user_data(self): full_name = payload.get("en_name") or payload.get("name") or "" user_data = { - "email": payload.get("email"), + "email": email, "user": { "avatar": payload.get("avatar_url"), "first_name": full_name, From 89b1c9bfaa9b058679e7801a93653a2c55528675 Mon Sep 17 00:00:00 2001 From: Marcus Cheung Date: Tue, 12 May 2026 18:13:42 -0700 Subject: [PATCH 06/20] feat(workspace/lark): add Lark directory contacts + batch invite endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two endpoints under the workspace scope, both gated by WorkSpaceAdminPermission: - GET /api/workspaces//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//lark-invite/ Pre-creates User accounts (email = enterprise_email when exposed, else @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. --- apps/api/plane/app/urls/workspace.py | 12 + apps/api/plane/app/views/__init__.py | 4 + .../plane/app/views/workspace/lark_invite.py | 318 ++++++++++++++++++ 3 files changed, 334 insertions(+) create mode 100644 apps/api/plane/app/views/workspace/lark_invite.py diff --git a/apps/api/plane/app/urls/workspace.py b/apps/api/plane/app/urls/workspace.py index d79d5a74522..3994bb78eb5 100644 --- a/apps/api/plane/app/urls/workspace.py +++ b/apps/api/plane/app/urls/workspace.py @@ -36,6 +36,8 @@ WorkspaceHomePreferenceViewSet, WorkspaceStickyViewSet, WorkspaceUserPreferenceViewSet, + LarkContactsListEndpoint, + LarkWorkspaceInviteEndpoint, ) @@ -67,6 +69,16 @@ WorkspaceInvitationsViewset.as_view({"get": "list", "post": "create"}), name="workspace-invitations", ), + path( + "workspaces//lark-contacts/", + LarkContactsListEndpoint.as_view(), + name="workspace-lark-contacts", + ), + path( + "workspaces//lark-invite/", + LarkWorkspaceInviteEndpoint.as_view(), + name="workspace-lark-invite", + ), path( "workspaces//invitations//", WorkspaceInvitationsViewset.as_view({"delete": "destroy", "get": "retrieve", "patch": "partial_update"}), diff --git a/apps/api/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py index 84f7872ec85..c569a895780 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -65,6 +65,10 @@ WorkspaceJoinEndpoint, UserWorkspaceInvitationsViewSet, ) +from .workspace.lark_invite import ( + LarkContactsListEndpoint, + LarkWorkspaceInviteEndpoint, +) from .workspace.label import WorkspaceLabelsEndpoint from .workspace.state import WorkspaceStatesEndpoint from .workspace.user import ( diff --git a/apps/api/plane/app/views/workspace/lark_invite.py b/apps/api/plane/app/views/workspace/lark_invite.py new file mode 100644 index 00000000000..7bb54ed57b5 --- /dev/null +++ b/apps/api/plane/app/views/workspace/lark_invite.py @@ -0,0 +1,318 @@ +# 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 os +import logging + +import requests + +# Django imports +from django.db import transaction + +# Third party modules +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.app.permissions import WorkSpaceAdminPermission +from plane.app.views.base import BaseAPIView +from plane.db.models import User, Workspace, WorkspaceMember +from plane.license.utils.instance_value import get_configuration_value + +logger = logging.getLogger("plane.app.views.workspace.lark_invite") + +DEFAULT_MEMBER_ROLE = 15 # matches ROLE_CHOICES on WorkspaceMember; 15 = Member + + +def _get_lark_config(): + (client_id, client_secret, base_domain) = get_configuration_value( + [ + {"key": "LARK_CLIENT_ID", "default": os.environ.get("LARK_CLIENT_ID")}, + {"key": "LARK_CLIENT_SECRET", "default": os.environ.get("LARK_CLIENT_SECRET")}, + {"key": "LARK_BASE_DOMAIN", "default": os.environ.get("LARK_BASE_DOMAIN", "feishu.cn")}, + ] + ) + return client_id, client_secret, (base_domain or "feishu.cn") + + +def _tenant_access_token(): + client_id, client_secret, base_domain = _get_lark_config() + if not client_id or not client_secret: + return None, "LARK_NOT_CONFIGURED" + + url = f"https://open.{base_domain}/open-apis/auth/v3/tenant_access_token/internal" + try: + resp = requests.post( + url, + json={"app_id": client_id, "app_secret": client_secret}, + timeout=15, + ) + resp.raise_for_status() + body = resp.json() + except requests.RequestException as exc: + logger.warning("Lark tenant_access_token request failed: %s", exc) + return None, "LARK_TOKEN_REQUEST_FAILED" + + if body.get("code", 0) != 0: + logger.warning("Lark tenant_access_token returned non-zero code: %s", body) + return None, body.get("msg") or "LARK_TOKEN_ERROR" + + return body.get("tenant_access_token"), None + + +def _lark_get(token, path, params=None, base_domain=None): + if base_domain is None: + _, _, base_domain = _get_lark_config() + url = f"https://open.{base_domain}{path}" + resp = requests.get(url, params=params or {}, headers={"Authorization": f"Bearer {token}"}, timeout=20) + resp.raise_for_status() + return resp.json() + + +def _walk_department(token, dept_id, user_id_type="open_id"): + """Yield all users under a department, walking children iteratively. + + Lark's /users endpoint paginates by page_token; /departments//children + lists sub-departments. Breadth-first so the modal sees the full tree the + app is authorised to see. + """ + queue = [dept_id] + seen_depts = set() + while queue: + current = queue.pop(0) + if current in seen_depts: + continue + seen_depts.add(current) + + # users directly under this department, paginate via page_token + page_token = None + while True: + params = { + "department_id": current, + "user_id_type": user_id_type, + "page_size": 50, + } + if page_token: + params["page_token"] = page_token + try: + body = _lark_get(token, "/open-apis/contact/v3/users", params=params) + except requests.RequestException as exc: + logger.warning("Lark users fetch failed for dept %s: %s", current, exc) + break + + if body.get("code", 0) != 0: + # 40004 = no_dept_authority; skip silently so the other depts still resolve. + logger.info("Lark users fetch non-zero for dept %s: %s", current, body.get("msg")) + break + + for u in (body.get("data") or {}).get("items") or []: + yield u + + if not (body.get("data") or {}).get("has_more"): + break + page_token = (body.get("data") or {}).get("page_token") + if not page_token: + break + + # sub-departments + try: + sub_body = _lark_get( + token, + f"/open-apis/contact/v3/departments/{current}/children", + params={"department_id_type": "open_department_id", "page_size": 50}, + ) + for d in (sub_body.get("data") or {}).get("items") or []: + child_id = d.get("open_department_id") or d.get("department_id") + if child_id: + queue.append(child_id) + except requests.RequestException: + # children traversal is best-effort; missing a sub-tree shouldn't break listing + pass + + +def _batch_fetch_users(token, user_open_ids, user_id_type="open_id"): + """Lark's batch GET /users/batch supports up to 50 ids per call.""" + out = [] + for i in range(0, len(user_open_ids), 50): + chunk = user_open_ids[i : i + 50] + try: + body = _lark_get( + token, + "/open-apis/contact/v3/users/batch", + params=[("user_ids", uid) for uid in chunk] + [("user_id_type", user_id_type)], + ) + except requests.RequestException as exc: + logger.warning("Lark batch users fetch failed: %s", exc) + continue + if body.get("code", 0) != 0: + continue + out.extend((body.get("data") or {}).get("items") or []) + return out + + +class LarkContactsListEndpoint(BaseAPIView): + """Returns the union of all Lark users the app is authorised to see, used by + the workspace "Invite from Lark" modal. No pagination — the directory is + small enough that the client filters locally. + """ + + permission_classes = [WorkSpaceAdminPermission] + + def get(self, request, slug): + token, err = _tenant_access_token() + if err: + return Response({"error": err}, status=status.HTTP_400_BAD_REQUEST) + + try: + scopes_body = _lark_get( + token, + "/open-apis/contact/v3/scopes", + params={"user_id_type": "open_id", "page_size": 100}, + ) + except requests.RequestException as exc: + return Response({"error": f"LARK_SCOPES_FAILED: {exc}"}, status=status.HTTP_502_BAD_GATEWAY) + if scopes_body.get("code", 0) != 0: + return Response( + {"error": scopes_body.get("msg") or "LARK_SCOPES_ERROR"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + data = scopes_body.get("data") or {} + dept_ids = data.get("department_ids") or [] + direct_user_ids = data.get("user_ids") or [] + + seen = set() + contacts = [] + + for dept_id in dept_ids: + for u in _walk_department(token, dept_id): + key = u.get("union_id") or u.get("open_id") + if not key or key in seen: + continue + seen.add(key) + contacts.append(self._serialise(u)) + + direct_users = _batch_fetch_users(token, [uid for uid in direct_user_ids if uid not in seen]) + for u in direct_users: + key = u.get("union_id") or u.get("open_id") + if not key or key in seen: + continue + seen.add(key) + contacts.append(self._serialise(u)) + + return Response({"contacts": contacts}, status=status.HTTP_200_OK) + + @staticmethod + def _serialise(u): + # Surface only fields the modal needs; drops mobile/employee_no so we + # don't leak unused PII into the browser. + return { + "union_id": u.get("union_id"), + "open_id": u.get("open_id"), + "name": u.get("name") or u.get("en_name") or "", + "en_name": u.get("en_name") or "", + "email": u.get("email") or "", + "enterprise_email": u.get("enterprise_email") or "", + "avatar_url": (u.get("avatar") or {}).get("avatar_240") + or (u.get("avatar") or {}).get("avatar_url") + or u.get("avatar_url") + or "", + } + + +class LarkWorkspaceInviteEndpoint(BaseAPIView): + """Batch pre-creates Plane User accounts for selected Lark contacts and adds + them as active workspace members. Idempotent: existing users get linked, + existing members get re-activated rather than duplicated. + + Body: {"users": [{"union_id": "on_...", "open_id": "ou_...", "name": "...", + "email": "...", "avatar_url": "...", "role": 15}], "role": 15} + """ + + permission_classes = [WorkSpaceAdminPermission] + + def post(self, request, slug): + users_in = request.data.get("users") or [] + if not isinstance(users_in, list) or not users_in: + return Response({"error": "users[] is required"}, status=status.HTTP_400_BAD_REQUEST) + + default_role = int(request.data.get("role") or DEFAULT_MEMBER_ROLE) + + requester_member = WorkspaceMember.objects.get( + workspace__slug=slug, member=request.user, is_active=True + ) + if default_role > requester_member.role: + return Response( + {"error": "Cannot invite at a role higher than your own"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspace = Workspace.objects.get(slug=slug) + + invited = [] + skipped = [] + errors = [] + + for entry in users_in: + union_id = entry.get("union_id") + open_id = entry.get("open_id") + stable_id = union_id or open_id + if not stable_id: + errors.append({"entry": entry, "error": "missing union_id and open_id"}) + continue + + # Prefer the real directory email when Lark exposes one — gives the + # user a recognisable identity inside Plane. Fall back to + # @lark.local which matches the synthetic identifier the + # OAuth provider hands out on first sign-in. + email = (entry.get("enterprise_email") or entry.get("email") or "").strip().lower() + if not email: + email = f"{stable_id}@lark.local" + + role = int(entry.get("role") or default_role) + if role > requester_member.role: + errors.append({"entry": entry, "error": "role exceeds requester role"}) + continue + + try: + with transaction.atomic(): + user, user_created = User.objects.get_or_create( + email=email, + defaults={ + "first_name": entry.get("name") or "", + "last_name": "", + "is_password_autoset": True, + "is_email_verified": True, + }, + ) + if not user.first_name and entry.get("name"): + user.first_name = entry.get("name") or "" + user.save(update_fields=["first_name"]) + + wm, wm_created = WorkspaceMember.objects.get_or_create( + workspace=workspace, + member=user, + defaults={"role": role, "is_active": True}, + ) + if not wm_created and not wm.is_active: + wm.is_active = True + wm.role = role + wm.save(update_fields=["is_active", "role"]) + + invited.append( + { + "email": email, + "user_created": user_created, + "member_created": wm_created, + } + ) + except Exception as exc: + logger.exception("Failed to invite Lark user: %s", entry) + errors.append({"entry": entry, "error": str(exc)}) + + return Response( + {"invited": invited, "skipped": skipped, "errors": errors}, + status=status.HTTP_200_OK, + ) From cafb783a9e61b6151fe8b4e7aaa5299266dc9590 Mon Sep 17 00:00:00 2001 From: Marcus Cheung Date: Tue, 12 May 2026 18:13:49 -0700 Subject: [PATCH 07/20] feat(web/workspace): Invite-from-Lark modal in members settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../settings/(workspace)/members/page.tsx | 19 +- .../workspace/settings/lark-invite-modal.tsx | 256 ++++++++++++++++++ .../src/workspace/workspace.service.ts | 53 ++++ 3 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 apps/web/core/components/workspace/settings/lark-invite-modal.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx index 1e09ef32b11..68bef960339 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx @@ -19,8 +19,10 @@ import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view import { CountChip } from "@/components/common/count-chip"; import { PageHead } from "@/components/core/page-title"; import { MemberListFiltersDropdown } from "@/components/project/dropdowns/filters/member-list"; +import { LarkInviteModal } from "@/components/workspace/settings/lark-invite-modal"; import { WorkspaceMembersList } from "@/components/workspace/settings/members-list"; // hooks +import { useInstance } from "@/hooks/store/use-instance"; import { useMember } from "@/hooks/store/use-member"; import { useWorkspace } from "@/hooks/store/use-workspace"; import { useUserPermissions } from "@/hooks/store/user"; @@ -35,15 +37,17 @@ import { MembersWorkspaceSettingsHeader } from "./header"; const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsPage({ params }: Route.ComponentProps) { // states const [inviteModal, setInviteModal] = useState(false); + const [larkInviteModal, setLarkInviteModal] = useState(false); const [searchQuery, setSearchQuery] = useState(""); // router const { workspaceSlug } = params; // store hooks const { workspaceUserInfo, allowPermissions } = useUserPermissions(); const { - workspace: { workspaceMemberIds, inviteMembersToWorkspace, filtersStore }, + workspace: { workspaceMemberIds, inviteMembersToWorkspace, fetchWorkspaceMembers, filtersStore }, } = useMember(); const { currentWorkspace } = useWorkspace(); + const { config } = useInstance(); const { t } = useTranslation(); // derived values @@ -108,6 +112,14 @@ const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsP onClose={() => setInviteModal(false)} onSubmit={handleWorkspaceInvite} /> + {config?.is_lark_enabled && ( + setLarkInviteModal(false)} + onInvited={() => fetchWorkspaceMembers(workspaceSlug)} + /> + )}
+ {canPerformWorkspaceAdminActions && config?.is_lark_enabled && ( + + )} {canPerformWorkspaceAdminActions && ( + + + + +
+ {loading ? ( +
Loading Lark directory…
+ ) : loadError ? ( +
{loadError}
+ ) : filtered.length === 0 ? ( +
+ {contacts.length === 0 + ? "No contacts visible. Check the app's Range of Access in the Feishu developer console." + : "No matches for that search."} +
+ ) : ( +
    + {filtered.map((c) => { + const key = c.union_id || c.open_id; + const isSelected = selected.has(key); + const displayEmail = + c.enterprise_email || c.email || "(no email — synthetic identifier will be used)"; + return ( +
  • + +
  • + ); + })} +
+ )} +
+ +
+ +
+ + +
+
+ + + ); +}; diff --git a/packages/services/src/workspace/workspace.service.ts b/packages/services/src/workspace/workspace.service.ts index 3f1ad42e90e..fb9fdb9faff 100644 --- a/packages/services/src/workspace/workspace.service.ts +++ b/packages/services/src/workspace/workspace.service.ts @@ -144,4 +144,57 @@ export class WorkspaceService extends APIService { throw error?.response?.data; }); } + + /** + * Lists the Lark/Feishu directory contacts the app is authorised to see. + * Backed by /open-apis/contact/v3/scopes + /users on the Plane API side. + */ + async listLarkContacts(workspaceSlug: string): Promise<{ contacts: TLarkContact[] }> { + return this.get(`/api/workspaces/${workspaceSlug}/lark-contacts/`) + .then((res) => res?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Pre-creates Plane user accounts for the selected Lark contacts and adds + * them as active workspace members. Idempotent — existing users are linked. + */ + async larkInvite( + workspaceSlug: string, + payload: { users: TLarkInviteUser[]; role?: number } + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/lark-invite/`, payload) + .then((res) => res?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } + +export type TLarkContact = { + union_id: string; + open_id: string; + name: string; + en_name: string; + email: string; + enterprise_email: string; + avatar_url: string; +}; + +export type TLarkInviteUser = { + union_id?: string; + open_id?: string; + name?: string; + email?: string; + enterprise_email?: string; + avatar_url?: string; + role?: number; +}; + +export type TLarkInviteResponse = { + invited: { email: string; user_created: boolean; member_created: boolean }[]; + skipped: unknown[]; + errors: { entry: unknown; error: string }[]; +}; From 03ce3876aa2b7467188252d70c6efcc5251d8ee6 Mon Sep 17 00:00:00 2001 From: Marcus Cheung Date: Wed, 13 May 2026 02:12:55 -0700 Subject: [PATCH 08/20] perf(lark-contacts): concurrent dept crawl + 10-min Redis cache 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. --- .../plane/app/views/workspace/lark_invite.py | 122 ++++++++++++------ 1 file changed, 85 insertions(+), 37 deletions(-) diff --git a/apps/api/plane/app/views/workspace/lark_invite.py b/apps/api/plane/app/views/workspace/lark_invite.py index 7bb54ed57b5..1b48a06f925 100644 --- a/apps/api/plane/app/views/workspace/lark_invite.py +++ b/apps/api/plane/app/views/workspace/lark_invite.py @@ -3,12 +3,15 @@ # See the LICENSE file for details. # Python imports -import os +import hashlib import logging +import os +from concurrent.futures import ThreadPoolExecutor, as_completed import requests # Django imports +from django.core.cache import cache from django.db import transaction # Third party modules @@ -24,6 +27,8 @@ logger = logging.getLogger("plane.app.views.workspace.lark_invite") DEFAULT_MEMBER_ROLE = 15 # matches ROLE_CHOICES on WorkspaceMember; 15 = Member +CONTACTS_CACHE_TTL = 600 # 10 minutes; admins re-open this modal often during onboarding +DEPT_CRAWL_WORKERS = 5 # Lark v3 contact API tolerates a few concurrent calls comfortably def _get_lark_config(): @@ -152,57 +157,100 @@ def _batch_fetch_users(token, user_open_ids, user_id_type="open_id"): return out +def _cache_key(): + client_id, _, _ = _get_lark_config() + # Hash so the key doesn't leak the client_id into Redis logs/dumps. + digest = hashlib.sha1((client_id or "").encode("utf-8")).hexdigest()[:12] + return f"lark:contacts:{digest}" + + +def _crawl_directory(token): + """Concurrent traversal of every department the app can see, plus any users + visible directly (typically the app installer). Returns a deduplicated list + of serialised contacts. + """ + try: + scopes_body = _lark_get( + token, + "/open-apis/contact/v3/scopes", + params={"user_id_type": "open_id", "page_size": 100}, + ) + except requests.RequestException as exc: + raise RuntimeError(f"LARK_SCOPES_FAILED: {exc}") + if scopes_body.get("code", 0) != 0: + raise RuntimeError(scopes_body.get("msg") or "LARK_SCOPES_ERROR") + + data = scopes_body.get("data") or {} + dept_ids = data.get("department_ids") or [] + direct_user_ids = data.get("user_ids") or [] + + # Walk every department in parallel — each _walk_department call does its own + # paginated /users + recursive /departments//children traversal. + dept_results: list[list[dict]] = [] + if dept_ids: + with ThreadPoolExecutor(max_workers=DEPT_CRAWL_WORKERS) as pool: + future_map = {pool.submit(lambda d=d: list(_walk_department(token, d))): d for d in dept_ids} + for fut in as_completed(future_map): + try: + dept_results.append(fut.result()) + except Exception: + logger.exception("Lark department crawl failed for %s", future_map[fut]) + dept_results.append([]) + + seen: set[str] = set() + contacts: list[dict] = [] + for batch in dept_results: + for u in batch: + key = u.get("union_id") or u.get("open_id") + if not key or key in seen: + continue + seen.add(key) + contacts.append(LarkContactsListEndpoint._serialise(u)) + + # Plus users visible directly (often the app installer) not already pulled + # in via a department walk. + direct_users = _batch_fetch_users(token, [uid for uid in direct_user_ids if uid not in seen]) + for u in direct_users: + key = u.get("union_id") or u.get("open_id") + if not key or key in seen: + continue + seen.add(key) + contacts.append(LarkContactsListEndpoint._serialise(u)) + + return contacts + + class LarkContactsListEndpoint(BaseAPIView): """Returns the union of all Lark users the app is authorised to see, used by the workspace "Invite from Lark" modal. No pagination — the directory is small enough that the client filters locally. + + Results are cached for ~10 minutes so subsequent opens are instant. Pass + `?refresh=1` to force a re-crawl (useful after someone joins the directory). """ permission_classes = [WorkSpaceAdminPermission] def get(self, request, slug): + force_refresh = request.query_params.get("refresh") in ("1", "true", "yes") + key = _cache_key() + + if not force_refresh: + cached = cache.get(key) + if cached is not None: + return Response({"contacts": cached, "cached": True}, status=status.HTTP_200_OK) + token, err = _tenant_access_token() if err: return Response({"error": err}, status=status.HTTP_400_BAD_REQUEST) try: - scopes_body = _lark_get( - token, - "/open-apis/contact/v3/scopes", - params={"user_id_type": "open_id", "page_size": 100}, - ) - except requests.RequestException as exc: - return Response({"error": f"LARK_SCOPES_FAILED: {exc}"}, status=status.HTTP_502_BAD_GATEWAY) - if scopes_body.get("code", 0) != 0: - return Response( - {"error": scopes_body.get("msg") or "LARK_SCOPES_ERROR"}, - status=status.HTTP_502_BAD_GATEWAY, - ) - - data = scopes_body.get("data") or {} - dept_ids = data.get("department_ids") or [] - direct_user_ids = data.get("user_ids") or [] - - seen = set() - contacts = [] - - for dept_id in dept_ids: - for u in _walk_department(token, dept_id): - key = u.get("union_id") or u.get("open_id") - if not key or key in seen: - continue - seen.add(key) - contacts.append(self._serialise(u)) - - direct_users = _batch_fetch_users(token, [uid for uid in direct_user_ids if uid not in seen]) - for u in direct_users: - key = u.get("union_id") or u.get("open_id") - if not key or key in seen: - continue - seen.add(key) - contacts.append(self._serialise(u)) + contacts = _crawl_directory(token) + except RuntimeError as exc: + return Response({"error": str(exc)}, status=status.HTTP_502_BAD_GATEWAY) - return Response({"contacts": contacts}, status=status.HTTP_200_OK) + cache.set(key, contacts, CONTACTS_CACHE_TTL) + return Response({"contacts": contacts, "cached": False}, status=status.HTTP_200_OK) @staticmethod def _serialise(u): From 57220d665f99df24a6b82321526d7badf0f94c71 Mon Sep 17 00:00:00 2001 From: Marcus Cheung Date: Wed, 13 May 2026 02:13:03 -0700 Subject: [PATCH 09/20] feat(i18n): VITE_DEFAULT_LANGUAGE env + browser auto-detect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/admin/Dockerfile.admin | 5 ++++ apps/space/Dockerfile.space | 5 ++++ apps/web/Dockerfile.web | 5 ++++ packages/i18n/src/store/index.ts | 43 ++++++++++++++++++++++++++++++-- 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/apps/admin/Dockerfile.admin b/apps/admin/Dockerfile.admin index 3ee1d73bf98..5eaad7a9a9f 100644 --- a/apps/admin/Dockerfile.admin +++ b/apps/admin/Dockerfile.admin @@ -53,6 +53,11 @@ ENV VITE_WEB_BASE_URL=$VITE_WEB_BASE_URL ARG VITE_WEB_BASE_PATH="" ENV VITE_WEB_BASE_PATH=$VITE_WEB_BASE_PATH +# Optional build-time language pin for self-hosted deploys. Empty string falls +# through to navigator.language detection at runtime. +ARG VITE_DEFAULT_LANGUAGE="" +ENV VITE_DEFAULT_LANGUAGE=$VITE_DEFAULT_LANGUAGE + ARG VITE_WEBSITE_URL="https://plane.so" ENV VITE_WEBSITE_URL=$VITE_WEBSITE_URL ARG VITE_SUPPORT_EMAIL="support@plane.so" diff --git a/apps/space/Dockerfile.space b/apps/space/Dockerfile.space index 39a05176aeb..0a72f7e2705 100644 --- a/apps/space/Dockerfile.space +++ b/apps/space/Dockerfile.space @@ -59,6 +59,11 @@ ENV VITE_WEBSITE_URL=$VITE_WEBSITE_URL ARG VITE_SUPPORT_EMAIL="support@plane.so" ENV VITE_SUPPORT_EMAIL=$VITE_SUPPORT_EMAIL +# Optional build-time language pin for self-hosted deploys. Empty string falls +# through to navigator.language detection at runtime. +ARG VITE_DEFAULT_LANGUAGE="" +ENV VITE_DEFAULT_LANGUAGE=$VITE_DEFAULT_LANGUAGE + COPY .gitignore .gitignore COPY --from=builder /app/out/json/ . COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml diff --git a/apps/web/Dockerfile.web b/apps/web/Dockerfile.web index 8da6b1e8346..e7e6bde9298 100644 --- a/apps/web/Dockerfile.web +++ b/apps/web/Dockerfile.web @@ -67,6 +67,11 @@ ENV VITE_SPACE_BASE_PATH=$VITE_SPACE_BASE_PATH ARG VITE_WEB_BASE_URL="" ENV VITE_WEB_BASE_URL=$VITE_WEB_BASE_URL +# Optional build-time language pin for self-hosted deploys. Empty string falls +# through to navigator.language detection at runtime (see packages/i18n/src/store/initializeLanguage). +ARG VITE_DEFAULT_LANGUAGE="" +ENV VITE_DEFAULT_LANGUAGE=$VITE_DEFAULT_LANGUAGE + ENV NEXT_TELEMETRY_DISABLED=1 ENV TURBO_TELEMETRY_DISABLED=1 diff --git a/packages/i18n/src/store/index.ts b/packages/i18n/src/store/index.ts index 27a4bb7fd7b..67825ba1e19 100644 --- a/packages/i18n/src/store/index.ts +++ b/packages/i18n/src/store/index.ts @@ -49,7 +49,13 @@ export class TranslationStore { this.loadTranslations(); } - /** Initializes the language based on the local storage or browser language */ + /** Initializes the language with priority: + * 1. localStorage — user already picked one + * 2. VITE_DEFAULT_LANGUAGE — build-time override for self-hosted deploys + * 3. navigator.languages — browser locale, with prefix fallback so a + * bare "zh" resolves to the first supported "zh-*" + * 4. FALLBACK_LANGUAGE (en) + */ private initializeLanguage() { if (typeof window === "undefined") return; @@ -59,10 +65,43 @@ export class TranslationStore { return; } - // Fallback to default language + const envDefault = (import.meta.env?.VITE_DEFAULT_LANGUAGE ?? "") as string; + if (this.isValidLanguage(envDefault)) { + this.setLanguage(envDefault as TLanguage); + return; + } + + const fromBrowser = this.resolveBrowserLanguage(); + if (fromBrowser) { + this.setLanguage(fromBrowser); + return; + } + this.setLanguage(FALLBACK_LANGUAGE); } + /** Match navigator.languages against SUPPORTED_LANGUAGES. Tries an exact + * match first, then a language-prefix match so a browser that only sent + * "zh" still gets a "zh-*" locale rather than falling back to English. + */ + private resolveBrowserLanguage(): TLanguage | null { + const navigatorObj = typeof navigator !== "undefined" ? navigator : undefined; + if (!navigatorObj) return null; + const candidates = navigatorObj.languages?.length + ? Array.from(navigatorObj.languages) + : [navigatorObj.language]; + for (const raw of candidates) { + if (!raw) continue; + const [lang, region] = raw.split("-"); + const normalized = region ? `${lang.toLowerCase()}-${region.toUpperCase()}` : lang.toLowerCase(); + if (this.isValidLanguage(normalized)) return normalized as TLanguage; + const prefix = `${normalized.toLowerCase()}-`; + const prefixed = SUPPORTED_LANGUAGES.find((l) => l.value.toLowerCase().startsWith(prefix))?.value; + if (prefixed) return prefixed; + } + return null; + } + /** Loads the translations for the current language */ private async loadTranslations(): Promise { try { From e6cd4a10947018a58fe4b258ab4e82102768a6b2 Mon Sep 17 00:00:00 2001 From: Marcus Cheung Date: Wed, 13 May 2026 13:01:47 -0700 Subject: [PATCH 10/20] =?UTF-8?q?i18n(zh-CN):=20rename=20=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E9=A1=B9=20to=20=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../i18n/src/locales/zh-CN/empty-state.ts | 68 +-- .../i18n/src/locales/zh-CN/translations.ts | 432 +++++++++--------- 2 files changed, 250 insertions(+), 250 deletions(-) diff --git a/packages/i18n/src/locales/zh-CN/empty-state.ts b/packages/i18n/src/locales/zh-CN/empty-state.ts index 3a3c9c83164..1e7ccaac4b5 100644 --- a/packages/i18n/src/locales/zh-CN/empty-state.ts +++ b/packages/i18n/src/locales/zh-CN/empty-state.ts @@ -8,7 +8,7 @@ export default { common_empty_state: { progress: { title: "暂无进度指标可显示。", - description: "开始在工作项中设置属性值以在此查看进度指标。", + description: "开始在任务中设置属性值以在此查看进度指标。", }, updates: { title: "暂无更新。", @@ -42,9 +42,9 @@ export default { description: "您查找的项目不存在。", }, work_items: { - title: "从您的第一个工作项开始。", - description: "工作项是项目的构建块 — 分配负责人、设置优先级并轻松跟踪进度。", - cta_primary: "创建您的第一个工作项", + title: "从您的第一个任务开始。", + description: "任务是项目的构建块 — 分配负责人、设置优先级并轻松跟踪进度。", + cta_primary: "创建您的第一个任务", }, cycles: { title: "在周期中分组和限时您的工作。", @@ -52,22 +52,22 @@ export default { cta_primary: "设置您的第一个周期", }, cycle_work_items: { - title: "此周期中没有要显示的工作项", - description: "创建工作项以开始监控团队在此周期中的进度并按时实现目标。", - cta_primary: "创建工作项", - cta_secondary: "添加现有工作项", + title: "此周期中没有要显示的任务", + description: "创建任务以开始监控团队在此周期中的进度并按时实现目标。", + cta_primary: "创建任务", + cta_secondary: "添加现有任务", }, modules: { title: "将项目目标映射到模块并轻松跟踪。", description: - "模块由相互关联的工作项组成。它们有助于监控项目阶段的进度,每个阶段都有特定的截止日期和分析,以指示您离实现这些阶段有多近。", + "模块由相互关联的任务组成。它们有助于监控项目阶段的进度,每个阶段都有特定的截止日期和分析,以指示您离实现这些阶段有多近。", cta_primary: "设置您的第一个模块", }, module_work_items: { - title: "此模块中没有要显示的工作项", - description: "创建工作项以开始监控此模块。", - cta_primary: "创建工作项", - cta_secondary: "添加现有工作项", + title: "此模块中没有要显示的任务", + description: "创建任务以开始监控此模块。", + cta_primary: "创建任务", + cta_secondary: "添加现有任务", }, views: { title: "为项目保存自定义视图", @@ -76,19 +76,19 @@ export default { cta_primary: "创建视图", }, no_work_items_in_project: { - title: "项目中暂无工作项", - description: "将工作项添加到项目中,并使用视图将工作切分为可跟踪的部分。", - cta_primary: "添加工作项", + title: "项目中暂无任务", + description: "将任务添加到项目中,并使用视图将工作切分为可跟踪的部分。", + cta_primary: "添加任务", }, work_item_filter: { - title: "未找到工作项", + title: "未找到任务", description: "您当前的过滤器未返回任何结果。请尝试更改过滤器。", - cta_primary: "添加工作项", + cta_primary: "添加任务", }, pages: { title: "记录一切 — 从笔记到 PRD", description: - "页面让您在一个地方捕获和组织信息。编写会议笔记、项目文档和 PRD,嵌入工作项,并使用现成的组件进行结构化。", + "页面让您在一个地方捕获和组织信息。编写会议笔记、项目文档和 PRD,嵌入任务,并使用现成的组件进行结构化。", cta_primary: "创建您的第一个页面", }, archive_pages: { @@ -101,13 +101,13 @@ export default { cta_primary: "创建接收请求", }, intake_main: { - title: "选择一个接收工作项以查看其详细信息", + title: "选择一个接收任务以查看其详细信息", }, }, workspace_empty_state: { archive_work_items: { - title: "暂无已归档工作项", - description: "通过手动或自动化,您可以归档已完成或已取消的工作项。归档后在此处查找它们。", + title: "暂无已归档任务", + description: "通过手动或自动化,您可以归档已完成或已取消的任务。归档后在此处查找它们。", cta_primary: "设置自动化", }, archive_cycles: { @@ -122,26 +122,26 @@ export default { title: "为您的工作保留重要的参考、资源或文档", }, inbox_sidebar_all: { - title: "您订阅的工作项的更新将显示在此处", + title: "您订阅的任务的更新将显示在此处", }, inbox_sidebar_mentions: { - title: "您的工作项的提及将显示在此处", + title: "您的任务的提及将显示在此处", }, your_work_by_priority: { - title: "尚未分配工作项", + title: "尚未分配任务", }, your_work_by_state: { - title: "尚未分配工作项", + title: "尚未分配任务", }, views: { title: "暂无视图", - description: "将工作项添加到项目中并使用视图轻松过滤、排序和监控进度。", - cta_primary: "添加工作项", + description: "将任务添加到项目中并使用视图轻松过滤、排序和监控进度。", + cta_primary: "添加任务", }, drafts: { - title: "半成品工作项", - description: "要试用此功能,请开始添加工作项并在中途离开,或在下方创建您的第一个草稿。😉", - cta_primary: "创建草稿工作项", + title: "半成品任务", + description: "要试用此功能,请开始添加任务并在中途离开,或在下方创建您的第一个草稿。😉", + cta_primary: "创建草稿任务", }, projects_archived: { title: "没有已归档项目", @@ -151,7 +151,7 @@ export default { title: "创建项目以在此处可视化项目指标。", }, analytics_work_items: { - title: "创建包含工作项和受理人的项目,以开始在此处跟踪绩效、进度和团队影响。", + title: "创建包含任务和受理人的项目,以开始在此处跟踪绩效、进度和团队影响。", }, analytics_no_cycle: { title: "创建周期以将工作组织成有时限的阶段并跟踪冲刺进度。", @@ -166,12 +166,12 @@ export default { settings_empty_state: { estimates: { title: "暂无估算", - description: "定义团队如何衡量工作量,并在所有工作项中一致地跟踪它。", + description: "定义团队如何衡量工作量,并在所有任务中一致地跟踪它。", cta_primary: "添加估算系统", }, labels: { title: "暂无标签", - description: "创建个性化标签以有效分类和管理工作项。", + description: "创建个性化标签以有效分类和管理任务。", cta_primary: "创建您的第一个标签", }, exports: { diff --git a/packages/i18n/src/locales/zh-CN/translations.ts b/packages/i18n/src/locales/zh-CN/translations.ts index 9963936a36e..a6a49bbca1e 100644 --- a/packages/i18n/src/locales/zh-CN/translations.ts +++ b/packages/i18n/src/locales/zh-CN/translations.ts @@ -8,14 +8,14 @@ export default { sidebar: { projects: "项目", pages: "页面", - new_work_item: "新工作项", + new_work_item: "新任务", home: "主页", your_work: "我的工作", inbox: "收件箱", workspace: "工作区", views: "视图", analytics: "分析", - work_items: "工作项", + work_items: "任务", cycles: "周期", modules: "模块", intake: "收集", @@ -257,18 +257,18 @@ export default { failed_to_update_the_theme: "主题更新失败", email_notifications: "邮件通知", stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified: - "及时了解您订阅的工作项。启用此功能以获取通知。", + "及时了解您订阅的任务。启用此功能以获取通知。", email_notification_setting_updated_successfully: "邮件通知设置更新成功", failed_to_update_email_notification_setting: "邮件通知设置更新失败", notify_me_when: "在以下情况通知我", property_changes: "属性变更", - property_changes_description: "当工作项的属性(如负责人、优先级、估算等)发生变更时通知我。", + property_changes_description: "当任务的属性(如负责人、优先级、估算等)发生变更时通知我。", state_change: "状态变更", - state_change_description: "当工作项移动到不同状态时通知我", - issue_completed: "工作项完成", - issue_completed_description: "仅当工作项完成时通知我", + state_change_description: "当任务移动到不同状态时通知我", + issue_completed: "任务完成", + issue_completed_description: "仅当任务完成时通知我", comments: "评论", - comments_description: "当有人在工作项上发表评论时通知我", + comments_description: "当有人在任务上发表评论时通知我", mentions: "提及", mentions_description: "仅当有人在评论或描述中提及我时通知我", old_password: "旧密码", @@ -276,7 +276,7 @@ export default { sign_out: "退出登录", signing_out: "正在退出登录", active_cycles: "活动周期", - active_cycles_description: "监控各个项目的周期,跟踪高优先级工作项,并关注需要注意的周期。", + active_cycles_description: "监控各个项目的周期,跟踪高优先级任务,并关注需要注意的周期。", on_demand_snapshots_of_all_your_cycles: "所有周期的实时快照", upgrade: "升级", "10000_feet_view": "所有活动周期的全局视图。", @@ -286,9 +286,9 @@ export default { "跟踪所有活动周期的高级指标,查看其进度状态,并了解与截止日期相关的范围。", compare_burndowns: "比较燃尽图。", compare_burndowns_description: "通过查看每个周期的燃尽报告,监控每个团队的表现。", - quickly_see_make_or_break_issues: "快速查看关键工作项。", + quickly_see_make_or_break_issues: "快速查看关键任务。", quickly_see_make_or_break_issues_description: - "预览每个周期中与截止日期相关的高优先级工作项。一键查看每个周期的所有工作项。", + "预览每个周期中与截止日期相关的高优先级任务。一键查看每个周期的所有任务。", zoom_into_cycles_that_need_attention: "关注需要注意的周期。", zoom_into_cycles_that_need_attention_description: "一键调查任何不符合预期的周期状态。", stay_ahead_of_blockers: "提前预防阻塞。", @@ -297,7 +297,7 @@ export default { workspace_invites: "工作区邀请", enter_god_mode: "进入管理员模式", workspace_logo: "工作区标志", - new_issue: "新工作项", + new_issue: "新任务", your_work: "我的工作", drafts: "草稿", projects: "项目", @@ -325,7 +325,7 @@ export default { create_project: "创建项目", failed_to_remove_project_from_favorites: "无法从收藏中移除项目。请重试。", project_created_successfully: "项目创建成功", - project_created_successfully_description: "项目创建成功。您现在可以开始添加工作项了。", + project_created_successfully_description: "项目创建成功。您现在可以开始添加任务了。", project_name_already_taken: "项目名称已被使用。", project_identifier_already_taken: "项目标识符已被使用。", project_cover_image_alt: "项目封面图片", @@ -335,7 +335,7 @@ export default { project_id_must_be_at_least_1_character: "项目ID至少需要1个字符", project_id_must_be_at_most_5_characters: "项目ID最多只能有5个字符", project_id: "项目ID", - project_id_tooltip_content: "帮助您唯一标识项目中的工作项。最多10个字符。", + project_id_tooltip_content: "帮助您唯一标识项目中的任务。最多10个字符。", description_placeholder: "描述", only_alphanumeric_non_latin_characters_allowed: "仅允许字母数字和非拉丁字符。", project_id_is_required: "项目ID为必填项", @@ -368,21 +368,21 @@ export default { drag_to_rearrange: "拖动以重新排列", congrats: "恭喜!", open_project: "打开项目", - issues: "工作项", + issues: "任务", cycles: "周期", modules: "模块", pages: "页面", intake: "收集", time_tracking: "时间跟踪", work_management: "工作管理", - projects_and_issues: "项目和工作项", + projects_and_issues: "项目和任务", projects_and_issues_description: "在此项目中开启或关闭这些功能。", cycles_description: "为每个项目设置时间框,并根据需要调整周期。一个周期可以是两周,下一个周期是一周。", modules_description: "将工作组织为子项目,并指定专门的负责人和受理人。", views_description: "保存自定义排序、筛选和显示选项,或与团队共享。", pages_description: "创建和编辑自由格式的内容:笔记、文档,任何内容。", intake_description: "允许非成员提交 Bug、反馈和建议,且不会干扰您的工作流程。", - time_tracking_description: "记录在工作项和项目上花费的时间。", + time_tracking_description: "记录在任务和项目上花费的时间。", work_management_description: "轻松管理您的工作和项目。", documentation: "文档", message_support: "联系支持", @@ -414,30 +414,30 @@ export default { workspace_name: "工作区名称", deactivate_your_account: "停用您的账户", deactivate_your_account_description: - "一旦停用,您将无法被分配工作项,也不会被计入工作区的账单。要重新激活您的账户,您需要收到发送到此电子邮件地址的工作区邀请。", + "一旦停用,您将无法被分配任务,也不会被计入工作区的账单。要重新激活您的账户,您需要收到发送到此电子邮件地址的工作区邀请。", deactivating: "正在停用", confirm: "确认", confirming: "确认中", draft_created: "草稿已创建", - issue_created_successfully: "工作项创建成功", + issue_created_successfully: "任务创建成功", draft_creation_failed: "草稿创建失败", - issue_creation_failed: "工作项创建失败", - draft_issue: "草稿工作项", - issue_updated_successfully: "工作项更新成功", - issue_could_not_be_updated: "工作项无法更新", + issue_creation_failed: "任务创建失败", + draft_issue: "草稿任务", + issue_updated_successfully: "任务更新成功", + issue_could_not_be_updated: "任务无法更新", create_a_draft: "创建草稿", save_to_drafts: "保存到草稿", save: "保存", update: "更新", updating: "更新中", - create_new_issue: "创建新工作项", + create_new_issue: "创建新任务", editor_is_not_ready_to_discard_changes: "编辑器尚未准备好放弃更改", - failed_to_move_issue_to_project: "无法将工作项移动到项目", + failed_to_move_issue_to_project: "无法将任务移动到项目", create_more: "创建更多", add_to_project: "添加到项目", discard: "放弃", - duplicate_issue_found: "发现重复的工作项", - duplicate_issues_found: "发现重复的工作项", + duplicate_issue_found: "发现重复的任务", + duplicate_issues_found: "发现重复的任务", no_matching_results: "没有匹配的结果", title_is_required: "标题为必填项", title: "标题", @@ -458,8 +458,8 @@ export default { end_date: "结束日期", due_date: "截止日期", estimate: "估算", - change_parent_issue: "更改父工作项", - remove_parent_issue: "移除父工作项", + change_parent_issue: "更改父任务", + remove_parent_issue: "移除父任务", add_parent: "添加父项", loading_members: "正在加载成员", view_link_copied_to_clipboard: "视图链接已复制到剪贴板", @@ -482,15 +482,15 @@ export default { show_less: "显示更少", no_data_yet: "暂无数据", syncing: "同步中", - add_work_item: "添加工作项", + add_work_item: "添加任务", advanced_description_placeholder: "按'/'使用命令", - create_work_item: "创建工作项", + create_work_item: "创建任务", attachments: "附件", declining: "拒绝中", declined: "已拒绝", decline: "拒绝", unassigned: "未分配", - work_items: "工作项", + work_items: "任务", add_link: "添加链接", points: "点数", no_assignee: "无负责人", @@ -597,14 +597,14 @@ export default { empty: { project: "访问项目后,您的最近项目将显示在这里。", page: "访问页面后,您的最近页面将显示在这里。", - issue: "访问工作项后,您的最近工作项将显示在这里。", + issue: "访问任务后,您的最近任务将显示在这里。", default: "您还没有任何最近项目。", }, filters: { all: "所有", projects: "项目", pages: "页面", - issues: "工作项", + issues: "任务", }, }, new_at_plane: { @@ -677,9 +677,9 @@ export default { group_by: "分组方式", epic: "史诗", epics: "史诗", - work_item: "工作项", - work_items: "工作项", - sub_work_item: "子工作项", + work_item: "任务", + work_items: "任务", + sub_work_item: "子任务", add: "添加", warning: "警告", updating: "更新中", @@ -718,7 +718,7 @@ export default { private: "私有", }, done: "完成", - sub_work_items: "子工作项", + sub_work_items: "子任务", comment: "评论", workspace_level: "工作区级别", order_by: { @@ -744,8 +744,8 @@ export default { copied: "已复制!", link_copied: "链接已复制!", link_copied_to_clipboard: "链接已复制到剪贴板", - copied_to_clipboard: "工作项链接已复制到剪贴板", - is_copied_to_clipboard: "工作项已复制到剪贴板", + copied_to_clipboard: "任务链接已复制到剪贴板", + is_copied_to_clipboard: "任务已复制到剪贴板", no_links_added_yet: "暂无添加的链接", add_link: "添加链接", links: "链接", @@ -955,50 +955,50 @@ export default { }, }, issue: { - label: "{count, plural, one {工作项} other {工作项}}", - all: "所有工作项", - edit: "编辑工作项", + label: "{count, plural, one {任务} other {任务}}", + all: "所有任务", + edit: "编辑任务", title: { - label: "工作项标题", - required: "工作项标题为必填项", + label: "任务标题", + required: "任务标题为必填项", }, add: { - press_enter: "按'Enter'添加另一个工作项", - label: "添加工作项", + press_enter: "按'Enter'添加另一个任务", + label: "添加任务", cycle: { - failed: "无法将工作项添加到周期。请重试。", - success: "{count, plural, one {工作项} other {工作项}}已成功添加到周期。", - loading: "正在将{count, plural, one {工作项} other {工作项}}添加到周期", + failed: "无法将任务添加到周期。请重试。", + success: "{count, plural, one {任务} other {任务}}已成功添加到周期。", + loading: "正在将{count, plural, one {任务} other {任务}}添加到周期", }, assignee: "添加负责人", start_date: "添加开始日期", due_date: "添加截止日期", - parent: "添加父工作项", - sub_issue: "添加子工作项", + parent: "添加父任务", + sub_issue: "添加子任务", relation: "添加关系", link: "添加链接", - existing: "添加现有工作项", + existing: "添加现有任务", }, remove: { - label: "移除工作项", + label: "移除任务", cycle: { - loading: "正在从周期中移除工作项", - success: "已成功从周期中移除工作项。", - failed: "无法从周期中移除工作项。请重试。", + loading: "正在从周期中移除任务", + success: "已成功从周期中移除任务。", + failed: "无法从周期中移除任务。请重试。", }, module: { - loading: "正在从模块中移除工作项", - success: "已成功从模块中移除工作项。", - failed: "无法从模块中移除工作项。请重试。", + loading: "正在从模块中移除任务", + success: "已成功从模块中移除任务。", + failed: "无法从模块中移除任务。请重试。", }, parent: { - label: "移除父工作项", + label: "移除父任务", }, }, - new: "新建工作项", - adding: "正在添加工作项", + new: "新建任务", + adding: "正在添加任务", create: { - success: "工作项创建成功", + success: "任务创建成功", }, priority: { urgent: "紧急", @@ -1010,15 +1010,15 @@ export default { properties: { label: "显示属性", id: "ID", - issue_type: "工作项类型", - sub_issue_count: "子工作项数量", + issue_type: "任务类型", + sub_issue_count: "子任务数量", attachment_count: "附件数量", created_on: "创建于", - sub_issue: "子工作项", - work_item_count: "工作项数量", + sub_issue: "子任务", + work_item_count: "任务数量", }, extra: { - show_sub_issues: "显示子工作项", + show_sub_issues: "显示子任务", show_empty_groups: "显示空组", }, }, @@ -1069,35 +1069,35 @@ export default { }, empty_state: { issue_detail: { - title: "工作项不存在", - description: "您查找的工作项不存在、已归档或已删除。", + title: "任务不存在", + description: "您查找的任务不存在、已归档或已删除。", primary_button: { - text: "查看其他工作项", + text: "查看其他任务", }, }, }, sibling: { - label: "同级工作项", + label: "同级任务", }, archive: { - description: "只有已完成或已取消的\n工作项可以归档", - label: "归档工作项", - confirm_message: "您确定要归档此工作项吗?所有已归档的工作项稍后可以恢复。", + description: "只有已完成或已取消的\n任务可以归档", + label: "归档任务", + confirm_message: "您确定要归档此任务吗?所有已归档的任务稍后可以恢复。", success: { label: "归档成功", message: "您的归档可以在项目归档中找到。", }, failed: { - message: "无法归档工作项。请重试。", + message: "无法归档任务。请重试。", }, }, restore: { success: { title: "恢复成功", - message: "您的工作项可以在项目工作项中找到。", + message: "您的任务可以在项目任务中找到。", }, failed: { - message: "无法恢复工作项。请重试。", + message: "无法恢复任务。请重试。", }, }, relation: { @@ -1106,25 +1106,25 @@ export default { blocked_by: "被阻止于", blocking: "阻止", }, - copy_link: "复制工作项链接", + copy_link: "复制任务链接", delete: { - label: "删除工作项", - error: "删除工作项时出错", + label: "删除任务", + error: "删除任务时出错", }, subscription: { actions: { - subscribed: "工作项订阅成功", - unsubscribed: "工作项取消订阅成功", + subscribed: "任务订阅成功", + unsubscribed: "任务取消订阅成功", }, }, select: { - error: "请至少选择一个工作项", - empty: "未选择工作项", - add_selected: "添加所选工作项", + error: "请至少选择一个任务", + empty: "未选择任务", + add_selected: "添加所选任务", select_all: "全选", deselect_all: "取消全选", }, - open_in_full_screen: "在全屏中打开工作项", + open_in_full_screen: "在全屏中打开任务", }, attachment: { error: "无法附加文件。请重新上传。", @@ -1144,22 +1144,22 @@ export default { }, sub_work_item: { update: { - success: "子工作项更新成功", - error: "更新子工作项时出错", + success: "子任务更新成功", + error: "更新子任务时出错", }, remove: { - success: "子工作项移除成功", - error: "移除子工作项时出错", + success: "子任务移除成功", + error: "移除子任务时出错", }, empty_state: { sub_list_filters: { - title: "您没有符合您应用的过滤器的子工作项。", - description: "要查看所有子工作项,请清除所有应用的过滤器。", + title: "您没有符合您应用的过滤器的子任务。", + description: "要查看所有子任务,请清除所有应用的过滤器。", action: "清除过滤器", }, list_filters: { - title: "您没有符合您应用的过滤器的工作项。", - description: "要查看所有工作项,请清除所有应用的过滤器。", + title: "您没有符合您应用的过滤器的任务。", + description: "要查看所有任务,请清除所有应用的过滤器。", action: "清除过滤器", }, }, @@ -1198,30 +1198,30 @@ export default { }, modals: { decline: { - title: "拒绝工作项", - content: "您确定要拒绝工作项 {value} 吗?", + title: "拒绝任务", + content: "您确定要拒绝任务 {value} 吗?", }, delete: { - title: "删除工作项", - content: "您确定要删除工作项 {value} 吗?", - success: "工作项删除成功", + title: "删除任务", + content: "您确定要删除任务 {value} 吗?", + success: "任务删除成功", }, }, errors: { - snooze_permission: "只有项目管理员可以暂停/取消暂停工作项", - accept_permission: "只有项目管理员可以接受工作项", - decline_permission: "只有项目管理员可以拒绝工作项", + snooze_permission: "只有项目管理员可以暂停/取消暂停任务", + accept_permission: "只有项目管理员可以接受任务", + decline_permission: "只有项目管理员可以拒绝任务", }, actions: { accept: "接受", decline: "拒绝", snooze: "暂停", unsnooze: "取消暂停", - copy: "复制工作项链接", + copy: "复制任务链接", delete: "删除", - open: "打开工作项", + open: "打开任务", mark_as_duplicate: "标记为重复", - move: "将 {value} 移至项目工作项", + move: "将 {value} 移至项目任务", }, source: { "in-app": "应用内", @@ -1234,7 +1234,7 @@ export default { label: "收集", page_label: "{workspace} - 收集", modal: { - title: "创建收集工作项", + title: "创建收集任务", }, tabs: { open: "未处理", @@ -1242,19 +1242,19 @@ export default { }, empty_state: { sidebar_open_tab: { - title: "没有未处理的工作项", - description: "在此处查找未处理的工作项。创建新工作项。", + title: "没有未处理的任务", + description: "在此处查找未处理的任务。创建新任务。", }, sidebar_closed_tab: { - title: "没有已处理的工作项", - description: "所有已接受或已拒绝的工作项都可以在这里找到。", + title: "没有已处理的任务", + description: "所有已接受或已拒绝的任务都可以在这里找到。", }, sidebar_filter: { - title: "没有匹配的工作项", - description: "收集中没有符合筛选条件的工作项。创建新工作项。", + title: "没有匹配的任务", + description: "收集中没有符合筛选条件的任务。创建新任务。", }, detail: { - title: "选择一个工作项以查看其详细信息。", + title: "选择一个任务以查看其详细信息。", }, }, }, @@ -1314,7 +1314,7 @@ export default { general: { title: "项目、活动和指标概览", description: - "欢迎使用 Plane,我们很高兴您能来到这里。创建您的第一个项目并跟踪您的工作项,这个页面将转变为帮助您进展的空间。管理员还将看到帮助团队进展的项目。", + "欢迎使用 Plane,我们很高兴您能来到这里。创建您的第一个项目并跟踪您的任务,这个页面将转变为帮助您进展的空间。管理员还将看到帮助团队进展的项目。", primary_button: { text: "构建您的第一个项目", comic: { @@ -1330,26 +1330,26 @@ export default { page_label: "{workspace} - 分析", open_tasks: "总开放任务", error: "获取数据时出现错误。", - work_items_closed_in: "已关闭的工作项", + work_items_closed_in: "已关闭的任务", selected_projects: "已选择的项目", total_members: "总成员数", total_cycles: "总周期数", total_modules: "总模块数", pending_work_items: { - title: "待处理工作项", - empty_state: "同事的待处理工作项分析将显示在这里。", + title: "待处理任务", + empty_state: "同事的待处理任务分析将显示在这里。", }, work_items_closed_in_a_year: { - title: "一年内关闭的工作项", - empty_state: "关闭工作项以查看以图表形式显示的分析。", + title: "一年内关闭的任务", + empty_state: "关闭任务以查看以图表形式显示的分析。", }, most_work_items_created: { - title: "创建最多工作项", - empty_state: "同事及其创建的工作项数量将显示在这里。", + title: "创建最多任务", + empty_state: "同事及其创建的任务数量将显示在这里。", }, most_work_items_closed: { - title: "关闭最多工作项", - empty_state: "同事及其关闭的工作项数量将显示在这里。", + title: "关闭最多任务", + empty_state: "同事及其关闭的任务数量将显示在这里。", }, tabs: { scope_and_demand: "范围和需求", @@ -1357,16 +1357,16 @@ export default { }, empty_state: { customized_insights: { - description: "分配给您的工作项将按状态分类显示在此处。", + description: "分配给您的任务将按状态分类显示在此处。", title: "暂无数据", }, created_vs_resolved: { - description: "随着时间推移创建和解决的工作项将显示在此处。", + description: "随着时间推移创建和解决的任务将显示在此处。", title: "暂无数据", }, project_insights: { title: "暂无数据", - description: "分配给您的工作项将按状态分类显示在此处。", + description: "分配给您的任务将按状态分类显示在此处。", }, general: { title: "跟踪进度、工作量和分配。发现趋势,消除障碍,加速工作进展", @@ -1419,7 +1419,7 @@ export default { permission: "您没有执行此操作的权限。", cycle_delete: "删除周期失败", module_delete: "删除模块失败", - issue_delete: "删除工作项失败", + issue_delete: "删除任务失败", }, state: { backlog: "待办", @@ -1445,7 +1445,7 @@ export default { general: { title: "没有活动项目", description: - "将每个项目视为目标导向工作的父级。项目是工作项、周期和模块所在的地方,与您的同事一起帮助您实现目标。创建新项目或筛选已归档的项目。", + "将每个项目视为目标导向工作的父级。项目是任务、周期和模块所在的地方,与您的同事一起帮助您实现目标。创建新项目或筛选已归档的项目。", primary_button: { text: "开始您的第一个项目", comic: { @@ -1456,7 +1456,7 @@ export default { }, no_projects: { title: "没有项目", - description: "要创建工作项或管理您的工作,您需要创建一个项目或成为项目的一部分。", + description: "要创建任务或管理您的工作,您需要创建一个项目或成为项目的一部分。", primary_button: { text: "开始您的第一个项目", comic: { @@ -1478,33 +1478,33 @@ export default { add_view: "添加视图", empty_state: { "all-issues": { - title: "项目中没有工作项", - description: "第一个项目完成!现在,将您的工作分解成可跟踪的工作项。让我们开始吧!", + title: "项目中没有任务", + description: "第一个项目完成!现在,将您的工作分解成可跟踪的任务。让我们开始吧!", primary_button: { - text: "创建新工作项", + text: "创建新任务", }, }, assigned: { - title: "还没有工作项", - description: "可以在这里跟踪分配给您的工作项。", + title: "还没有任务", + description: "可以在这里跟踪分配给您的任务。", primary_button: { - text: "创建新工作项", + text: "创建新任务", }, }, created: { - title: "还没有工作项", - description: "您创建的所有工作项都会出现在这里,直接在这里跟踪它们。", + title: "还没有任务", + description: "您创建的所有任务都会出现在这里,直接在这里跟踪它们。", primary_button: { - text: "创建新工作项", + text: "创建新任务", }, }, subscribed: { - title: "还没有工作项", - description: "订阅您感兴趣的工作项,在这里跟踪所有这些工作项。", + title: "还没有任务", + description: "订阅您感兴趣的任务,在这里跟踪所有这些任务。", }, "custom-view": { - title: "还没有工作项", - description: "符合筛选条件的工作项,在这里跟踪所有这些工作项。", + title: "还没有任务", + description: "符合筛选条件的任务,在这里跟踪所有这些任务。", }, }, delete_view: { @@ -1630,7 +1630,7 @@ export default { exporting: "导出中", previous_exports: "以前的导出", export_separate_files: "将数据导出为单独的文件", - filters_info: "应用筛选器以根据您的条件导出特定工作项。", + filters_info: "应用筛选器以根据您的条件导出特定任务。", modal: { title: "导出到", toasts: { @@ -1747,16 +1747,16 @@ export default { stats: { workload: "工作量", overview: "概览", - created: "已创建的工作项", - assigned: "已分配的工作项", - subscribed: "已订阅的工作项", + created: "已创建的任务", + assigned: "已分配的任务", + subscribed: "已订阅的任务", state_distribution: { - title: "按状态分类的工作项", - empty: "创建工作项以在图表中查看按状态分类的工作项,以便更好地分析。", + title: "按状态分类的任务", + empty: "创建任务以在图表中查看按状态分类的任务,以便更好地分析。", }, priority_distribution: { - title: "按优先级分类的工作项", - empty: "创建工作项以在图表中查看按优先级分类的工作项,以便更好地分析。", + title: "按优先级分类的任务", + empty: "创建任务以在图表中查看按优先级分类的任务,以便更好地分析。", }, recent_activity: { title: "最近活动", @@ -1782,19 +1782,19 @@ export default { empty_state: { activity: { title: "尚无活动", - description: "通过创建新工作项开始!为其添加详细信息和属性。在 Plane 中探索更多内容以查看您的活动。", + description: "通过创建新任务开始!为其添加详细信息和属性。在 Plane 中探索更多内容以查看您的活动。", }, assigned: { - title: "没有分配给您的工作项", - description: "可以从这里跟踪分配给您的工作项。", + title: "没有分配给您的任务", + description: "可以从这里跟踪分配给您的任务。", }, created: { - title: "尚无工作项", - description: "您创建的所有工作项都会出现在这里,直接在这里跟踪它们。", + title: "尚无任务", + description: "您创建的所有任务都会出现在这里,直接在这里跟踪它们。", }, subscribed: { - title: "尚无工作项", - description: "订阅您感兴趣的工作项,在这里跟踪所有这些工作项。", + title: "尚无任务", + description: "订阅您感兴趣的任务,在这里跟踪所有这些任务。", }, }, }, @@ -1823,8 +1823,8 @@ export default { project_lead: "项目负责人", default_assignee: "默认受理人", guest_super_permissions: { - title: "为访客用户授予查看所有工作项的权限:", - sub_heading: "这将允许访客查看所有项目工作项。", + title: "为访客用户授予查看所有任务的权限:", + sub_heading: "这将允许访客查看所有项目任务。", }, invite_members: { title: "邀请成员", @@ -1914,13 +1914,13 @@ export default { automations: { label: "自动化", "auto-archive": { - title: "自动归档已关闭的工作项", - description: "Plane 将自动归档已完成或已取消的工作项。", + title: "自动归档已关闭的任务", + description: "Plane 将自动归档已完成或已取消的任务。", duration: "自动归档已关闭", }, "auto-close": { - title: "自动关闭工作项", - description: "Plane 将自动关闭尚未完成或取消的工作项。", + title: "自动关闭任务", + description: "Plane 将自动关闭尚未完成或取消的任务。", duration: "自动关闭不活跃", auto_close_status: "自动关闭状态", }, @@ -1928,11 +1928,11 @@ export default { empty_state: { labels: { title: "尚无标签", - description: "创建标签以帮助组织和筛选项目中的工作项。", + description: "创建标签以帮助组织和筛选项目中的任务。", }, estimates: { title: "尚无估算系统", - description: "创建一组估算以传达每个工作项的工作量。", + description: "创建一组估算以传达每个任务的工作量。", primary_button: "添加估算系统", }, }, @@ -1987,16 +1987,16 @@ export default { start_date: "开始日期", end_date: "结束日期", in_your_timezone: "在您的时区", - transfer_work_items: "转移 {count} 工作项", + transfer_work_items: "转移 {count} 任务", date_range: "日期范围", add_date: "添加日期", active_cycle: { label: "活动周期", progress: "进度", chart: "燃尽图", - priority_issue: "优先工作项", + priority_issue: "优先任务", assignees: "受理人", - issue_burndown: "工作项燃尽", + issue_burndown: "任务燃尽", ideal: "理想", current: "当前", labels: "标签", @@ -2076,18 +2076,18 @@ export default { }, }, no_issues: { - title: "尚未向周期添加工作项", - description: "添加或创建您希望在此周期内时间框定和交付的工作项", + title: "尚未向周期添加任务", + description: "添加或创建您希望在此周期内时间框定和交付的任务", primary_button: { - text: "创建新工作项", + text: "创建新任务", }, secondary_button: { - text: "添加现有工作项", + text: "添加现有任务", }, }, completed_no_issues: { - title: "周期中没有工作项", - description: "周期中没有工作项。工作项已被转移或隐藏。要查看隐藏的工作项(如果有),请相应更新您的显示属性。", + title: "周期中没有任务", + description: "周期中没有任务。任务已被转移或隐藏。要查看隐藏的任务(如果有),请相应更新您的显示属性。", }, active: { title: "没有活动周期", @@ -2102,26 +2102,26 @@ export default { project_issues: { empty_state: { no_issues: { - title: "创建工作项并将其分配给某人,甚至是您自己", + title: "创建任务并将其分配给某人,甚至是您自己", description: - "将工作项视为工作、任务或待完成的工作。工作项及其子工作项通常是基于时间的、分配给团队成员的可执行项。您的团队通过创建、分配和完成工作项来推动项目实现其目标。", + "将任务视为工作、任务或待完成的工作。任务及其子任务通常是基于时间的、分配给团队成员的可执行项。您的团队通过创建、分配和完成任务来推动项目实现其目标。", primary_button: { - text: "创建您的第一个工作项", + text: "创建您的第一个任务", comic: { - title: "工作项是 Plane 中的基本构建块。", - description: "重新设计 Plane 界面、重塑公司品牌或启动新的燃料喷射系统都是可能包含子工作项的工作项示例。", + title: "任务是 Plane 中的基本构建块。", + description: "重新设计 Plane 界面、重塑公司品牌或启动新的燃料喷射系统都是可能包含子任务的任务示例。", }, }, }, no_archived_issues: { - title: "尚无已归档的工作项", - description: "通过手动或自动化方式,您可以归档已完成或已取消的工作项。归档后可以在这里找到它们。", + title: "尚无已归档的任务", + description: "通过手动或自动化方式,您可以归档已完成或已取消的任务。归档后可以在这里找到它们。", primary_button: { text: "设置自动化", }, }, issues_empty_filter: { - title: "未找到符合筛选条件的工作项", + title: "未找到符合筛选条件的任务", secondary_button: { text: "清除所有筛选条件", }, @@ -2139,7 +2139,7 @@ export default { general: { title: "将项目里程碑映射到模块,轻松跟踪汇总工作。", description: - "属于逻辑层次结构父级的一组工作项形成一个模块。将其视为按项目里程碑跟踪工作的方式。它们有自己的周期和截止日期以及分析功能,帮助您了解距离里程碑的远近。", + "属于逻辑层次结构父级的一组任务形成一个模块。将其视为按项目里程碑跟踪工作的方式。它们有自己的周期和截止日期以及分析功能,帮助您了解距离里程碑的远近。", primary_button: { text: "构建您的第一个模块", comic: { @@ -2149,13 +2149,13 @@ export default { }, }, no_issues: { - title: "模块中没有工作项", - description: "创建或添加您想作为此模块一部分完成的工作项", + title: "模块中没有任务", + description: "创建或添加您想作为此模块一部分完成的任务", primary_button: { - text: "创建新工作项", + text: "创建新任务", }, secondary_button: { - text: "添加现有工作项", + text: "添加现有任务", }, }, archived: { @@ -2191,7 +2191,7 @@ export default { primary_button: { text: "创建您的第一个视图", comic: { - title: "视图基于工作项属性运作。", + title: "视图基于任务属性运作。", description: "您可以在此处创建一个视图,根据需要使用任意数量的属性作为筛选条件。", }, }, @@ -2211,7 +2211,7 @@ export default { general: { title: "写笔记、文档或完整的知识库。让 Plane 的 AI 助手 Galileo 帮助您开始", description: - "页面是 Plane 中的思维记录空间。记录会议笔记,轻松格式化,嵌入工作项,使用组件库进行布局,并将它们全部保存在项目上下文中。要快速完成任何文档,可以通过快捷键或点击按钮调用 Plane 的 AI Galileo。", + "页面是 Plane 中的思维记录空间。记录会议笔记,轻松格式化,嵌入任务,使用组件库进行布局,并将它们全部保存在项目上下文中。要快速完成任何文档,可以通过快捷键或点击按钮调用 Plane 的 AI Galileo。", primary_button: { text: "创建您的第一个页面", }, @@ -2246,10 +2246,10 @@ export default { issue_relation: { empty_state: { search: { - title: "未找到匹配的工作项", + title: "未找到匹配的任务", }, no_issues: { - title: "未找到工作项", + title: "未找到任务", }, }, }, @@ -2257,7 +2257,7 @@ export default { empty_state: { general: { title: "尚无评论", - description: "评论可用作工作项的讨论和跟进空间", + description: "评论可用作任务的讨论和跟进空间", }, }, }, @@ -2291,12 +2291,12 @@ export default { title: "选择以查看详情。", }, all: { - title: "没有分配的工作项", - description: "在这里可以看到分配给您的工作项的更新", + title: "没有分配的任务", + description: "在这里可以看到分配给您的任务的更新", }, mentions: { - title: "没有分配的工作项", - description: "在这里可以看到分配给您的工作项的更新", + title: "没有分配的任务", + description: "在这里可以看到分配给您的任务的更新", }, }, tabs: { @@ -2320,19 +2320,19 @@ export default { active_cycle: { empty_state: { progress: { - title: "向周期添加工作项以查看其进度", + title: "向周期添加任务以查看其进度", }, chart: { - title: "向周期添加工作项以查看燃尽图。", + title: "向周期添加任务以查看燃尽图。", }, priority_issue: { - title: "一目了然地观察周期中处理的高优先级工作项。", + title: "一目了然地观察周期中处理的高优先级任务。", }, assignee: { - title: "为工作项添加负责人以查看按负责人划分的工作明细。", + title: "为任务添加负责人以查看按负责人划分的工作明细。", }, label: { - title: "为工作项添加标签以查看按标签划分的工作明细。", + title: "为任务添加标签以查看按标签划分的工作明细。", }, }, }, @@ -2341,7 +2341,7 @@ export default { inbox: { title: "项目未启用收集功能。", description: - "收集功能帮助您管理项目的传入请求,并将其添加为工作流中的工作项。从项目设置启用收集功能以管理请求。", + "收集功能帮助您管理项目的传入请求,并将其添加为工作流中的任务。从项目设置启用收集功能以管理请求。", primary_button: { text: "管理功能", }, @@ -2378,10 +2378,10 @@ export default { }, }, workspace_draft_issues: { - draft_an_issue: "起草工作项", + draft_an_issue: "起草任务", empty_state: { - title: "半写的工作项,以及即将推出的评论将在这里显示。", - description: "要试用此功能,请开始添加工作项并中途离开,或在下方创建您的第一个草稿。😉", + title: "半写的任务,以及即将推出的评论将在这里显示。", + description: "要试用此功能,请开始添加任务并中途离开,或在下方创建您的第一个草稿。😉", primary_button: { text: "创建您的第一个草稿", }, @@ -2393,7 +2393,7 @@ export default { toasts: { created: { success: "草稿已创建", - error: "无法创建工作项。请重试。", + error: "无法创建任务。请重试。", }, deleted: { success: "草稿已删除", @@ -2486,37 +2486,37 @@ export default { importer: { github: { title: "GitHub", - description: "从 GitHub 仓库导入工作项并同步。", + description: "从 GitHub 仓库导入任务并同步。", }, jira: { title: "Jira", - description: "从 Jira 项目和史诗导入工作项和史诗。", + description: "从 Jira 项目和史诗导入任务和史诗。", }, }, exporter: { csv: { title: "CSV", - description: "将工作项导出为 CSV 文件。", + description: "将任务导出为 CSV 文件。", short_description: "导出为 CSV", }, excel: { title: "Excel", - description: "将工作项导出为 Excel 文件。", + description: "将任务导出为 Excel 文件。", short_description: "导出为 Excel", }, xlsx: { title: "Excel", - description: "将工作项导出为 Excel 文件。", + description: "将任务导出为 Excel 文件。", short_description: "导出为 Excel", }, json: { title: "JSON", - description: "将工作项导出为 JSON 文件。", + description: "将任务导出为 JSON 文件。", short_description: "导出为 JSON", }, }, default_global_view: { - all_issues: "所有工作项", + all_issues: "所有任务", assigned: "已分配", created: "已创建", subscribed: "已订阅", @@ -2560,7 +2560,7 @@ export default { order_by: { name: "名称", progress: "进度", - issues: "工作项数量", + issues: "任务数量", due_date: "截止日期", created_at: "创建日期", manual: "手动", From aa060ac6b6999e048f36d905ad7cd832233e96bc Mon Sep 17 00:00:00 2001 From: Marcus Cheung Date: Wed, 13 May 2026 13:19:50 -0700 Subject: [PATCH 11/20] feat(auth/lark): auto-join SSO users into a default workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../plane/authentication/views/app/lark.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/apps/api/plane/authentication/views/app/lark.py b/apps/api/plane/authentication/views/app/lark.py index 5856c67e7ba..37bf911cefe 100644 --- a/apps/api/plane/authentication/views/app/lark.py +++ b/apps/api/plane/authentication/views/app/lark.py @@ -3,6 +3,8 @@ # See the LICENSE file for details. # Python imports +import logging +import os import uuid # Django import @@ -15,6 +17,7 @@ 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.db.models import Workspace, WorkspaceMember from plane.license.models import Instance from plane.authentication.utils.host import base_host from plane.authentication.adapter.error import ( @@ -23,6 +26,47 @@ ) from plane.utils.path_validator import get_safe_redirect_url +logger = logging.getLogger("plane.authentication.views.app.lark") + + +def _ensure_default_workspace_membership(user): + """Auto-join Lark-authenticated users into a designated workspace so new + employees land on the team's workspace instead of the "create your first + workspace" onboarding screen. + + Controlled by env vars: + LARK_DEFAULT_WORKSPACE_SLUG — workspace slug to attach users to + LARK_DEFAULT_WORKSPACE_ROLE — int role (default 15 = Member) + + Unset slug → no-op (preserves upstream onboarding behaviour). Missing + workspace → warn + no-op so a typo in env can't lock anyone out. + """ + slug = (os.environ.get("LARK_DEFAULT_WORKSPACE_SLUG") or "").strip() + if not slug: + return + + try: + role = int(os.environ.get("LARK_DEFAULT_WORKSPACE_ROLE", "15")) + except ValueError: + role = 15 + + workspace = Workspace.objects.filter(slug=slug).first() + if workspace is None: + logger.warning("LARK_DEFAULT_WORKSPACE_SLUG=%s not found — skipping auto-join", slug) + return + + existing = WorkspaceMember.objects.filter(workspace=workspace, member=user).first() + if existing: + # Re-activate previously-removed members but don't downgrade an admin + # back to Member just because they signed in via Lark again. + if not existing.is_active: + existing.is_active = True + existing.save(update_fields=["is_active"]) + return + + WorkspaceMember.objects.create(workspace=workspace, member=user, role=role, is_active=True) + logger.info("Lark SSO auto-joined %s to workspace %s as role=%s", user.id, slug, role) + class LarkOauthInitiateEndpoint(View): def get(self, request): @@ -87,6 +131,10 @@ def get(self, request): try: provider = LarkOAuthProvider(request=request, code=code, callback=post_user_auth_workflow) user = provider.authenticate() + # Auto-join the designated org workspace before computing the + # redirect, so first-time SSO users skip the "create your first + # workspace" onboarding and land directly on the team's workspace. + _ensure_default_workspace_membership(user) # Login the user and record his device info user_login(request=request, user=user, is_app=True) # Get the redirection path From 255f23af9f44ceaa7967ef61719899a140c98dc4 Mon Sep 17 00:00:00 2001 From: Marcus Cheung Date: Wed, 13 May 2026 13:41:48 -0700 Subject: [PATCH 12/20] feat(lark): hourly directory auto-sync + manual trigger endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= 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. --- apps/api/plane/app/urls/workspace.py | 6 + apps/api/plane/app/views/__init__.py | 1 + .../plane/app/views/workspace/lark_invite.py | 23 +++ apps/api/plane/bgtasks/lark_sync_task.py | 145 ++++++++++++++++++ apps/api/plane/celery.py | 7 + 5 files changed, 182 insertions(+) create mode 100644 apps/api/plane/bgtasks/lark_sync_task.py diff --git a/apps/api/plane/app/urls/workspace.py b/apps/api/plane/app/urls/workspace.py index 3994bb78eb5..ea4b2692188 100644 --- a/apps/api/plane/app/urls/workspace.py +++ b/apps/api/plane/app/urls/workspace.py @@ -37,6 +37,7 @@ WorkspaceStickyViewSet, WorkspaceUserPreferenceViewSet, LarkContactsListEndpoint, + LarkSyncTriggerEndpoint, LarkWorkspaceInviteEndpoint, ) @@ -79,6 +80,11 @@ LarkWorkspaceInviteEndpoint.as_view(), name="workspace-lark-invite", ), + path( + "workspaces//lark-sync/", + LarkSyncTriggerEndpoint.as_view(), + name="workspace-lark-sync", + ), path( "workspaces//invitations//", WorkspaceInvitationsViewset.as_view({"delete": "destroy", "get": "retrieve", "patch": "partial_update"}), diff --git a/apps/api/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py index c569a895780..e192ab43c02 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -67,6 +67,7 @@ ) from .workspace.lark_invite import ( LarkContactsListEndpoint, + LarkSyncTriggerEndpoint, LarkWorkspaceInviteEndpoint, ) from .workspace.label import WorkspaceLabelsEndpoint diff --git a/apps/api/plane/app/views/workspace/lark_invite.py b/apps/api/plane/app/views/workspace/lark_invite.py index 1b48a06f925..2ad0252160b 100644 --- a/apps/api/plane/app/views/workspace/lark_invite.py +++ b/apps/api/plane/app/views/workspace/lark_invite.py @@ -270,6 +270,29 @@ def _serialise(u): } +class LarkSyncTriggerEndpoint(BaseAPIView): + """Synchronously runs the same logic as the hourly Celery task so a + workspace admin can pull in new Feishu hires on demand. Returns the + counts dict so the UI can render a confirmation toast. + """ + + permission_classes = [WorkSpaceAdminPermission] + + def post(self, request, slug): + # Import inside the method to avoid a circular import at module load: + # lark_sync_task imports helpers from this file. + from plane.bgtasks.lark_sync_task import sync_lark_directory, DEFAULT_ROLE + + try: + role = int(request.data.get("role") or DEFAULT_ROLE) + except (TypeError, ValueError): + role = DEFAULT_ROLE + + stats = sync_lark_directory(slug, role=role, force_refresh=True) + http_status = status.HTTP_502_BAD_GATEWAY if stats.get("error") else status.HTTP_200_OK + return Response(stats, status=http_status) + + class LarkWorkspaceInviteEndpoint(BaseAPIView): """Batch pre-creates Plane User accounts for selected Lark contacts and adds them as active workspace members. Idempotent: existing users get linked, diff --git a/apps/api/plane/bgtasks/lark_sync_task.py b/apps/api/plane/bgtasks/lark_sync_task.py new file mode 100644 index 00000000000..023647bd6bc --- /dev/null +++ b/apps/api/plane/bgtasks/lark_sync_task.py @@ -0,0 +1,145 @@ +# 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 logging +import os +import uuid + +# Third party +from celery import shared_task + +# Django +from django.core.cache import cache +from django.db import transaction + +# Module imports +from plane.app.views.workspace.lark_invite import ( + _cache_key, + _crawl_directory, + _tenant_access_token, +) +from plane.db.models import User, Workspace, WorkspaceMember + +logger = logging.getLogger("plane.bgtasks.lark_sync_task") + +DEFAULT_ROLE = 15 # 15 = Member on WorkspaceMember.ROLE_CHOICES + + +def sync_lark_directory(workspace_slug, role=DEFAULT_ROLE, force_refresh=False): + """Idempotently mirror the Lark/Feishu directory into a workspace. + + For each visible contact: + * Find-or-create a Plane User keyed by the real enterprise_email when + available, otherwise the same synthetic `@lark.local` + identifier the OAuth provider uses on first sign-in. + * Find-or-create the corresponding active WorkspaceMember row. + + Returns a stats dict for logging / API response. Safe to call repeatedly — + re-runs are no-ops for already-synced people. + """ + workspace = Workspace.objects.filter(slug=workspace_slug).first() + if workspace is None: + return {"error": f"workspace '{workspace_slug}' not found"} + + token, err = _tenant_access_token() + if err: + return {"error": f"tenant_access_token: {err}"} + + contacts = None if force_refresh else cache.get(_cache_key()) + if contacts is None: + try: + contacts = _crawl_directory(token) + cache.set(_cache_key(), contacts, 600) + except RuntimeError as exc: + return {"error": str(exc)} + + user_new = user_existing = mem_new = mem_reactivated = mem_existing = 0 + skipped = 0 + + for c in contacts: + stable = c.get("union_id") or c.get("open_id") + if not stable: + skipped += 1 + continue + raw_email = (c.get("enterprise_email") or c.get("email") or "").strip().lower() + email = raw_email or f"{stable}@lark.local" + + try: + with transaction.atomic(): + user, created = User.objects.get_or_create( + email=email, + defaults={ + # username has a unique constraint and isn't auto-derived + # in User.save — must be set on creation + "username": uuid.uuid4().hex, + "first_name": c.get("name") or "", + "last_name": "", + "is_password_autoset": True, + "is_email_verified": True, + }, + ) + if created: + user_new += 1 + else: + user_existing += 1 + # Backfill display name on accounts the OAuth provider + # created before we had directory access + if not user.first_name and c.get("name"): + user.first_name = c.get("name") + user.save(update_fields=["first_name"]) + + wm, wm_created = WorkspaceMember.objects.get_or_create( + workspace=workspace, + member=user, + defaults={"role": role, "is_active": True}, + ) + if wm_created: + mem_new += 1 + elif not wm.is_active: + wm.is_active = True + wm.save(update_fields=["is_active"]) + mem_reactivated += 1 + else: + mem_existing += 1 + except Exception: + logger.exception("Lark sync failed for contact %s", stable) + skipped += 1 + + stats = { + "workspace": workspace_slug, + "contacts_seen": len(contacts), + "users_created": user_new, + "users_existing": user_existing, + "members_created": mem_new, + "members_reactivated": mem_reactivated, + "members_already_active": mem_existing, + "skipped": skipped, + "workspace_member_total": WorkspaceMember.objects.filter( + workspace=workspace, is_active=True + ).count(), + } + logger.info("Lark sync complete: %s", stats) + return stats + + +@shared_task +def sync_lark_directory_task(): + """Periodic Celery wrapper. Reads the target workspace + role from env so + the schedule entry in celery.py stays parameter-free. No-op unless both + LARK_AUTO_SYNC_ENABLED is truthy and LARK_DEFAULT_WORKSPACE_SLUG is set. + """ + if (os.environ.get("LARK_AUTO_SYNC_ENABLED") or "").strip().lower() not in ("1", "true", "yes"): + return {"skipped": "LARK_AUTO_SYNC_ENABLED not set"} + + slug = (os.environ.get("LARK_DEFAULT_WORKSPACE_SLUG") or "").strip() + if not slug: + return {"skipped": "LARK_DEFAULT_WORKSPACE_SLUG not set"} + + try: + role = int(os.environ.get("LARK_DEFAULT_WORKSPACE_ROLE", DEFAULT_ROLE)) + except ValueError: + role = DEFAULT_ROLE + + return sync_lark_directory(slug, role=role, force_refresh=True) diff --git a/apps/api/plane/celery.py b/apps/api/plane/celery.py index 562d04856f5..3bfa2833eaa 100644 --- a/apps/api/plane/celery.py +++ b/apps/api/plane/celery.py @@ -77,6 +77,13 @@ "task": "plane.bgtasks.exporter_expired_task.delete_old_s3_link", "schedule": crontab(hour=3, minute=45), # UTC 03:45 }, + # Hourly mirror of the Lark/Feishu directory into LARK_DEFAULT_WORKSPACE_SLUG. + # The task itself short-circuits unless LARK_AUTO_SYNC_ENABLED is truthy, + # so this entry is a no-op for deploys that haven't opted in. + "sync-every-hour-lark-directory": { + "task": "plane.bgtasks.lark_sync_task.sync_lark_directory_task", + "schedule": crontab(minute=0), # Top of every hour, UTC + }, } From e7f41b7ce27b2b16077d6ee4cc140195e517acdb Mon Sep 17 00:00:00 2001 From: Marcus Cheung Date: Wed, 13 May 2026 13:41:56 -0700 Subject: [PATCH 13/20] feat(web/workspace): Sync from Lark button next to Invite from Lark 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. --- .../settings/(workspace)/members/page.tsx | 34 +++++++++++++++++++ .../src/workspace/workspace.service.ts | 26 ++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx index 68bef960339..916652d2b82 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx @@ -6,6 +6,9 @@ import { useState } from "react"; import { observer } from "mobx-react"; +import { WorkspaceService } from "@plane/services"; + +const workspaceServiceForSync = new WorkspaceService(); // types import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; @@ -38,6 +41,7 @@ const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsP // states const [inviteModal, setInviteModal] = useState(false); const [larkInviteModal, setLarkInviteModal] = useState(false); + const [larkSyncing, setLarkSyncing] = useState(false); const [searchQuery, setSearchQuery] = useState(""); // router const { workspaceSlug } = params; @@ -150,6 +154,36 @@ const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsP memberType="workspace" /> + {canPerformWorkspaceAdminActions && config?.is_lark_enabled && ( + + )} {canPerformWorkspaceAdminActions && config?.is_lark_enabled && (