diff --git a/apps/admin/Dockerfile.admin b/apps/admin/Dockerfile.admin index 19ad2c392a1..5eaad7a9a9f 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 @@ -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/admin/app/(all)/(dashboard)/ai/page.tsx b/apps/admin/app/(all)/(dashboard)/ai/page.tsx index dec32009827..7ab60ddc1ff 100644 --- a/apps/admin/app/(all)/(dashboard)/ai/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/ai/page.tsx @@ -26,7 +26,7 @@ const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentP {formattedConfig ? ( diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx index 7df6faf17af..114994a5b27 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx @@ -182,7 +182,7 @@ export function InstanceGitlabConfigForm(props: Props) {
-
GitLab-provided details for Plane
+
GitLab-provided details for Tick
{GITLAB_FORM_FIELDS.map((field) => ( [{ title: "Authentication Settings - Plane Web" }]; +export const meta: Route.MetaFunction = () => [{ title: "Authentication Settings - Tick" }]; export default InstanceAuthenticationPage; diff --git a/apps/admin/app/(all)/(dashboard)/general/form.tsx b/apps/admin/app/(all)/(dashboard)/general/form.tsx index 0b402b76c7a..2a6c6b53588 100644 --- a/apps/admin/app/(all)/(dashboard)/general/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/general/form.tsx @@ -122,9 +122,9 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
-
Let Plane collect anonymous usage data
+
Let Tick collect anonymous usage data
- No PII is collected.This anonymized data is used to understand how you use Plane and build new features + No PII is collected.This anonymized data is used to understand how you use Tick and build new features in line with{" "}
- + - {!isSidebarCollapsed && "Redirect to Plane"} + {!isSidebarCollapsed && "Redirect to Tick"} diff --git a/apps/admin/app/(all)/(home)/page.tsx b/apps/admin/app/(all)/(home)/page.tsx index 7947adcdcce..527924458a8 100644 --- a/apps/admin/app/(all)/(home)/page.tsx +++ b/apps/admin/app/(all)/(home)/page.tsx @@ -46,5 +46,5 @@ export default observer(HomePage); export const meta: Route.MetaFunction = () => [ { title: "Admin – Instance Setup & Sign-In" }, - { name: "description", content: "Configure your Plane instance or sign in to the admin portal." }, + { name: "description", content: "Configure your Tick instance or sign in to the admin portal." }, ]; diff --git a/apps/admin/app/(all)/(home)/sign-in-form.tsx b/apps/admin/app/(all)/(home)/sign-in-form.tsx index 4e0afb8ea13..7cb67af2cf6 100644 --- a/apps/admin/app/(all)/(home)/sign-in-form.tsx +++ b/apps/admin/app/(all)/(home)/sign-in-form.tsx @@ -114,7 +114,7 @@ export function InstanceSignInForm() {
+ + + diff --git a/apps/admin/app/root.tsx b/apps/admin/app/root.tsx index 5d4eafb765a..86b318ac3ec 100644 --- a/apps/admin/app/root.tsx +++ b/apps/admin/app/root.tsx @@ -21,7 +21,7 @@ import interVariableWoff2 from "@fontsource-variable/inter/files/inter-latin-wgh import "@fontsource/material-symbols-rounded"; import "@fontsource/ibm-plex-mono"; -const APP_TITLE = "Plane | Simple, extensible, open-source project management tool."; +const APP_TITLE = "Tick · 任务管理"; const APP_DESCRIPTION = "Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind."; 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/app/urls/workspace.py b/apps/api/plane/app/urls/workspace.py index d79d5a74522..ea4b2692188 100644 --- a/apps/api/plane/app/urls/workspace.py +++ b/apps/api/plane/app/urls/workspace.py @@ -36,6 +36,9 @@ WorkspaceHomePreferenceViewSet, WorkspaceStickyViewSet, WorkspaceUserPreferenceViewSet, + LarkContactsListEndpoint, + LarkSyncTriggerEndpoint, + LarkWorkspaceInviteEndpoint, ) @@ -67,6 +70,21 @@ 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//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 84f7872ec85..e192ab43c02 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -65,6 +65,11 @@ WorkspaceJoinEndpoint, UserWorkspaceInvitationsViewSet, ) +from .workspace.lark_invite import ( + LarkContactsListEndpoint, + LarkSyncTriggerEndpoint, + 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..2ad0252160b --- /dev/null +++ b/apps/api/plane/app/views/workspace/lark_invite.py @@ -0,0 +1,389 @@ +# 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 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 +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 +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(): + (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 + + +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: + contacts = _crawl_directory(token) + except RuntimeError as exc: + return Response({"error": str(exc)}, status=status.HTTP_502_BAD_GATEWAY) + + cache.set(key, contacts, CONTACTS_CACHE_TTL) + return Response({"contacts": contacts, "cached": False}, 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 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, + 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, + ) 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..7388e8719ce --- /dev/null +++ b/apps/api/plane/authentication/provider/oauth/lark.py @@ -0,0 +1,331 @@ +# 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}" + # 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"{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 + + 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 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", + ) + + # 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={ + "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"], + 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 _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: + self.logger.warning( + "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, + }, + ) + 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": email, + "user": { + "avatar": payload.get("avatar_url"), + "first_name": full_name, + "last_name": "", + # Without an explicit display_name, User.save() defaults to the + # email prefix — which is the meaningless union_id for our + # synthetic @lark.local emails. Hand-set it from the Lark name. + "display_name": full_name or email.split("@")[0], + "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..37bf911cefe --- /dev/null +++ b/apps/api/plane/authentication/views/app/lark.py @@ -0,0 +1,152 @@ +# 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 + +# 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.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 ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) +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): + 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() + # 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 + 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/bgtasks/apps.py b/apps/api/plane/bgtasks/apps.py index e5fb0aa5479..259e9046122 100644 --- a/apps/api/plane/bgtasks/apps.py +++ b/apps/api/plane/bgtasks/apps.py @@ -7,3 +7,9 @@ class BgtasksConfig(AppConfig): name = "plane.bgtasks" + + def ready(self): + # Register Lark autojoin signal handler. Imported here so Django sees + # the @receiver decorator at startup. The handler is a no-op unless + # LARK_AUTO_JOIN_NEW_PROJECTS is enabled at the env level. + from plane.bgtasks import signals # noqa: F401 diff --git a/apps/api/plane/bgtasks/issue_activities_task.py b/apps/api/plane/bgtasks/issue_activities_task.py index 032feb02a60..2d87ca61ed7 100644 --- a/apps/api/plane/bgtasks/issue_activities_task.py +++ b/apps/api/plane/bgtasks/issue_activities_task.py @@ -1583,6 +1583,16 @@ def issue_activity( # Save all the values to database issue_activities_created = IssueActivity.objects.bulk_create(issue_activities) + # Fan out Feishu Bot DMs for assignee/state/comment events. bulk_create + # above bypasses post_save signals, so this is the canonical hook point. + # No-op unless LARK_NOTIFICATIONS_ENABLED is truthy; never raises. + try: + from plane.bgtasks.lark_notify_task import dispatch_lark_for_activities + + dispatch_lark_for_activities(issue_activities_created) + except Exception as exc: + log_exception(exc) + if notification: notifications.delay( type=type, diff --git a/apps/api/plane/bgtasks/lark_notify_task.py b/apps/api/plane/bgtasks/lark_notify_task.py new file mode 100644 index 00000000000..67e0af1087d --- /dev/null +++ b/apps/api/plane/bgtasks/lark_notify_task.py @@ -0,0 +1,212 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Async tasks that bridge Plane issue events to Feishu Bot direct messages. + +The dispatcher (`dispatch_lark_for_activities`) is called inline from +`issue_activities_task.issue_activity` right after IssueActivity rows are +bulk_create'd — Django's post_save signal does NOT fire on bulk_create, so +the activity audit log is the only reliable place to fan out notifications +without patching every write path. Each Celery task is best-effort: failures +are logged but never bubbled back into the originating HTTP request. +""" + +# Python imports +import logging +import os +import re + +# Third party +from celery import shared_task + +# Module imports +from plane.utils.lark_notify import ( + card_issue_assigned, + card_issue_comment, + card_issue_state_changed, + get_union_id, + send_interactive_card, +) + +logger = logging.getLogger("plane.bgtasks.lark_notify_task") + + +def _lark_notifications_enabled(): + return (os.environ.get("LARK_NOTIFICATIONS_ENABLED") or "").strip().lower() in ( + "1", + "true", + "yes", + ) + + +def dispatch_lark_for_activities(activities): + """Fan out Feishu Bot DMs based on freshly-created IssueActivity rows. + + Called from issue_activities_task right after `bulk_create`. Iterates the + same rows that just hit the DB and queues a Celery task per notifiable + event. We do not touch the rows themselves — only read their fields. + + No-op unless LARK_NOTIFICATIONS_ENABLED is truthy. Exceptions swallowed so + a Lark integration glitch can never break Plane's issue write paths. + """ + if not activities or not _lark_notifications_enabled(): + return + + for activity in activities: + try: + issue_id = getattr(activity, "issue_id", None) + if issue_id is None: + continue + field = (getattr(activity, "field", "") or "").strip() + verb = (getattr(activity, "verb", "") or "").strip() + actor_id = getattr(activity, "actor_id", None) + actor_str = str(actor_id) if actor_id else None + issue_str = str(issue_id) + + if field == "assignees" and getattr(activity, "new_identifier", None): + # new_identifier holds the added assignee's user_id; the + # `dropped_assignee` branch sets old_identifier instead. + notify_issue_assigned_task.delay( + issue_str, str(activity.new_identifier), actor_str + ) + continue + + if field == "state": + old_id = getattr(activity, "old_identifier", None) + new_id = getattr(activity, "new_identifier", None) + notify_issue_state_changed_task.delay( + issue_str, + str(old_id) if old_id else None, + str(new_id) if new_id else None, + actor_str, + ) + continue + + comment_id = getattr(activity, "issue_comment_id", None) + if comment_id and field == "comment" and verb == "created": + notify_issue_comment_task.delay(issue_str, str(comment_id), actor_str) + continue + except Exception: + logger.exception("Failed to dispatch Lark notification for activity") + + +def _user_display(user): + if user is None: + return None + return user.display_name or user.first_name or (user.email.split("@")[0] if user.email else None) + + +def _strip_html(s): + if not s: + return "" + # Lightweight: drop tags + collapse whitespace. Comment HTML is from a + # rich editor; we just want a single-line preview for the card. + text = re.sub(r"<[^>]+>", " ", s) + text = re.sub(r"\s+", " ", text).strip() + if len(text) > 140: + text = text[:137] + "…" + return text + + +@shared_task +def notify_issue_assigned_task(issue_id, assignee_id, by_user_id): + """Fired when a user is added as assignee on an issue.""" + from plane.db.models import Issue, User + + try: + issue = ( + Issue.objects.select_related("project", "workspace").filter(id=issue_id).first() + ) + if issue is None: + return + assignee = User.objects.filter(id=assignee_id).first() + if assignee is None: + return + if by_user_id and by_user_id == assignee_id: + # Self-assignment — don't notify yourself + return + + union_id = get_union_id(assignee) + if not union_id: + logger.info("No lark union_id for user %s — skipping notify", assignee_id) + return + + by_user = User.objects.filter(id=by_user_id).first() if by_user_id else None + send_interactive_card(union_id, card_issue_assigned(issue, _user_display(by_user))) + except Exception: + logger.exception("notify_issue_assigned_task failed: issue=%s assignee=%s", issue_id, assignee_id) + + +@shared_task +def notify_issue_state_changed_task(issue_id, old_state_id, new_state_id, by_user_id): + """Fired when an issue's state field changes. Notifies every active + assignee except the person who made the change.""" + from plane.db.models import Issue, State, User + + try: + issue = ( + Issue.objects.select_related("project", "workspace") + .prefetch_related("assignees") + .filter(id=issue_id) + .first() + ) + if issue is None: + return + old_state = State.objects.filter(id=old_state_id).first() if old_state_id else None + new_state = State.objects.filter(id=new_state_id).first() if new_state_id else None + by_user = User.objects.filter(id=by_user_id).first() if by_user_id else None + by_display = _user_display(by_user) + + for assignee in issue.assignees.all(): + if by_user_id and assignee.id == by_user_id: + continue + union_id = get_union_id(assignee) + if not union_id: + continue + send_interactive_card( + union_id, + card_issue_state_changed( + issue, + getattr(old_state, "name", None), + getattr(new_state, "name", None), + by_display, + ), + ) + except Exception: + logger.exception("notify_issue_state_changed_task failed: issue=%s", issue_id) + + +@shared_task +def notify_issue_comment_task(issue_id, comment_id, by_user_id): + """Fired when a new comment is added to an issue. Notifies every + active assignee except the commenter.""" + from plane.db.models import Issue, IssueComment, User + + try: + issue = ( + Issue.objects.select_related("project", "workspace") + .prefetch_related("assignees") + .filter(id=issue_id) + .first() + ) + comment = IssueComment.objects.filter(id=comment_id).first() + if issue is None or comment is None: + return + + excerpt = _strip_html(comment.comment_html or comment.comment_stripped or "") + if not excerpt: + return + + by_user = User.objects.filter(id=by_user_id).first() if by_user_id else None + by_display = _user_display(by_user) + + for assignee in issue.assignees.all(): + if by_user_id and assignee.id == by_user_id: + continue + union_id = get_union_id(assignee) + if not union_id: + continue + send_interactive_card(union_id, card_issue_comment(issue, excerpt, by_display)) + except Exception: + logger.exception("notify_issue_comment_task failed: issue=%s comment=%s", issue_id, comment_id) diff --git a/apps/api/plane/bgtasks/lark_project_autojoin.py b/apps/api/plane/bgtasks/lark_project_autojoin.py new file mode 100644 index 00000000000..fb953d82129 --- /dev/null +++ b/apps/api/plane/bgtasks/lark_project_autojoin.py @@ -0,0 +1,111 @@ +# 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 + +# Third party +from celery import shared_task + +# Django +from django.db import transaction + +# Module imports +from plane.db.models import Project, ProjectMember, WorkspaceMember + +logger = logging.getLogger("plane.bgtasks.lark_project_autojoin") + + +def autojoin_all_workspace_members(project_id): + """Idempotently add every active workspace member as an active project + member with the role they hold on the workspace. Per-row .save() preserves + the ProjectMember.save() override that creates the companion + ProjectUserProperty row used for sidebar ordering. + + Returns a stats dict. Safe to call repeatedly. + """ + project = Project.objects.filter(id=project_id).first() + if project is None: + return {"error": f"project {project_id} not found"} + + members = list( + WorkspaceMember.objects.filter( + workspace_id=project.workspace_id, is_active=True + ).select_related("member") + ) + + new = reactivated = existing = 0 + for wm in members: + if wm.member_id is None: + continue + try: + with transaction.atomic(): + pm, created = ProjectMember.objects.get_or_create( + project=project, + member=wm.member, + defaults={ + "role": wm.role, + "workspace_id": project.workspace_id, + "is_active": True, + }, + ) + if created: + new += 1 + elif not pm.is_active: + pm.is_active = True + pm.role = wm.role + pm.save(update_fields=["is_active", "role"]) + reactivated += 1 + else: + existing += 1 + except Exception: + logger.exception("autojoin failed for project=%s member=%s", project_id, wm.member_id) + + stats = { + "project_id": str(project.id), + "project_identifier": project.identifier, + "workspace_members_seen": len(members), + "project_members_created": new, + "project_members_reactivated": reactivated, + "project_members_already_active": existing, + "project_member_total": ProjectMember.objects.filter( + project=project, is_active=True + ).count(), + } + logger.info("Project autojoin complete: %s", stats) + return stats + + +@shared_task +def autojoin_workspace_members_to_project_task(project_id): + """Celery wrapper called by the post_save signal. No-op unless + LARK_AUTO_JOIN_NEW_PROJECTS is truthy. + + Scoped to LARK_DEFAULT_WORKSPACE_SLUG to avoid auto-populating projects + in unrelated workspaces on multi-tenant deploys. + + Only Public projects (Project.network == 2) get auto-populated; Secret + projects (network == 0) intentionally stay invitation-only — that's the + whole point of marking them private. Toggling network later does NOT + backfill; the project owner can use the "Sync from Lark" path (or invite + manually) once they flip to public. + """ + if (os.environ.get("LARK_AUTO_JOIN_NEW_PROJECTS") or "").strip().lower() not in ( + "1", + "true", + "yes", + ): + return {"skipped": "LARK_AUTO_JOIN_NEW_PROJECTS not enabled"} + + target_slug = (os.environ.get("LARK_DEFAULT_WORKSPACE_SLUG") or "").strip() + project = Project.objects.filter(id=project_id).select_related("workspace").first() + if project is None: + return {"error": f"project {project_id} not found"} + if target_slug and project.workspace.slug != target_slug: + return {"skipped": f"workspace {project.workspace.slug} != {target_slug}"} + if project.network != 2: + return {"skipped": f"project {project_id} is not Public (network={project.network})"} + + return autojoin_all_workspace_members(project_id) 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..a045a0e4988 --- /dev/null +++ b/apps/api/plane/bgtasks/lark_sync_task.py @@ -0,0 +1,158 @@ +# 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" + + name = c.get("name") or "" + 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": name, + "last_name": "", + # display_name without an explicit value defaults to the + # email prefix in User.save; for lark.local synthetic + # emails that's a meaningless union_id, so set it now + "display_name": name or email.split("@")[0], + "is_password_autoset": True, + "is_email_verified": True, + }, + ) + if created: + user_new += 1 + else: + user_existing += 1 + # Backfill name fields on accounts the OAuth provider + # created before we had directory access + update_fields = [] + if not user.first_name and name: + user.first_name = name + update_fields.append("first_name") + # Always replace the email-derived placeholder display name + # when a real Feishu name is available + if name and user.display_name != name: + user.display_name = name + update_fields.append("display_name") + if update_fields: + user.save(update_fields=update_fields) + + 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/bgtasks/magic_link_code_task.py b/apps/api/plane/bgtasks/magic_link_code_task.py index eef7adea037..be977068cd5 100644 --- a/apps/api/plane/bgtasks/magic_link_code_task.py +++ b/apps/api/plane/bgtasks/magic_link_code_task.py @@ -33,7 +33,7 @@ def magic_link(email, key, token): ) = get_email_configuration() # Send the mail - subject = f"Your unique Plane login code is {token}" + subject = f"Your Tick login code: {token}" context = {"code": token, "email": email} html_content = render_to_string("emails/auth/magic_signin.html", context) diff --git a/apps/api/plane/bgtasks/signals.py b/apps/api/plane/bgtasks/signals.py new file mode 100644 index 00000000000..950fe8a1650 --- /dev/null +++ b/apps/api/plane/bgtasks/signals.py @@ -0,0 +1,46 @@ +# 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 + +# Django imports +from django.db import transaction +from django.db.models.signals import post_save +from django.dispatch import receiver + +# Module imports +from plane.bgtasks.lark_project_autojoin import ( + autojoin_workspace_members_to_project_task, +) +# Side-effect import: forces Celery to register notify_issue_* tasks at app +# startup. Without this, only the lazy import inside issue_activities_task +# would load the module — too late for the worker's autodiscovery sweep. +from plane.bgtasks import lark_notify_task # noqa: F401 +from plane.db.models import Project + +logger = logging.getLogger("plane.bgtasks.signals") + + +@receiver(post_save, sender=Project) +def queue_lark_project_autojoin(sender, instance, created, **kwargs): + """When a new Project is created, queue a background job to add every + active workspace member as a ProjectMember. The task short-circuits + unless LARK_AUTO_JOIN_NEW_PROJECTS is enabled, so this signal is a + cheap no-op for deploys that haven't opted in. + + Runs out-of-band via Celery rather than inline because adding 500 + members serially during project creation would block the HTTP response. + """ + if not created or instance.id is None: + return + transaction.on_commit( + lambda: autojoin_workspace_members_to_project_task.delay(str(instance.id)) + ) + + +# Issue notification dispatch lives in issue_activities_task.issue_activity, +# right after IssueActivity.objects.bulk_create — Django's post_save signal +# does NOT fire on bulk_create, so a receiver here would silently never run. +# See plane/bgtasks/lark_notify_task.dispatch_lark_for_activities. 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 + }, } 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/apps/api/plane/utils/lark_notify.py b/apps/api/plane/utils/lark_notify.py new file mode 100644 index 00000000000..87e4b92d29a --- /dev/null +++ b/apps/api/plane/utils/lark_notify.py @@ -0,0 +1,243 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Lark/Feishu Bot messaging helpers. + +Keeps the Bot HTTP integration isolated so signal handlers and Celery +tasks stay readable. Token caching uses the Django cache so we don't +re-mint a tenant_access_token (2-hour TTL) on every notification. +""" + +# Python imports +import json +import logging +import os + +# Third party +import requests + +# Django imports +from django.core.cache import cache + +logger = logging.getLogger("plane.utils.lark_notify") + +_TOKEN_CACHE_KEY = "lark:tenant_token" +_TOKEN_CACHE_TTL = 5400 # 90 min; Lark tokens expire after 2h, refresh well before + + +def _base_host(): + domain = (os.environ.get("LARK_BASE_DOMAIN") or "feishu.cn").strip() + return f"https://open.{domain}" + + +def _get_tenant_token(): + cached = cache.get(_TOKEN_CACHE_KEY) + if cached: + return cached + + app_id = (os.environ.get("LARK_CLIENT_ID") or "").strip() + app_secret = (os.environ.get("LARK_CLIENT_SECRET") or "").strip() + if not (app_id and app_secret): + return None + + try: + resp = requests.post( + f"{_base_host()}/open-apis/auth/v3/tenant_access_token/internal", + json={"app_id": app_id, "app_secret": app_secret}, + timeout=10, + ) + resp.raise_for_status() + body = resp.json() + except requests.RequestException as exc: + logger.warning("Lark token fetch failed: %s", exc) + return None + + if body.get("code", 0) != 0: + logger.warning("Lark token returned non-zero code: %s", body) + return None + + token = body.get("tenant_access_token") + if token: + cache.set(_TOKEN_CACHE_KEY, token, _TOKEN_CACHE_TTL) + return token + + +def get_union_id(user): + """Resolve a Plane user to their Lark union_id. + + Priority: + 1. Account row created by the OAuth provider (provider='lark') — + provider_account_id is the union_id (see lark.py provider). + 2. Synthetic email parsing for users bulk-imported via the sync + task but who haven't signed in via Lark yet. + """ + if user is None: + return None + # Late import to avoid app-loading order issues + from plane.db.models import Account + + try: + account = Account.objects.filter(user=user, provider="lark").first() + if account and account.provider_account_id: + return account.provider_account_id + except Exception: + logger.exception("Account lookup failed for user=%s", user.id) + + email = (user.email or "").strip().lower() + if email.endswith("@lark.local"): + return email.split("@")[0] + return None + + +def _send(union_id, msg_type, content_dict): + if not union_id: + return False + token = _get_tenant_token() + if not token: + logger.warning("No Lark tenant_access_token — skipping notify to %s", union_id) + return False + + try: + resp = requests.post( + f"{_base_host()}/open-apis/im/v1/messages", + params={"receive_id_type": "union_id"}, + headers={"Authorization": f"Bearer {token}"}, + json={ + "receive_id": union_id, + "msg_type": msg_type, + "content": json.dumps(content_dict, ensure_ascii=False), + }, + timeout=10, + ) + body = resp.json() + except requests.RequestException as exc: + logger.warning("Lark IM send failed for %s: %s", union_id, exc) + return False + + if body.get("code", 0) != 0: + logger.warning("Lark IM non-zero for %s: %s", union_id, body) + return False + return True + + +def send_text(union_id, text): + return _send(union_id, "text", {"text": text}) + + +def send_interactive_card(union_id, card): + """`card` is the full interactive-card JSON dict (header + elements).""" + return _send(union_id, "interactive", card) + + +# ---------- Card builders -------------------------------------------------- + + +def _plane_base_url(): + return (os.environ.get("PLANE_PUBLIC_BASE_URL") or "https://task.vijimgroup.com").rstrip("/") + + +def issue_url(workspace_slug, project_id, issue_id): + return f"{_plane_base_url()}/{workspace_slug}/projects/{project_id}/issues/{issue_id}/" + + +def _short_id(issue): + project_identifier = getattr(issue.project, "identifier", "") if getattr(issue, "project", None) else "" + return f"{project_identifier}-{issue.sequence_id}" if project_identifier else f"#{issue.sequence_id}" + + +def card_issue_assigned(issue, assigner_name): + short = _short_id(issue) + url = issue_url(issue.workspace.slug, issue.project_id, issue.id) + return { + "config": {"wide_screen_mode": True}, + "header": { + "title": {"tag": "plain_text", "content": "📋 你被分配了新任务"}, + "template": "blue", + }, + "elements": [ + { + "tag": "div", + "fields": [ + {"is_short": True, "text": {"tag": "lark_md", "content": f"**任务**\n{short}"}}, + { + "is_short": True, + "text": {"tag": "lark_md", "content": f"**分配人**\n{assigner_name or '系统'}"}, + }, + ], + }, + {"tag": "div", "text": {"tag": "lark_md", "content": f"**标题**\n{issue.name}"}}, + { + "tag": "action", + "actions": [ + { + "tag": "button", + "text": {"tag": "plain_text", "content": "查看任务 →"}, + "type": "primary", + "url": url, + } + ], + }, + ], + } + + +def card_issue_state_changed(issue, old_state_name, new_state_name, changer_name): + short = _short_id(issue) + url = issue_url(issue.workspace.slug, issue.project_id, issue.id) + return { + "config": {"wide_screen_mode": True}, + "header": { + "title": {"tag": "plain_text", "content": "🔄 任务状态变更"}, + "template": "turquoise", + }, + "elements": [ + {"tag": "div", "text": {"tag": "lark_md", "content": f"**{short}**: {issue.name}"}}, + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": f"**{old_state_name or '?'}** → **{new_state_name or '?'}** _by {changer_name or '系统'}_", + }, + }, + { + "tag": "action", + "actions": [ + { + "tag": "button", + "text": {"tag": "plain_text", "content": "查看任务 →"}, + "url": url, + } + ], + }, + ], + } + + +def card_issue_comment(issue, comment_excerpt, commenter_name): + short = _short_id(issue) + url = issue_url(issue.workspace.slug, issue.project_id, issue.id) + return { + "config": {"wide_screen_mode": True}, + "header": { + "title": {"tag": "plain_text", "content": "💬 任务有新评论"}, + "template": "green", + }, + "elements": [ + {"tag": "div", "text": {"tag": "lark_md", "content": f"**{short}**: {issue.name}"}}, + { + "tag": "div", + "text": {"tag": "lark_md", "content": f"**{commenter_name or '某人'}**: {comment_excerpt}"}, + }, + { + "tag": "action", + "actions": [ + { + "tag": "button", + "text": {"tag": "plain_text", "content": "查看任务 →"}, + "url": url, + } + ], + }, + ], + } diff --git a/apps/space/Dockerfile.space b/apps/space/Dockerfile.space index 60d4a155aa8..0a72f7e2705 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 @@ -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/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/app/root.tsx b/apps/space/app/root.tsx index fe504b1edf0..f459bb1a9d4 100644 --- a/apps/space/app/root.tsx +++ b/apps/space/app/root.tsx @@ -24,8 +24,8 @@ import interVariableWoff2 from "@fontsource-variable/inter/files/inter-latin-wgh import "@fontsource/material-symbols-rounded"; import "@fontsource/ibm-plex-mono"; -const APP_TITLE = "Plane Publish | Make your Plane boards public with one-click"; -const APP_DESCRIPTION = "Plane Publish is a customer feedback management tool built on top of plane.so"; +const APP_TITLE = "Tick · 公开看板"; +const APP_DESCRIPTION = "Tick public boards — built on Plane (open source)."; export const links: Route.LinksFunction = () => [ { rel: "apple-touch-icon", sizes: "180x180", href: appleTouchIcon }, 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/Dockerfile.web b/apps/web/Dockerfile.web index 38af19e74ba..e7e6bde9298 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 # ***************************************************************************** @@ -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/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..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"; @@ -19,8 +22,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 +40,18 @@ import { MembersWorkspaceSettingsHeader } from "./header"; const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsPage({ params }: Route.ComponentProps) { // states const [inviteModal, setInviteModal] = useState(false); + const [larkInviteModal, setLarkInviteModal] = useState(false); + const [larkSyncing, setLarkSyncing] = 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 +116,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 && 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/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 { diff --git a/apps/web/manifest.json b/apps/web/manifest.json index 5a00515a03c..a1720db5c44 100644 --- a/apps/web/manifest.json +++ b/apps/web/manifest.json @@ -4,9 +4,9 @@ "display": "standalone", "scope": "/", "start_url": "/", - "name": "Plane | Accelerate software development with peace.", - "short_name": "Plane", - "description": "Plane accelerated the software development by order of magnitude for agencies and product companies.", + "name": "Tick · 任务管理", + "short_name": "Tick", + "description": "Tick — Vijim Group internal task management.", "icons": [ { "src": "/icon-192x192.png", diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json index 35917737d44..e5764c6869e 100644 --- a/apps/web/public/manifest.json +++ b/apps/web/public/manifest.json @@ -1,6 +1,6 @@ { - "name": "Plane", - "short_name": "Plane", + "name": "Tick", + "short_name": "Tick", "icons": [ { "src": "/icons/icon-192x192.png", diff --git a/packages/constants/src/metadata.ts b/packages/constants/src/metadata.ts index c44e44105a7..a6f2d8a6ea6 100644 --- a/packages/constants/src/metadata.ts +++ b/packages/constants/src/metadata.ts @@ -4,19 +4,19 @@ * See the LICENSE file for details. */ -export const SITE_NAME = "Plane | Simple, extensible, open-source project management tool."; -export const SITE_TITLE = "Plane | Simple, extensible, open-source project management tool."; +export const SITE_NAME = "Tick · 任务管理"; +export const SITE_TITLE = "Tick · 任务管理"; export const SITE_DESCRIPTION = "Open-source project management tool to manage work items, cycles, and product roadmaps easily"; export const SITE_KEYWORDS = "software development, plan, ship, software, accelerate, code management, release management, project management, work items tracking, agile, scrum, kanban, collaboration"; export const SITE_URL = "https://app.plane.so/"; -export const TWITTER_USER_NAME = "Plane | Simple, extensible, open-source project management tool."; +export const TWITTER_USER_NAME = "Tick · 任务管理"; // Plane Sites Metadata -export const SPACE_SITE_NAME = "Plane Publish | Make your Plane boards and roadmaps pubic with just one-click. "; -export const SPACE_SITE_TITLE = "Plane Publish | Make your Plane boards public with one-click"; -export const SPACE_SITE_DESCRIPTION = "Plane Publish is a customer feedback management tool built on top of plane.so"; +export const SPACE_SITE_NAME = "Tick · 公开看板"; +export const SPACE_SITE_TITLE = "Tick · 公开看板"; +export const SPACE_SITE_DESCRIPTION = "Tick public boards — built on Plane (open source)."; export const SPACE_SITE_KEYWORDS = "software development, customer feedback, software, accelerate, code management, release management, project management, work items tracking, agile, scrum, kanban, collaboration"; export const SPACE_SITE_URL = "https://app.plane.so/"; diff --git a/packages/i18n/src/locales/en/core.ts b/packages/i18n/src/locales/en/core.ts index 0e60e06807a..21ed15696fa 100644 --- a/packages/i18n/src/locales/en/core.ts +++ b/packages/i18n/src/locales/en/core.ts @@ -90,7 +90,7 @@ export default { already_have_an_account: "Already have an account?", login: "Log in", create_account: "Create an account", - new_to_plane: "New to Plane?", + new_to_plane: "New to Tick?", back_to_sign_in: "Back to sign in", resend_in: "Resend in {seconds} seconds", sign_in_with_unique_code: "Sign in with unique code", diff --git a/packages/i18n/src/locales/en/translations.ts b/packages/i18n/src/locales/en/translations.ts index 9f3eff04d1a..f636293934f 100644 --- a/packages/i18n/src/locales/en/translations.ts +++ b/packages/i18n/src/locales/en/translations.ts @@ -243,7 +243,7 @@ export default { full_changelog: "Full changelog", support: "Support", forum: "Forum", - powered_by_plane_pages: "Powered by Plane Pages", + powered_by_plane_pages: "Powered by Tick Pages", please_select_at_least_one_invitation: "Please select at least one invitation.", please_select_at_least_one_invitation_description: "Please select at least one invitation to join the workspace.", we_see_that_someone_has_invited_you_to_join_a_workspace: "We see that someone has invited you to join a workspace", @@ -408,7 +408,7 @@ export default { not_right_now: "Not right now", create_project: { title: "Create a project", - description: "Most things start with a project in Plane.", + description: "Most things start with a project in Tick.", cta: "Get started", }, invite_team: { @@ -422,7 +422,7 @@ export default { cta: "Configure this workspace", }, personalize_account: { - title: "Make Plane yours.", + title: "Make Tick yours.", description: "Choose your picture, colors, and more.", cta: "Personalize now", }, @@ -456,7 +456,7 @@ export default { }, }, new_at_plane: { - title: "New at Plane", + title: "New at Tick", }, quick_tutorial: { title: "Quick tutorial", @@ -1112,7 +1112,7 @@ export default { }, workspace_creation: { heading: "Create your workspace", - subheading: "To start using Plane, you need to create or join a workspace.", + subheading: "To start using Tick, you need to create or join a workspace.", form: { name: { label: "Name your workspace", @@ -1167,11 +1167,11 @@ export default { general: { title: "Overview of your projects, activity, and metrics", description: - "Welcome to Plane, we are excited to have you here. Create your first project and track your work items, and this page will transform into a space that helps you progress. Admins will also see items which help their team progress.", + "Welcome to Tick, we are excited to have you here. Create your first project and track your work items, and this page will transform into a space that helps you progress. Admins will also see items which help their team progress.", primary_button: { text: "Build your first project", comic: { - title: "Everything starts with a project in Plane", + title: "Everything starts with a project in Tick", description: "A project could be a product's roadmap, a marketing campaign, or launching a new car.", }, }, @@ -1298,7 +1298,7 @@ export default { primary_button: { text: "Start your first project", comic: { - title: "Everything starts with a project in Plane", + title: "Everything starts with a project in Tick", description: "A project could be a product's roadmap, a marketing campaign, or launching a new car.", }, }, @@ -1309,7 +1309,7 @@ export default { primary_button: { text: "Start your first project", comic: { - title: "Everything starts with a project in Plane", + title: "Everything starts with a project in Tick", description: "A project could be a product's roadmap, a marketing campaign, or launching a new car.", }, }, @@ -1427,7 +1427,7 @@ export default { page_label: "{workspace} - General settings", key_created: "Key created", copy_key: - "Copy and save this secret key in Plane Pages. You can't see this key after you hit Close. A CSV file containing the key has been downloaded.", + "Copy and save this secret key in Tick Pages. You can't see this key after you hit Close. A CSV file containing the key has been downloaded.", token_copied: "Token copied to clipboard.", settings: { general: { @@ -1586,7 +1586,7 @@ export default { delete: { title: "Delete personal access token", description: - "Any application using this token will no longer have the access to Plane data. This action cannot be undone.", + "Any application using this token will no longer have the access to Tick data. This action cannot be undone.", success: { title: "Success!", message: "The token has been successfully deleted", @@ -1602,7 +1602,7 @@ export default { api_tokens: { title: "No personal access tokens created", description: - "Plane APIs can be used to integrate your data in Plane with any external system. Create a token to get started.", + "Tick APIs can be used to integrate your data in Tick with any external system. Create a token to get started.", }, webhooks: { title: "No webhooks added", @@ -1666,7 +1666,7 @@ export default { activity: { title: "No activities yet", description: - "Get started by creating a new work item! Add details and properties to it. Explore more in Plane to see your activity.", + "Get started by creating a new work item! Add details and properties to it. Explore more in Tick to see your activity.", }, assigned: { title: "No work items are assigned to you", @@ -1828,12 +1828,12 @@ export default { "Configure automated actions to streamline your project management workflow and reduce manual tasks.", "auto-archive": { title: "Auto-archive closed work items", - description: "Plane will auto archive work items that have been completed or canceled.", + description: "Tick will auto archive work items that have been completed or canceled.", duration: "Auto-archive work items that are closed for", }, "auto-close": { title: "Auto-close work items", - description: "Plane will automatically close work items that haven't been completed or canceled.", + description: "Tick will automatically close work items that haven't been completed or canceled.", duration: "Auto-close work items that are inactive for", auto_close_status: "Auto-close status", }, @@ -2026,9 +2026,9 @@ export default { primary_button: { text: "Create your first work item", comic: { - title: "Work items are building blocks in Plane.", + title: "Work items are building blocks in Tick.", description: - "Redesign the Plane UI, Rebrand the company, or Launch the new fuel injection system are examples of work items that likely have sub-work items.", + "Redesign the Tick UI, Rebrand the company, or Launch the new fuel injection system are examples of work items that likely have sub-work items.", }, }, }, @@ -2132,9 +2132,9 @@ export default { empty_state: { general: { title: - "Write a note, a doc, or a full knowledge base. Get Galileo, Plane's AI assistant, to help you get started", + "Write a note, a doc, or a full knowledge base. Get Galileo, Tick's AI assistant, to help you get started", description: - "Pages are thoughts potting space in Plane. Take down meeting notes, format them easily, embed work items, lay them out using a library of components, and keep them all in your project's context. To make short work of any doc, invoke Galileo, Plane's AI, with a shortcut or the click of a button.", + "Pages are thoughts potting space in Tick. Take down meeting notes, format them easily, embed work items, lay them out using a library of components, and keep them all in your project's context. To make short work of any doc, invoke Galileo, Tick's AI, with a shortcut or the click of a button.", primary_button: { text: "Create your first page", }, @@ -2509,7 +2509,7 @@ export default { }, self_hosted_maintenance_message: { plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start: - "Plane didn't start up. This could be because one or more Plane services failed to start.", + "Tick didn't start up. This could be because one or more Tick services failed to start.", choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure: "Choose View Logs from setup.sh and Docker logs to be sure.", }, @@ -2694,7 +2694,7 @@ export default { }, help_actions: { open_keyboard_shortcuts: "Open keyboard shortcuts", - open_plane_documentation: "Open Plane documentation", + open_plane_documentation: "Open Tick documentation", join_forum: "Join our Forum", report_bug: "Report a bug", chat_with_us: "Chat with us", diff --git a/packages/i18n/src/locales/zh-CN/empty-state.ts b/packages/i18n/src/locales/zh-CN/empty-state.ts index 3a3c9c83164..2a044ae62ba 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: "暂无更新。", @@ -20,7 +20,7 @@ export default { }, not_found: { title: "糟糕!似乎出了点问题", - description: "我们目前无法获取您的 Plane 账户。这可能是网络错误。", + description: "我们目前无法获取您的 Tick 账户。这可能是网络错误。", cta_primary: "尝试重新加载", }, server_error: { @@ -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..b074b76380e 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: "收集", @@ -89,7 +89,7 @@ export default { already_have_an_account: "已有账号?", login: "登录", create_account: "创建账号", - new_to_plane: "首次使用 Plane?", + new_to_plane: "首次使用 Tick?", back_to_sign_in: "返回登录", resend_in: "{seconds} 秒后重新发送", sign_in_with_unique_code: "使用唯一码登录", @@ -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,18 +597,18 @@ export default { empty: { project: "访问项目后,您的最近项目将显示在这里。", page: "访问页面后,您的最近页面将显示在这里。", - issue: "访问工作项后,您的最近工作项将显示在这里。", + issue: "访问任务后,您的最近任务将显示在这里。", default: "您还没有任何最近项目。", }, filters: { all: "所有", projects: "项目", pages: "页面", - issues: "工作项", + issues: "任务", }, }, new_at_plane: { - title: "Plane新功能", + title: "Tick 新功能", }, quick_tutorial: { title: "快速教程", @@ -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,25 +1242,25 @@ 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: "选择一个任务以查看其详细信息。", }, }, }, workspace_creation: { heading: "创建您的工作区", - subheading: "要开始使用 Plane,您需要创建或加入一个工作区。", + subheading: "要开始使用 Tick,您需要创建或加入一个工作区。", form: { name: { label: "为您的工作区命名", @@ -1314,11 +1314,11 @@ export default { general: { title: "项目、活动和指标概览", description: - "欢迎使用 Plane,我们很高兴您能来到这里。创建您的第一个项目并跟踪您的工作项,这个页面将转变为帮助您进展的空间。管理员还将看到帮助团队进展的项目。", + "欢迎使用 Tick,我们很高兴您能来到这里。创建您的第一个项目并跟踪您的任务,这个页面将转变为帮助您进展的空间。管理员还将看到帮助团队进展的项目。", primary_button: { text: "构建您的第一个项目", comic: { - title: "在 Plane 中一切都从项目开始", + title: "在 Tick 中一切都从项目开始", description: "项目可以是产品路线图、营销活动或新车发布。", }, }, @@ -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,22 +1445,22 @@ export default { general: { title: "没有活动项目", description: - "将每个项目视为目标导向工作的父级。项目是工作项、周期和模块所在的地方,与您的同事一起帮助您实现目标。创建新项目或筛选已归档的项目。", + "将每个项目视为目标导向工作的父级。项目是任务、周期和模块所在的地方,与您的同事一起帮助您实现目标。创建新项目或筛选已归档的项目。", primary_button: { text: "开始您的第一个项目", comic: { - title: "在 Plane 中一切都从项目开始", + title: "在 Tick 中一切都从项目开始", description: "项目可以是产品路线图、营销活动或新车发布。", }, }, }, no_projects: { title: "没有项目", - description: "要创建工作项或管理您的工作,您需要创建一个项目或成为项目的一部分。", + description: "要创建任务或管理您的工作,您需要创建一个项目或成为项目的一部分。", primary_button: { text: "开始您的第一个项目", comic: { - title: "在 Plane 中一切都从项目开始", + title: "在 Tick 中一切都从项目开始", description: "项目可以是产品路线图、营销活动或新车发布。", }, }, @@ -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: { @@ -1557,7 +1557,7 @@ export default { label: "工作区设置", page_label: "{workspace} - 常规设置", key_created: "密钥已创建", - copy_key: "复制并将此密钥保存在 Plane Pages 中。关闭后您将无法看到此密钥。包含密钥的 CSV 文件已下载。", + copy_key: "复制并将此密钥保存在 Tick Pages 中。关闭后您将无法看到此密钥。包含密钥的 CSV 文件已下载。", token_copied: "令牌已复制到剪贴板。", settings: { general: { @@ -1630,7 +1630,7 @@ export default { exporting: "导出中", previous_exports: "以前的导出", export_separate_files: "将数据导出为单独的文件", - filters_info: "应用筛选器以根据您的条件导出特定工作项。", + filters_info: "应用筛选器以根据您的条件导出特定任务。", modal: { title: "导出到", toasts: { @@ -1705,7 +1705,7 @@ export default { generating: "生成中", delete: { title: "删除 API 令牌", - description: "使用此令牌的任何应用程序将无法再访问 Plane 数据。此操作无法撤消。", + description: "使用此令牌的任何应用程序将无法再访问 Tick 数据。此操作无法撤消。", success: { title: "成功!", message: "API 令牌已成功删除", @@ -1720,7 +1720,7 @@ export default { empty_state: { api_tokens: { title: "尚未创建 API 令牌", - description: "Plane API 可用于将您在 Plane 中的数据与任何外部系统集成。创建令牌以开始使用。", + description: "Tick API 可用于将您在 Tick 中的数据与任何外部系统集成。创建令牌以开始使用。", }, webhooks: { title: "尚未添加 webhook", @@ -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: "通过创建新任务开始!为其添加详细信息和属性。在 Tick 中探索更多内容以查看您的活动。", }, 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: "Tick 将自动归档已完成或已取消的任务。", duration: "自动归档已关闭", }, "auto-close": { - title: "自动关闭工作项", - description: "Plane 将自动关闭尚未完成或取消的工作项。", + title: "自动关闭任务", + description: "Tick 将自动关闭尚未完成或取消的任务。", 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: "任务是 Tick 中的基本构建块。", + description: "重新设计 Tick 界面、重塑公司品牌或启动新的燃料喷射系统都是可能包含子任务的任务示例。", }, }, }, 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: "您可以在此处创建一个视图,根据需要使用任意数量的属性作为筛选条件。", }, }, @@ -2209,9 +2209,9 @@ export default { project_page: { empty_state: { general: { - title: "写笔记、文档或完整的知识库。让 Plane 的 AI 助手 Galileo 帮助您开始", + title: "写笔记、文档或完整的知识库。让 Tick 的 AI 助手 Galileo 帮助您开始", description: - "页面是 Plane 中的思维记录空间。记录会议笔记,轻松格式化,嵌入工作项,使用组件库进行布局,并将它们全部保存在项目上下文中。要快速完成任何文档,可以通过快捷键或点击按钮调用 Plane 的 AI Galileo。", + "页面是 Tick 中的思维记录空间。记录会议笔记,轻松格式化,嵌入任务,使用组件库进行布局,并将它们全部保存在项目上下文中。要快速完成任何文档,可以通过快捷键或点击按钮调用 Tick 的 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: "手动", @@ -2581,7 +2581,7 @@ export default { }, self_hosted_maintenance_message: { plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start: - "Plane 未能启动。这可能是因为一个或多个 Plane 服务启动失败。", + "Tick 未能启动。这可能是因为一个或多个 Tick 服务启动失败。", choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure: "请选择“查看日志”来查看 setup.sh 和 Docker 日志,以确认问题。", }, 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 { diff --git a/packages/services/src/workspace/workspace.service.ts b/packages/services/src/workspace/workspace.service.ts index 3f1ad42e90e..760bf112dd6 100644 --- a/packages/services/src/workspace/workspace.service.ts +++ b/packages/services/src/workspace/workspace.service.ts @@ -144,4 +144,83 @@ 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; + }); + } + + /** + * Synchronously mirrors the entire Lark directory into the workspace — + * find-or-create user accounts and active membership rows for every visible + * contact. Same logic as the hourly Celery task; manual trigger. + */ + async larkSync(workspaceSlug: string, payload: { role?: number } = {}): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/lark-sync/`, 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 }[]; +}; + +export type TLarkSyncResponse = { + workspace?: string; + contacts_seen?: number; + users_created?: number; + users_existing?: number; + members_created?: number; + members_reactivated?: number; + members_already_active?: number; + skipped?: number; + workspace_member_total?: number; + error?: string; +}; 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;