Compare commits

...

22 Commits

Author SHA1 Message Date
DCCONSTRUCTIONS b0a682b63b SEC - TASKER: enforce workspace-scoped agent access 2026-05-15 00:47:59 +03:00
DCCONSTRUCTIONS 65ee15b86c SEC - TASKER: pass workspace slug to NODE.DC policy 2026-05-14 21:18:53 +03:00
DCCONSTRUCTIONS 3b8e0ea594 FEAT - TASKER: управление Codex Agent API через Gateway proxy 2026-05-14 21:11:45 +03:00
DCCONSTRUCTIONS 97566faba3 FEAT - TASKER: показ Codex Agent API по Launcher entitlement 2026-05-14 20:49:52 +03:00
DCCONSTRUCTIONS 2ae353c8d5 FEAT - TASKER: internal adapter для Codex Agent Gateway 2026-05-14 19:48:03 +03:00
DCCONSTRUCTIONS 533f8c6356 ARCH - TASKER DEPLOY: закрепление live baseline и env-driven сборки 2026-05-14 18:07:48 +03:00
DCCONSTRUCTIONS c455ce3c34 FIX - NAS DEPLOY: стабилизация real-domain Tasker OIDC baseline 2026-05-14 12:14:07 +03:00
DCCONSTRUCTIONS 4eb6362565 ARCH - NAS DEPLOY: стабилизация Tasker build baseline 2026-05-13 22:43:07 +03:00
DCCONSTRUCTIONS af01a205f0 FIX - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: бесшовный realtime проектов Tasker 2026-05-13 01:39:18 +03:00
DCCONSTRUCTIONS 2717726440 FIX - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: контроль approval workspace-инвайтов 2026-05-13 01:39:05 +03:00
DCCONSTRUCTIONS 6f34c3cd34 REALTIME - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: обновление роли workspace 2026-05-12 23:28:22 +03:00
DCCONSTRUCTIONS a86ed9f5f3 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: латинский URL workspace 2026-05-12 23:12:07 +03:00
DCCONSTRUCTIONS 268ab2c9b9 REALTIME - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: обновление доступов workspace 2026-05-12 22:26:48 +03:00
DCCONSTRUCTIONS 5e7c9e08a0 SECURITY - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: ограничение создания проектов 2026-05-12 22:24:36 +03:00
DCCONSTRUCTIONS 87e1857f53 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: guest-доступ Operational Core 2026-05-12 19:36:01 +03:00
DCCONSTRUCTIONS d0e2f423e6 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: маппинг имени профиля Tasker 2026-05-12 18:28:26 +03:00
DCCONSTRUCTIONS 1f7ecc39a0 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: синхронизация профиля Tasker 2026-05-12 17:57:57 +03:00
DCCONSTRUCTIONS 480f85cce8 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: единый realtime слой Tasker
Добавлен NODE.DC realtime event stream для workspace/project/member/invite/profile событий. Обновлены frontend stores и live controller. Доработаны settings modal, member dropdown и confirm remove под NODE.DC UX.
2026-05-12 17:28:40 +03:00
DCCONSTRUCTIONS fc59481703 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: корректный flow уведомлений Tasker 2026-05-12 15:11:17 +03:00
DCCONSTRUCTIONS 6737138ab7 SECURITY - TASKER: add staging env guard 2026-05-12 13:03:31 +03:00
DCCONSTRUCTIONS 854a596149 UI - TASKER: stabilize member controls 2026-05-12 12:50:52 +03:00
DCCONSTRUCTIONS 4ffcd64ddc SECURITY - TASKER: cleanup access and invite approval 2026-05-12 12:50:52 +03:00
120 changed files with 5863 additions and 604 deletions

View File

@ -22,16 +22,22 @@
Формат:
```text
<ТИП> - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: <НАЗВАНИЕ ЭТАПА>
<ТИП> - <КОНТЕКСТ РАБОТ>: <НАЗВАНИЕ ЭТАПА>
```
`КОНТЕКСТ РАБОТ` выбирается по фактическому направлению этапа: `NAS DEPLOY`, `AUTH`, `LAUNCHER`,
`TASKER`, `BACKEND`, `FRONTEND`, `DESIGN SYSTEM`, `DATA MIGRATION`, `INFRA` и т.п.
## Типы коммитов
Использовать верхний уровень по смыслу изменений:
- `UI` — если изменения в интерфейсе, текстах, навигации, отображении, UX
- `ФУНКЦИИ` — если изменения в прикладной логике, API, действиях, синхронизации, обработчиках
- `АРХ` — если изменения в архитектуре, структуре данных, инфраструктурных правилах, каркасе реализации
- `FEAT` — если изменения в прикладной логике, API, действиях, синхронизации, обработчиках
- `FIX` — если исправляется конкретный дефект или regression
- `ARCH` — если изменения в архитектуре, структуре данных, инфраструктурных правилах, каркасе реализации
- `CHORE` — если изменения обслуживающие: сборка, конфиги, скрипты, зависимости, регламенты
- `DOCS` — если меняется только документация
Если этап затрагивает несколько слоев, выбирать доминирующий.
@ -52,7 +58,7 @@
Следующая задача:
добрать source-side индикатор изменений в списке внешних контуров
UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: переименование модуля во внутренний контур
UI - TASKER: переименование модуля во внутренний контур
```
Пользователь отвечает подтверждением в духе:
@ -65,11 +71,11 @@ UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: переименован
## Примеры
```text
UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: переименование модуля во внутренний контур
ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: создание запроса в целевой проект
ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: синхронизация статусов источника и цели
АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: каркас source-target связи
АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: регламент этапов и именования коммитов
UI - TASKER: переименование модуля во внутренний контур
FEAT - TASKER ROUTING: создание запроса в целевой проект
FEAT - TASKER ROUTING: синхронизация статусов источника и цели
ARCH - DATA MODEL: каркас source-target связи
CHORE - AGENT RULES: регламент этапов и именования коммитов
```
## Дополнение

View File

@ -0,0 +1,67 @@
# NODE.DC Tasker deploy model
## Source of truth
`NODEDC_TASKMANAGER` is the source repository for Tasker code and Docker build inputs.
The NAS directory `/volume1/docker/nodedc-platform/tasker/plane-src` is a deploy checkout/copy only. It must not become the long-term source of truth.
Runtime state stays outside the repository:
- PostgreSQL data: Docker volume `nodedc-tasker_pgdata`
- Redis data: Docker volume `nodedc-tasker_redisdata`
- RabbitMQ data: Docker volume `nodedc-tasker_rabbitmq_data`
- MinIO uploads: Docker volume `nodedc-tasker_uploads`
- NAS environment files: `/volume1/docker/nodedc-platform/tasker/plane-app/.env.synology`
## Environment contract
The same codebase supports local and production-like runs. The deployment mode is selected by environment variables and Docker build args, not by branching the code.
Frontend brand and launcher integration:
- `VITE_NODEDC_LAUNCHER_URL` points the web/admin bundles to Launcher.
- Production default for NAS builds: `https://hub.nodedc.ru`
- Local default in source helpers: `http://launcher.local.nodedc`
Backend integration:
- `PLANE_NODEDC_LAUNCHER_URL`
- `PLANE_NODEDC_LAUNCHER_PUBLIC_URL`
- `PLANE_NODEDC_HANDOFF_URL`
- `PLANE_NODEDC_WORKSPACE_POLICY_URL`
- `PLANE_NODEDC_ACCESS_CHECK_URL`
- `PLANE_NODEDC_ACCESS_TOKEN`
Production secrets remain in NAS env files or a secret store. They must not be committed.
## NAS legacy rebuild
The current NAS-compatible script lives in `plane-src/rebuild-nas-legacy.sh`.
Default production-style web rebuild:
```sh
cd /volume1/docker/nodedc-platform/tasker/plane-src
BUILD_BACKEND=0 BUILD_WEB=1 BUILD_ADMIN=0 sh rebuild-nas-legacy.sh
```
Local/staging-style rebuild can override integration endpoints:
```sh
VITE_NODEDC_LAUNCHER_URL=http://launcher.local.nodedc \
SMOKE_BASE_URL=http://task.local.nodedc:18080 \
SMOKE_RESOLVE= \
BUILD_BACKEND=0 BUILD_WEB=1 BUILD_ADMIN=0 \
sh rebuild-nas-legacy.sh
```
The script recreates only services whose images were rebuilt. It must not remove Docker volumes.
## Safety rules
- Do not run `docker compose down -v` on production/staging data.
- Do not delete `nodedc-tasker_*` Docker volumes without a verified backup.
- Run database backups before backend migrations.
- Frontend-only rebuilds do not modify PostgreSQL or MinIO data.
- Backend image rebuilds are safe only when no destructive migration or data command is included.

View File

@ -30,6 +30,7 @@ x-proxy-env: &proxy-env
CERT_EMAIL: ${CERT_EMAIL:-}
CERT_ACME_CA: ${CERT_ACME_CA:-https://acme-v02.api.letsencrypt.org/directory}
CERT_ACME_DNS: ${CERT_ACME_DNS:-}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-0.0.0.0/0}
LISTEN_HTTP_PORT: ${LISTEN_HTTP_PORT:-8090}
LISTEN_HTTPS_PORT: ${LISTEN_HTTPS_PORT:-8443}
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
@ -70,6 +71,7 @@ x-app-env: &app-env
PLANE_NODEDC_ACCESS_SERVICE_SLUG: ${PLANE_NODEDC_ACCESS_SERVICE_SLUG:-task-manager}
PLANE_NODEDC_ACCESS_TIMEOUT_SECONDS: ${PLANE_NODEDC_ACCESS_TIMEOUT_SECONDS:-3}
PLANE_NODEDC_ACCESS_CACHE_SECONDS: ${PLANE_NODEDC_ACCESS_CACHE_SECONDS:-0}
PLANE_NODEDC_ACCESS_ENFORCE_UNLINKED: ${PLANE_NODEDC_ACCESS_ENFORCE_UNLINKED:-0}
PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL: ${PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL:-http://launcher.local.nodedc/}
PLANE_NODEDC_GLOBAL_LOGOUT_URL: ${PLANE_NODEDC_GLOBAL_LOGOUT_URL:-http://launcher.local.nodedc/auth/logout?global=1&returnTo=/}
PLANE_NODEDC_LAUNCHER_PUBLIC_URL: ${PLANE_NODEDC_LAUNCHER_PUBLIC_URL:-http://launcher.local.nodedc}
@ -77,6 +79,9 @@ x-app-env: &app-env
PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS: ${PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS:-3}
PLANE_NODEDC_WORKSPACE_POLICY_URL: ${PLANE_NODEDC_WORKSPACE_POLICY_URL:-http://launcher.local.nodedc/api/internal/access/check}
PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS: ${PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS:-3}
PLANE_NODEDC_AGENT_GATEWAY_URL: ${PLANE_NODEDC_AGENT_GATEWAY_URL:-}
PLANE_NODEDC_AGENT_GATEWAY_TOKEN: ${PLANE_NODEDC_AGENT_GATEWAY_TOKEN:-}
PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS: ${PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS:-5}
GUNICORN_WORKERS: 1
POSTHOG_API_KEY: ${POSTHOG_API_KEY:-}
POSTHOG_HOST: ${POSTHOG_HOST:-}

View File

@ -98,17 +98,18 @@ PLANE_OIDC_ISSUER=http://auth.local.nodedc/application/o/task-manager/
PLANE_OIDC_CLIENT_ID=nodedc-task-manager
PLANE_OIDC_CLIENT_SECRET=c510f7e389c95a610f34f7569c9ee7fbb744d214bc21e82734578d971e02e0aaa9812aeb83b33efdb76eb90c0a819b0a
PLANE_OIDC_REDIRECT_URI=http://task.local.nodedc/auth/oidc/callback
PLANE_OIDC_SCOPE=openid email profile groups
PLANE_OIDC_SCOPE="openid email profile groups"
PLANE_OIDC_REQUIRED_GROUPS=nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user
PLANE_OIDC_AUTO_LINK_EMAIL=1
PLANE_OIDC_AUTO_CREATE_USER=1
PLANE_NODEDC_SKIP_PROFILE_ONBOARDING=1
PLANE_NODEDC_ACCESS_ENFORCEMENT=1
PLANE_NODEDC_ACCESS_CHECK_URL=http://launcher.local.nodedc/api/internal/access/check
PLANE_NODEDC_ACCESS_TOKEN=
PLANE_NODEDC_ACCESS_TOKEN=local-dev-nodedc-internal-token-change-me
PLANE_NODEDC_ACCESS_SERVICE_SLUG=task-manager
PLANE_NODEDC_ACCESS_TIMEOUT_SECONDS=3
PLANE_NODEDC_ACCESS_CACHE_SECONDS=0
PLANE_NODEDC_ACCESS_ENFORCE_UNLINKED=1
PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL=http://launcher.local.nodedc/
PLANE_NODEDC_GLOBAL_LOGOUT_URL=http://launcher.local.nodedc/auth/logout?global=1&returnTo=/
PLANE_NODEDC_LAUNCHER_PUBLIC_URL=http://launcher.local.nodedc
@ -116,3 +117,6 @@ PLANE_NODEDC_HANDOFF_URL=http://launcher.local.nodedc/api/internal/handoff/consu
PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS=3
PLANE_NODEDC_WORKSPACE_POLICY_URL=http://launcher.local.nodedc/api/internal/access/check
PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS=3
PLANE_NODEDC_AGENT_GATEWAY_URL=http://host.docker.internal:4100
PLANE_NODEDC_AGENT_GATEWAY_TOKEN=local-dev-codex-agent-gateway-token-change-me
PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS=5

View File

@ -0,0 +1,100 @@
APP_DOMAIN=task.staging.nodedc.example
APP_RELEASE=v1.3.0
WEB_REPLICAS=1
SPACE_REPLICAS=1
ADMIN_REPLICAS=1
API_REPLICAS=1
WORKER_REPLICAS=1
BEAT_WORKER_REPLICAS=1
LIVE_REPLICAS=1
LISTEN_HTTP_PORT=8090
LISTEN_HTTPS_PORT=8443
WEB_URL=https://task.staging.nodedc.example
DEBUG=0
CORS_ALLOWED_ORIGINS=https://task.staging.nodedc.example,https://launcher.staging.nodedc.example
API_BASE_URL=http://api:8000
COOKIE_DOMAIN=.staging.nodedc.example
PGHOST=plane-db
PGDATABASE=plane
POSTGRES_USER=plane
POSTGRES_PASSWORD=replace-with-random-staging-secret
POSTGRES_DB=plane
POSTGRES_PORT=5432
PGDATA=/var/lib/postgresql/data
DATABASE_URL=
REDIS_HOST=plane-redis
REDIS_PORT=6379
REDIS_URL=
RABBITMQ_HOST=plane-mq
RABBITMQ_PORT=5672
RABBITMQ_USER=plane
RABBITMQ_PASSWORD=replace-with-random-staging-secret
RABBITMQ_VHOST=plane
AMQP_URL=
CERT_ACME_CA=https://acme-v02.api.letsencrypt.org/directory
TRUSTED_PROXIES=replace-with-platform-edge-proxy-cidr
SITE_ADDRESS=:80
CERT_EMAIL=admin@nodedc.example
CERT_ACME_DNS=
SECRET_KEY=replace-with-random-staging-secret
USE_MINIO=1
AWS_REGION=
AWS_ACCESS_KEY_ID=replace-with-random-staging-secret
AWS_SECRET_ACCESS_KEY=replace-with-random-staging-secret
AWS_S3_ENDPOINT_URL=http://plane-minio:9000
AWS_S3_BUCKET_NAME=uploads
FILE_SIZE_LIMIT=5242880
PROXY_BODY_SIZE_LIMIT=1073741824
POSTHOG_API_KEY=
POSTHOG_HOST=
INSTANCE_CHANGELOG_URL=
IS_INTERCOM_ENABLED=0
INTERCOM_APP_ID=
GUNICORN_WORKERS=1
MINIO_ENDPOINT_SSL=0
API_KEY_RATE_LIMIT=60/minute
LIVE_SERVER_SECRET_KEY=replace-with-random-staging-secret
DOCKERHUB_USER=makeplane
PULL_POLICY=if_not_present
CUSTOM_BUILD=false
ENABLE_SIGNUP=0
ENABLE_EMAIL_PASSWORD=0
ENABLE_MAGIC_LINK_LOGIN=0
PLANE_OIDC_ISSUER=https://auth.staging.nodedc.example/application/o/task-manager/
PLANE_OIDC_CLIENT_ID=nodedc-task-manager
PLANE_OIDC_CLIENT_SECRET=replace-with-random-staging-secret
PLANE_OIDC_REDIRECT_URI=https://task.staging.nodedc.example/auth/oidc/callback
PLANE_OIDC_SCOPE="openid email profile groups"
PLANE_OIDC_REQUIRED_GROUPS=nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user
PLANE_OIDC_AUTO_LINK_EMAIL=1
PLANE_OIDC_AUTO_CREATE_USER=1
PLANE_NODEDC_SKIP_PROFILE_ONBOARDING=1
PLANE_NODEDC_ACCESS_ENFORCEMENT=1
PLANE_NODEDC_ACCESS_CHECK_URL=https://launcher.staging.nodedc.example/api/internal/access/check
PLANE_NODEDC_ACCESS_TOKEN=replace-with-same-value-as-NODEDC_INTERNAL_ACCESS_TOKEN
PLANE_NODEDC_ACCESS_SERVICE_SLUG=task-manager
PLANE_NODEDC_ACCESS_TIMEOUT_SECONDS=3
PLANE_NODEDC_ACCESS_CACHE_SECONDS=0
PLANE_NODEDC_ACCESS_ENFORCE_UNLINKED=1
PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL=https://launcher.staging.nodedc.example/
PLANE_NODEDC_GLOBAL_LOGOUT_URL="https://launcher.staging.nodedc.example/auth/logout?global=1&returnTo=/"
PLANE_NODEDC_LAUNCHER_PUBLIC_URL=https://launcher.staging.nodedc.example
PLANE_NODEDC_HANDOFF_URL=https://launcher.staging.nodedc.example/api/internal/handoff/consume
PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS=3
PLANE_NODEDC_WORKSPACE_POLICY_URL=https://launcher.staging.nodedc.example/api/internal/access/check
PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS=3
PLANE_NODEDC_AGENT_GATEWAY_URL=https://codex-api.staging.nodedc.example
PLANE_NODEDC_AGENT_GATEWAY_TOKEN=replace-with-codex-agent-gateway-internal-token
PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS=5
PLANE_NODEDC_WORKSPACE_CREATION_MODE=any_authorized_user

View File

@ -1,6 +1,7 @@
node_modules
.next
.yarn
.pnpm-store/
### NextJS ###
# Dependencies
@ -20,6 +21,8 @@ dist/
out/
build/
.react-router/
**/build/
**/.react-router/
# Misc
.DS_Store

View File

@ -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,9 @@ ENV VITE_WEB_BASE_URL=$VITE_WEB_BASE_URL
ARG VITE_WEB_BASE_PATH=""
ENV VITE_WEB_BASE_PATH=$VITE_WEB_BASE_PATH
ARG VITE_NODEDC_LAUNCHER_URL=""
ENV VITE_NODEDC_LAUNCHER_URL=$VITE_NODEDC_LAUNCHER_URL
ARG VITE_WEBSITE_URL="https://plane.so"
ENV VITE_WEBSITE_URL=$VITE_WEBSITE_URL
ARG VITE_SUPPORT_EMAIL="support@plane.so"
@ -67,8 +70,8 @@ COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
# Fetch dependencies to cache store, then install offline with dev deps
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store --prod=false
RUN pnpm fetch --store-dir=/pnpm/store
RUN CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store --prod=false
# Build only the admin package
RUN pnpm turbo run build --filter=admin

View File

@ -4,14 +4,16 @@
* See the LICENSE file for details.
*/
import Link from "next/link";
import { useNodeDCBrandLinkUrl } from "@/hooks/use-nodedc-brand-link-url";
export function AuthHeader() {
const logoLinkUrl = useNodeDCBrandLinkUrl();
return (
<div className="sticky top-0 flex w-full flex-shrink-0 items-center justify-between gap-6">
<Link href="/">
<a href={logoLinkUrl}>
<span className="tracking-normal text-16 font-semibold text-primary">NODE.DC</span>
</Link>
</a>
<span className="rounded-full bg-white/6 px-3 py-1 text-11 font-medium text-secondary">
Глобальное администрирование
</span>

View File

@ -21,6 +21,7 @@ import { cn, getFileURL } from "@plane/utils";
import NodeDcLogo from "@/app/assets/logos/nodedc-logo.svg?url";
// hooks
import { useUser } from "@/hooks/store";
import { useNodeDCBrandLinkUrl } from "@/hooks/use-nodedc-brand-link-url";
// local imports
const authService = new AuthService();
@ -42,6 +43,7 @@ export const AdminHeader = observer(function AdminHeader() {
const { currentUser, signOut } = useUser();
const { resolvedTheme, setTheme } = useNextTheme();
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const logoLinkUrl = useNodeDCBrandLinkUrl();
const isFeatureRoute = FEATURE_NAVIGATION.some((item) => pathName?.startsWith(item.href));
const adminName = currentUser?.display_name || currentUser?.email || "Глобальный админ";
@ -60,7 +62,7 @@ export const AdminHeader = observer(function AdminHeader() {
return (
<header className="nodedc-admin-header relative z-30 flex w-full flex-shrink-0 flex-col gap-4">
<div className="nodedc-admin-header-top grid w-full items-center gap-4">
<a href="/" className="nodedc-admin-logo-link inline-flex w-fit items-center" aria-label="NODE.DC">
<a href={logoLinkUrl} className="nodedc-admin-logo-link inline-flex w-fit items-center" aria-label="NODE.DC">
<img src={NodeDcLogo} alt="NODE.DC" className="nodedc-admin-logo" />
</a>

View File

@ -0,0 +1,28 @@
export function buildNodeDCLauncherUrl(): string {
const configuredUrl = process.env.VITE_NODEDC_LAUNCHER_URL;
if (configuredUrl) {
return configuredUrl;
}
if (typeof window === "undefined") {
return "http://launcher.local.nodedc/";
}
const hostname = window.location.hostname.toLowerCase();
if (hostname.endsWith(".nodedc.ru")) {
return "https://hub.nodedc.ru/";
}
if (hostname.endsWith(".nas.nodedc")) {
const port = window.location.port ? `:${window.location.port}` : "";
return `${window.location.protocol}//launcher.nas.nodedc${port}/`;
}
return "http://launcher.local.nodedc/";
}
export function buildNodeDCBrandConfigUrl(): string {
return new URL("/api/public/brand", buildNodeDCLauncherUrl()).toString();
}

View File

@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
import { buildNodeDCBrandConfigUrl, buildNodeDCLauncherUrl } from "@/helpers/nodedc-brand";
type TNodeDCBrandPayload = {
logoLinkUrl?: string | null;
};
export function useNodeDCBrandLinkUrl() {
const [logoLinkUrl, setLogoLinkUrl] = useState(buildNodeDCLauncherUrl);
useEffect(() => {
let isMounted = true;
fetch(buildNodeDCBrandConfigUrl(), { cache: "no-store" })
.then((response) => (response.ok ? response.json() : null))
.then((payload: TNodeDCBrandPayload | null) => {
const configuredUrl = typeof payload?.logoLinkUrl === "string" ? payload.logoLinkUrl.trim() : "";
if (isMounted && configuredUrl) setLogoLinkUrl(configuredUrl);
return undefined;
})
.catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : "Не удалось загрузить brand config NODE.DC");
});
return () => {
isMounted = false;
};
}, []);
return logoLinkUrl;
}

View File

@ -0,0 +1,15 @@
import os
def nodedc_env_flag(name):
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
def nodedc_guest_read_all_issues_enabled():
return nodedc_env_flag("PLANE_NODEDC_GUEST_READ_ALL_ISSUES") or nodedc_env_flag(
"PLANE_NODEDC_ACCESS_ENFORCEMENT"
)
def should_limit_guest_to_own_issues(project):
return not project.guest_view_all_features and not nodedc_guest_read_all_issues_enabled()

View File

@ -34,7 +34,7 @@ class ProjectBasePermission(BasePermission):
return WorkspaceMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
role=ROLE.ADMIN.value,
is_active=True,
).exists()
@ -78,7 +78,7 @@ class ProjectMemberPermission(BasePermission):
return WorkspaceMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
role=ROLE.ADMIN.value,
is_active=True,
).exists()

View File

@ -135,3 +135,34 @@ def publish_issue_event_on_commit(event_type, issue, actor_id=None, changed_fiel
transaction.on_commit(_publish)
if publish_external_bridge:
publish_external_contour_issue_event_on_commit(event_type, issue, actor_id=actor_id, changed_fields=changed_fields)
def publish_assignee_cleanup_issue_events_on_commit(project_id=None, workspace_id=None, assignee_id=None, actor_id=None):
if not assignee_id:
return
from plane.db.models import Issue
issues = (
Issue.objects.filter(
deleted_at__isnull=True,
issue_assignee__assignee_id=assignee_id,
issue_assignee__deleted_at__isnull=True,
)
.select_related("workspace")
.distinct()
)
if project_id:
issues = issues.filter(project_id=project_id)
if workspace_id:
issues = issues.filter(workspace_id=workspace_id)
for issue in issues:
publish_issue_event_on_commit(
"issue.updated",
issue,
actor_id=actor_id,
changed_fields=["assignees"],
publish_external_bridge=True,
)

View File

@ -0,0 +1,161 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import json
import logging
from uuid import uuid4
from django.core.serializers.json import DjangoJSONEncoder
from django.db import transaction
from django.utils import timezone
from plane.settings.redis import redis_instance
logger = logging.getLogger(__name__)
NODEDC_EVENT_CHANNEL_PREFIX = "plane:nodedc-events:user"
def nodedc_user_event_channel(user_id):
return f"{NODEDC_EVENT_CHANNEL_PREFIX}:{user_id}"
def _normalize_user_ids(user_ids):
normalized_user_ids = []
seen_user_ids = set()
for user_id in user_ids or []:
if not user_id:
continue
normalized_user_id = str(user_id)
if normalized_user_id in seen_user_ids:
continue
seen_user_ids.add(normalized_user_id)
normalized_user_ids.append(normalized_user_id)
return normalized_user_ids
def _publish_payload_to_users(user_ids, payload):
client = redis_instance()
for user_id in _normalize_user_ids(user_ids):
client.publish(
nodedc_user_event_channel(user_id),
json.dumps({**payload, "target_user_id": user_id}, cls=DjangoJSONEncoder),
)
def publish_nodedc_event_to_users_on_commit(event_type, user_ids, payload=None):
event_payload = {
"event_id": str(uuid4()),
"type": event_type,
"emitted_at": timezone.now(),
**(payload or {}),
}
def _publish():
try:
_publish_payload_to_users(user_ids, event_payload)
except Exception:
logger.exception("Failed to publish NODE.DC realtime event")
transaction.on_commit(_publish)
def publish_nodedc_workspace_event_on_commit(workspace, event_type, payload=None, extra_user_ids=None):
workspace_payload = {
"workspace_id": str(workspace.id),
"workspace_slug": workspace.slug,
**(payload or {}),
}
extra_user_ids = _normalize_user_ids(extra_user_ids or [])
def _publish():
try:
from plane.db.models import WorkspaceMember
workspace_user_ids = WorkspaceMember.objects.filter(
workspace_id=workspace.id,
is_active=True,
member__is_bot=False,
deleted_at__isnull=True,
).values_list("member_id", flat=True)
_publish_payload_to_users(
[*workspace_user_ids, *extra_user_ids],
{
"event_id": str(uuid4()),
"type": event_type,
"emitted_at": timezone.now(),
**workspace_payload,
},
)
except Exception:
logger.exception("Failed to publish NODE.DC workspace realtime event")
transaction.on_commit(_publish)
def publish_nodedc_user_profile_event_on_commit(user, changed_fields=None):
changed_fields = sorted(set(changed_fields or []))
payload = {
"member_id": str(user.id),
"email": user.email,
"display_name": user.display_name,
"avatar": user.avatar or None,
"changed_fields": changed_fields,
}
def _publish():
try:
from plane.db.models import WorkspaceMember
memberships = (
WorkspaceMember.objects.filter(
member_id=user.id,
is_active=True,
deleted_at__isnull=True,
)
.select_related("workspace")
.only("workspace_id", "workspace__slug")
)
workspace_ids = []
client = redis_instance()
for membership in memberships:
workspace_ids.append(str(membership.workspace_id))
workspace_user_ids = WorkspaceMember.objects.filter(
workspace_id=membership.workspace_id,
is_active=True,
member__is_bot=False,
deleted_at__isnull=True,
).values_list("member_id", flat=True)
event_payload = {
"event_id": str(uuid4()),
"type": "user.profile.updated",
"emitted_at": timezone.now(),
"workspace_id": str(membership.workspace_id),
"workspace_slug": membership.workspace.slug,
**payload,
}
for target_user_id in _normalize_user_ids([*workspace_user_ids, user.id]):
client.publish(
nodedc_user_event_channel(target_user_id),
json.dumps({**event_payload, "target_user_id": target_user_id}, cls=DjangoJSONEncoder),
)
if not workspace_ids:
_publish_payload_to_users(
[user.id],
{
"event_id": str(uuid4()),
"type": "user.profile.updated",
"emitted_at": timezone.now(),
**payload,
},
)
except Exception:
logger.exception("Failed to publish NODE.DC profile realtime event")
transaction.on_commit(_publish)

View File

@ -5,6 +5,7 @@
from .analytic import urlpatterns as analytic_urls
from .api import urlpatterns as api_urls
from .asset import urlpatterns as asset_urls
from .codex_agents import urlpatterns as codex_agent_urls
from .cycle import urlpatterns as cycle_urls
from .estimate import urlpatterns as estimate_urls
from .external import urlpatterns as external_urls
@ -28,6 +29,7 @@ from .voice_tasker import urlpatterns as voice_tasker_urls
urlpatterns = [
*analytic_urls,
*asset_urls,
*codex_agent_urls,
*cycle_urls,
*estimate_urls,
*external_urls,

View File

@ -0,0 +1,54 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.urls import path
from plane.app.views import (
CodexAgentDetailEndpoint,
CodexAgentGrantListEndpoint,
CodexAgentListEndpoint,
CodexAgentRevokeEndpoint,
CodexAgentSetupEndpoint,
CodexAgentTokenListEndpoint,
CodexAgentTokenRevokeEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/codex-agent-api/agents/",
CodexAgentListEndpoint.as_view(),
name="codex-agent-api-agents",
),
path(
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/",
CodexAgentDetailEndpoint.as_view(),
name="codex-agent-api-agent-detail",
),
path(
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/revoke/",
CodexAgentRevokeEndpoint.as_view(),
name="codex-agent-api-agent-revoke",
),
path(
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/grants/",
CodexAgentGrantListEndpoint.as_view(),
name="codex-agent-api-agent-grants",
),
path(
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/tokens/",
CodexAgentTokenListEndpoint.as_view(),
name="codex-agent-api-agent-tokens",
),
path(
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/tokens/<uuid:token_id>/revoke/",
CodexAgentTokenRevokeEndpoint.as_view(),
name="codex-agent-api-agent-token-revoke",
),
path(
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/setup/",
CodexAgentSetupEndpoint.as_view(),
name="codex-agent-api-agent-setup",
),
]

View File

@ -174,6 +174,16 @@ from .module.archive import ModuleArchiveUnarchiveEndpoint
from .api import ApiTokenEndpoint
from .codex_agents import (
CodexAgentDetailEndpoint,
CodexAgentGrantListEndpoint,
CodexAgentListEndpoint,
CodexAgentRevokeEndpoint,
CodexAgentSetupEndpoint,
CodexAgentTokenListEndpoint,
CodexAgentTokenRevokeEndpoint,
)
from .page.base import (
PageViewSet,
PageFavoriteViewSet,

View File

@ -23,6 +23,8 @@ from plane.settings.storage import S3Storage
from plane.app.permissions import allow_permission, ROLE
from plane.utils.cache import invalidate_cache_directly
from plane.throttles.asset import AssetRateThrottle
from plane.app.realtime.nodedc_events import publish_nodedc_user_profile_event_on_commit
from plane.authentication.nodedc_profile_sync import push_nodedc_user_profile_update_on_commit
from plane.utils.upload_limits import get_project_storage_quota_response, resolve_workspace_upload_size_limit
from plane.utils.file_dedup import (
UploadedObjectMissing,
@ -57,6 +59,8 @@ class UserAssetsV2Endpoint(BaseAPIView):
# Save the new avatar
user.avatar_asset_id = asset_id
user.save()
publish_nodedc_user_profile_event_on_commit(user, changed_fields=["avatar"])
push_nodedc_user_profile_update_on_commit(user, changed_fields=["avatar"])
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
invalidate_cache_directly(
path="/api/users/me/settings/",
@ -89,8 +93,11 @@ class UserAssetsV2Endpoint(BaseAPIView):
# User Avatar
if entity_type == FileAsset.EntityTypeContext.USER_AVATAR:
user = User.objects.get(id=asset.user_id)
user.avatar = ""
user.avatar_asset_id = None
user.save()
publish_nodedc_user_profile_event_on_commit(user, changed_fields=["avatar"])
push_nodedc_user_profile_update_on_commit(user, changed_fields=["avatar"])
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
invalidate_cache_directly(
path="/api/users/me/settings/",

View File

@ -0,0 +1,330 @@
# Python imports
import os
from urllib.parse import quote
# Third party imports
import requests
from django.core.exceptions import ValidationError
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.app.permissions import ROLE, allow_permission
from plane.app.views.base import BaseAPIView
from plane.authentication.nodedc_workspace_policy import get_nodedc_workspace_creation_policy
from plane.db.models import Project, ProjectMember, Workspace, WorkspaceMember
def get_gateway_config():
base_url = (
os.environ.get("PLANE_NODEDC_AGENT_GATEWAY_URL", "").strip()
or os.environ.get("NODEDC_AGENT_GATEWAY_INTERNAL_URL", "").strip()
or os.environ.get("NODEDC_AGENT_GATEWAY_URL", "").strip()
).rstrip("/")
token = (
os.environ.get("PLANE_NODEDC_AGENT_GATEWAY_TOKEN", "").strip()
or os.environ.get("NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN", "").strip()
)
timeout = float(os.environ.get("PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS", "5") or "5")
return base_url, token, timeout
def owner_path(user):
return quote(str(user.id), safe="")
def agent_path(agent_id):
return quote(str(agent_id), safe="")
def token_path(token_id):
return quote(str(token_id), safe="")
def require_gateway_config():
base_url, token, timeout = get_gateway_config()
if not base_url or not token:
return None, Response(
{
"ok": False,
"error": "codex_agent_gateway_not_configured",
"message": "NODE.DC Codex Agent Gateway URL/token is not configured.",
},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
return (base_url, token, timeout), None
def require_codex_agent_entitlement(user, slug):
workspace_policy = get_nodedc_workspace_creation_policy(user, workspace_slug=slug)
service_modules = workspace_policy.get("service_modules") or {}
if not service_modules.get("codex_agents"):
return None, Response(
{
"ok": False,
"error": "codex_agents_not_entitled",
"message": "Codex Agent API is not enabled for this NODE.DC user/workspace.",
},
status=status.HTTP_403_FORBIDDEN,
)
return workspace_policy, None
def require_workspace(slug):
try:
return Workspace.objects.get(slug=slug), None
except Workspace.DoesNotExist:
return None, Response(
{"ok": False, "error": "workspace_not_found"},
status=status.HTTP_404_NOT_FOUND,
)
def is_workspace_admin(user, workspace):
return WorkspaceMember.objects.filter(
workspace=workspace,
member=user,
role=ROLE.ADMIN.value,
is_active=True,
).exists()
def validate_project_in_workspace(workspace, project_id, user):
workspace_admin = is_workspace_admin(user, workspace)
if not project_id:
if not workspace_admin:
return Response(
{
"ok": False,
"error": "project_required",
"message": "Workspace members must select a concrete project for Codex Agent grants.",
},
status=status.HTTP_400_BAD_REQUEST,
)
return None
try:
project = Project.objects.filter(id=project_id, workspace=workspace, archived_at__isnull=True).first()
except ValidationError:
project = None
if project is None:
return Response(
{
"ok": False,
"error": "project_not_found",
"message": "Project is not available in this workspace.",
},
status=status.HTTP_404_NOT_FOUND,
)
if workspace_admin:
return None
if not ProjectMember.objects.filter(
project=project,
member=user,
role__gte=ROLE.MEMBER.value,
is_active=True,
).exists():
return Response(
{
"ok": False,
"error": "project_access_denied",
"message": "Codex Agent grants are limited to projects where this user is an active project member.",
},
status=status.HTTP_403_FORBIDDEN,
)
return None
def gateway_request(method, path, payload=None):
config, error_response = require_gateway_config()
if error_response is not None:
return error_response
base_url, token, timeout = config
try:
response = requests.request(
method,
f"{base_url}{path}",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
},
json=payload,
timeout=timeout,
)
except requests.RequestException:
return Response(
{
"ok": False,
"error": "codex_agent_gateway_unavailable",
"message": "NODE.DC Codex Agent Gateway is unavailable.",
},
status=status.HTTP_502_BAD_GATEWAY,
)
try:
data = response.json()
except ValueError:
data = {
"ok": False,
"error": "codex_agent_gateway_invalid_response",
"message": "NODE.DC Codex Agent Gateway returned a non-JSON response.",
}
return Response(data, status=response.status_code)
class CodexAgentEntitledEndpoint(BaseAPIView):
def require_entitlement(self, request, slug):
_, entitlement_error = require_codex_agent_entitlement(request.user, slug)
return entitlement_error
class CodexAgentListEndpoint(CodexAgentEntitledEndpoint):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def get(self, request, slug):
entitlement_error = self.require_entitlement(request, slug)
if entitlement_error is not None:
return entitlement_error
return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents")
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug):
entitlement_error = self.require_entitlement(request, slug)
if entitlement_error is not None:
return entitlement_error
display_name = str(request.data.get("display_name") or "").strip()
if not display_name:
return Response(
{"ok": False, "error": "display_name_required"},
status=status.HTTP_400_BAD_REQUEST,
)
payload = {
"display_name": display_name,
"owner_email": request.user.email or None,
"avatar_url": request.data.get("avatar_url") or None,
}
return gateway_request("POST", f"/api/internal/v1/owners/{owner_path(request.user)}/agents", payload)
class CodexAgentDetailEndpoint(CodexAgentEntitledEndpoint):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def get(self, request, slug, agent_id):
entitlement_error = self.require_entitlement(request, slug)
if entitlement_error is not None:
return entitlement_error
return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}")
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def patch(self, request, slug, agent_id):
entitlement_error = self.require_entitlement(request, slug)
if entitlement_error is not None:
return entitlement_error
payload = {"actor_user_id": str(request.user.id)}
if "display_name" in request.data:
payload["display_name"] = request.data.get("display_name")
if "avatar_url" in request.data:
payload["avatar_url"] = request.data.get("avatar_url") or None
return gateway_request("PATCH", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}", payload)
class CodexAgentRevokeEndpoint(CodexAgentEntitledEndpoint):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug, agent_id):
entitlement_error = self.require_entitlement(request, slug)
if entitlement_error is not None:
return entitlement_error
return gateway_request(
"POST",
f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/revoke",
{"actor_user_id": str(request.user.id)},
)
class CodexAgentGrantListEndpoint(CodexAgentEntitledEndpoint):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def get(self, request, slug, agent_id):
entitlement_error = self.require_entitlement(request, slug)
if entitlement_error is not None:
return entitlement_error
return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/grants")
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug, agent_id):
entitlement_error = self.require_entitlement(request, slug)
if entitlement_error is not None:
return entitlement_error
workspace, workspace_error = require_workspace(slug)
if workspace_error is not None:
return workspace_error
project_id = request.data.get("project_id")
project_error = validate_project_in_workspace(workspace, project_id, request.user)
if project_error is not None:
return project_error
payload = {
"workspace_slug": slug,
"project_id": str(project_id) if project_id else None,
"scopes": request.data.get("scopes") or [],
"mode": request.data.get("mode") or "voluntary",
}
return gateway_request("POST", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/grants", payload)
class CodexAgentTokenListEndpoint(CodexAgentEntitledEndpoint):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def get(self, request, slug, agent_id):
entitlement_error = self.require_entitlement(request, slug)
if entitlement_error is not None:
return entitlement_error
return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/tokens")
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug, agent_id):
entitlement_error = self.require_entitlement(request, slug)
if entitlement_error is not None:
return entitlement_error
payload = {
"name": request.data.get("name") or "Local Codex token",
"expires_at": request.data.get("expires_at") or None,
}
return gateway_request("POST", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/tokens", payload)
class CodexAgentTokenRevokeEndpoint(CodexAgentEntitledEndpoint):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug, agent_id, token_id):
entitlement_error = self.require_entitlement(request, slug)
if entitlement_error is not None:
return entitlement_error
return gateway_request(
"POST",
f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/tokens/{token_path(token_id)}/revoke",
{"actor_user_id": str(request.user.id)},
)
class CodexAgentSetupEndpoint(CodexAgentEntitledEndpoint):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def get(self, request, slug, agent_id):
entitlement_error = self.require_entitlement(request, slug)
if entitlement_error is not None:
return entitlement_error
return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/setup")

View File

@ -34,7 +34,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
model = FileAsset
parser_classes = (MultiPartParser, FormParser)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id, issue_id):
serializer = IssueAttachmentSerializer(data=request.data)
workspace = Workspace.objects.get(slug=slug)
@ -104,7 +104,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
serializer_class = IssueAttachmentSerializer
model = FileAsset
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id, issue_id):
name = request.data.get("name")
type = request.data.get("type", False)
@ -209,7 +209,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
serializer = IssueAttachmentSerializer(issue_attachments, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def patch(self, request, slug, project_id, issue_id, pk):
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
serializer = IssueAttachmentSerializer(issue_attachment)

View File

@ -33,6 +33,10 @@ from rest_framework.response import Response
# Module imports
from plane.app.permissions import ROLE, allow_permission
from plane.app.nodedc_access import (
nodedc_guest_read_all_issues_enabled,
should_limit_guest_to_own_issues,
)
from plane.app.realtime.issue_events import publish_issue_event_on_commit
from plane.app.serializers import (
IssueCreateSerializer,
@ -333,7 +337,7 @@ class IssueViewSet(BaseViewSet):
role=5,
is_active=True,
).exists()
and not project.guest_view_all_features
and should_limit_guest_to_own_issues(project)
):
issue_queryset = issue_queryset.filter(created_by=request.user)
filtered_issue_queryset = filtered_issue_queryset.filter(created_by=request.user)
@ -641,7 +645,7 @@ class IssueViewSet(BaseViewSet):
role=5,
is_active=True,
).exists()
and not project.guest_view_all_features
and should_limit_guest_to_own_issues(project)
and not issue.created_by == request.user
):
return Response(
@ -972,7 +976,7 @@ class IssuePaginatedViewSet(BaseViewSet):
role=5,
is_active=True,
)
if project_member.exists() and not project.guest_view_all_features:
if project_member.exists() and should_limit_guest_to_own_issues(project):
base_queryset = base_queryset.filter(created_by=request.user)
queryset = queryset.filter(created_by=request.user)
@ -1093,8 +1097,17 @@ class IssueDetailEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
# check for the project member role, if the role is 5 then check for the guest_view_all_features
# if it is true then show all the issues else show only the issues created by the user
guest_read_filter = Q(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__project_projectmember__role=ROLE.GUEST.value,
)
if not nodedc_guest_read_all_issues_enabled():
guest_read_filter = guest_read_filter & (
Q(project__guest_view_all_features=True)
| Q(project__guest_view_all_features=False, created_by=self.request.user)
)
permission_subquery = (
Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id, id=OuterRef("id"))
.filter(
@ -1103,19 +1116,7 @@ class IssueDetailEndpoint(BaseAPIView):
project__project_projectmember__is_active=True,
project__project_projectmember__role__gt=ROLE.GUEST.value,
)
| Q(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__project_projectmember__role=ROLE.GUEST.value,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__project_projectmember__role=ROLE.GUEST.value,
project__guest_view_all_features=False,
created_by=self.request.user,
)
| guest_read_filter
)
.values("id")
)
@ -1418,7 +1419,7 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
role=5,
is_active=True,
).exists()
and not project.guest_view_all_features
and should_limit_guest_to_own_issues(project)
and not issue.created_by == request.user
):
return Response(

View File

@ -60,7 +60,7 @@ class IssueCommentViewSet(BaseViewSet):
.distinct()
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id, issue_id):
project = Project.objects.get(pk=project_id)
issue = Issue.objects.get(pk=issue_id)
@ -180,7 +180,7 @@ class CommentReactionViewSet(BaseViewSet):
.distinct()
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id, comment_id):
try:
serializer = CommentReactionSerializer(data=request.data)
@ -209,7 +209,7 @@ class CommentReactionViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, slug, project_id, comment_id, reaction_code):
comment_reaction = CommentReaction.objects.get(
workspace__slug=slug,

View File

@ -42,7 +42,7 @@ class IssueReactionViewSet(BaseViewSet):
.distinct()
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id, issue_id):
serializer = IssueReactionSerializer(data=request.data)
if serializer.is_valid():
@ -61,7 +61,7 @@ class IssueReactionViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, slug, project_id, issue_id, reaction_code):
issue_reaction = IssueReaction.objects.get(
workspace__slug=slug,

View File

@ -20,6 +20,7 @@ from plane.app.serializers import (
IssueDescriptionVersionDetailSerializer,
)
from plane.app.permissions import allow_permission, ROLE
from plane.app.nodedc_access import should_limit_guest_to_own_issues
from plane.utils.global_paginator import paginate
from plane.utils.timezone_converter import user_timezone_converter
@ -96,7 +97,7 @@ class WorkItemDescriptionVersionEndpoint(BaseAPIView):
role=ROLE.GUEST.value,
is_active=True,
).exists()
and not project.guest_view_all_features
and should_limit_guest_to_own_issues(project)
and not issue.created_by == request.user
):
return Response(

View File

@ -62,7 +62,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
notifications = (
Notification.objects.filter(workspace__slug=slug, receiver_id=request.user.id)
.filter(entity_name="issue")
.filter(Q(entity_name="issue") | Q(sender__startswith="in_app:nodedc:"))
.annotate(is_inbox_issue=Exists(intake_issue))
.annotate(is_intake_issue=Exists(intake_issue))
.annotate(

View File

@ -25,6 +25,10 @@ from plane.app.serializers import (
from plane.app.views.base import BaseAPIView, BaseViewSet
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.bgtasks.webhook_task import model_activity, webhook_activity
from plane.authentication.nodedc_project_memberships import (
ensure_project_admin_membership,
ensure_workspace_admin_project_memberships,
)
from plane.db.models import (
UserFavorite,
DeployBoard,
@ -49,6 +53,20 @@ class ProjectViewSet(BaseViewSet):
webhook_event = "project"
use_read_replica = True
def ensure_workspace_admin_project_access(self, request, slug):
workspace_member = (
WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=slug,
is_active=True,
role=ROLE.ADMIN.value,
)
.select_related("workspace")
.first()
)
if workspace_member is not None:
ensure_workspace_admin_project_memberships(workspace_member.workspace)
def get_queryset(self):
sort_order = ProjectUserProperty.objects.filter(
user=self.request.user,
@ -99,6 +117,7 @@ class ProjectViewSet(BaseViewSet):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def list_detail(self, request, slug):
self.ensure_workspace_admin_project_access(request, slug)
fields = [field for field in request.GET.get("fields", "").split(",") if field]
projects = self.get_queryset().order_by("sort_order", "name")
if WorkspaceMember.objects.filter(
@ -119,11 +138,8 @@ class ProjectViewSet(BaseViewSet):
role=ROLE.MEMBER.value,
).exists():
projects = projects.filter(
Q(
project_projectmember__member=self.request.user,
project_projectmember__is_active=True,
)
| Q(network=2)
project_projectmember__member=self.request.user,
project_projectmember__is_active=True,
)
if request.GET.get("per_page", False) and request.GET.get("cursor", False):
@ -139,6 +155,7 @@ class ProjectViewSet(BaseViewSet):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def list(self, request, slug):
self.ensure_workspace_admin_project_access(request, slug)
sort_order = ProjectUserProperty.objects.filter(
user=self.request.user,
project_id=OuterRef("pk"),
@ -209,11 +226,8 @@ class ProjectViewSet(BaseViewSet):
role=ROLE.MEMBER.value,
).exists():
projects = projects.filter(
Q(
project_projectmember__member=self.request.user,
project_projectmember__is_active=True,
)
| Q(network=2)
project_projectmember__member=self.request.user,
project_projectmember__is_active=True,
)
return Response(projects, status=status.HTTP_200_OK)
@ -227,7 +241,14 @@ class ProjectViewSet(BaseViewSet):
member_ids = [str(project_member.member_id) for project_member in project.members_list]
if str(request.user.id) not in member_ids:
if project.network == ProjectNetwork.SECRET.value:
if WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=slug,
is_active=True,
role=ROLE.ADMIN.value,
).exists():
ensure_project_admin_membership(project, request.user)
elif project.network == ProjectNetwork.SECRET.value:
return Response(
{"error": "You do not have permission"},
status=status.HTTP_403_FORBIDDEN,
@ -249,7 +270,7 @@ class ProjectViewSet(BaseViewSet):
serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
@allow_permission([ROLE.ADMIN], level="WORKSPACE")
def create(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
@ -290,6 +311,7 @@ class ProjectViewSet(BaseViewSet):
)
project = self.get_queryset().filter(pk=serializer.data["id"]).first()
ensure_workspace_admin_project_memberships(workspace, project=project)
# Create the model activity
model_activity.delay(

View File

@ -9,6 +9,12 @@ from django.db.models import Min
# Module imports
from .base import BaseViewSet, BaseAPIView
from plane.app.realtime.issue_events import publish_assignee_cleanup_issue_events_on_commit
from plane.app.realtime.nodedc_events import publish_nodedc_workspace_event_on_commit
from plane.authentication.nodedc_project_memberships import (
ensure_project_admin_membership,
ensure_workspace_admin_project_memberships,
)
from plane.authentication.nodedc_workspace_policy import (
is_nodedc_launcher_managed_workspace,
nodedc_launcher_managed_workspace_response,
@ -22,7 +28,7 @@ from plane.app.serializers import (
from plane.app.permissions import WorkspaceUserPermission
from plane.db.models import IssueAssignee, Project, ProjectMember, ProjectUserProperty, WorkspaceMember
from plane.db.models import IssueAssignee, Notification, Project, ProjectMember, ProjectUserProperty, WorkspaceMember
from plane.bgtasks.project_add_user_email_task import project_add_user_email
from plane.utils.host import base_host
from plane.app.permissions.base import allow_permission, ROLE
@ -145,7 +151,49 @@ class ProjectMemberViewSet(BaseViewSet):
project_members = ProjectMember.objects.filter(
project_id=project_id,
member_id__in=[member.get("member_id") for member in members],
).select_related("member", "project", "workspace")
Notification.objects.bulk_create(
[
Notification(
workspace=project_member.workspace,
project=project_member.project,
sender="in_app:nodedc:project_member_added",
triggered_by=request.user,
receiver=project_member.member,
entity_identifier=project_member.project_id,
entity_name="project_member_added",
title=f"Вам открыли доступ к проекту {project_member.project.name}",
message=f"Вы добавлены в проект {project_member.project.name} workspace {project_member.workspace.name}.",
message_stripped=(
f"Вы добавлены в проект {project_member.project.name} workspace {project_member.workspace.name}."
),
data={
"notification_type": "project_member_added",
"target_url": f"/{slug}/projects/{project_id}/issues",
"workspace_slug": slug,
"workspace_name": project_member.workspace.name,
"project_id": str(project_member.project_id),
"project_name": project_member.project.name,
"role": project_member.role,
},
)
for project_member in project_members
if project_member.member_id != request.user.id
],
batch_size=10,
)
for project_member in project_members:
publish_nodedc_workspace_event_on_commit(
project_member.workspace,
"project_member.created",
payload={
"project_id": str(project_member.project_id),
"member_id": str(project_member.member_id),
"role": project_member.role,
"source": "tasker",
},
extra_user_ids=[project_member.member_id],
)
# Send emails to notify the users
[
project_add_user_email.delay(
@ -271,6 +319,17 @@ class ProjectMemberViewSet(BaseViewSet):
if serializer.is_valid():
serializer.save()
publish_nodedc_workspace_event_on_commit(
project_member.workspace,
"project_member.updated",
payload={
"project_id": str(project_member.project_id),
"member_id": str(project_member.member_id),
"role": project_member.role,
"source": "tasker",
},
extra_user_ids=[project_member.member_id],
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -306,9 +365,24 @@ class ProjectMemberViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
publish_assignee_cleanup_issue_events_on_commit(
project_id=project_id,
assignee_id=project_member.member_id,
actor_id=request.user.id,
)
project_member.is_active = False
project_member.save()
IssueAssignee.objects.filter(project_id=project_id, assignee_id=project_member.member_id).delete()
publish_nodedc_workspace_event_on_commit(
project_member.workspace,
"project_member.deleted",
payload={
"project_id": str(project_member.project_id),
"member_id": str(project_member.member_id),
"source": "tasker",
},
extra_user_ids=[project_member.member_id],
)
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@ -337,21 +411,56 @@ class ProjectMemberViewSet(BaseViewSet):
},
status=status.HTTP_400_BAD_REQUEST,
)
publish_assignee_cleanup_issue_events_on_commit(
project_id=project_id,
assignee_id=project_member.member_id,
actor_id=request.user.id,
)
# Deactivate the user
project_member.is_active = False
project_member.save()
IssueAssignee.objects.filter(project_id=project_id, assignee_id=project_member.member_id).delete()
publish_nodedc_workspace_event_on_commit(
project_member.workspace,
"project_member.deleted",
payload={
"project_id": str(project_member.project_id),
"member_id": str(project_member.member_id),
"source": "tasker",
},
extra_user_ids=[project_member.member_id],
)
return Response(status=status.HTTP_204_NO_CONTENT)
class ProjectMemberUserEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
project_member = ProjectMember.objects.get(
project_member = ProjectMember.objects.filter(
project_id=project_id,
workspace__slug=slug,
member=request.user,
is_active=True,
)
).first()
if project_member is None:
project = Project.objects.filter(pk=project_id, workspace__slug=slug).select_related("workspace").first()
is_workspace_admin = WorkspaceMember.objects.filter(
workspace__slug=slug,
member=request.user,
is_active=True,
role=ROLE.ADMIN.value,
).exists()
if project is not None and is_workspace_admin:
ensure_project_admin_membership(project, request.user)
project_member = ProjectMember.objects.filter(
project=project,
member=request.user,
is_active=True,
).first()
if project_member is None:
return Response({"error": "Project member not found"}, status=status.HTTP_404_NOT_FOUND)
serializer = ProjectMemberSerializer(project_member)
return Response(serializer.data, status=status.HTTP_200_OK)
@ -362,6 +471,19 @@ class UserProjectRolesEndpoint(BaseAPIView):
use_read_replica = True
def get(self, request, slug):
workspace_member = (
WorkspaceMember.objects.filter(
workspace__slug=slug,
member=request.user,
is_active=True,
)
.select_related("workspace")
.first()
)
if workspace_member is not None and workspace_member.role == ROLE.ADMIN.value:
ensure_workspace_admin_project_memberships(workspace_member.workspace)
project_members = ProjectMember.objects.filter(
workspace__slug=slug,
member_id=request.user.id,

View File

@ -52,9 +52,12 @@ from plane.utils.host import base_host
from plane.bgtasks.user_email_update_task import send_email_update_magic_code, send_email_update_confirmation
from plane.authentication.rate_limit import EmailVerificationThrottle
from plane.license.utils.instance_value import get_configuration_value
from plane.app.realtime.nodedc_events import publish_nodedc_user_profile_event_on_commit
from plane.authentication.nodedc_profile_sync import push_nodedc_user_profile_update_on_commit
logger = logging.getLogger("plane")
NODEDC_PROFILE_SYNC_FIELDS = ("display_name", "first_name", "last_name", "avatar")
class UserEndpoint(BaseViewSet):
@ -91,7 +94,28 @@ class UserEndpoint(BaseViewSet):
return Response({"is_instance_admin": is_admin}, status=status.HTTP_200_OK)
def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs)
user = self.get_object()
previous_profile = {field: getattr(user, field, None) for field in NODEDC_PROFILE_SYNC_FIELDS}
response = super().partial_update(request, *args, **kwargs)
if response.status_code < 400:
user.refresh_from_db()
changed_fields = [
field for field in NODEDC_PROFILE_SYNC_FIELDS if previous_profile.get(field) != getattr(user, field, None)
]
if {"first_name", "last_name"} & set(changed_fields) and "display_name" not in changed_fields:
display_name = " ".join([user.first_name, user.last_name]).strip()
if display_name and user.display_name != display_name:
user.display_name = display_name
user.save(update_fields=["display_name", "updated_at"])
changed_fields.append("display_name")
if changed_fields:
publish_nodedc_user_profile_event_on_commit(user, changed_fields=changed_fields)
push_nodedc_user_profile_update_on_commit(user, changed_fields=changed_fields)
return response
def _validate_new_email(self, user, new_email):
"""

View File

@ -30,6 +30,7 @@ from plane.app.permissions import (
WorkSpaceBasePermission,
WorkspaceEntityPermission,
)
from plane.app.realtime.nodedc_events import publish_nodedc_event_to_users_on_commit
# Module imports
from plane.app.serializers import WorkSpaceSerializer, WorkspaceThemeSerializer
@ -197,6 +198,14 @@ class WorkSpaceViewSet(BaseViewSet):
def destroy(self, request, *args, **kwargs):
# Get the workspace
workspace = self.get_object()
workspace_member_ids = list(
WorkspaceMember.objects.filter(
workspace_id=workspace.id,
is_active=True,
member__is_bot=False,
deleted_at__isnull=True,
).values_list("member_id", flat=True)
)
self.remove_last_workspace_ids_from_user_settings(workspace.id)
track_event.delay(
user_id=request.user.id,
@ -211,7 +220,16 @@ class WorkSpaceViewSet(BaseViewSet):
"deleted_at": str(timezone.now().isoformat()),
},
)
return super().destroy(request, *args, **kwargs)
response = super().destroy(request, *args, **kwargs)
publish_nodedc_event_to_users_on_commit(
"workspace.deleted",
workspace_member_ids,
{
"workspace_id": str(workspace.id),
"workspace_slug": workspace.slug,
},
)
return response
class UserWorkSpacesEndpoint(BaseAPIView):

View File

@ -20,6 +20,7 @@ from rest_framework.response import Response
# Module imports
from plane.app.permissions import WorkSpaceAdminPermission
from plane.app.realtime.nodedc_events import publish_nodedc_workspace_event_on_commit
from plane.authentication.nodedc_workspace_policy import (
get_nodedc_workspace_creation_policy,
is_nodedc_launcher_managed_workspace,
@ -37,7 +38,7 @@ from plane.app.serializers import (
from plane.app.views.base import BaseAPIView
from plane.bgtasks.event_tracking_task import track_event
from plane.bgtasks.workspace_invitation_task import workspace_invitation
from plane.db.models import User, Workspace, WorkspaceMember, WorkspaceMemberInvite
from plane.db.models import Notification, User, Workspace, WorkspaceMember, WorkspaceMemberInvite
from plane.utils.cache import invalidate_cache, invalidate_cache_directly
from plane.utils.host import base_host
from plane.utils.analytics_events import USER_JOINED_WORKSPACE, USER_INVITED_TO_WORKSPACE
@ -45,6 +46,12 @@ from plane.utils.workspace_bans import is_workspace_member_currently_banned, rel
from .. import BaseViewSet
NODEDC_ACCEPTABLE_WORKSPACE_INVITE_APPROVAL_STATUSES = (
WorkspaceMemberInvite.NODEDC_APPROVAL_NOT_REQUIRED,
WorkspaceMemberInvite.NODEDC_APPROVAL_APPROVED,
)
class WorkspaceInvitationsViewset(BaseViewSet):
"""Endpoint for creating, listing and deleting workspaces"""
@ -155,10 +162,30 @@ class WorkspaceInvitationsViewset(BaseViewSet):
approval_response = request_nodedc_workspace_invite_approval(request, workspace, invitation)
approval_request = approval_response.get("taskerInviteRequest") if isinstance(approval_response, dict) else None
approval_request_id = approval_request.get("id") if isinstance(approval_request, dict) else None
approval_request_status = approval_request.get("status") if isinstance(approval_request, dict) else None
update_fields = ["updated_at"]
if approval_request_id:
invitation.nodedc_approval_request_id = approval_request_id
invitation.save(update_fields=["nodedc_approval_request_id", "updated_at"])
update_fields.append("nodedc_approval_request_id")
if approval_request_status == "approved":
invitation.nodedc_approval_status = WorkspaceMemberInvite.NODEDC_APPROVAL_APPROVED
invitation.nodedc_approval_decided_at = timezone.now()
update_fields.extend(["nodedc_approval_status", "nodedc_approval_decided_at"])
if len(update_fields) > 1:
invitation.save(update_fields=update_fields)
approved_requests.append(approval_request_id)
invited_user = User.objects.filter(email__iexact=invitation.email, is_bot=False).first()
publish_nodedc_workspace_event_on_commit(
workspace,
"workspace_invite.created",
payload={
"invite_id": str(invitation.id),
"email": invitation.email,
"status": invitation.nodedc_approval_status,
"source": "tasker",
},
extra_user_ids=[request.user.id, getattr(invited_user, "id", None)],
)
except Exception:
WorkspaceMemberInvite.objects.filter(id__in=[invitation.id for invitation in workspace_invitations]).delete()
return Response(
@ -197,6 +224,18 @@ class WorkspaceInvitationsViewset(BaseViewSet):
"invitee_email": invitation.email,
},
)
invited_user = User.objects.filter(email__iexact=invitation.email, is_bot=False).first()
publish_nodedc_workspace_event_on_commit(
workspace,
"workspace_invite.created",
payload={
"invite_id": str(invitation.id),
"email": invitation.email,
"status": invitation.nodedc_approval_status,
"source": "tasker",
},
extra_user_ids=[request.user.id, getattr(invited_user, "id", None)],
)
return Response({"message": "Emails sent successfully"}, status=status.HTTP_200_OK)
@ -214,7 +253,23 @@ class WorkspaceInvitationsViewset(BaseViewSet):
status=status.HTTP_502_BAD_GATEWAY,
)
invited_user = User.objects.filter(email__iexact=workspace_member_invite.email, is_bot=False).first()
workspace = workspace_member_invite.workspace
invite_payload = {
"invite_id": str(workspace_member_invite.id),
"email": workspace_member_invite.email,
"status": workspace_member_invite.nodedc_approval_status,
"source": "tasker",
}
extra_user_ids = [workspace_member_invite.created_by_id, getattr(invited_user, "id", None)]
workspace_member_invite.delete()
publish_nodedc_workspace_event_on_commit(
workspace,
"workspace_invite.deleted",
payload=invite_payload,
extra_user_ids=extra_user_ids,
)
return Response(status=status.HTTP_204_NO_CONTENT)
@ -314,6 +369,42 @@ class WorkspaceJoinEndpoint(BaseAPIView):
"joined_at": str(timezone.now()),
},
)
if workspace_invite.created_by_id and workspace_invite.created_by_id != user.id:
Notification.objects.create(
workspace=workspace_invite.workspace,
sender="in_app:nodedc:workspace_invite_accepted",
triggered_by=user,
receiver_id=workspace_invite.created_by_id,
entity_identifier=workspace_invite.workspace_id,
entity_name="workspace_invite_accepted",
title=f"{user.display_name or user.email} принял приглашение",
message=(
f"{user.display_name or user.email} присоединился к workspace "
f"{workspace_invite.workspace.name}."
),
message_stripped=(
f"{user.display_name or user.email} присоединился к workspace "
f"{workspace_invite.workspace.name}."
),
data={
"notification_type": "workspace_invite_accepted",
"target_url": f"/{workspace_invite.workspace.slug}/settings/members",
"workspace_slug": workspace_invite.workspace.slug,
"workspace_name": workspace_invite.workspace.name,
"invitee_email": user.email,
},
)
publish_nodedc_workspace_event_on_commit(
workspace_invite.workspace,
"workspace_member.created",
payload={
"member_id": str(user.id),
"role": workspace_invite.role,
"source": "tasker",
},
extra_user_ids=[user.id, workspace_invite.created_by_id],
)
# Delete the invitation
workspace_invite.delete()
@ -346,7 +437,13 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
def get_queryset(self):
return self.filter_queryset(
super().get_queryset().filter(email=self.request.user.email).select_related("workspace")
super()
.get_queryset()
.filter(
email=self.request.user.email,
nodedc_approval_status__in=NODEDC_ACCEPTABLE_WORKSPACE_INVITE_APPROVAL_STATUSES,
)
.select_related("workspace")
)
@invalidate_cache(path="/api/workspaces/", user=False)
@ -354,10 +451,19 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
def create(self, request):
invitations = request.data.get("invitations", [])
workspace_invitations = WorkspaceMemberInvite.objects.filter(
pk__in=invitations, email=request.user.email
pk__in=invitations,
email=request.user.email,
nodedc_approval_status__in=NODEDC_ACCEPTABLE_WORKSPACE_INVITE_APPROVAL_STATUSES,
).order_by("-created_at")
if len(set(invitations)) != workspace_invitations.count():
return Response(
{"error": "NODE.DC has not approved one or more workspace invitations yet"},
status=status.HTTP_403_FORBIDDEN,
)
# If the user is already a member of workspace and was deactivated then activate the user
accepted_notifications = []
for invitation in workspace_invitations:
workspace_member = WorkspaceMember.objects.filter(
workspace_id=invitation.workspace_id, member=request.user
@ -394,6 +500,43 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
"joined_at": str(timezone.now()),
},
)
if invitation.created_by_id and invitation.created_by_id != request.user.id:
accepted_notifications.append(
Notification(
workspace=invitation.workspace,
sender="in_app:nodedc:workspace_invite_accepted",
triggered_by=request.user,
receiver_id=invitation.created_by_id,
entity_identifier=invitation.workspace_id,
entity_name="workspace_invite_accepted",
title=f"{request.user.display_name or request.user.email} принял приглашение",
message=(
f"{request.user.display_name or request.user.email} присоединился к workspace "
f"{invitation.workspace.name}."
),
message_stripped=(
f"{request.user.display_name or request.user.email} присоединился к workspace "
f"{invitation.workspace.name}."
),
data={
"notification_type": "workspace_invite_accepted",
"target_url": f"/{invitation.workspace.slug}/settings/members",
"workspace_slug": invitation.workspace.slug,
"workspace_name": invitation.workspace.name,
"invitee_email": request.user.email,
},
)
)
publish_nodedc_workspace_event_on_commit(
invitation.workspace,
"workspace_member.created",
payload={
"member_id": str(request.user.id),
"role": invitation.role,
"source": "tasker",
},
extra_user_ids=[request.user.id, invitation.created_by_id],
)
# Bulk create the user for all the workspaces
WorkspaceMember.objects.bulk_create(
@ -408,6 +551,7 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
],
ignore_conflicts=True,
)
Notification.objects.bulk_create(accepted_notifications, batch_size=10)
# Delete joined workspace invites
workspace_invitations.delete()

View File

@ -12,6 +12,8 @@ from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import WorkspaceEntityPermission, allow_permission, ROLE
from plane.app.realtime.issue_events import publish_assignee_cleanup_issue_events_on_commit
from plane.app.realtime.nodedc_events import publish_nodedc_workspace_event_on_commit
from plane.authentication.nodedc_workspace_policy import (
get_nodedc_workspace_creation_policy,
is_nodedc_launcher_managed_workspace,
@ -54,7 +56,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
workspace_member = WorkspaceMember.objects.get(member=request.user, workspace__slug=slug, is_active=True)
# Get all active workspace members
workspace_members = self.get_queryset()
workspace_members = self.get_queryset().filter(is_active=True)
if workspace_member.role > 5:
serializer = WorkspaceMemberAdminSerializer(workspace_members, fields=("id", "member", "role"), many=True)
else:
@ -102,6 +104,16 @@ class WorkSpaceMemberViewSet(BaseViewSet):
if serializer.is_valid():
serializer.save()
publish_nodedc_workspace_event_on_commit(
workspace_member.workspace,
"workspace_member.updated",
payload={
"member_id": str(workspace_member.member_id),
"role": workspace_member.role,
"source": "tasker",
},
extra_user_ids=[workspace_member.member_id],
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -166,6 +178,20 @@ class WorkSpaceMemberViewSet(BaseViewSet):
status=status.HTTP_502_BAD_GATEWAY,
)
project_ids = list(
ProjectMember.objects.filter(
workspace__slug=slug,
member_id=workspace_member.member_id,
is_active=True,
).values_list("project_id", flat=True)
)
publish_assignee_cleanup_issue_events_on_commit(
workspace_id=workspace_member.workspace_id,
assignee_id=workspace_member.member_id,
actor_id=request.user.id,
)
# Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
@ -174,6 +200,16 @@ class WorkSpaceMemberViewSet(BaseViewSet):
workspace_member.is_active = False
workspace_member.save()
publish_nodedc_workspace_event_on_commit(
workspace_member.workspace,
"workspace_member.deleted",
payload={
"member_id": str(workspace_member.member_id),
"project_ids": [str(project_id) for project_id in project_ids],
"source": "tasker",
},
extra_user_ids=[workspace_member.member_id],
)
return Response(status=status.HTTP_204_NO_CONTENT)
@invalidate_cache(
@ -224,6 +260,20 @@ class WorkSpaceMemberViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
project_ids = list(
ProjectMember.objects.filter(
workspace__slug=slug,
member_id=workspace_member.member_id,
is_active=True,
).values_list("project_id", flat=True)
)
publish_assignee_cleanup_issue_events_on_commit(
workspace_id=workspace_member.workspace_id,
assignee_id=workspace_member.member_id,
actor_id=request.user.id,
)
# # Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
@ -233,6 +283,16 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# # Deactivate the user
workspace_member.is_active = False
workspace_member.save()
publish_nodedc_workspace_event_on_commit(
workspace_member.workspace,
"workspace_member.deleted",
payload={
"member_id": str(workspace_member.member_id),
"project_ids": [str(project_id) for project_id in project_ids],
"source": "tasker",
},
extra_user_ids=[workspace_member.member_id],
)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -70,7 +70,6 @@ def get_access_config():
token = (
os.environ.get("PLANE_NODEDC_ACCESS_TOKEN", "").strip()
or os.environ.get("NODEDC_INTERNAL_ACCESS_TOKEN", "").strip()
or os.environ.get("PLANE_OIDC_CLIENT_SECRET", "").strip()
)
return {

View File

@ -0,0 +1,142 @@
import logging
import os
from urllib.parse import urlparse
import requests
from django.db import transaction
from plane.authentication.views.nodedc_logout import get_nodedc_internal_token
from plane.db.models import ExternalIdentityLink, User
logger = logging.getLogger("plane")
OIDC_PROVIDER = "authentik"
def get_nodedc_profile_sync_url():
launcher_base_url = (
os.environ.get("PLANE_NODEDC_LAUNCHER_URL", "").strip()
or os.environ.get("PLANE_NODEDC_LAUNCHER_PUBLIC_URL", "").strip()
or "http://launcher.local.nodedc"
).rstrip("/")
return (
os.environ.get("PLANE_NODEDC_PROFILE_SYNC_URL", "").strip()
or f"{launcher_base_url}/api/internal/tasker/profile-sync"
)
def get_tasker_public_origin():
explicit_origin = os.environ.get("PLANE_NODEDC_TASK_PUBLIC_URL", "").strip()
if explicit_origin:
return explicit_origin.rstrip("/")
configured_url = os.environ.get("WEB_URL", "").strip()
if configured_url:
parsed_url = urlparse(configured_url)
if parsed_url.scheme and parsed_url.netloc:
return f"{parsed_url.scheme}://{parsed_url.netloc}"
task_domain = os.environ.get("TASK_DOMAIN", "").strip()
if task_domain:
return f"http://{task_domain}"
return ""
def normalize_tasker_avatar_url(value):
if not isinstance(value, str):
return None
avatar_url = value.strip()
if not avatar_url:
return None
if avatar_url.startswith(("http://", "https://", "data:")):
return avatar_url
if avatar_url.startswith("/"):
tasker_origin = get_tasker_public_origin()
return f"{tasker_origin}{avatar_url}" if tasker_origin else avatar_url
return avatar_url
def get_user_full_name(user):
return " ".join(
value for value in [getattr(user, "first_name", ""), getattr(user, "last_name", "")] if value
).strip()
def get_user_display_name(user, changed_fields=None):
changed_fields = set(changed_fields or [])
full_name = get_user_full_name(user)
display_name = getattr(user, "display_name", "")
if {"first_name", "last_name"} & changed_fields and "display_name" not in changed_fields and full_name:
return full_name
if display_name:
return display_name
return full_name or user.email
def get_nodedc_subject(user):
link = ExternalIdentityLink.objects.filter(provider=OIDC_PROVIDER, user=user, status="active").first()
return link.subject if link else None
def build_nodedc_profile_payload(user, changed_fields=None):
changed_fields = sorted(set(changed_fields or []))
display_name = get_user_display_name(user, changed_fields=changed_fields)
return {
"source": "tasker",
"planeUserId": str(user.id),
"subject": get_nodedc_subject(user),
"email": user.email,
"name": display_name,
"displayName": display_name,
"firstName": user.first_name,
"lastName": user.last_name,
"avatarUrl": normalize_tasker_avatar_url(user.avatar_url),
"changedFields": changed_fields,
}
def push_nodedc_user_profile_update(user, changed_fields=None):
request_url = get_nodedc_profile_sync_url()
token = get_nodedc_internal_token()
if not request_url or not token:
logger.warning("NODE.DC profile sync is not configured")
return None
response = requests.post(
request_url,
json=build_nodedc_profile_payload(user, changed_fields=changed_fields),
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
},
timeout=float(os.environ.get("PLANE_NODEDC_PROFILE_SYNC_TIMEOUT_SECONDS", "3") or "3"),
)
response.raise_for_status()
return response.json()
def push_nodedc_user_profile_update_on_commit(user, changed_fields=None):
user_id = user.id
changed_fields = sorted(set(changed_fields or []))
def _push():
fresh_user = User.objects.filter(id=user_id, is_bot=False).first()
if fresh_user is None:
return
try:
push_nodedc_user_profile_update(fresh_user, changed_fields=changed_fields)
except Exception:
logger.exception("Failed to push NODE.DC profile update to Launcher")
transaction.on_commit(_push)

View File

@ -0,0 +1,113 @@
ADMIN_ROLE = 20
AUTO_ADMIN_COMMENT = "nodedc:workspace-admin"
AUTO_ADMIN_CREATED_COMMENT = f"{AUTO_ADMIN_COMMENT}:created"
AUTO_ADMIN_PREVIOUS_PREFIX = f"{AUTO_ADMIN_COMMENT}:previous:"
def get_auto_admin_previous_role(comment):
if not isinstance(comment, str) or not comment.startswith(AUTO_ADMIN_PREVIOUS_PREFIX):
return None
try:
previous_role = int(comment.replace(AUTO_ADMIN_PREVIOUS_PREFIX, "", 1))
except ValueError:
return None
return previous_role if previous_role in {5, 15, ADMIN_ROLE} else None
def ensure_project_admin_membership(project, user):
from plane.db.models import ProjectMember
project_member = ProjectMember.objects.filter(
project=project,
member=user,
deleted_at__isnull=True,
).first()
if project_member is None:
ProjectMember.objects.create(
workspace=project.workspace,
project=project,
member=user,
role=ADMIN_ROLE,
is_active=True,
comment=AUTO_ADMIN_CREATED_COMMENT,
)
return 1
update_fields = []
if project_member.role != ADMIN_ROLE:
project_member.comment = f"{AUTO_ADMIN_PREVIOUS_PREFIX}{project_member.role}"
project_member.role = ADMIN_ROLE
update_fields.extend(["comment", "role"])
if not project_member.is_active:
project_member.is_active = True
update_fields.append("is_active")
if update_fields:
update_fields.append("updated_at")
project_member.save(update_fields=update_fields)
return 1
return 0
def revoke_auto_project_admin_memberships(workspace, user):
from plane.db.models import ProjectMember
revoked = 0
project_memberships = ProjectMember.objects.filter(
project__workspace=workspace,
member=user,
role=ADMIN_ROLE,
deleted_at__isnull=True,
comment__startswith=AUTO_ADMIN_COMMENT,
)
for project_member in project_memberships:
previous_role = get_auto_admin_previous_role(project_member.comment)
if project_member.comment == AUTO_ADMIN_CREATED_COMMENT or previous_role is None:
project_member.is_active = False
project_member.save(update_fields=["is_active", "updated_at"])
else:
project_member.role = previous_role
project_member.comment = None
project_member.is_active = True
project_member.save(update_fields=["role", "comment", "is_active", "updated_at"])
revoked += 1
return revoked
def ensure_user_admin_project_memberships(workspace, user):
from plane.db.models import Project
restored = 0
for project in Project.objects.filter(workspace=workspace, deleted_at__isnull=True).select_related("workspace"):
restored += ensure_project_admin_membership(project, user)
return restored
def ensure_workspace_admin_project_memberships(workspace, project=None):
from plane.db.models import WorkspaceMember
admin_memberships = (
WorkspaceMember.objects.filter(
workspace=workspace,
role=ADMIN_ROLE,
is_active=True,
deleted_at__isnull=True,
member__is_bot=False,
)
.select_related("member")
.order_by("created_at")
)
restored = 0
for workspace_member in admin_memberships:
if project is not None:
restored += ensure_project_admin_membership(project, workspace_member.member)
else:
restored += ensure_user_admin_project_memberships(workspace, workspace_member.member)
return restored

View File

@ -25,6 +25,7 @@ def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
"default_managed_by": "tasker",
"invite_approval": "tasker",
"default_invite_approval": "tasker",
"service_modules": {},
"workspaces": [],
"reason": "NODE.DC workspace policy is not configured.",
}
@ -45,6 +46,7 @@ def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
"default_managed_by": "tasker",
"invite_approval": "tasker",
"default_invite_approval": "tasker",
"service_modules": {},
"workspaces": [],
"reason": "NODE.DC identity is not linked." if enforce_unlinked else "Standalone user without NODE.DC identity.",
}
@ -57,6 +59,7 @@ def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
"subject": link.subject,
"email": link.email or user.email,
"userId": None,
"workspaceSlug": workspace_slug,
},
headers={
"Authorization": f"Bearer {token}",
@ -75,11 +78,18 @@ def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
"default_managed_by": "tasker",
"invite_approval": "disabled",
"default_invite_approval": "tasker",
"service_modules": {},
"workspaces": [],
"reason": "NODE.DC workspace policy is unavailable.",
}
workspace_policy = payload.get("workspacePolicy") if isinstance(payload.get("workspacePolicy"), dict) else {}
service_modules = normalize_service_modules(
workspace_policy.get("serviceModules")
or workspace_policy.get("service_modules")
or payload.get("serviceModules")
or payload.get("service_modules")
)
access_allowed = bool(payload.get("allowed"))
if not workspace_policy:
return {
@ -90,6 +100,7 @@ def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
"default_managed_by": "tasker",
"invite_approval": "tasker",
"default_invite_approval": "tasker",
"service_modules": service_modules,
"workspaces": [],
"reason": payload.get("reason") or "NODE.DC access check does not expose workspace policy.",
}
@ -117,6 +128,7 @@ def get_nodedc_workspace_creation_policy(user, workspace_slug=None):
"default_managed_by": normalize_managed_by(workspace_policy.get("defaultManagedBy") or workspace_policy.get("managedBy")),
"invite_approval": invite_approval,
"default_invite_approval": default_invite_approval,
"service_modules": service_modules,
"workspaces": workspaces,
"reason": workspace_policy.get("reason") or payload.get("reason") or "NODE.DC workspace policy decision.",
}
@ -159,6 +171,18 @@ def normalize_workspace_management_list(value):
return workspaces
def normalize_service_modules(value):
if not isinstance(value, dict):
return {}
service_modules = {}
for module_key in ("codex_agents",):
if value.get(module_key) is True:
service_modules[module_key] = True
return service_modules
def resolve_workspace_managed_by(workspace_slug, workspaces, fallback):
if isinstance(workspace_slug, str) and workspace_slug.strip():
normalized_slug = workspace_slug.strip()
@ -185,4 +209,8 @@ def nodedc_launcher_managed_workspace_response():
def is_nodedc_workspace_invite_approval_required(policy):
return bool(policy.get("enabled")) and policy.get("managed_by") == "tasker" and policy.get("invite_approval") == "nodedc"
return (
bool(policy.get("enabled"))
and policy.get("managed_by") == "tasker"
and policy.get("invite_approval") in {"nodedc", "launcher"}
)

View File

@ -343,19 +343,11 @@ def resolve_linked_user(claims, groups, auto_link, auto_create, sync_profile, sk
if link is None and auto_link and email:
user = User.objects.filter(email__iexact=email, is_active=True).first()
if user:
link, _ = ExternalIdentityLink.objects.get_or_create(
provider=OIDC_PROVIDER,
subject=subject,
defaults={"user": user, "email": email, "groups": groups},
)
link = resolve_identity_link_for_user(user, subject, email, groups)
if link is None and auto_create and email:
user, user_created = get_or_create_oidc_user(email=email, claims=claims)
link, _ = ExternalIdentityLink.objects.get_or_create(
provider=OIDC_PROVIDER,
subject=subject,
defaults={"user": user, "email": email, "groups": groups},
)
link = resolve_identity_link_for_user(user, subject, email, groups)
if user_created:
logger.info(
"NODEDC OIDC provisioned Tasker user: user_id=%s email_hash=%s subject_hash=%s",
@ -394,6 +386,52 @@ def resolve_linked_user(claims, groups, auto_link, auto_create, sync_profile, sk
return user
def resolve_identity_link_for_user(user, subject, email, groups):
user_link = ExternalIdentityLink.objects.select_related("user").filter(
provider=OIDC_PROVIDER,
user=user,
).first()
conflicting_link = ExternalIdentityLink.objects.filter(
provider=OIDC_PROVIDER,
subject=subject,
).exclude(user=user).first()
if conflicting_link:
logger.warning(
"NODEDC OIDC subject is already linked to another user: "
"provider=%s user_id=%s subject_hash=%s",
OIDC_PROVIDER,
conflicting_link.user_id,
hash_subject(subject),
)
return None
if user_link:
if user_link.status != ExternalIdentityLink.Status.ACTIVE:
logger.warning(
"NODEDC OIDC denied disabled external identity link during email auto-link: "
"provider=%s user_id=%s subject_hash=%s",
OIDC_PROVIDER,
user.id,
hash_subject(subject),
)
return None
user_link.subject = subject
user_link.email = email
user_link.groups = groups
user_link.save(update_fields=["subject", "email", "groups", "updated_at"])
return user_link
link, _ = ExternalIdentityLink.objects.get_or_create(
provider=OIDC_PROVIDER,
subject=subject,
defaults={"user": user, "email": email, "groups": groups},
)
return link
def get_or_create_oidc_user(email, claims):
user = User.objects.filter(email__iexact=email).first()

View File

@ -0,0 +1,705 @@
from html import escape
from django.core.exceptions import ValidationError
from django.db import transaction
from django.http import JsonResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from plane.app.realtime.issue_events import publish_issue_event_on_commit
from plane.authentication.views.nodedc_logout import is_internal_logout_request_authorized
from plane.authentication.nodedc_workspace_policy import get_nodedc_workspace_creation_policy
from plane.authentication.views.nodedc_workspace_adapter import parse_json_body
from plane.db.models import (
Issue,
IssueAssignee,
IssueComment,
IssueLabel,
Label,
Project,
ProjectMember,
State,
User,
WorkspaceMember,
)
NODEDC_STRUCTURED_BLOCKS_KEY = "nodedc_structured_blocks"
AGENT_EMAIL_DOMAIN = "agents.nodedc.local"
AGENT_BOT_TYPE = "nodedc_codex_agent"
ALLOWED_PRIORITIES = ["none", "low", "medium", "high", "urgent"]
def unauthorized_response():
return JsonResponse({"ok": False, "error": "internal_access_unauthorized"}, status=401)
def invalid_json_response():
return JsonResponse({"ok": False, "error": "invalid_json"}, status=400)
def validation_error(error, status=400):
return JsonResponse({"ok": False, "error": error}, status=status)
def serialize_project(project):
return {
"id": str(project.id),
"workspace_slug": project.workspace.slug,
"name": project.name,
"identifier": project.identifier,
}
def serialize_state(state):
return {
"id": str(state.id),
"name": state.name,
"color": state.color,
"group": state.group,
"sequence": state.sequence,
"default": state.default,
}
def serialize_label(label):
return {
"id": str(label.id),
"name": label.name,
"color": label.color,
}
def serialize_member(project_member):
return {
"id": str(project_member.member_id),
"project_member_id": str(project_member.id),
"display_name": project_member.member.display_name,
"email": project_member.member.email,
"role": project_member.role,
}
def serialize_issue(issue):
return {
"id": str(issue.id),
"project_id": str(issue.project_id),
"workspace_slug": issue.workspace.slug,
"title": issue.name,
"description": issue.description_html,
"priority": issue.priority,
"state_id": str(issue.state_id) if issue.state_id else None,
"sequence_id": issue.sequence_id,
"detail_layout": issue.detail_layout or {},
"label_ids": [
str(label_id)
for label_id in IssueLabel.objects.filter(
issue=issue,
deleted_at__isnull=True,
).values_list("label_id", flat=True)
],
"assignee_ids": [
str(assignee_id)
for assignee_id in IssueAssignee.objects.filter(
issue=issue,
deleted_at__isnull=True,
).values_list("assignee_id", flat=True)
],
"created_by": str(issue.created_by_id) if issue.created_by_id else None,
"updated_by": str(issue.updated_by_id) if issue.updated_by_id else None,
"created_at": issue.created_at.isoformat(),
"updated_at": issue.updated_at.isoformat(),
}
def serialize_comment(comment):
return {
"id": str(comment.id),
"issue_id": str(comment.issue_id),
"body": comment.comment_html,
"actor_id": str(comment.actor_id) if comment.actor_id else None,
"created_at": comment.created_at.isoformat(),
}
def resolve_project(project_id, workspace_slug=None):
queryset = Project.objects.filter(
id=project_id,
deleted_at__isnull=True,
archived_at__isnull=True,
).select_related("workspace")
if workspace_slug:
queryset = queryset.filter(workspace__slug=workspace_slug)
return queryset.first()
def resolve_issue(project_id, issue_id, workspace_slug=None):
project = resolve_project(project_id, workspace_slug)
if project is None:
return None, None
issue = (
Issue.issue_objects.filter(project=project, id=issue_id)
.select_related("workspace", "project", "state")
.first()
)
return project, issue
def get_agent_header(request, key):
return request.headers.get(key, "").strip()
def get_agent_identity(request):
agent_id = get_agent_header(request, "X-NODEDC-Agent-Id")
owner_user_id = get_agent_header(request, "X-NODEDC-Agent-Owner-User-Id")
token_id = get_agent_header(request, "X-NODEDC-Agent-Token-Id")
if not agent_id or not owner_user_id or not token_id:
return None
return {
"agent_id": agent_id,
"owner_user_id": owner_user_id,
"token_id": token_id,
}
def ensure_agent_actor(request, workspace, project=None):
identity = get_agent_identity(request)
if identity is None:
return None
agent_id = identity["agent_id"]
short_id = agent_id.replace("-", "")[:12]
email = f"agent+{agent_id}@{AGENT_EMAIL_DOMAIN}"
username = f"nodedc_agent_{short_id}"
display_name = f"Codex Agent {short_id}"
user, _ = User.objects.get_or_create(
email=email,
defaults={
"username": username,
"display_name": display_name,
"first_name": "Codex",
"last_name": f"Agent {short_id}",
"is_bot": True,
"bot_type": AGENT_BOT_TYPE,
"is_active": True,
},
)
update_fields = []
if not user.is_bot:
user.is_bot = True
update_fields.append("is_bot")
if user.bot_type != AGENT_BOT_TYPE:
user.bot_type = AGENT_BOT_TYPE
update_fields.append("bot_type")
if not user.is_active:
user.is_active = True
update_fields.append("is_active")
if user.display_name != display_name:
user.display_name = display_name
update_fields.append("display_name")
if update_fields:
update_fields.append("updated_at")
user.save(update_fields=update_fields)
WorkspaceMember.objects.get_or_create(
workspace=workspace,
member=user,
defaults={
"role": 15,
"company_role": "codex-agent",
"is_active": True,
"is_banned": False,
},
)
if project is not None:
ProjectMember.objects.get_or_create(
workspace=workspace,
project=project,
member=user,
defaults={
"role": 15,
"is_active": True,
},
)
return user
def validate_internal_request(request):
if not is_internal_logout_request_authorized(request):
return unauthorized_response()
if get_agent_identity(request) is None:
return validation_error("missing_agent_headers", status=400)
return None
def resolve_agent_owner(request):
identity = get_agent_identity(request)
if identity is None:
return None
try:
return User.objects.filter(id=identity["owner_user_id"], is_active=True).first()
except (TypeError, ValueError, ValidationError):
return None
def validate_agent_workspace_entitlement(request, workspace_slug):
if not workspace_slug:
return validation_error("workspace_slug_required")
owner = resolve_agent_owner(request)
if owner is None:
return validation_error("agent_owner_not_found", status=403)
workspace_policy = get_nodedc_workspace_creation_policy(owner, workspace_slug=workspace_slug)
service_modules = workspace_policy.get("service_modules") or {}
if service_modules.get("codex_agents") is not True:
return validation_error("codex_agents_not_entitled", status=403)
return None
def html_from_text(value):
text = value.strip() if isinstance(value, str) else ""
return f"<p>{escape(text)}</p>" if text else "<p></p>"
def merge_structured_blocks(detail_layout, structured_blocks):
if structured_blocks is None:
return detail_layout or {}
if not isinstance(structured_blocks, list):
return None
updated_layout = dict(detail_layout or {})
updated_layout[NODEDC_STRUCTURED_BLOCKS_KEY] = structured_blocks
return updated_layout
def update_issue_labels(issue, label_ids):
labels = list(Label.objects.filter(id__in=label_ids, project=issue.project, deleted_at__isnull=True))
if len(labels) != len(set(label_ids)):
return False
IssueLabel.objects.filter(issue=issue, deleted_at__isnull=True).delete()
for label in labels:
IssueLabel.objects.create(issue=issue, label=label, project=issue.project, workspace=issue.workspace)
return True
def update_issue_assignees(issue, member_ids):
project_members = list(
ProjectMember.objects.filter(
project=issue.project,
member_id__in=member_ids,
is_active=True,
deleted_at__isnull=True,
).select_related("member")
)
if len(project_members) != len(set(member_ids)):
return False
IssueAssignee.objects.filter(issue=issue, deleted_at__isnull=True).delete()
for project_member in project_members:
IssueAssignee.objects.create(
issue=issue,
assignee=project_member.member,
project=issue.project,
workspace=issue.workspace,
)
return True
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentProjectResolveEndpoint(View):
def post(self, request):
error_response = validate_internal_request(request)
if error_response is not None:
return error_response
payload = parse_json_body(request)
if payload is None:
return invalid_json_response()
grants = payload.get("grants")
if not isinstance(grants, list):
return validation_error("grants_required")
project_filters = []
workspace_slugs = []
for grant in grants:
if not isinstance(grant, dict):
continue
workspace_slug = grant.get("workspace_slug")
project_id = grant.get("project_id")
if not isinstance(workspace_slug, str) or not workspace_slug:
continue
entitlement_error = validate_agent_workspace_entitlement(request, workspace_slug)
if entitlement_error is not None:
continue
if isinstance(project_id, str) and project_id:
project_filters.append((workspace_slug, project_id))
else:
workspace_slugs.append(workspace_slug)
projects = []
if workspace_slugs:
projects.extend(
Project.objects.filter(
workspace__slug__in=workspace_slugs,
deleted_at__isnull=True,
archived_at__isnull=True,
).select_related("workspace")
)
for workspace_slug, project_id in project_filters:
project = resolve_project(project_id, workspace_slug)
if project is not None:
projects.append(project)
unique_projects = {str(project.id): project for project in projects}
return JsonResponse(
{
"ok": True,
"projects": [serialize_project(project) for project in unique_projects.values()],
}
)
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentProjectContextEndpoint(View):
def get(self, request, project_id):
error_response = validate_internal_request(request)
if error_response is not None:
return error_response
project = resolve_project(project_id, request.GET.get("workspace_slug"))
if project is None:
return validation_error("project_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
if entitlement_error is not None:
return entitlement_error
states = State.objects.filter(project=project, deleted_at__isnull=True).order_by("sequence")
labels = Label.objects.filter(project=project, deleted_at__isnull=True).order_by("sort_order")
members = (
ProjectMember.objects.filter(project=project, is_active=True, deleted_at__isnull=True, member__is_bot=False)
.select_related("member")
.order_by("member__display_name")
)
return JsonResponse(
{
"ok": True,
"project": serialize_project(project),
"states": [serialize_state(state) for state in states],
"labels": [serialize_label(label) for label in labels],
"members": [serialize_member(member) for member in members],
}
)
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentIssueListEndpoint(View):
def get(self, request):
error_response = validate_internal_request(request)
if error_response is not None:
return error_response
project_id = request.GET.get("project_id")
if not project_id:
return validation_error("project_id_required")
project = resolve_project(project_id, request.GET.get("workspace_slug"))
if project is None:
return validation_error("project_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
if entitlement_error is not None:
return entitlement_error
queryset = (
Issue.issue_objects.filter(project=project)
.select_related("workspace", "project", "state")
.order_by("-updated_at")
)
query = request.GET.get("query")
if query:
queryset = queryset.filter(name__icontains=query)
return JsonResponse({"ok": True, "issues": [serialize_issue(issue) for issue in queryset[:100]]})
def post(self, request):
error_response = validate_internal_request(request)
if error_response is not None:
return error_response
payload = parse_json_body(request)
if payload is None:
return invalid_json_response()
project = resolve_project(payload.get("project_id"), payload.get("workspace_slug"))
if project is None:
return validation_error("project_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
if entitlement_error is not None:
return entitlement_error
title = payload.get("title")
if not isinstance(title, str) or not title.strip():
return validation_error("title_required")
priority = payload.get("priority") or "none"
if priority not in ALLOWED_PRIORITIES:
return validation_error("invalid_priority")
detail_layout = merge_structured_blocks({}, payload.get("structured_blocks"))
if detail_layout is None:
return validation_error("invalid_structured_blocks")
with transaction.atomic():
actor = ensure_agent_actor(request, project.workspace, project)
if actor is None:
return validation_error("missing_agent_headers")
issue = Issue(
project=project,
workspace=project.workspace,
name=title.strip(),
description_html=html_from_text(payload.get("description")),
priority=priority,
detail_layout=detail_layout,
external_source=AGENT_BOT_TYPE,
external_id=get_agent_header(request, "X-NODEDC-Agent-Id"),
)
issue.save(created_by_id=actor.id)
publish_issue_event_on_commit("issue.created", issue, actor_id=actor.id, changed_fields=payload.keys())
return JsonResponse({"ok": True, "issue": serialize_issue(issue)}, status=201)
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentIssueUpdateEndpoint(View):
def patch(self, request, issue_id):
error_response = validate_internal_request(request)
if error_response is not None:
return error_response
payload = parse_json_body(request)
if payload is None:
return invalid_json_response()
project, issue = resolve_issue(payload.get("project_id"), issue_id, payload.get("workspace_slug"))
if project is None:
return validation_error("project_not_found", status=404)
if issue is None:
return validation_error("issue_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
if entitlement_error is not None:
return entitlement_error
detail_layout = (
merge_structured_blocks(issue.detail_layout, payload.get("structured_blocks"))
if "structured_blocks" in payload
else issue.detail_layout
)
if detail_layout is None:
return validation_error("invalid_structured_blocks")
update_fields = []
if isinstance(payload.get("title"), str) and payload["title"].strip():
issue.name = payload["title"].strip()
update_fields.append("name")
if "description" in payload:
issue.description_html = html_from_text(payload.get("description"))
update_fields.append("description_html")
if payload.get("priority") in ALLOWED_PRIORITIES:
issue.priority = payload["priority"]
update_fields.append("priority")
elif "priority" in payload:
return validation_error("invalid_priority")
if "structured_blocks" in payload:
issue.detail_layout = detail_layout
update_fields.append("detail_layout")
if update_fields:
actor = ensure_agent_actor(request, project.workspace, project)
if actor is None:
return validation_error("missing_agent_headers")
issue.updated_by = actor
update_fields.extend(["updated_by", "updated_at"])
issue.save(update_fields=update_fields, disable_auto_set_user=True)
publish_issue_event_on_commit("issue.updated", issue, actor_id=actor.id, changed_fields=update_fields)
return JsonResponse({"ok": True, "issue": serialize_issue(issue)})
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentIssueMoveEndpoint(View):
def post(self, request, issue_id):
error_response = validate_internal_request(request)
if error_response is not None:
return error_response
payload = parse_json_body(request)
if payload is None:
return invalid_json_response()
project, issue = resolve_issue(payload.get("project_id"), issue_id, payload.get("workspace_slug"))
if project is None:
return validation_error("project_not_found", status=404)
if issue is None:
return validation_error("issue_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
if entitlement_error is not None:
return entitlement_error
state = State.objects.filter(project=project, id=payload.get("state_id"), deleted_at__isnull=True).first()
if state is None:
return validation_error("state_not_found", status=404)
actor = ensure_agent_actor(request, project.workspace, project)
if actor is None:
return validation_error("missing_agent_headers")
issue.state = state
issue.updated_by = actor
if state.group == "completed":
issue.completed_at = timezone.now()
else:
issue.completed_at = None
issue.save(update_fields=["state", "updated_by", "completed_at", "updated_at"], disable_auto_set_user=True)
publish_issue_event_on_commit("issue.updated", issue, actor_id=actor.id, changed_fields=["state"])
return JsonResponse({"ok": True, "issue": serialize_issue(issue)})
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentIssueCommentEndpoint(View):
def post(self, request, issue_id):
error_response = validate_internal_request(request)
if error_response is not None:
return error_response
payload = parse_json_body(request)
if payload is None:
return invalid_json_response()
project, issue = resolve_issue(payload.get("project_id"), issue_id, payload.get("workspace_slug"))
if project is None:
return validation_error("project_not_found", status=404)
if issue is None:
return validation_error("issue_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
if entitlement_error is not None:
return entitlement_error
body = payload.get("body")
if not isinstance(body, str) or not body.strip():
return validation_error("body_required")
actor = ensure_agent_actor(request, project.workspace, project)
if actor is None:
return validation_error("missing_agent_headers")
comment = IssueComment(
project=project,
workspace=project.workspace,
issue=issue,
actor=actor,
comment_html=html_from_text(body),
comment_json={},
)
comment.save(created_by_id=actor.id)
return JsonResponse({"ok": True, "comment": serialize_comment(comment)}, status=201)
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentIssueLabelsEndpoint(View):
def put(self, request, issue_id):
error_response = validate_internal_request(request)
if error_response is not None:
return error_response
payload = parse_json_body(request)
if payload is None:
return invalid_json_response()
project, issue = resolve_issue(payload.get("project_id"), issue_id, payload.get("workspace_slug"))
if project is None:
return validation_error("project_not_found", status=404)
if issue is None:
return validation_error("issue_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
if entitlement_error is not None:
return entitlement_error
label_ids = payload.get("label_ids")
if not isinstance(label_ids, list):
return validation_error("label_ids_required")
if not update_issue_labels(issue, label_ids):
return validation_error("label_not_found", status=404)
actor = ensure_agent_actor(request, project.workspace, project)
if actor is not None:
issue.updated_by = actor
issue.save(update_fields=["updated_by", "updated_at"], disable_auto_set_user=True)
publish_issue_event_on_commit("issue.updated", issue, actor_id=actor.id, changed_fields=["labels"])
return JsonResponse({"ok": True, "issue": serialize_issue(issue)})
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentIssueAssigneesEndpoint(View):
def put(self, request, issue_id):
error_response = validate_internal_request(request)
if error_response is not None:
return error_response
payload = parse_json_body(request)
if payload is None:
return invalid_json_response()
project, issue = resolve_issue(payload.get("project_id"), issue_id, payload.get("workspace_slug"))
if project is None:
return validation_error("project_not_found", status=404)
if issue is None:
return validation_error("issue_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
if entitlement_error is not None:
return entitlement_error
member_ids = payload.get("member_ids")
if not isinstance(member_ids, list):
return validation_error("member_ids_required")
if not update_issue_assignees(issue, member_ids):
return validation_error("member_not_found", status=404)
actor = ensure_agent_actor(request, project.workspace, project)
if actor is not None:
issue.updated_by = actor
issue.save(update_fields=["updated_by", "updated_at"], disable_auto_set_user=True)
publish_issue_event_on_commit("issue.updated", issue, actor_id=actor.id, changed_fields=["assignees"])
return JsonResponse({"ok": True, "issue": serialize_issue(issue)})

View File

@ -6,14 +6,17 @@ from secrets import compare_digest
from django.contrib.auth import logout
from django.conf import settings
from django.core.cache import cache
from django.db import transaction
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from plane.app.realtime.issue_events import publish_assignee_cleanup_issue_events_on_commit
from plane.app.realtime.nodedc_events import publish_nodedc_workspace_event_on_commit
from plane.authentication.utils.host import user_ip
from plane.db.models import ExternalIdentityLink, Session, User
from plane.db.models import ExternalIdentityLink, IssueAssignee, ProjectMember, Session, User, WorkspaceMember
OIDC_PROVIDER = "authentik"
@ -33,7 +36,6 @@ def get_nodedc_internal_token():
return (
os.environ.get("PLANE_NODEDC_ACCESS_TOKEN", "").strip()
or os.environ.get("NODEDC_INTERNAL_ACCESS_TOKEN", "").strip()
or os.environ.get("PLANE_OIDC_CLIENT_SECRET", "").strip()
)
@ -225,6 +227,79 @@ def invalidate_user_sessions(user, request):
return deleted_count
def revoke_user_external_identity_links(user):
deleted_count, _ = ExternalIdentityLink.objects.filter(
provider=OIDC_PROVIDER,
user=user,
status=ExternalIdentityLink.Status.ACTIVE,
).delete()
return deleted_count
def delete_queryset(queryset):
result = queryset.delete()
return result[0] if isinstance(result, tuple) else result
def revoke_user_tasker_access(user):
workspace_memberships = list(
WorkspaceMember.objects.filter(member=user)
.select_related("workspace")
.only("id", "workspace_id", "workspace__id", "workspace__slug")
)
project_memberships = list(
ProjectMember.objects.filter(member=user)
.select_related("workspace", "project")
.only("id", "workspace_id", "project_id", "workspace__id", "workspace__slug")
)
workspace_by_id = {}
project_ids_by_workspace_id = {}
for membership in workspace_memberships:
workspace_by_id[membership.workspace_id] = membership.workspace
project_ids_by_workspace_id.setdefault(membership.workspace_id, set())
for membership in project_memberships:
workspace_by_id[membership.workspace_id] = membership.workspace
project_ids_by_workspace_id.setdefault(membership.workspace_id, set()).add(membership.project_id)
publish_assignee_cleanup_issue_events_on_commit(project_id=membership.project_id, assignee_id=user.id)
deleted_issue_assignees = delete_queryset(IssueAssignee.objects.filter(assignee=user))
deleted_project_memberships = delete_queryset(ProjectMember.objects.filter(member=user))
deleted_workspace_memberships = delete_queryset(WorkspaceMember.objects.filter(member=user))
for workspace_id, workspace in workspace_by_id.items():
project_ids = [str(project_id) for project_id in project_ids_by_workspace_id.get(workspace_id, set())]
publish_nodedc_workspace_event_on_commit(
workspace,
"workspace_member.deleted",
payload={
"member_id": str(user.id),
"project_ids": project_ids,
"source": "launcher",
},
extra_user_ids=[user.id],
)
for membership in project_memberships:
publish_nodedc_workspace_event_on_commit(
membership.workspace,
"project_member.deleted",
payload={
"project_id": str(membership.project_id),
"member_id": str(user.id),
"source": "launcher",
},
extra_user_ids=[user.id],
)
return {
"workspaceMemberships": deleted_workspace_memberships,
"projectMemberships": deleted_project_memberships,
"issueAssignees": deleted_issue_assignees,
}
class NodeDCFrontChannelLogoutEndpoint(View):
def get(self, request):
logout_current_user(request)
@ -269,12 +344,27 @@ class NodeDCInternalSessionLogoutEndpoint(View):
return JsonResponse({"ok": True, "deletedSessions": 0, "user": None})
guard_keys = mark_logout_guard(user=user, payload=payload)
deleted_sessions = invalidate_user_sessions(user, request)
with transaction.atomic():
deleted_sessions = invalidate_user_sessions(user, request)
deleted_identity_links = 0
deleted_tasker_access = {
"workspaceMemberships": 0,
"projectMemberships": 0,
"issueAssignees": 0,
}
if payload.get("revokeIdentityLinks") is True or payload.get("revokeIdentityLink") is True:
deleted_identity_links = revoke_user_external_identity_links(user)
if payload.get("revokeTaskerAccess") is True or payload.get("revokeMemberships") is True:
deleted_tasker_access = revoke_user_tasker_access(user)
return JsonResponse(
{
"ok": True,
"deletedSessions": deleted_sessions,
"deletedIdentityLinks": deleted_identity_links,
"deletedTaskerAccess": deleted_tasker_access,
"guardKeys": len(guard_keys),
"user": {
"id": str(user.id),

View File

@ -9,7 +9,16 @@ from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from plane.authentication.nodedc_project_memberships import (
ensure_user_admin_project_memberships,
revoke_auto_project_admin_memberships,
)
from plane.authentication.views.nodedc_logout import is_internal_logout_request_authorized
from plane.app.realtime.issue_events import publish_assignee_cleanup_issue_events_on_commit
from plane.app.realtime.nodedc_events import (
publish_nodedc_user_profile_event_on_commit,
publish_nodedc_workspace_event_on_commit,
)
from plane.utils.host import base_host
from plane.db.models import (
ExternalIdentityLink,
@ -136,6 +145,18 @@ def first_payload_string(payload, *keys):
return ""
def split_display_name(value):
if not isinstance(value, str):
return "", ""
parts = value.strip().split(None, 1)
if not parts:
return "", ""
if len(parts) == 1:
return parts[0], ""
return parts[0], parts[1]
def normalize_nodedc_avatar_url(value):
if not isinstance(value, str):
return ""
@ -175,12 +196,43 @@ def resolve_nodedc_launcher_origin():
return ""
def sync_user_avatar_from_payload(user, payload):
def sync_user_profile_from_payload(user, payload):
updated_fields = []
display_name = first_payload_string(payload, "displayName", "display_name", "name")
first_name = first_payload_string(payload, "firstName", "first_name")
last_name = first_payload_string(payload, "lastName", "last_name")
has_avatar = any(key in payload for key in ["avatarUrl", "avatar_url", "avatar"])
avatar_url = normalize_nodedc_avatar_url(first_payload_string(payload, "avatarUrl", "avatar_url", "avatar"))
if avatar_url and user.avatar != avatar_url:
if display_name and not first_name and not last_name:
first_name, last_name = split_display_name(display_name)
if display_name and user.display_name != display_name:
user.display_name = display_name
updated_fields.append("display_name")
if first_name and user.first_name != first_name:
user.first_name = first_name
updated_fields.append("first_name")
if (last_name or first_name) and user.last_name != last_name:
user.last_name = last_name
updated_fields.append("last_name")
if has_avatar and (user.avatar != avatar_url or user.avatar_asset_id is not None):
user.avatar = avatar_url
user.save(update_fields=["avatar", "updated_at"])
user.avatar_asset_id = None
updated_fields.extend(["avatar", "avatar_asset"])
if updated_fields:
updated_fields.append("updated_at")
user.save(update_fields=updated_fields)
return updated_fields
def sync_user_avatar_from_payload(user, payload):
sync_user_profile_from_payload(user, payload)
def serialize_project(project):
@ -284,24 +336,7 @@ def serialize_project_membership(project_member, created):
def restore_admin_project_memberships(workspace, user):
restored = 0
for project_member in ProjectMember.objects.filter(
project__workspace=workspace,
member=user,
deleted_at__isnull=True,
):
update_fields = []
if project_member.role != ADMIN_ROLE:
project_member.role = ADMIN_ROLE
update_fields.append("role")
if not project_member.is_active:
project_member.is_active = True
update_fields.append("is_active")
if update_fields:
update_fields.append("updated_at")
project_member.save(update_fields=update_fields)
restored += 1
return restored
return ensure_user_admin_project_memberships(workspace, user)
@method_decorator(csrf_exempt, name="dispatch")
@ -328,6 +363,40 @@ class NodeDCInternalWorkspaceListEndpoint(View):
)
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCInternalUserProfileSyncEndpoint(View):
def post(self, request):
if not is_internal_logout_request_authorized(request):
return internal_unauthorized_response()
payload = parse_json_body(request)
if payload is None:
return JsonResponse({"ok": False, "error": "invalid_json"}, status=400)
user = resolve_user(payload)
if user is None:
return JsonResponse({"ok": False, "error": "user_not_found"}, status=404)
with transaction.atomic():
updated_fields = sync_user_profile_from_payload(user, payload)
if updated_fields:
publish_nodedc_user_profile_event_on_commit(user, changed_fields=updated_fields)
return JsonResponse(
{
"ok": True,
"updatedFields": updated_fields,
"user": {
"id": str(user.id),
"email": user.email,
"displayName": user.display_name,
"avatar": user.avatar or None,
},
}
)
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCInternalWorkspaceMembershipEnsureEndpoint(View):
def post(self, request):
@ -359,6 +428,7 @@ class NodeDCInternalWorkspaceMembershipEnsureEndpoint(View):
deleted_at__isnull=True,
).first()
created = membership is None
previous_role = membership.role if membership is not None else None
if membership is None:
membership = WorkspaceMember.objects.create(
@ -381,12 +451,25 @@ class NodeDCInternalWorkspaceMembershipEnsureEndpoint(View):
if role == ADMIN_ROLE:
restore_admin_project_memberships(workspace, user)
elif previous_role == ADMIN_ROLE:
revoke_auto_project_admin_memberships(workspace, user)
if set_last_workspace:
profile, _ = Profile.objects.get_or_create(user=user)
profile.last_workspace_id = workspace.id
profile.save(update_fields=["last_workspace_id", "updated_at"])
publish_nodedc_workspace_event_on_commit(
workspace,
"workspace_member.created" if created else "workspace_member.updated",
payload={
"member_id": str(user.id),
"role": membership.role,
"source": "launcher",
},
extra_user_ids=[user.id],
)
return JsonResponse({"ok": True, "membership": serialize_membership(membership, created)})
@ -428,7 +511,16 @@ class NodeDCInternalWorkspaceMembershipRemoveEndpoint(View):
}
)
project_ids = list(
ProjectMember.objects.filter(
project__workspace=workspace,
member=user,
is_active=True,
).values_list("project_id", flat=True)
)
with transaction.atomic():
publish_assignee_cleanup_issue_events_on_commit(workspace_id=workspace.id, assignee_id=user.id)
ProjectMember.objects.filter(
project__workspace=workspace,
member=user,
@ -441,6 +533,17 @@ class NodeDCInternalWorkspaceMembershipRemoveEndpoint(View):
membership.is_active = False
membership.save(update_fields=["is_active", "updated_at"])
publish_nodedc_workspace_event_on_commit(
workspace,
"workspace_member.deleted",
payload={
"member_id": str(user.id),
"project_ids": [str(project_id) for project_id in project_ids],
"source": "launcher",
},
extra_user_ids=[user.id],
)
return JsonResponse(
{
"ok": True,
@ -483,6 +586,19 @@ class NodeDCInternalWorkspaceInviteApproveEndpoint(View):
]
)
invited_user = User.objects.filter(email__iexact=invitation.email, is_bot=False).first()
publish_nodedc_workspace_event_on_commit(
invitation.workspace,
"workspace_invite.approved",
payload={
"invite_id": str(invitation.id),
"email": invitation.email,
"status": invitation.nodedc_approval_status,
"source": "launcher",
},
extra_user_ids=[invitation.created_by_id, getattr(invited_user, "id", None)],
)
return JsonResponse({"ok": True, "invite": serialize_workspace_invite(request, invitation)})
@ -514,6 +630,19 @@ class NodeDCInternalWorkspaceInviteRejectEndpoint(View):
]
)
invited_user = User.objects.filter(email__iexact=invitation.email, is_bot=False).first()
publish_nodedc_workspace_event_on_commit(
invitation.workspace,
"workspace_invite.rejected",
payload={
"invite_id": str(invitation.id),
"email": invitation.email,
"status": invitation.nodedc_approval_status,
"source": "launcher",
},
extra_user_ids=[invitation.created_by_id, getattr(invited_user, "id", None)],
)
return JsonResponse({"ok": True, "invite": serialize_workspace_invite(request, invitation)})
@ -601,6 +730,28 @@ class NodeDCInternalProjectMembershipEnsureEndpoint(View):
profile.last_workspace_id = workspace.id
profile.save(update_fields=["last_workspace_id", "updated_at"])
publish_nodedc_workspace_event_on_commit(
workspace,
"workspace_member.updated",
payload={
"member_id": str(user.id),
"role": workspace_membership.role,
"source": "launcher",
},
extra_user_ids=[user.id],
)
publish_nodedc_workspace_event_on_commit(
workspace,
"project_member.created" if created else "project_member.updated",
payload={
"project_id": str(project.id),
"member_id": str(user.id),
"role": project_member.role,
"source": "launcher",
},
extra_user_ids=[user.id],
)
return JsonResponse({"ok": True, "membership": serialize_project_membership(project_member, created)})
@ -648,10 +799,22 @@ class NodeDCInternalProjectMembershipRemoveEndpoint(View):
}
)
publish_assignee_cleanup_issue_events_on_commit(project_id=project.id, assignee_id=user.id)
project_member.is_active = False
project_member.save(update_fields=["is_active", "updated_at"])
IssueAssignee.objects.filter(project=project, assignee=user).delete()
publish_nodedc_workspace_event_on_commit(
workspace,
"project_member.deleted",
payload={
"project_id": str(project.id),
"member_id": str(user.id),
"source": "launcher",
},
extra_user_ids=[user.id],
)
return JsonResponse(
{
"ok": True,

View File

@ -15,9 +15,20 @@ from plane.authentication.views.nodedc_logout import (
NodeDCFrontChannelLogoutEndpoint,
NodeDCInternalSessionLogoutEndpoint,
)
from plane.authentication.views.nodedc_agent_adapter import (
NodeDCAgentIssueAssigneesEndpoint,
NodeDCAgentIssueCommentEndpoint,
NodeDCAgentIssueLabelsEndpoint,
NodeDCAgentIssueListEndpoint,
NodeDCAgentIssueMoveEndpoint,
NodeDCAgentIssueUpdateEndpoint,
NodeDCAgentProjectContextEndpoint,
NodeDCAgentProjectResolveEndpoint,
)
from plane.authentication.views.nodedc_workspace_adapter import (
NodeDCInternalProjectMembershipEnsureEndpoint,
NodeDCInternalProjectMembershipRemoveEndpoint,
NodeDCInternalUserProfileSyncEndpoint,
NodeDCInternalWorkspaceInviteApproveEndpoint,
NodeDCInternalWorkspaceInviteRejectEndpoint,
NodeDCInternalWorkspaceListEndpoint,
@ -38,6 +49,11 @@ urlpatterns = [
NodeDCInternalWorkspaceListEndpoint.as_view(),
name="nodedc-internal-workspaces",
),
path(
"api/internal/nodedc/users/profile-sync/",
NodeDCInternalUserProfileSyncEndpoint.as_view(),
name="nodedc-internal-user-profile-sync",
),
path(
"api/internal/nodedc/workspace-memberships/ensure/",
NodeDCInternalWorkspaceMembershipEnsureEndpoint.as_view(),
@ -68,6 +84,46 @@ urlpatterns = [
NodeDCInternalProjectMembershipRemoveEndpoint.as_view(),
name="nodedc-internal-project-membership-remove",
),
path(
"api/internal/nodedc/agent/projects/resolve",
NodeDCAgentProjectResolveEndpoint.as_view(),
name="nodedc-agent-project-resolve",
),
path(
"api/internal/nodedc/agent/projects/<uuid:project_id>/context",
NodeDCAgentProjectContextEndpoint.as_view(),
name="nodedc-agent-project-context",
),
path(
"api/internal/nodedc/agent/issues",
NodeDCAgentIssueListEndpoint.as_view(),
name="nodedc-agent-issue-list",
),
path(
"api/internal/nodedc/agent/issues/<uuid:issue_id>",
NodeDCAgentIssueUpdateEndpoint.as_view(),
name="nodedc-agent-issue-update",
),
path(
"api/internal/nodedc/agent/issues/<uuid:issue_id>/move",
NodeDCAgentIssueMoveEndpoint.as_view(),
name="nodedc-agent-issue-move",
),
path(
"api/internal/nodedc/agent/issues/<uuid:issue_id>/comments",
NodeDCAgentIssueCommentEndpoint.as_view(),
name="nodedc-agent-issue-comment",
),
path(
"api/internal/nodedc/agent/issues/<uuid:issue_id>/labels",
NodeDCAgentIssueLabelsEndpoint.as_view(),
name="nodedc-agent-issue-labels",
),
path(
"api/internal/nodedc/agent/issues/<uuid:issue_id>/assignees",
NodeDCAgentIssueAssigneesEndpoint.as_view(),
name="nodedc-agent-issue-assignees",
),
path("api/", include("plane.app.urls")),
path("api/public/", include("plane.space.urls")),
path("api/instances/", include("plane.license.urls")),

View File

@ -26,7 +26,7 @@ class ProjectBasePermission(BasePermission):
return WorkspaceMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
role=ROLE.ADMIN.value,
is_active=True,
).exists()
@ -68,7 +68,7 @@ class ProjectMemberPermission(BasePermission):
return WorkspaceMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
role=ROLE.ADMIN.value,
is_active=True,
).exists()

View File

@ -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
# *****************************************************************************
@ -40,8 +40,8 @@ COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
# Fetch dependencies to cache store, then install offline with dev deps
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store
RUN pnpm fetch --store-dir=/pnpm/store
RUN CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store
ENV TURBO_TELEMETRY_DISABLED=1

View File

@ -8,6 +8,7 @@ import { CollaborationController } from "./collaboration.controller";
import { DocumentController } from "./document.controller";
import { HealthController } from "./health.controller";
import { IssueStreamController } from "./issue-stream.controller";
import { NodeDCStreamController } from "./nodedc-stream.controller";
import { PdfExportController } from "./pdf-export.controller";
export const CONTROLLERS = [
@ -15,5 +16,6 @@ export const CONTROLLERS = [
DocumentController,
HealthController,
IssueStreamController,
NodeDCStreamController,
PdfExportController,
];

View File

@ -0,0 +1,126 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Request } from "express";
import type Redis from "ioredis";
import type { WebSocket as WSSocket } from "ws";
// plane imports
import { Controller, WebSocket as WSDecorator } from "@plane/decorators";
import { logger } from "@plane/logger";
// redis
import { redisManager } from "@/redis";
// services
import { UserService } from "@/services/user.service";
const NODEDC_EVENT_CHANNEL_PREFIX = "plane:nodedc-events:user";
const HEARTBEAT_INTERVAL_MS = 25_000;
const sendJson = (ws: WSSocket, payload: Record<string, unknown>) => {
if (ws.readyState !== 1) return;
ws.send(JSON.stringify(payload));
};
@Controller("/nodedc")
export class NodeDCStreamController {
[key: string]: unknown;
@WSDecorator("/stream")
handleConnection(ws: WSSocket, req: Request) {
void this.handleNodeDCStream(ws, req);
}
private async handleNodeDCStream(ws: WSSocket, req: Request) {
const cookie = req.headers.cookie?.toString();
if (!cookie) {
ws.close(1008, "Missing NODE.DC stream credentials");
return;
}
let subscriber: Redis | undefined;
let heartbeat: NodeJS.Timeout | undefined;
let isCleanedUp = false;
const cleanup = async () => {
if (isCleanedUp) return;
isCleanedUp = true;
if (heartbeat) clearInterval(heartbeat);
if (!subscriber) return;
try {
await subscriber.unsubscribe();
subscriber.disconnect();
} catch (error) {
logger.error("NODEDC_STREAM_CONTROLLER: Redis cleanup failed:", error);
}
};
try {
const userService = new UserService();
const user = await userService.currentUser(cookie);
const redisClient = redisManager.getClient();
if (!redisClient) {
ws.close(1011, "NODE.DC stream unavailable");
return;
}
const channel = `${NODEDC_EVENT_CHANNEL_PREFIX}:${user.id}`;
subscriber = redisClient.duplicate({ lazyConnect: true });
await subscriber.connect();
await subscriber.subscribe(channel);
subscriber.on("message", (_channel, message) => {
try {
const event = JSON.parse(message) as Record<string, unknown>;
sendJson(ws, event);
} catch (error) {
logger.error("NODEDC_STREAM_CONTROLLER: Failed to forward event:", error);
}
});
subscriber.on("error", (error) => {
logger.error("NODEDC_STREAM_CONTROLLER: Redis subscriber error:", error);
ws.close(1011, "NODE.DC stream subscriber failed");
});
heartbeat = setInterval(() => {
sendJson(ws, { type: "nodedc.stream.ping", server_ts: new Date().toISOString() });
}, HEARTBEAT_INTERVAL_MS);
sendJson(ws, {
type: "nodedc.stream.ready",
user_id: user.id,
server_ts: new Date().toISOString(),
});
} catch (error) {
logger.error("NODEDC_STREAM_CONTROLLER: WebSocket authentication failed:", error);
ws.close(1008, "NODE.DC stream authentication failed");
await cleanup();
return;
}
ws.on("message", (message) => {
try {
const payload = JSON.parse(message.toString()) as { type?: string };
if (payload.type === "nodedc.stream.pong") return;
} catch {
// Client messages are optional for this stream.
}
});
ws.on("close", () => {
void cleanup();
});
ws.on("error", (error: Error) => {
logger.error("NODEDC_STREAM_CONTROLLER: WebSocket connection error:", error);
ws.close(1011, "NODE.DC stream connection failed");
void cleanup();
});
}
}

View File

@ -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
@ -68,8 +68,8 @@ COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
# Fetch dependencies to cache store, then install offline with dev deps
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store --prod=false
RUN pnpm fetch --store-dir=/pnpm/store
RUN CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store --prod=false
# Build only the space package
RUN pnpm turbo run build --filter=space

View File

@ -67,6 +67,15 @@ ENV VITE_SPACE_BASE_PATH=$VITE_SPACE_BASE_PATH
ARG VITE_WEB_BASE_URL=""
ENV VITE_WEB_BASE_URL=$VITE_WEB_BASE_URL
ARG VITE_NODEDC_OIDC_LOGIN_ENABLED=""
ENV VITE_NODEDC_OIDC_LOGIN_ENABLED=$VITE_NODEDC_OIDC_LOGIN_ENABLED
ARG VITE_NODEDC_OIDC_LOGIN_URL=""
ENV VITE_NODEDC_OIDC_LOGIN_URL=$VITE_NODEDC_OIDC_LOGIN_URL
ARG VITE_NODEDC_LAUNCHER_URL=""
ENV VITE_NODEDC_LAUNCHER_URL=$VITE_NODEDC_LAUNCHER_URL
ENV NEXT_TELEMETRY_DISABLED=1
ENV TURBO_TELEMETRY_DISABLED=1
@ -79,6 +88,7 @@ FROM nginx:1.27-alpine AS production
COPY apps/web/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=installer /app/apps/web/build/client /usr/share/nginx/html
RUN chmod -R a+rX /usr/share/nginx/html
EXPOSE 3000

View File

@ -0,0 +1,97 @@
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:$PNPM_HOME/bin:$PATH"
RUN corepack enable
# *****************************************************************************
# STAGE 1: Build the project
# *****************************************************************************
FROM base AS builder
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
ARG TURBO_VERSION=2.9.4
RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION}
COPY . .
RUN turbo prune --scope=web --docker
# *****************************************************************************
# STAGE 2: Install dependencies & build the project
# *****************************************************************************
# Add lockfile and package.json's of isolated subworkspace
FROM base AS installer
RUN apk add --no-cache libc6-compat
WORKDIR /app
# First install the dependencies (as they change less often)
COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN corepack enable pnpm
# Copy full directory structure before fetch to ensure all package.json files are available
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
# Fetch dependencies to cache store, then install offline with dev deps
RUN pnpm fetch --store-dir=/pnpm/store
RUN CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store
ARG VITE_API_BASE_URL=""
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ARG VITE_ADMIN_BASE_URL=""
ENV VITE_ADMIN_BASE_URL=$VITE_ADMIN_BASE_URL
ARG VITE_ADMIN_BASE_PATH="/nodedcsudo"
ENV VITE_ADMIN_BASE_PATH=$VITE_ADMIN_BASE_PATH
ARG VITE_LIVE_BASE_URL=""
ENV VITE_LIVE_BASE_URL=$VITE_LIVE_BASE_URL
ARG VITE_LIVE_BASE_PATH="/live"
ENV VITE_LIVE_BASE_PATH=$VITE_LIVE_BASE_PATH
ARG VITE_SPACE_BASE_URL=""
ENV VITE_SPACE_BASE_URL=$VITE_SPACE_BASE_URL
ARG VITE_SPACE_BASE_PATH="/spaces"
ENV VITE_SPACE_BASE_PATH=$VITE_SPACE_BASE_PATH
ARG VITE_WEB_BASE_URL=""
ENV VITE_WEB_BASE_URL=$VITE_WEB_BASE_URL
ARG VITE_NODEDC_OIDC_LOGIN_ENABLED=""
ENV VITE_NODEDC_OIDC_LOGIN_ENABLED=$VITE_NODEDC_OIDC_LOGIN_ENABLED
ARG VITE_NODEDC_OIDC_LOGIN_URL=""
ENV VITE_NODEDC_OIDC_LOGIN_URL=$VITE_NODEDC_OIDC_LOGIN_URL
ARG VITE_NODEDC_LAUNCHER_URL=""
ENV VITE_NODEDC_LAUNCHER_URL=$VITE_NODEDC_LAUNCHER_URL
ENV NEXT_TELEMETRY_DISABLED=1
ENV TURBO_TELEMETRY_DISABLED=1
RUN pnpm turbo run build --filter=web
# *****************************************************************************
# STAGE 3: Serve with nginx
# *****************************************************************************
FROM nginx:1.27-alpine AS production
COPY apps/web/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=installer /app/apps/web/build/client /usr/share/nginx/html
RUN chmod -R a+rX /usr/share/nginx/html
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -fsS http://127.0.0.1:3000/ >/dev/null || exit 1
CMD ["nginx", "-g", "daemon off;"]

View File

@ -45,7 +45,7 @@ function AnalyticsPage({ params }: Route.ComponentProps) {
// permissions
const canPerformEmptyStateActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
[EUserPermissions.ADMIN],
EUserPermissionsLevel.WORKSPACE
);

View File

@ -78,7 +78,7 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
// auth
const isAuthorizedUser = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
[EUserPermissions.ADMIN],
EUserPermissionsLevel.WORKSPACE
);

View File

@ -8,11 +8,10 @@
import { useTranslation } from "@plane/i18n";
import { Shapes } from "lucide-react";
import { useEffect, useState } from "react";
import { cn } from "@plane/utils";
// components
import { TopNavPowerK } from "@/components/navigation";
import { buildNodeDCBrandConfigUrl, buildNodeDCLauncherUrl } from "@/helpers/nodedc-auth";
import { useNodeDCBrandLinkUrl } from "@/hooks/use-nodedc-brand-link-url";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
import { useHome } from "@/hooks/store/use-home";
@ -32,26 +31,7 @@ export const ExpandedProjectShellToolbarLayout = ({
}: TProjectShellToolbarLayoutProps) => {
const { t } = useTranslation();
const { toggleWidgetSettings } = useHome();
const [logoLinkUrl, setLogoLinkUrl] = useState(buildNodeDCLauncherUrl);
useEffect(() => {
let isMounted = true;
fetch(buildNodeDCBrandConfigUrl(), { cache: "no-store" })
.then((response) => (response.ok ? response.json() : null))
.then((payload: { logoLinkUrl?: string } | null) => {
if (isMounted && payload?.logoLinkUrl) {
setLogoLinkUrl(payload.logoLinkUrl);
}
})
.catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : "Не удалось загрузить brand config NODE.DC");
});
return () => {
isMounted = false;
};
}, []);
const logoLinkUrl = useNodeDCBrandLinkUrl();
return (
<div className={cn("nodedc-expanded-toolbar-shell", { "nodedc-home-top-toolbar": isWorkspaceHome })}>

View File

@ -9,6 +9,7 @@
import { Menu } from "@headlessui/react";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { PlusIcon, ProjectIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
@ -16,6 +17,7 @@ import { cn, copyUrlToClipboard } from "@plane/utils";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
// components
import { SidebarProjectsListItem } from "@/components/workspace/sidebar/projects-list-item";
@ -29,6 +31,8 @@ export const ProjectsToolbarMenu = observer(function ProjectsToolbarMenu({
const { workspaceSlug } = useParams();
const { joinedProjectIds } = useProject();
const { toggleCreateProjectModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
const canCreateProject = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const handleCopyText = (projectId: string) =>
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => {
@ -81,20 +85,22 @@ export const ProjectsToolbarMenu = observer(function ProjectsToolbarMenu({
/>
))}
</div>
<div className="mt-2 border-t border-white/8 px-1 pt-2">
<Menu.Item>
<button
type="button"
className="flex w-full items-center gap-3 rounded-md px-2 py-2 text-left text-13 font-medium text-secondary transition-colors hover:bg-layer-transparent-hover hover:text-primary"
onClick={() => toggleCreateProjectModal(true)}
>
<span className="grid size-8 flex-shrink-0 place-items-center">
<PlusIcon className="size-4" />
</span>
<span>{t("create_project")}</span>
</button>
</Menu.Item>
</div>
{canCreateProject && (
<div className="mt-2 border-t border-white/8 px-1 pt-2">
<Menu.Item>
<button
type="button"
className="flex w-full items-center gap-3 rounded-md px-2 py-2 text-left text-13 font-medium text-secondary transition-colors hover:bg-layer-transparent-hover hover:text-primary"
onClick={() => toggleCreateProjectModal(true)}
>
<span className="grid size-8 flex-shrink-0 place-items-center">
<PlusIcon className="size-4" />
</span>
<span>{t("create_project")}</span>
</button>
</Menu.Item>
</div>
)}
</div>
</Menu.Items>
</Menu>

View File

@ -0,0 +1,12 @@
import { redirect } from "react-router";
// local imports
import type { Route } from "./+types/page";
export function clientLoader({ params }: Route.ClientLoaderArgs) {
const { workspaceSlug } = params;
throw redirect(`/${workspaceSlug}/?workspaceSettings=codex-agent-api`);
}
export default function CodexAgentApiSettingsPage() {
return null;
}

View File

@ -8,7 +8,7 @@ import { observer } from "mobx-react";
import Link from "next/link";
import { useTheme } from "next-themes";
// plane imports
import { PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
import { Button, getButtonStyling } from "@plane/propel/button";
import { cn } from "@plane/utils";
// assets
@ -16,11 +16,14 @@ import ProjectDarkEmptyState from "@/app/assets/empty-state/project-settings/no-
import ProjectLightEmptyState from "@/app/assets/empty-state/project-settings/no-projects-light.png?url";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useUserPermissions } from "@/hooks/store/user";
function ProjectSettingsPage() {
// store hooks
const { resolvedTheme } = useTheme();
const { toggleCreateProjectModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
const canCreateProject = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
// derived values
const resolvedPath = resolvedTheme === "dark" ? ProjectDarkEmptyState : ProjectLightEmptyState;
return (
@ -35,12 +38,14 @@ function ProjectSettingsPage() {
<Link href="https://plane.so/" target="_blank" className={cn(getButtonStyling("secondary", "base"))}>
Learn more about projects
</Link>
<Button
onClick={() => toggleCreateProjectModal(true)}
data-ph-element={PROJECT_TRACKER_ELEMENTS.EMPTY_STATE_CREATE_PROJECT_BUTTON}
>
Start your first project
</Button>
{canCreateProject && (
<Button
onClick={() => toggleCreateProjectModal(true)}
data-ph-element={PROJECT_TRACKER_ELEMENTS.EMPTY_STATE_CREATE_PROJECT_BUTTON}
>
Start your first project
</Button>
)}
</div>
</div>
);

View File

@ -11,4 +11,4 @@ export default function InvitationsLayout() {
return <Outlet />;
}
export const meta: Route.MetaFunction = () => [{ title: "Invitations" }];
export const meta: Route.MetaFunction = () => [{ title: "Приглашения - NODE.DC Tasker" }];

View File

@ -9,23 +9,20 @@ import { observer } from "mobx-react";
import Link from "next/link";
import useSWR, { mutate } from "swr";
import { CheckCircle2 } from "lucide-react";
import { ArrowRight, Bell, CheckCircle2, MailCheck, Sparkles } from "lucide-react";
// plane imports
import { ROLE_DETAILS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// types
import { Button } from "@plane/propel/button";
import { PlaneLogo } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IWorkspaceMemberInvitation } from "@plane/types";
import { truncateText } from "@plane/utils";
// assets
import emptyInvitation from "@/app/assets/empty-state/invitation.svg?url";
// components
import { EmptyState } from "@/components/common/empty-state";
import { NodeDCStandaloneShell } from "@/components/nodedc/standalone-shell";
import { WorkspaceLogo } from "@/components/workspace/logo";
import { USER_WORKSPACES_LIST } from "@/constants/fetch-keys";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUser, useUserProfile } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
@ -47,14 +44,29 @@ function UserInvitationsPage() {
const { data: currentUser } = useUser();
const { updateUserProfile } = useUserProfile();
const { fetchWorkspaces } = useWorkspace();
const { fetchWorkspaces, workspaces } = useWorkspace();
const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications();
const { data: invitations } = useSWR("USER_WORKSPACE_INVITATIONS", () => workspaceService.userWorkspaceInvitations());
useSWR(USER_WORKSPACES_LIST, () => fetchWorkspaces());
const fallbackWorkspaceSlug = Object.values(workspaces ?? {})?.[0]?.slug;
useSWR(
fallbackWorkspaceSlug ? ["STANDALONE_UNREAD_NOTIFICATION_COUNT", fallbackWorkspaceSlug] : null,
fallbackWorkspaceSlug ? () => getUnreadNotificationsCount(fallbackWorkspaceSlug) : null
);
const notificationsCount =
unreadNotificationsCount.mention_unread_notifications_count > 0
? unreadNotificationsCount.mention_unread_notifications_count
: unreadNotificationsCount.total_unread_notifications_count || invitations?.length || 0;
const redirectWorkspaceSlug =
// currentUserSettings?.workspace?.last_workspace_slug ||
// currentUserSettings?.workspace?.fallback_workspace_slug ||
"";
const hasInvitations = !!invitations && invitations.length > 0;
const handleInvitation = (workspace_invitation: IWorkspaceMemberInvitation, action: "accepted" | "withdraw") => {
if (action === "accepted") {
@ -64,7 +76,7 @@ function UserInvitationsPage() {
}
};
const submitInvitations = () => {
const submitInvitations = async () => {
if (invitationsRespond.length === 0) {
setToast({
type: TOAST_TYPE.ERROR,
@ -76,88 +88,102 @@ function UserInvitationsPage() {
setIsJoiningWorkspaces(true);
workspaceService
.joinWorkspaces({ invitations: invitationsRespond })
.then(() => {
mutate(USER_WORKSPACES_LIST);
const firstInviteId = invitationsRespond[0];
const redirectWorkspace = invitations?.find((i) => i.id === firstInviteId)?.workspace;
updateUserProfile({ last_workspace_id: redirectWorkspace?.id })
.then(() => {
setIsJoiningWorkspaces(false);
fetchWorkspaces().then(() => {
router.push(`/${redirectWorkspace?.slug}`);
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: t("something_went_wrong_please_try_again"),
});
setIsJoiningWorkspaces(false);
});
})
.catch((_err) => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: t("something_went_wrong_please_try_again"),
});
setIsJoiningWorkspaces(false);
try {
await workspaceService.joinWorkspaces({ invitations: invitationsRespond });
void mutate(USER_WORKSPACES_LIST);
const firstInviteId = invitationsRespond[0];
const redirectWorkspace = invitations?.find((i) => i.id === firstInviteId)?.workspace;
await updateUserProfile({ last_workspace_id: redirectWorkspace?.id });
await fetchWorkspaces();
router.push(redirectWorkspace?.slug ? `/${redirectWorkspace.slug}` : "/");
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: t("something_went_wrong_please_try_again"),
});
} finally {
setIsJoiningWorkspaces(false);
}
};
const openNotifications = () => {
if (fallbackWorkspaceSlug) {
router.push(`/${fallbackWorkspaceSlug}?workspaceNotifications=open`);
return;
}
router.push("/invitations");
};
return (
<AuthenticationWrapper>
<div className="flex min-h-screen flex-col bg-surface-1 sm:flex-row">
<div className="flex items-center justify-between border-b border-subtle px-5 py-4 sm:w-72 sm:flex-col sm:items-start sm:justify-between sm:border-b-0 sm:px-8 sm:py-8">
<Link href="/" className="inline-flex items-center">
<PlaneLogo className="h-9 w-auto text-primary" />
</Link>
<div className="text-13 text-primary sm:pt-6">{currentUser?.email}</div>
</div>
<NodeDCStandaloneShell
notificationsCount={notificationsCount}
onOpenNotifications={openNotifications}
showUserControls={!!currentUser}
>
{invitations ? (
invitations.length > 0 ? (
<div className="flex flex-1 items-center justify-center px-6 py-8 sm:px-12 sm:py-12">
<div className="w-full max-w-3xl space-y-10">
<div className="space-y-3">
<h5 className="text-16">{t("we_see_that_someone_has_invited_you_to_join_a_workspace")}</h5>
<h4 className="text-20 font-semibold">{t("join_a_workspace")}</h4>
hasInvitations ? (
<div className="flex flex-1 items-center justify-center py-10">
<div className="w-full max-w-4xl space-y-7">
<div className="nodedc-glass-surface rounded-[2rem] border-0 px-6 py-6 sm:px-8">
<div className="flex flex-wrap items-start justify-between gap-5">
<div className="min-w-0 space-y-3">
<div className="inline-flex items-center gap-2 rounded-full bg-white/6 px-3 py-1.5 text-11 font-semibold tracking-[0.16em] text-[rgb(var(--nodedc-accent-rgb))] uppercase">
<Bell className="size-3.5" />
Новые приглашения
</div>
<div>
<h1 className="text-28 font-semibold tracking-[-0.03em] text-primary">Принять доступ</h1>
<p className="mt-2 max-w-2xl text-13 leading-6 text-secondary">
Выберите рабочие пространства, к которым хотите присоединиться. После принятия Tasker
откроет первый выбранный workspace.
</p>
</div>
</div>
<div className="flex size-14 items-center justify-center rounded-[1.15rem] bg-[rgb(var(--nodedc-card-active-rgb))] text-[rgb(var(--nodedc-on-card-active-rgb))]">
<MailCheck className="size-7" />
</div>
</div>
</div>
<div className="max-h-[45vh] space-y-4 overflow-y-auto md:max-h-[52vh] md:max-w-2xl">
<div className="max-h-[48vh] space-y-3 overflow-y-auto pr-1 md:max-h-[54vh]">
{invitations.map((invitation) => {
const isSelected = invitationsRespond.includes(invitation.id);
return (
<div
<button
type="button"
key={invitation.id}
className={`flex cursor-pointer items-center gap-2 rounded-sm border px-3.5 py-5 ${
isSelected ? "border-accent-strong" : "border-subtle hover:bg-layer-1"
className={`group flex w-full cursor-pointer items-center gap-4 rounded-[1.6rem] px-4 py-4 text-left transition ${
isSelected
? "bg-[rgb(var(--nodedc-card-active-rgb))] text-[rgb(var(--nodedc-on-card-active-rgb))]"
: "nodedc-settings-card hover:bg-white/[0.055]"
}`}
onClick={() => handleInvitation(invitation, isSelected ? "withdraw" : "accepted")}
>
<div className="flex-shrink-0">
<div className="flex-shrink-0 rounded-full bg-black/10 p-1">
<WorkspaceLogo
logo={invitation.workspace.logo_url}
name={invitation.workspace.name}
classNames="size-9 flex-shrink-0"
classNames="size-11 flex-shrink-0"
/>
</div>
<div className="min-w-0 flex-1">
<div className="text-13 font-medium">{truncateText(invitation.workspace.name, 30)}</div>
<p className="text-11 text-secondary">
<div className="text-15 font-semibold">{truncateText(invitation.workspace.name, 42)}</div>
<p className={`mt-1 text-12 ${isSelected ? "opacity-70" : "text-secondary"}`}>
{t(ROLE_DETAILS[invitation.role as keyof typeof ROLE_DETAILS]?.i18n_title || "")}
</p>
</div>
<span className={`flex-shrink-0 ${isSelected ? "text-accent-primary" : "text-secondary"}`}>
<span className={`flex-shrink-0 ${isSelected ? "opacity-100" : "text-tertiary"}`}>
<CheckCircle2 className="h-5 w-5" />
</span>
</div>
</button>
);
})}
</div>
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-3">
<Button
variant="primary"
type="submit"
@ -165,13 +191,14 @@ function UserInvitationsPage() {
onClick={submitInvitations}
disabled={isJoiningWorkspaces || invitationsRespond.length === 0}
loading={isJoiningWorkspaces}
className="nodedc-empty-state-primary min-w-[12rem]"
>
{t("accept_and_join")}
Принять выбранные
</Button>
<Link href={`/${redirectWorkspaceSlug}`}>
<span>
<Button variant="secondary" size="lg">
{t("go_home")}
<Button variant="secondary" size="lg" className="nodedc-empty-state-secondary">
Вернуться на главную
</Button>
</span>
</Link>
@ -179,20 +206,32 @@ function UserInvitationsPage() {
</div>
</div>
) : (
<div className="fixed top-0 left-0 grid h-full w-full place-items-center">
<EmptyState
title={t("no_pending_invites")}
description={t("you_can_see_here_if_someone_invites_you_to_a_workspace")}
image={emptyInvitation}
primaryButton={{
text: t("back_to_home"),
onClick: () => router.push("/"),
}}
/>
<div className="flex flex-1 items-center justify-center py-10">
<div className="nodedc-glass-surface relative w-full max-w-[34rem] overflow-hidden rounded-[2.2rem] px-8 py-9 text-center">
<div className="pointer-events-none absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-[rgb(var(--nodedc-accent-rgb))]/55 to-transparent" />
<div className="mx-auto flex size-24 items-center justify-center rounded-[2rem] bg-white/[0.035] text-[rgb(var(--nodedc-accent-rgb))]">
<Sparkles className="size-11" />
</div>
<div className="mt-6 space-y-2">
<h1 className="text-24 font-semibold tracking-[-0.03em]">Нет ожидающих приглашений</h1>
<p className="mx-auto max-w-sm text-13 leading-6 text-secondary">
Когда вас пригласят в workspace, здесь появится карточка доступа с возможностью принять приглашение.
</p>
</div>
<Button
variant="primary"
size="lg"
onClick={() => router.push("/")}
className="nodedc-empty-state-primary mt-7"
appendIcon={<ArrowRight className="size-4" />}
>
Вернуться на главную
</Button>
</div>
</div>
)
) : null}
</div>
</NodeDCStandaloneShell>
</AuthenticationWrapper>
);
}

View File

@ -11,4 +11,4 @@ export default function WorkspaceInvitationsLayout() {
return <Outlet />;
}
export const meta: Route.MetaFunction = () => [{ title: "Workspace Invitations" }];
export const meta: Route.MetaFunction = () => [{ title: "Workspace приглашение - NODE.DC Tasker" }];

View File

@ -5,13 +5,14 @@
*/
import { observer } from "mobx-react";
import type { ReactNode } from "react";
import { useSearchParams } from "next/navigation";
import useSWR from "swr";
import { Boxes, User2 } from "lucide-react";
import { CheckIcon, CloseIcon } from "@plane/propel/icons";
import { ArrowRight, Check, MailCheck, X } from "lucide-react";
import { Button } from "@plane/propel/button";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { EmptySpace, EmptySpaceItem } from "@/components/ui/empty-space";
import { NodeDCStandaloneShell } from "@/components/nodedc/standalone-shell";
// constants
import { WORKSPACE_INVITATION } from "@/constants/fetch-keys";
// helpers
@ -45,82 +46,140 @@ function WorkspaceInvitationPage() {
: null
);
const handleAccept = () => {
const handleAccept = async () => {
if (!invitationDetail) return;
workspaceService
.joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, {
try {
await workspaceService.joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, {
accepted: true,
token: token,
})
.then(() => {
if (invitationDetail.email === currentUser?.email) {
router.push(`/${invitationDetail.workspace.slug}`);
} else {
router.push("/");
}
})
.catch((err: unknown) => console.error(err));
});
router.push(invitationDetail.email === currentUser?.email ? `/${invitationDetail.workspace.slug}` : "/");
} catch (err: unknown) {
console.error(err);
}
};
const handleReject = () => {
const handleReject = async () => {
if (!invitationDetail || !token) return;
void workspaceService
.joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, {
try {
await workspaceService.joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, {
accepted: false,
token: token,
})
.then(() => {
router.push("/");
})
.catch((err: unknown) => console.error(err));
});
router.push("/");
} catch (err: unknown) {
console.error(err);
}
};
return (
<AuthenticationWrapper pageType={EPageTypes.PUBLIC}>
<div className="flex h-full w-full flex-col items-center justify-center px-3">
<NodeDCStandaloneShell showUserControls={!!currentUser}>
{invitationDetail && !invitationDetail.responded_at ? (
error ? (
<div className="shadow-2xl flex w-full flex-col space-y-4 rounded-sm border border-subtle bg-surface-1 px-4 py-8 text-center md:w-1/3">
<h2 className="text-18 uppercase">INVITATION NOT FOUND</h2>
</div>
<InvitationShell
title="Приглашение не найдено"
description="Ссылка устарела или была отозвана администратором workspace."
action={<HomeButton routerPush={() => router.push("/")} />}
/>
) : (
<EmptySpace
title={`You have been invited to ${invitationDetail.workspace.name}`}
description="Your workspace is where you'll create projects, collaborate on your work items, and organize different streams of work in your NODE.DC account."
>
<EmptySpaceItem Icon={CheckIcon} title="Accept" action={handleAccept} />
<EmptySpaceItem Icon={CloseIcon} title="Ignore" action={handleReject} />
</EmptySpace>
<InvitationShell
eyebrow="Workspace invite"
title={`Вас пригласили в ${invitationDetail.workspace.name}`}
description="Примите приглашение, чтобы получить доступ к workspace и связанным проектам Tasker."
action={
<div className="flex flex-wrap justify-center gap-3">
<Button
variant="primary"
size="lg"
onClick={handleAccept}
className="nodedc-empty-state-primary"
prependIcon={<Check className="size-4" />}
>
Принять
</Button>
<Button
variant="secondary"
size="lg"
onClick={handleReject}
className="nodedc-empty-state-secondary"
prependIcon={<X className="size-4" />}
>
Отклонить
</Button>
</div>
}
/>
)
) : error || invitationDetail?.responded_at ? (
invitationDetail?.accepted ? (
<EmptySpace
title={`You are already a member of ${invitationDetail.workspace.name}`}
description="Your workspace is where you'll create projects, collaborate on your work items, and organize different streams of work in your NODE.DC account."
>
<EmptySpaceItem Icon={Boxes} title="Continue to home" href="/" />
</EmptySpace>
<InvitationShell
title={`Вы уже участник ${invitationDetail.workspace.name}`}
description="Приглашение принято. Можно вернуться в Tasker и продолжить работу."
action={<HomeButton routerPush={() => router.push("/")} />}
/>
) : (
<EmptySpace
title="This invitation link is not active anymore."
description="Your workspace is where you'll create projects, collaborate on your work items, and organize different streams of work in your NODE.DC account."
link={{ text: "Or start from an empty project", href: "/" }}
>
{!currentUser ? (
<EmptySpaceItem Icon={User2} title="Sign in to continue" href="/" />
) : (
<EmptySpaceItem Icon={Boxes} title="Continue to home" href="/" />
)}
</EmptySpace>
<InvitationShell
title="Ссылка приглашения больше не активна"
description={
currentUser
? "Вернитесь на главную страницу Tasker или запросите новое приглашение."
: "Войдите через NODE.DC и запросите новое приглашение, если доступ всё ещё нужен."
}
action={<HomeButton routerPush={() => router.push("/")} />}
/>
)
) : (
<div className="flex h-full w-full items-center justify-center">
<div className="relative z-[1] flex h-full w-full items-center justify-center">
<LogoSpinner />
</div>
)}
</div>
</NodeDCStandaloneShell>
</AuthenticationWrapper>
);
}
export default observer(WorkspaceInvitationPage);
function InvitationShell({
action,
description,
eyebrow = "NODE.DC Tasker",
title,
}: {
action: ReactNode;
description: string;
eyebrow?: string;
title: string;
}) {
return (
<div className="nodedc-glass-surface relative z-[1] w-full max-w-[36rem] overflow-hidden rounded-[2.2rem] px-8 py-9 text-center">
<div className="pointer-events-none absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-[rgb(var(--nodedc-accent-rgb))]/55 to-transparent" />
<div className="mx-auto flex size-24 items-center justify-center rounded-[2rem] bg-white/[0.035] text-[rgb(var(--nodedc-accent-rgb))]">
<MailCheck className="size-11" />
</div>
<div className="mt-6 space-y-2">
<div className="text-11 font-semibold tracking-[0.18em] text-[rgb(var(--nodedc-accent-rgb))] uppercase">
{eyebrow}
</div>
<h1 className="text-24 font-semibold tracking-[-0.03em]">{title}</h1>
<p className="mx-auto max-w-sm text-13 leading-6 text-secondary">{description}</p>
</div>
<div className="mt-7">{action}</div>
</div>
);
}
function HomeButton({ routerPush }: { routerPush: () => void }) {
return (
<Button
variant="primary"
size="lg"
onClick={routerPush}
className="nodedc-empty-state-primary"
appendIcon={<ArrowRight className="size-4" />}
>
Вернуться на главную
</Button>
);
}

View File

@ -297,6 +297,10 @@ export const coreRoutes: RouteConfigEntry[] = [
":workspaceSlug/settings/ai-voice-tasker",
"./(all)/[workspaceSlug]/(settings)/settings/(workspace)/ai-voice-tasker/page.tsx"
),
route(
":workspaceSlug/settings/codex-agent-api",
"./(all)/[workspaceSlug]/(settings)/settings/(workspace)/codex-agent-api/page.tsx"
),
]),
// --------------------------------------------------------------------

View File

@ -11,7 +11,7 @@ import type { IWorkspaceMember, TProjectMembership } from "@plane/types";
import { renderFormattedDate } from "@plane/utils";
// components
import { MemberHeaderColumn } from "@/components/project/member-header-column";
import { AccountTypeColumn, NameColumn } from "@/components/project/settings/member-columns";
import { AccountTypeColumn, NameColumn, ProjectMemberActionsColumn } from "@/components/project/settings/member-columns";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUser, useUserPermissions } from "@/hooks/store/user";
@ -21,6 +21,9 @@ export interface RowData extends Pick<TProjectMembership, "original_role"> {
member: IWorkspaceMember;
}
const stickyNameHeaderClassName = "nodedc-settings-table-sticky left-0 z-20 min-w-max";
const stickyNameCellClassName = "nodedc-settings-table-sticky left-0 z-10 min-w-max";
type TUseProjectColumnsProps = {
projectId: string;
workspaceSlug: string;
@ -60,13 +63,16 @@ export const useProjectColumns = (props: TUseProjectColumnsProps) => {
{
key: "Full Name",
content: "Full name",
thClassName: "text-left",
thClassName: stickyNameHeaderClassName,
tdClassName: stickyNameCellClassName,
thRender: () => (
<MemberHeaderColumn
property="full_name"
displayFilters={displayFilters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
<div className="w-max min-w-[8.5rem] pr-3">
<MemberHeaderColumn
property="full_name"
displayFilters={displayFilters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
</div>
),
tdRender: (rowData: RowData) => (
<NameColumn
@ -88,7 +94,7 @@ export const useProjectColumns = (props: TUseProjectColumnsProps) => {
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
tdRender: (rowData: RowData) => <div className="w-32">{rowData.member.display_name}</div>,
tdRender: (rowData: RowData) => <div className="min-w-[7.5rem] pr-3">{rowData.member.display_name}</div>,
},
{
key: "Email",
@ -100,7 +106,7 @@ export const useProjectColumns = (props: TUseProjectColumnsProps) => {
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
tdRender: (rowData: RowData) => <div className="w-48 text-secondary">{rowData.member.email}</div>,
tdRender: (rowData: RowData) => <div className="min-w-[10.5rem] pr-3 text-secondary">{rowData.member.email}</div>,
},
{
key: "Account Type",
@ -131,7 +137,22 @@ export const useProjectColumns = (props: TUseProjectColumnsProps) => {
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
tdRender: (rowData: RowData) => <div>{renderFormattedDate(rowData?.member?.joining_date)}</div>,
tdRender: (rowData: RowData) => (
<div className="min-w-[7rem] pr-3">{renderFormattedDate(rowData?.member?.joining_date)}</div>
),
},
{
key: "Actions",
content: <span className="sr-only">Действия</span>,
tdRender: (rowData: RowData) => (
<ProjectMemberActionsColumn
rowData={rowData}
workspaceSlug={workspaceSlug}
isAdmin={isAdmin}
currentUser={currentUser}
setRemoveMemberModal={setRemoveMemberModal}
/>
),
},
];
return {

View File

@ -12,6 +12,7 @@ import { useTranslation } from "@plane/i18n";
import { PlaneLockup } from "@plane/propel/icons";
import { PageHead } from "@/components/core/page-title";
import { EAuthModes } from "@/helpers/authentication.helper";
import { useNodeDCBrandLinkUrl } from "@/hooks/use-nodedc-brand-link-url";
import { useInstance } from "@/hooks/store/use-instance";
const authContentMap = {
@ -68,17 +69,19 @@ type TAuthHeaderBase = {
export function AuthHeaderBase(props: TAuthHeaderBase) {
const { pageTitle, additionalAction } = props;
const logoLinkUrl = useNodeDCBrandLinkUrl();
return (
<>
<PageHead title={pageTitle + " - NODE.DC"} />
<div className="sticky top-0 flex w-full flex-shrink-0 items-center justify-between gap-6 px-2 py-1">
<Link href="/">
<a href={logoLinkUrl}>
<PlaneLockup
height={31}
width={148}
className="nodedc-auth-logo-lockup text-primary transition-opacity hover:opacity-90"
/>
</Link>
</a>
{additionalAction}
</div>
</>

View File

@ -42,7 +42,7 @@ export const NoProjectsEmptyState = observer(function NoProjectsEmptyState() {
const { t } = useTranslation();
// derived values
const canCreateProject = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
[EUserPermissions.ADMIN],
EUserPermissionsLevel.WORKSPACE
);
const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);

View File

@ -12,14 +12,23 @@ import GradientBgLogo from "@/app/assets/auth/gradient-bg-logo.webp?url";
import DefaultLayout from "@/layouts/default-layout";
import { PlaneLockup } from "@plane/propel/icons";
import { Button } from "@plane/propel/button";
import { useNodeDCBrandLinkUrl } from "@/hooks/use-nodedc-brand-link-url";
export function InstanceNotReady() {
const logoLinkUrl = useNodeDCBrandLinkUrl();
return (
<DefaultLayout>
<div className="relative z-10 flex h-screen w-screen overflow-hidden">
<div className="flex h-full w-full flex-col items-center px-8 pt-6 pb-10">
<div className="sticky top-0 flex w-full shrink-0 items-center justify-between gap-6">
<PlaneLockup height={40} width={190} className="nodedc-auth-logo-lockup text-primary" />
<a href={logoLinkUrl} aria-label="NODE.DC">
<PlaneLockup
height={40}
width={190}
className="nodedc-auth-logo-lockup text-primary transition-opacity hover:opacity-90"
/>
</a>
</div>
<div className="flex h-full w-full flex-col items-center justify-center gap-7">
<div className="nodedc-error-shell flex max-w-3xl flex-col items-center gap-11 text-center">

View File

@ -120,10 +120,14 @@ export const BaseCalendarRoot = observer(function BaseCalendarRoot(props: IBaseC
issueProjectId,
updateIssue
).catch((err) => {
const message =
err?.detail === "You are not allowed to move this work item"
? "У вас нет прав перемещать эту карточку"
: "Не удалось выполнить действие";
setToast({
title: "Error!",
title: "Ошибка",
type: TOAST_TYPE.ERROR,
message: err?.detail ?? "Failed to perform this action",
message,
});
});
};

View File

@ -23,6 +23,7 @@ export const GlobalViewEmptyState = observer(function GlobalViewEmptyState() {
const { toggleCreateIssueModal, toggleCreateProjectModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
// derived values
const canCreateProject = allowPermissions([EUserWorkspaceRoles.ADMIN], EUserPermissionsLevel.WORKSPACE);
const hasMemberLevelPermission = allowPermissions(
[EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
EUserPermissionsLevel.WORKSPACE
@ -41,7 +42,7 @@ export const GlobalViewEmptyState = observer(function GlobalViewEmptyState() {
onClick: () => {
toggleCreateProjectModal(true);
},
disabled: !hasMemberLevelPermission,
disabled: !canCreateProject,
variant: "primary",
},
]}

View File

@ -103,7 +103,7 @@ export const BaseGanttRoot = observer(function BaseGanttRoot(props: IBaseGanttRo
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: "Error while updating work item dates, Please try again Later",
message: "Не удалось обновить даты карточки. Попробуйте позже.",
});
}),
[issues, projectId, workspaceSlug]

View File

@ -305,10 +305,10 @@ export const KanbanIssueBlock = observer(function KanbanIssueBlock(props: IssueB
else {
setToast({
type: TOAST_TYPE.WARNING,
title: "Cannot move work item",
title: "Нельзя переместить карточку",
message: !canEditIssueProperties
? "You are not allowed to move this work item"
: "Drag and drop is disabled for the current grouping",
? "У вас нет прав перемещать эту карточку"
: "Перетаскивание отключено для текущей группировки",
});
}
}}

View File

@ -243,10 +243,10 @@ export const IssueBlock = observer(function IssueBlock(props: IssueBlockProps) {
if (!isDraggingAllowed) {
setToast({
type: TOAST_TYPE.WARNING,
title: "Cannot move work item",
title: "Нельзя переместить карточку",
message: !canEditIssueProperties
? "You are not allowed to move this work item"
: "Drag and drop is disabled for the current grouping",
? "У вас нет прав перемещать эту карточку"
: "Перетаскивание отключено для текущей группировки",
});
}
}}

View File

@ -183,7 +183,7 @@ const getCycleColumns = (): IGroupByColumn[] | undefined => {
icon: <CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5" />,
payload: { cycle_id: cycle.id },
isDropDisabled,
dropErrorMessage: isDropDisabled ? "Work item cannot be moved to completed cycles" : undefined,
dropErrorMessage: isDropDisabled ? "Карточку нельзя перенести в завершённый цикл" : undefined,
});
});
cycles.push({

View File

@ -39,10 +39,7 @@ export const WorkspaceDraftIssuesRoot = observer(function WorkspaceDraftIssuesRo
const { toggleCreateProjectModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
// derived values
const hasMemberLevelPermission = allowPermissions(
[EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
const canCreateProject = allowPermissions([EUserWorkspaceRoles.ADMIN], EUserPermissionsLevel.WORKSPACE);
//swr hook for fetching issue properties
useWorkspaceIssueProperties(workspaceSlug);
@ -77,7 +74,7 @@ export const WorkspaceDraftIssuesRoot = observer(function WorkspaceDraftIssuesRo
onClick: () => {
toggleCreateProjectModal(true);
},
disabled: !hasMemberLevelPermission,
disabled: !canCreateProject,
variant: "primary",
},
]}

View File

@ -0,0 +1,77 @@
"use client";
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import { useTranslation } from "@plane/i18n";
import { InboxIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { useNodeDCBrandLinkUrl } from "@/hooks/use-nodedc-brand-link-url";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
type TNodeDCStandaloneShellProps = {
children: ReactNode;
notificationsCount?: number;
onOpenNotifications?: () => void;
showUserControls?: boolean;
};
export const NodeDCStandaloneShell = (props: TNodeDCStandaloneShellProps) => {
const { children, notificationsCount = 0, onOpenNotifications, showUserControls = false } = props;
const { t } = useTranslation();
const logoLinkUrl = useNodeDCBrandLinkUrl();
return (
<div className="relative flex min-h-screen w-full overflow-hidden bg-[#050507] text-primary">
<div className="pointer-events-none absolute inset-0 opacity-80">
<div className="absolute top-[-18rem] left-[-12rem] h-[34rem] w-[34rem] rounded-full bg-[rgb(var(--nodedc-accent-rgb))]/10 blur-[120px]" />
<div className="absolute right-[-14rem] bottom-[-18rem] h-[38rem] w-[38rem] rounded-full bg-white/7 blur-[140px]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_10%,rgba(255,255,255,0.06),transparent_38%),linear-gradient(180deg,rgba(255,255,255,0.035),rgba(255,255,255,0))]" />
</div>
<header className="nodedc-expanded-toolbar-shell absolute inset-x-0 top-0 z-[2]">
<div className="nodedc-expanded-toolbar-top">
<div className="nodedc-expanded-toolbar-left">
<a href={logoLinkUrl} className="nodedc-expanded-brand-link" aria-label="NODE.DC">
<img src="/nodedc-logo.svg" alt="NODE DC" className="nodedc-expanded-brand-logo" />
</a>
</div>
<div className="nodedc-expanded-toolbar-center" />
<div className="nodedc-expanded-toolbar-right">
{showUserControls && (
<div className="nodedc-expanded-user-group">
{onOpenNotifications && (
<Tooltip tooltipContent={t("notification.label")} position="bottom">
<button
type="button"
className="nodedc-toolbar-icon-button nodedc-expanded-notification-button relative flex items-center justify-center"
data-active={false}
aria-label={t("notification.label")}
onClick={onOpenNotifications}
>
<span className="nodedc-toolbar-icon-active-dot">
<InboxIcon className="size-5" />
</span>
{notificationsCount > 0 && (
<span className="nodedc-toolbar-notification-dot absolute top-1.5 right-1.5 size-2 rounded-full bg-danger-primary" />
)}
</button>
</Tooltip>
)}
<UserMenuRoot variant="expanded-toolbar" />
</div>
)}
</div>
</div>
</header>
<main className="relative z-[1] flex min-h-screen w-full items-center justify-center px-5 py-10 pt-[calc(var(--nodedc-shell-height)+2.25rem)]">
{children}
</main>
</div>
);
};

View File

@ -8,12 +8,14 @@ import { observer } from "mobx-react";
// plane imports
import { PlaneLockup } from "@plane/propel/icons";
// hooks
import { useNodeDCBrandLinkUrl } from "@/hooks/use-nodedc-brand-link-url";
import { useUser } from "@/hooks/store/user";
// local imports
import { SwitchAccountDropdown } from "./switch-account-dropdown";
export const OnboardingHeader = observer(function OnboardingHeader() {
const { data: user } = useUser();
const logoLinkUrl = useNodeDCBrandLinkUrl();
const userName = user?.display_name
? user.display_name
@ -27,7 +29,9 @@ export const OnboardingHeader = observer(function OnboardingHeader() {
<div className="h-full w-full bg-accent-primary" />
</div>
<div className="flex w-full items-center justify-between gap-6 px-6">
<PlaneLockup height={20} width={95} className="text-primary" />
<a href={logoLinkUrl} aria-label="NODE.DC">
<PlaneLockup height={20} width={95} className="text-primary transition-opacity hover:opacity-90" />
</a>
<SwitchAccountDropdown fullName={userName} />
</div>
</div>

View File

@ -16,6 +16,7 @@ import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IUser, IWorkspace } from "@plane/types";
import { Spinner } from "@plane/ui";
import { cn, validateWorkspaceName, validateSlug } from "@plane/utils";
import { createWorkspaceSlug } from "@/helpers/workspace-slug";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
import { useWorkspace } from "@/hooks/store/use-workspace";
@ -160,7 +161,7 @@ export const WorkspaceCreateStep = observer(function WorkspaceCreateStep({
onChange={(event) => {
onChange(event.target.value);
setValue("name", event.target.value);
setValue("slug", event.target.value.toLocaleLowerCase().trim().replace(/ /g, "-"), {
setValue("slug", createWorkspaceSlug(event.target.value), {
shouldValidate: true,
});
}}
@ -215,12 +216,13 @@ export const WorkspaceCreateStep = observer(function WorkspaceCreateStep({
id="slug"
name="slug"
type="text"
value={value.toLocaleLowerCase().trim().replace(/ /g, "-")}
value={createWorkspaceSlug(value)}
onChange={(e) => {
const validation = validateSlug(e.target.value);
const nextSlug = createWorkspaceSlug(e.target.value);
const validation = validateSlug(nextSlug);
if (validation === true) setInvalidSlug(false);
else setInvalidSlug(true);
onChange(e.target.value.toLowerCase());
onChange(nextSlug);
}}
ref={ref}
placeholder={t("workspace_creation.form.url.placeholder")}

View File

@ -48,7 +48,7 @@ export const usePowerKCreationCommandsRecord = (): Record<TPowerKCreationCommand
// derived values
const canCreateWorkItem = canPerformAnyCreateAction && workspaceProjectIds && workspaceProjectIds.length > 0;
const canCreateProject = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
[EUserPermissions.ADMIN],
EUserPermissionsLevel.WORKSPACE
);
const hasProjectMemberLevelPermissions = (ctx: TPowerKContext) =>

View File

@ -48,7 +48,7 @@ export const ProjectCardList = observer(function ProjectCardList(props: TProject
// permissions
const canPerformEmptyStateActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
[EUserPermissions.ADMIN],
EUserPermissionsLevel.WORKSPACE
);

View File

@ -6,7 +6,6 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { AlertTriangle } from "lucide-react";
// types
import { Button } from "@plane/propel/button";
@ -22,12 +21,11 @@ type Props = {
onSubmit: () => Promise<void>;
isOpen: boolean;
onClose: () => void;
projectId: string;
};
export const ConfirmProjectMemberRemove = observer(function ConfirmProjectMemberRemove(props: Props) {
const { data, onSubmit, isOpen, onClose } = props;
// router
const { projectId } = useParams();
const { data, onSubmit, isOpen, onClose, projectId } = props;
// states
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
// store hooks
@ -50,44 +48,62 @@ export const ConfirmProjectMemberRemove = observer(function ConfirmProjectMember
if (!projectId) return <></>;
const isCurrentUser = currentUser?.id === data?.id;
const currentProjectDetails = getProjectById(projectId.toString());
const currentProjectDetails = getProjectById(projectId);
const memberName = data?.display_name || "участника";
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
<div className="bg-surface-1 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-danger-subtle sm:mx-0 sm:h-10 sm:w-10">
<AlertTriangle className="h-6 w-6 text-danger-primary" aria-hidden="true" />
<ModalCore
isOpen={isOpen}
handleClose={handleClose}
position={EModalPosition.CENTER}
width={EModalWidth.XXL}
className="overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] p-0 shadow-[0_28px_80px_rgba(0,0,0,0.42)]"
>
<div className="p-6 sm:p-7">
<div className="flex items-start gap-4">
<div className="grid size-12 shrink-0 place-items-center rounded-[1.2rem] bg-[rgb(var(--nodedc-accent-rgb))]/18 text-[rgb(var(--nodedc-accent-rgb))]">
<AlertTriangle className="h-5 w-5" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-16 leading-6 font-medium text-primary">
{isCurrentUser ? "Leave project?" : `Remove ${data?.display_name}?`}
<div className="min-w-0">
<p className="text-12 font-semibold tracking-[0.24em] text-tertiary uppercase">NODE.DC Tasker</p>
<h3 className="mt-2 text-18 leading-6 font-semibold text-primary">
{isCurrentUser ? "Покинуть проект?" : `Удалить ${memberName} из проекта?`}
</h3>
<div className="mt-2">
<p className="text-13 text-secondary">
{isCurrentUser ? (
<>
Are you sure you want to leave the <span className="font-bold">{currentProjectDetails?.name}</span>{" "}
project? You will be able to join the project if invited again or if it{"'"}s public.
</>
) : (
<>
Are you sure you want to remove member- <span className="font-bold">{data?.display_name}</span>?
They will no longer have access to this project. This action cannot be undone.
</>
)}
</p>
</div>
<p className="mt-3 text-13 leading-6 text-secondary">
{isCurrentUser ? (
<>
Вы потеряете доступ к проекту{" "}
<span className="font-semibold text-primary">{currentProjectDetails?.name}</span>. Вернуться можно
будет только после нового приглашения или если проект публичный.
</>
) : (
<>
Пользователь <span className="font-semibold text-primary">{memberName}</span> потеряет доступ к
проекту. Действие нельзя отменить автоматически.
</>
)}
</p>
</div>
</div>
</div>
<div className="flex justify-end gap-2 p-4 sm:px-6">
<Button variant="secondary" size="lg" onClick={handleClose}>
Cancel
</Button>
<Button variant="error-fill" size="lg" tabIndex={1} onClick={handleDeletion} loading={isDeleteLoading}>
{isCurrentUser ? (isDeleteLoading ? "Leaving..." : "Leave") : isDeleteLoading ? "Removing..." : "Remove"}
</Button>
<div className="mt-7 flex justify-end gap-3">
<Button
variant="secondary"
size="lg"
onClick={handleClose}
className="nodedc-modal-secondary-button min-w-[11.5rem] px-8"
>
Отмена
</Button>
<Button
variant="primary"
size="lg"
onClick={handleDeletion}
loading={isDeleteLoading}
className="nodedc-modal-primary-button min-w-[12.5rem] px-8"
>
{isCurrentUser ? (isDeleteLoading ? "Выходим..." : "Покинуть") : isDeleteLoading ? "Удаляем..." : "Удалить"}
</Button>
</div>
</div>
</ModalCore>
);

View File

@ -5,12 +5,14 @@
*/
import { useEffect, useState } from "react";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
import { getAssetIdFromUrl, checkURLValidity } from "@plane/utils";
// plane ui
// helpers
// hooks
import useKeypress from "@/hooks/use-keypress";
import { useUserPermissions } from "@/hooks/store/user";
// plane web components
import { CreateProjectForm } from "@/plane-web/components/projects/create/root";
// plane web types
@ -36,9 +38,11 @@ enum EProjectCreationSteps {
export function CreateProjectModal(props: Props) {
const { isOpen, onClose, setToFavorite = false, workspaceSlug, data, templateId } = props;
const { allowPermissions } = useUserPermissions();
// states
const [currentStep, setCurrentStep] = useState<EProjectCreationSteps>(EProjectCreationSteps.CREATE_PROJECT);
const [createdProjectId, setCreatedProjectId] = useState<string | null>(null);
const canCreateProject = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE, workspaceSlug);
useEffect(() => {
if (isOpen) {
@ -47,6 +51,10 @@ export function CreateProjectModal(props: Props) {
}
}, [isOpen]);
useEffect(() => {
if (isOpen && !canCreateProject) onClose();
}, [canCreateProject, isOpen, onClose]);
const handleNextStep = (projectId: string) => {
if (!projectId) return;
setCreatedProjectId(projectId);
@ -65,6 +73,8 @@ export function CreateProjectModal(props: Props) {
if (isOpen) onClose();
});
if (!canCreateProject) return null;
return (
<ModalCore
isOpen={isOpen}

View File

@ -33,7 +33,7 @@ export const ProjectsBaseHeader = observer(function ProjectsBaseHeader() {
const pathname = usePathname();
// auth
const isAuthorizedUser = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
[EUserPermissions.ADMIN],
EUserPermissionsLevel.WORKSPACE
);
const isArchived = pathname.includes("/archives");

View File

@ -48,6 +48,7 @@ export const ProjectMemberListItem = observer(function ProjectMemberListItem(pro
await leaveProject(workspaceSlug.toString(), projectId.toString())
.then(async () => {
router.push(`/${workspaceSlug}/projects`);
return undefined;
})
.catch((err) => {
setToast({
@ -55,6 +56,7 @@ export const ProjectMemberListItem = observer(function ProjectMemberListItem(pro
title: "You cant leave this project yet.",
message: err?.error || "Something went wrong. Please try again.",
});
return undefined;
});
} else
await removeMemberFromProject(workspaceSlug.toString(), projectId.toString(), memberId).catch((err) =>
@ -73,20 +75,24 @@ export const ProjectMemberListItem = observer(function ProjectMemberListItem(pro
<ConfirmProjectMemberRemove
isOpen={removeMemberModal !== null}
onClose={() => setRemoveMemberModal(null)}
projectId={projectId}
data={{ id: removeMemberModal.member.id, display_name: removeMemberModal.member.display_name || "" }}
onSubmit={() => handleRemove(removeMemberModal.member.id)}
/>
)}
<Table
columns={columns}
data={(memberDetails?.filter((member): member is IProjectMemberDetails => member !== null) ?? []) as any}
keyExtractor={(rowData) => rowData?.member.id ?? ""}
tHeadClassName="border-b border-subtle"
thClassName="text-left font-medium divide-x-0 text-placeholder"
tBodyClassName="divide-y-0"
tBodyTrClassName="divide-x-0 p-4 h-[40px] text-secondary"
tHeadTrClassName="divide-x-0"
/>
<div className="horizontal-scrollbar scrollbar-sm w-full overflow-x-auto overflow-y-hidden rounded-[1.2rem]">
<Table
columns={columns}
data={(memberDetails?.filter((member): member is IProjectMemberDetails => member !== null) ?? []) as any}
keyExtractor={(rowData) => rowData?.member.id ?? ""}
tableClassName="nodedc-settings-table-surface min-w-full table-auto border-separate border-spacing-0 overflow-visible"
tHeadClassName="border-b border-white/6"
thClassName="text-left font-medium divide-x-0 text-placeholder"
tBodyClassName="divide-y-0"
tBodyTrClassName="divide-x-0 h-11 px-4 text-secondary"
tHeadTrClassName="divide-x-0"
/>
</div>
</>
);
});

View File

@ -4,7 +4,7 @@
* See the LICENSE file for details.
*/
import { useState } from "react";
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
@ -32,11 +32,25 @@ export const ProjectMemberList = observer(function ProjectMemberList(props: TPro
const [inviteModal, setInviteModal] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const {
project: { projectMemberIds, getFilteredProjectMemberDetails, filters },
project: {
fetchProjectMembers,
getFilteredProjectMemberDetails,
getProjectMemberFetchStatus,
getProjectMemberIds,
filters,
},
} = useMember();
const { allowPermissions } = useUserPermissions();
const { t } = useTranslation();
const hasFetchedProjectMembers = getProjectMemberFetchStatus(projectId.toString());
const projectMemberIds = getProjectMemberIds(projectId.toString(), true);
useEffect(() => {
if (!workspaceSlug || !projectId) return;
void fetchProjectMembers(workspaceSlug.toString(), projectId.toString(), true).catch(console.error);
}, [fetchProjectMembers, projectId, workspaceSlug]);
const searchedProjectMembers = (projectMemberIds ?? []).filter((userId) => {
const memberDetails = projectId ? getFilteredProjectMemberDetails(userId, projectId.toString()) : null;
@ -53,7 +67,7 @@ export const ProjectMemberList = observer(function ProjectMemberList(props: TPro
projectId ? getFilteredProjectMemberDetails(memberId, projectId.toString()) : null
);
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId);
// Handler for role filter updates
const handleRoleFilterUpdate = (role: string) => {
@ -90,7 +104,6 @@ export const ProjectMemberList = observer(function ProjectMemberList(props: TPro
className="w-full max-w-[234px] border-none bg-transparent text-13 placeholder:text-placeholder focus:outline-none"
placeholder={t("search")}
value={searchQuery}
autoFocus
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
@ -113,10 +126,10 @@ export const ProjectMemberList = observer(function ProjectMemberList(props: TPro
)}
</div>
</div>
{!projectMemberIds ? (
{!hasFetchedProjectMembers ? (
<MembersSettingsLoader />
) : (
<div className="nodedc-settings-card overflow-scroll px-1 py-1">
<div className="nodedc-settings-card overflow-hidden px-1 py-1">
{searchedProjectMembers.length !== 0 && (
<ProjectMemberListItem
memberDetails={memberDetails ?? []}

View File

@ -21,21 +21,25 @@ type Props = {
value: any;
onChange: (val: string) => void;
isDisabled?: boolean;
projectId?: string;
};
export const MemberSelect = observer(function MemberSelect(props: Props) {
const { value, onChange, isDisabled = false } = props;
const { value, onChange, isDisabled = false, projectId: explicitProjectId } = props;
const { t } = useTranslation();
// router
const { projectId } = useParams();
const { projectId: routeProjectId } = useParams();
const projectId = explicitProjectId ?? routeProjectId?.toString();
// store hooks
const {
project: { projectMemberIds, getProjectMemberDetails },
project: { getProjectMemberDetails, getProjectMemberIds },
} = useMember();
const projectMemberIds = projectId ? getProjectMemberIds(projectId, true) : null;
const options = projectMemberIds
?.map((userId) => {
const memberDetails = projectId ? getProjectMemberDetails(userId, projectId.toString()) : null;
const memberDetails = projectId ? getProjectMemberDetails(userId, projectId) : null;
if (!memberDetails?.member) return;
const isGuest = memberDetails.role === EUserProjectRoles.GUEST;
@ -59,18 +63,18 @@ export const MemberSelect = observer(function MemberSelect(props: Props) {
content: React.ReactNode;
}[]
| undefined;
const selectedOption = projectId ? getProjectMemberDetails(value, projectId.toString()) : null;
const selectedOption = projectId ? getProjectMemberDetails(value, projectId) : null;
return (
<SearchSelectionDropdown
value={value}
label={
<div className="flex h-3.5 items-center gap-2">
<div className="flex min-h-5 min-w-0 items-center gap-2">
{selectedOption && (
<Avatar name={selectedOption.member?.display_name} src={getFileURL(selectedOption.member?.avatar_url)} />
)}
{selectedOption ? (
selectedOption.member?.display_name
<span className="truncate">{selectedOption.member?.display_name}</span>
) : (
<div className="flex items-center gap-2">
<Ban className="h-3.5 w-3.5 rotate-90 text-placeholder" />
@ -81,7 +85,6 @@ export const MemberSelect = observer(function MemberSelect(props: Props) {
}
buttonClassName="nodedc-settings-select !w-full !justify-between !px-4 !py-3"
options={
options &&
options && [
...options,
{

View File

@ -19,6 +19,7 @@ import { Loader, ToggleSwitch } from "@plane/ui";
import { PROJECT_DETAILS } from "@/constants/fetch-keys";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useMember } from "@/hooks/store/use-member";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import { MemberSelect } from "./member-select";
@ -60,6 +61,9 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
// store hooks
const { allowPermissions } = useUserPermissions();
const {
project: { fetchProjectMembers, getProjectMemberFetchStatus },
} = useMember();
const { currentProjectDetails, fetchProjectDetails, updateProject } = useProject();
// derived values
const isAdmin = allowPermissions(
@ -76,6 +80,14 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug, projectId) : null
);
const hasFetchedProjectMembers = getProjectMemberFetchStatus(projectId);
useEffect(() => {
if (!workspaceSlug || !projectId || hasFetchedProjectMembers) return;
void fetchProjectMembers(workspaceSlug, projectId, true).catch(console.error);
}, [fetchProjectMembers, hasFetchedProjectMembers, projectId, workspaceSlug]);
useEffect(() => {
if (!currentProjectDetails) return;
@ -113,6 +125,7 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
type: TOAST_TYPE.SUCCESS,
message: t("project_settings.general.toast.success"),
});
return undefined;
})
.catch((err) => {
console.error(err);
@ -131,6 +144,7 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
type: TOAST_TYPE.SUCCESS,
message: t("project_settings.general.toast.success"),
});
return undefined;
})
.catch((err) => {
console.error(err);
@ -154,6 +168,7 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
submitChanges({ project_lead: val });
}}
isDisabled={!isAdmin}
projectId={projectId}
/>
)}
/>
@ -178,6 +193,7 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
submitChanges({ default_assignee: val });
}}
isDisabled={!isAdmin}
projectId={projectId}
/>
)}
/>

View File

@ -54,8 +54,8 @@ export const SendProjectInvitationModal = observer(function SendProjectInvitatio
// store hooks
const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
const {
project: { getProjectMemberDetails, bulkAddMembersToProject },
workspace: { workspaceMemberIds, getWorkspaceMemberDetails },
project: { getProjectMemberDetails, bulkAddMembersToProject, fetchProjectMembers },
workspace: { workspaceMemberIds, getWorkspaceMemberDetails, fetchWorkspaceMembers },
} = useMember();
// form info
const {
@ -78,6 +78,15 @@ export const SendProjectInvitationModal = observer(function SendProjectInvitatio
return !isInvited;
});
useEffect(() => {
if (!isOpen || !workspaceSlug || !projectId) return;
void Promise.all([
fetchWorkspaceMembers(workspaceSlug.toString()),
fetchProjectMembers(workspaceSlug.toString(), projectId.toString(), true),
]).catch(console.error);
}, [fetchProjectMembers, fetchWorkspaceMembers, isOpen, projectId, workspaceSlug]);
const onSubmit = async (formData: FormValues) => {
if (!workspaceSlug || !projectId || isSubmitting) return;
@ -133,19 +142,22 @@ export const SendProjectInvitationModal = observer(function SendProjectInvitatio
const memberDetails = getWorkspaceMemberDetails(userId);
if (!memberDetails?.member) return;
const displayName = memberDetails.member.display_name || memberDetails.member.email || "";
const fullName = [memberDetails.member.first_name, memberDetails.member.last_name].filter(Boolean).join(" ");
const secondaryLabel = fullName && fullName !== displayName ? ` (${fullName})` : "";
return {
value: `${memberDetails?.member.id}`,
query: `${memberDetails?.member.first_name} ${
memberDetails?.member.last_name
} ${memberDetails?.member.display_name.toLowerCase()}`,
value: `${memberDetails.member.id}`,
query: `${displayName} ${fullName} ${memberDetails.member.email ?? ""}`.toLowerCase(),
content: (
<div className="flex w-full items-center gap-2">
<div className="shrink-0 pt-0.5">
<Avatar name={memberDetails?.member.display_name} src={getFileURL(memberDetails?.member.avatar_url)} />
<Avatar name={displayName} src={getFileURL(memberDetails.member.avatar_url)} />
</div>
<div className="truncate">
{memberDetails?.member.display_name} (
{memberDetails?.member.first_name + " " + memberDetails?.member.last_name})
{displayName}
{secondaryLabel}
</div>
</div>
),
@ -226,8 +238,9 @@ export const SendProjectInvitationModal = observer(function SendProjectInvitatio
EUserPermissions[newValue as keyof typeof EUserPermissions]
);
}}
noResultsMessage="Нет доступных участников workspace"
options={options}
optionsClassName="w-48"
optionsClassName="min-w-[24rem]"
/>
);
}}
@ -249,25 +262,29 @@ export const SendProjectInvitationModal = observer(function SendProjectInvitatio
<SelectionDropdown
options={Object.entries(checkCurrentOptionWorkspaceRole(watch(`members.${index}.member_id`)))
.filter(([key]) => parseInt(key) <= (currentProjectRole ?? EUserPermissions.GUEST))
.map(([key, label]) => ({
key,
title: label,
isChecked: String(field.value) === key,
onClick: () =>
setValue(
`members.${index}.role`,
EUserPermissions[ROLE[parseInt(key)].toUpperCase() as keyof typeof EUserPermissions]
),
}))}
.map(([key, label]) => {
const roleKey = parseInt(key) as keyof typeof ROLE;
return {
key,
title: label,
isChecked: String(field.value) === key,
onClick: () =>
setValue(
`members.${index}.role`,
EUserPermissions[ROLE[roleKey].toUpperCase() as keyof typeof EUserPermissions]
),
};
})}
menuButton={
<div className="shadow-sm flex w-24 items-center justify-between gap-1 rounded-md border border-subtle px-3 py-2.5 text-left text-13 text-secondary duration-300 hover:bg-layer-1 hover:text-primary focus:outline-none">
<div className="shadow-sm flex min-w-[7.5rem] items-center justify-between gap-1 rounded-md border border-subtle px-4 py-2.5 text-left text-13 text-secondary duration-300 hover:bg-layer-1 hover:text-primary focus:outline-none">
<span className="capitalize">
{field.value ? ROLE[field.value] : t("project_invitation_modal.select_role")}
</span>
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div>
}
menuButtonWrapperClassName="w-24"
menuButtonWrapperClassName="min-w-[7.5rem]"
/>
)}
/>
@ -302,10 +319,10 @@ export const SendProjectInvitationModal = observer(function SendProjectInvitatio
{t("common.add_more")}
</button>
<div className="flex items-center gap-2">
<Button variant="secondary" size="lg" onClick={handleClose}>
<Button variant="secondary" size="lg" onClick={handleClose} className="min-w-[7.5rem] px-6">
{t("cancel")}
</Button>
<Button variant="primary" size="lg" type="submit" loading={isSubmitting}>
<Button variant="primary" size="lg" type="submit" loading={isSubmitting} className="min-w-[12rem] px-6">
{isSubmitting
? `${fields && fields.length > 1 ? `${t("add_members")}...` : `${t("add_member")}...`}`
: `${fields && fields.length > 1 ? t("add_members") : t("add_member")}`}

View File

@ -7,14 +7,12 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { Controller, useForm } from "react-hook-form";
import { CircleMinus } from "lucide-react";
import { Disclosure } from "@headlessui/react";
// plane imports
import { ROLE, EUserPermissions, MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
import { ChevronDownIcon, TrashIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { EUserProjectRoles, IUser, IWorkspaceMember, TProjectMembership } from "@plane/types";
import { ActionDropdown } from "@plane/ui";
import { getFileURL } from "@plane/utils";
import { cn, getFileURL } from "@plane/utils";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
// hooks
import { useMember } from "@/hooks/store/use-member";
@ -39,61 +37,69 @@ type AccountTypeProps = {
projectId: string;
};
const PROJECT_ROLE_LABELS: Record<EUserPermissions, string> = {
[EUserPermissions.GUEST]: "Гость",
[EUserPermissions.MEMBER]: "Участник",
[EUserPermissions.ADMIN]: "Админ",
};
const getProjectRoleLabel = (role: EUserPermissions | EUserProjectRoles | undefined) => {
const normalizedRole = Number(role) as EUserPermissions;
return role ? (PROJECT_ROLE_LABELS[normalizedRole] ?? "Не назначено") : "Не назначено";
};
export function NameColumn(props: NameProps) {
const { rowData, workspaceSlug, isAdmin, currentUser, setRemoveMemberModal } = props;
const { rowData, workspaceSlug } = props;
// derived values
const { avatar_url, display_name, email, first_name, id, last_name } = rowData.member;
const fullName = [first_name, last_name].filter(Boolean).join(" ") || display_name || email;
return (
<Disclosure>
{({}) => (
<div className="group relative">
<div className="flex w-72 items-center gap-2">
<div className="flex flex-1 items-center gap-x-2 gap-y-2">
{avatar_url && avatar_url.trim() !== "" ? (
<Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex size-6 items-center justify-center rounded-full text-on-color capitalize">
<img
src={getFileURL(avatar_url)}
className="absolute top-0 left-0 h-full w-full rounded-full object-cover"
alt={display_name || email}
/>
</span>
</Link>
) : (
<Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex size-6 items-center justify-center rounded-full bg-layer-3 text-11 text-on-color capitalize">
{(email ?? display_name ?? "?")[0]}
</span>
</Link>
)}
{first_name} {last_name}
</div>
{(isAdmin || id === currentUser?.id) && (
<ActionDropdown
placement="bottom-end"
buttonClassName="p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
items={[
{
key: "remove-member",
action: () => setRemoveMemberModal(rowData),
customContent: (
<div
className="flex cursor-pointer items-center gap-x-1 font-medium text-danger-primary"
data-ph-element={MEMBER_TRACKER_ELEMENTS.PROJECT_MEMBER_TABLE_CONTEXT_MENU}
>
<CircleMinus className="size-3.5 flex-shrink-0" />
{rowData.member?.id === currentUser?.id ? "Leave " : "Remove "}
</div>
),
},
]}
/>
)}
</div>
</div>
<div className="flex w-max min-w-[8.5rem] items-center gap-x-2 gap-y-2 pr-3">
{avatar_url && avatar_url.trim() !== "" ? (
<Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex size-6 items-center justify-center rounded-full text-on-color capitalize">
<img
src={getFileURL(avatar_url)}
className="absolute top-0 left-0 h-full w-full rounded-full object-cover"
alt={display_name || email}
/>
</span>
</Link>
) : (
<Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex size-6 items-center justify-center rounded-full bg-layer-3 text-11 text-tertiary capitalize">
{(email ?? display_name ?? "?")[0]}
</span>
</Link>
)}
</Disclosure>
<span className="truncate">{fullName}</span>
</div>
);
}
export function ProjectMemberActionsColumn(props: NameProps) {
const { rowData, isAdmin, currentUser, setRemoveMemberModal } = props;
const { display_name, email, id } = rowData.member;
const canRemoveMember = isAdmin || id === currentUser?.id;
const isCurrentUser = id === currentUser?.id;
const label = isCurrentUser ? "Покинуть проект" : `Удалить ${display_name || email}`;
return (
<div className="flex w-12 justify-end">
{canRemoveMember && (
<button
aria-label={label}
className="nodedc-external-icon-button"
data-ph-element={MEMBER_TRACKER_ELEMENTS.PROJECT_MEMBER_TABLE_CONTEXT_MENU}
title={label}
type="button"
onClick={() => setRemoveMemberModal(rowData)}
>
<TrashIcon className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
@ -112,7 +118,7 @@ export const AccountTypeColumn = observer(function AccountTypeColumn(props: Acco
formState: { errors },
} = useForm();
// derived values
const roleLabel = ROLE[rowData.original_role ?? EUserPermissions.GUEST];
const roleLabel = getProjectRoleLabel(rowData.original_role ?? EUserPermissions.GUEST);
const isCurrentUser = currentUser?.id === rowData.member.id;
const isRowDataWorkspaceAdmin = [EUserPermissions.ADMIN].includes(
Number(getWorkspaceMemberDetails(rowData.member.id)?.role) ?? EUserPermissions.GUEST
@ -154,39 +160,55 @@ export const AccountTypeColumn = observer(function AccountTypeColumn(props: Acco
rules={{ required: "Role is required." }}
render={() => (
<SelectionDropdown
options={Object.entries(checkCurrentOptionWorkspaceRole(rowData.member.id)).map(([key, label]) => ({
key,
title: label,
isChecked: String(rowData.original_role) === key,
onClick: async () => {
if (!workspaceSlug) return;
await updateMemberRole(workspaceSlug.toString(), projectId.toString(), rowData.member.id, key).catch(
(err) => {
dropdownClassName="!p-2"
dropdownContentClassName="!w-44"
options={Object.entries(checkCurrentOptionWorkspaceRole(rowData.member.id)).map(([key]) => {
const role = Number(key) as EUserPermissions;
return {
key,
title: getProjectRoleLabel(role),
isChecked: String(rowData.original_role) === key,
onClick: async () => {
if (!workspaceSlug) return;
await updateMemberRole(
workspaceSlug.toString(),
projectId.toString(),
rowData.member.id,
Number(key) as EUserProjectRoles
).catch((err) => {
console.log(err, "err");
const error = err.error;
const errorString = Array.isArray(error) ? error[0] : error;
setToast({
type: TOAST_TYPE.ERROR,
title: "You cant change this role yet.",
message: errorString ?? "An error occurred while updating member role. Please try again.",
title: "Ошибка",
message: errorString ?? "Не удалось обновить роль участника. Попробуйте ещё раз.",
});
}
);
},
}))}
menuButton={
<div className="flex">
<span>{roleLabel}</span>
});
},
};
})}
menuButton={({ open }) => (
<div
className={cn(
"nodedc-settings-chip flex min-h-10 min-w-[9rem] items-center justify-between gap-2 px-4 py-2 text-caption-sm-medium",
errors.role ? "border-danger-strong" : ""
)}
>
<span className="truncate">{roleLabel}</span>
<ChevronDownIcon className={cn("h-3 w-3 transition-transform", open ? "rotate-180" : "")} />
</div>
}
menuButtonWrapperClassName={`w-32 rounded-md p-0 !justify-start !px-0 hover:bg-surface-1 ${errors.role ? "border-danger-strong" : "border-none"}`}
)}
menuButtonWrapperClassName="flex rounded-[1.25rem] border-0 outline-none"
placement="bottom-end"
/>
)}
/>
) : (
<div className="flex w-32">
<span>{roleLabel}</span>
<div className="nodedc-settings-chip flex min-h-10 min-w-[9rem] items-center px-4 py-2 text-caption-sm-medium">
<span className="truncate">{roleLabel}</span>
</div>
)}
</>

View File

@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { observer } from "mobx-react";
@ -194,7 +193,7 @@ export const ProjectSettingsModal = observer(function ProjectSettingsModal() {
handleClose={handleClose}
position={EModalPosition.CENTER}
width={EModalWidth.VIIXL}
className="h-[88vh] max-h-[920px] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)]"
className="h-[88vh] max-h-[920px] !max-w-[calc(100vw-1.5rem)] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)] sm:!max-w-[calc(100vw-2rem)] xl:!max-w-[88rem]"
>
{workspaceSlug && activeProjectId ? (
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={activeProjectId}>
@ -233,7 +232,7 @@ export const ProjectSettingsModal = observer(function ProjectSettingsModal() {
size="sm"
className="min-h-0 flex-1 overflow-y-auto"
>
<div className="mx-auto w-full max-w-[74rem] px-5 pb-7 lg:px-8">
<div className="mx-auto w-full max-w-[82rem] px-5 pb-7 lg:px-6">
<ProjectSettingsModalContent
activeTab={activeTab}
projectId={activeProjectId}

View File

@ -9,7 +9,12 @@ import { usePathname } from "next/navigation";
import { useParams } from "react-router";
import useSWR from "swr";
// plane imports
import { EUserPermissionsLevel, GROUPED_WORKSPACE_SETTINGS, WORKSPACE_SETTINGS, WORKSPACE_SETTINGS_CATEGORIES } from "@plane/constants";
import {
EUserPermissionsLevel,
GROUPED_WORKSPACE_SETTINGS,
WORKSPACE_SETTINGS,
WORKSPACE_SETTINGS_CATEGORIES,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { TWorkspaceSettingsTabs } from "@plane/types";
import { joinUrlPath } from "@plane/utils";
@ -19,12 +24,14 @@ import { SettingsSidebarItem } from "@/components/settings/sidebar/item";
import { useUserPermissions } from "@/hooks/store/user";
// services
import { WorkspaceAIService } from "@/services/workspace-ai.service";
import { WorkspaceService } from "@/services/workspace.service";
// local imports
import { WORKSPACE_SETTINGS_ICONS } from "./item-icon";
const HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set(["billing-and-plans"]);
const WORKSPACE_FEATURE_GATED_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["ai-voice-tasker"]);
const WORKSPACE_FEATURE_GATED_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["ai-voice-tasker", "codex-agent-api"]);
const workspaceAIService = new WorkspaceAIService();
const workspaceService = new WorkspaceService();
export const WorkspaceSettingsSidebarItemCategories = observer(function WorkspaceSettingsSidebarItemCategories() {
// params
@ -42,7 +49,12 @@ export const WorkspaceSettingsSidebarItemCategories = observer(function Workspac
canLoadVoiceTaskerEntitlement ? `WORKSPACE_AI_SETTINGS_${workspaceSlug}` : null,
() => workspaceAIService.retrieveSettings(workspaceSlug as string)
);
const { data: nodedcWorkspacePolicy } = useSWR(
workspaceSlug ? `NODEDC_WORKSPACE_POLICY_${workspaceSlug}` : null,
() => workspaceService.getNodeDCWorkspacePolicy(workspaceSlug as string)
);
const isVoiceTaskerEntitled = aiSettings?.feature_entitlement_enabled === true;
const isCodexAgentEntitled = nodedcWorkspacePolicy?.service_modules?.codex_agents === true;
return (
<div className="mt-4 flex flex-col divide-y divide-white/6">
@ -51,7 +63,11 @@ export const WorkspaceSettingsSidebarItemCategories = observer(function Workspac
const accessibleItems = categoryItems.filter(
(item) =>
!HIDDEN_WORKSPACE_SETTINGS_KEYS.has(item.key) &&
(!WORKSPACE_FEATURE_GATED_SETTINGS_KEYS.has(item.key) || isVoiceTaskerEntitled) &&
(!WORKSPACE_FEATURE_GATED_SETTINGS_KEYS.has(item.key) ||
isWorkspaceFeatureSettingsEntitled(item.key, {
isCodexAgentEntitled,
isVoiceTaskerEntitled,
})) &&
allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug)
);
@ -59,7 +75,7 @@ export const WorkspaceSettingsSidebarItemCategories = observer(function Workspac
return (
<div key={category} className="shrink-0 py-3.5 first:pt-0 last:pb-0">
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-tertiary">
<div className="px-3 py-1.5 text-[11px] font-semibold tracking-[0.18em] text-tertiary uppercase">
{t(category)}
</div>
<div className="flex flex-col">
@ -87,3 +103,16 @@ export const WorkspaceSettingsSidebarItemCategories = observer(function Workspac
</div>
);
});
function isWorkspaceFeatureSettingsEntitled(
itemKey: TWorkspaceSettingsTabs,
entitlements: {
isCodexAgentEntitled: boolean;
isVoiceTaskerEntitled: boolean;
}
) {
if (itemKey === "ai-voice-tasker") return entitlements.isVoiceTaskerEntitled;
if (itemKey === "codex-agent-api") return entitlements.isCodexAgentEntitled;
return true;
}

View File

@ -5,7 +5,7 @@
*/
import type { LucideIcon } from "lucide-react";
import { ArrowUpToLine, Building, CreditCard, Database, Mic, Users, Webhook } from "lucide-react";
import { ArrowUpToLine, Bot, Building, CreditCard, Database, Mic, Users, Webhook } from "lucide-react";
// plane imports
import type { ISvgIcons } from "@plane/propel/icons";
import type { TWorkspaceSettingsTabs } from "@plane/types";
@ -18,4 +18,5 @@ export const WORKSPACE_SETTINGS_ICONS: Record<TWorkspaceSettingsTabs, LucideIcon
storage: Database,
webhooks: Webhook,
"ai-voice-tasker": Mic,
"codex-agent-api": Bot,
};

View File

@ -0,0 +1,148 @@
"use client";
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { ArrowRight, BellRing, FolderKanban, UserRound, UsersRound } from "lucide-react";
import { Button } from "@plane/propel/button";
import { Avatar } from "@plane/ui";
import { calculateTimeAgo, getFileURL } from "@plane/utils";
import { closeWorkspaceNotificationsModal } from "@/components/workspace-notifications/notifications-modal.utils";
import { useNotification } from "@/hooks/store/notifications/use-notification";
import { useAppRouter } from "@/hooks/use-app-router";
import { NotificationOption } from "../sidebar/notification-card/options";
type TNodeDCNotificationDetailProps = {
notificationId: string;
workspaceSlug: string;
};
export const NodeDCNotificationDetail = (props: TNodeDCNotificationDetailProps) => {
const { notificationId, workspaceSlug } = props;
const router = useAppRouter();
const { asJson: notification } = useNotification(notificationId);
const [isSnoozeStateModalOpen, setIsSnoozeStateModalOpen] = useState(false);
const [customSnoozeModal, setCustomSnoozeModal] = useState(false);
if (!notification?.id) return <></>;
const targetUrl = notification.data?.target_url;
const isProjectTarget = !!notification.data?.project_id || notification.sender?.includes("project_");
const actionLabel = isProjectTarget ? "Открыть проект" : "Перейти в пространство";
const actor = notification.triggered_by_details;
const contextItems = [
{
icon: <UsersRound className="size-4" />,
label: "Workspace",
value: notification.data?.workspace_name,
},
{
icon: <FolderKanban className="size-4" />,
label: "Проект",
value: notification.data?.project_name,
},
{
icon: <UserRound className="size-4" />,
label: "Роль",
value: notification.data?.role,
},
].filter((item) => item.value);
const handleOpenTarget = () => {
if (!targetUrl) return;
closeWorkspaceNotificationsModal();
router.push(targetUrl);
};
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between gap-4 border-b border-white/6 px-8 py-5">
<div className="min-w-0">
<div className="text-13 font-semibold tracking-[0.16em] text-[rgb(var(--nodedc-accent-rgb))] uppercase">
NODE.DC уведомление
</div>
<div className="mt-1 truncate text-12 text-tertiary">
{notification.created_at ? calculateTimeAgo(notification.created_at) : "Новое событие"}
</div>
</div>
<div className="flex shrink-0 items-center gap-3">
{targetUrl && (
<Button
variant="primary"
size="lg"
onClick={handleOpenTarget}
className="nodedc-empty-state-primary min-w-[12rem]"
appendIcon={<ArrowRight className="size-4" />}
>
{actionLabel}
</Button>
)}
<NotificationOption
workspaceSlug={workspaceSlug}
notificationId={notification.id}
isSnoozeStateModalOpen={isSnoozeStateModalOpen}
setIsSnoozeStateModalOpen={setIsSnoozeStateModalOpen}
customSnoozeModal={customSnoozeModal}
setCustomSnoozeModal={setCustomSnoozeModal}
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-8 py-8">
<div className="mx-auto max-w-3xl">
<div className="nodedc-glass-surface relative overflow-hidden rounded-[2rem] px-8 py-8">
<div className="pointer-events-none absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-[rgb(var(--nodedc-accent-rgb))]/55 to-transparent" />
<div className="flex items-start gap-5">
<div className="flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-full bg-white/[0.045]">
{actor ? (
<Avatar
name={actor.display_name || actor.first_name}
src={getFileURL(actor.avatar_url)}
size={64}
shape="circle"
className="object-cover"
/>
) : (
<BellRing className="size-8 text-[rgb(var(--nodedc-accent-rgb))]" />
)}
</div>
<div className="min-w-0 flex-1">
<h2 className="text-24 font-semibold tracking-[-0.03em] text-primary">
{notification.title || "Новое событие в Tasker"}
</h2>
<p className="mt-3 max-w-2xl text-15 leading-7 text-secondary">
{notification.message_stripped ||
[notification.data?.project_name, notification.data?.workspace_name].filter(Boolean).join(" · ")}
</p>
{actor?.display_name && (
<div className="mt-5 text-13 text-tertiary">
Инициатор: <span className="text-secondary">{actor.display_name}</span>
</div>
)}
</div>
</div>
{contextItems.length > 0 && (
<div className="mt-8 grid gap-3 md:grid-cols-3">
{contextItems.map((item) => (
<div key={item.label} className="rounded-[1.25rem] bg-white/[0.035] px-4 py-4">
<div className="flex items-center gap-2 text-12 font-semibold tracking-[0.14em] text-tertiary uppercase">
{item.icon}
{item.label}
</div>
<div className="mt-2 truncate text-15 font-semibold text-primary">{item.value}</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
};

View File

@ -10,8 +10,10 @@ import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { X } from "lucide-react";
// plane imports
import { ENotificationLoader, ENotificationQueryParamType } from "@plane/constants";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { useWorkspace } from "@/hooks/store/use-workspace";
// local imports
import { NotificationsRoot } from "./root";
@ -31,6 +33,7 @@ const getInitialOpenState = () => {
export const WorkspaceNotificationsModal = observer(function WorkspaceNotificationsModal() {
const [isOpen, setIsOpen] = useState(getInitialOpenState);
const { currentWorkspace } = useWorkspace();
const { getNotifications, setCurrentSelectedNotificationId } = useWorkspaceNotifications();
useEffect(() => {
const syncFromLocation = () => {
@ -54,6 +57,17 @@ export const WorkspaceNotificationsModal = observer(function WorkspaceNotificati
};
}, []);
useEffect(() => {
if (!isOpen || !currentWorkspace?.slug) return;
setCurrentSelectedNotificationId(undefined);
void getNotifications(
currentWorkspace.slug,
ENotificationLoader.MUTATION_LOADER,
ENotificationQueryParamType.CURRENT
);
}, [currentWorkspace?.slug, getNotifications, isOpen, setCurrentSelectedNotificationId]);
const handleClose = () => closeWorkspaceNotificationsModal();
return (

View File

@ -13,7 +13,9 @@ import { EmptyStateCompact } from "@plane/propel/empty-state";
import { cn } from "@plane/utils";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { NodeDCNotificationDetail } from "@/components/workspace-notifications/detail/nodedc-notification-detail";
// hooks
import { useNotification } from "@/hooks/store/notifications/use-notification";
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
@ -40,9 +42,11 @@ export const NotificationsRoot = observer(function NotificationsRoot({ workspace
} = useWorkspaceNotifications();
const { fetchUserProjectInfo } = useUserPermissions();
const { isWorkItem, PeekOverviewComponent, setPeekWorkItem } = useNotificationPreview();
const { asJson: selectedNotification } = useNotification(currentSelectedNotificationId);
// derived values
const { workspace_slug, project_id, issue_id, is_inbox_issue, is_external_contour } =
notificationLiteByNotificationId(currentSelectedNotificationId);
const isNodeDCNotification = selectedNotification?.sender?.startsWith("in_app:nodedc:") ?? false;
// fetching workspace work item properties
useWorkspaceIssueProperties(workspaceSlug);
@ -128,6 +132,8 @@ export const NotificationsRoot = observer(function NotificationsRoot({ workspace
/>
)}
</>
) : isNodeDCNotification && workspaceSlug ? (
<NodeDCNotificationDetail notificationId={currentSelectedNotificationId} workspaceSlug={workspaceSlug} />
) : (
<PeekOverviewComponent embedIssue embedRemoveCurrentNotification={embedRemoveCurrentNotification} />
)}

View File

@ -160,10 +160,10 @@ export function NotificationContent({
renderCommentBox?: boolean;
}) {
const { data, triggered_by_details: triggeredBy } = notification;
const notificationField = data?.issue_activity.field;
const newValue = data?.issue_activity.new_value;
const oldValue = data?.issue_activity.old_value;
const verb = data?.issue_activity.verb;
const notificationField = data?.issue_activity?.field;
const newValue = data?.issue_activity?.new_value;
const oldValue = data?.issue_activity?.old_value;
const verb = data?.issue_activity?.verb;
const fieldData: TNotificationFieldData = {
field: notificationField,

View File

@ -40,8 +40,10 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
const issueId = notification?.data?.issue?.id || undefined;
const workspace = getWorkspaceBySlug(workspaceSlug);
const notificationField = notification?.data?.issue_activity.field || undefined;
const notificationField = notification?.data?.issue_activity?.field || undefined;
const notificationTriggeredBy = notification.triggered_by_details || undefined;
const isNodeDCNotification = notification.sender?.startsWith("in_app:nodedc:") ?? false;
const isIssueNotification = !!notificationField && !!projectId && !!issueId && notification.entity_name === "issue";
const handleNotificationIssuePeekOverview = async () => {
if (workspaceSlug && projectId && issueId && !isSnoozeStateModalOpen && !customSnoozeModal) {
@ -65,8 +67,32 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
}
};
if (!workspaceSlug || !notificationId || !notification?.id || !notificationField || !workspace?.id || !projectId)
return <></>;
const handleNodeDCNotification = async () => {
if (isSnoozeStateModalOpen || customSnoozeModal) return;
setPeekIssue(undefined);
setCurrentSelectedNotificationId(notificationId);
if (notification.read_at === null) {
try {
await markNotificationAsRead(workspaceSlug);
} catch (error) {
console.error(error);
}
}
};
const handleNotificationClick = () => {
if (isIssueNotification) {
void handleNotificationIssuePeekOverview();
return;
}
if (isNodeDCNotification) void handleNodeDCNotification();
};
if (!workspaceSlug || !notificationId || !notification?.id || !workspace?.id) return <></>;
if (!isIssueNotification && !isNodeDCNotification) return <></>;
return (
<Row
@ -77,7 +103,7 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
"bg-accent-primary/5": notification.read_at === null,
}
)}
onClick={handleNotificationIssuePeekOverview}
onClick={handleNotificationClick}
>
{notification.read_at === null && (
<div className="absolute top-[50%] left-2 h-1.5 w-1.5 flex-shrink-0 rounded-full bg-accent-primary" />
@ -85,7 +111,7 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
<div className="relative flex w-full gap-2">
<div className="relative flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-layer-1">
{notificationTriggeredBy && (
{notificationTriggeredBy ? (
<Avatar
name={notificationTriggeredBy.display_name || notificationTriggeredBy?.first_name}
src={getFileURL(notificationTriggeredBy.avatar_url)}
@ -93,18 +119,29 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
shape="circle"
className="bg-layer-1 text-body-sm-medium"
/>
) : (
<img src="/nodedc-logo.svg" alt="NODE DC" className="h-6 w-auto opacity-80" />
)}
</div>
<div className="-mt-2 w-full space-y-1">
<div className="relative flex h-8 items-center gap-3">
<div className="line-clamp-1 w-full truncate overflow-hidden text-body-xs-medium break-all whitespace-normal text-primary">
<NotificationContent
notification={notification}
workspaceId={workspace.id}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
{isIssueNotification && projectId ? (
<NotificationContent
notification={notification}
workspaceId={workspace.id}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
) : (
<span>
{notificationTriggeredBy?.display_name && (
<span className="font-medium text-primary">{notificationTriggeredBy.display_name} </span>
)}
<span className="text-tertiary">{notification.title}</span>
</span>
)}
</div>
<NotificationOption
workspaceSlug={workspaceSlug}
@ -118,8 +155,17 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
<div className="relative flex items-center gap-3 text-caption-sm-regular text-secondary">
<div className="line-clamp-1 w-full truncate overflow-hidden break-words whitespace-normal">
{notification?.data?.issue?.identifier}-{notification?.data?.issue?.sequence_id}&nbsp;
{notification?.data?.issue?.name}
{isIssueNotification ? (
<>
{notification?.data?.issue?.identifier}-{notification?.data?.issue?.sequence_id}&nbsp;
{notification?.data?.issue?.name}
</>
) : (
<>
{notification.message_stripped ||
[notification.data?.project_name, notification.data?.workspace_name].filter(Boolean).join(" · ")}
</>
)}
</div>
<div className="flex-shrink-0">
{notification?.snoozed_till ? (

View File

@ -71,11 +71,22 @@ export const ConfirmWorkspaceMemberRemove = observer(function ConfirmWorkspaceMe
</div>
</div>
</div>
<div className="flex justify-end gap-2 p-4 sm:px-6">
<Button variant="secondary" size="lg" onClick={handleClose}>
<div className="flex justify-end gap-3 p-4 sm:px-6">
<Button
variant="secondary"
size="lg"
onClick={handleClose}
className="nodedc-modal-secondary-button min-w-[11.5rem] px-8"
>
Отменить
</Button>
<Button variant="primary" size="lg" tabIndex={1} onClick={handleDeletion} loading={isRemoving}>
<Button
variant="primary"
size="lg"
onClick={handleDeletion}
loading={isRemoving}
className="nodedc-modal-primary-button min-w-[12.5rem] px-8"
>
{currentUser?.id === userDetails.id
? isRemoving
? "Выход..."

View File

@ -17,6 +17,7 @@ import type { IWorkspace } from "@plane/types";
import { Input } from "@plane/ui";
import { validateWorkspaceName, validateSlug } from "@plane/utils";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
import { createWorkspaceSlug } from "@/helpers/workspace-slug";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useAppRouter } from "@/hooks/use-app-router";
@ -61,7 +62,7 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props:
const inputShellClassName = isNodeDCAuth ? "nodedc-auth-input-shell flex w-full items-center px-4" : "flex flex-col gap-1";
const inputClassName = isNodeDCAuth ? "nodedc-auth-input h-12 w-full px-0 py-0 text-14" : "w-full";
const urlShellClassName = isNodeDCAuth
? "nodedc-auth-input-shell flex w-full items-center px-4"
? "nodedc-auth-input-shell flex w-full items-center px-4 text-14"
: "flex w-full items-center rounded-md border border-subtle bg-layer-2 px-3";
const urlInputClassName = isNodeDCAuth
? "nodedc-auth-input block h-12 w-full border-none bg-transparent !px-0 py-0 text-14"
@ -161,7 +162,7 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props:
onChange={(e) => {
onChange(e.target.value);
setValue("name", e.target.value);
setValue("slug", e.target.value.toLocaleLowerCase().trim().replace(/ /g, "-"), {
setValue("slug", createWorkspaceSlug(e.target.value), {
shouldValidate: true,
});
}}
@ -181,7 +182,9 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props:
{requiredMark}
</label>
<div className={urlShellClassName} data-error={Boolean(errors.slug) || slugError || invalidSlug}>
<span className="mr-1 text-12 whitespace-nowrap text-secondary">{window && window.location.host}/</span>
<span className={isNodeDCAuth ? "mr-1 whitespace-nowrap text-secondary" : "mr-1 text-12 whitespace-nowrap text-secondary"}>
{window && window.location.host}/
</span>
<Controller
control={control}
name="slug"
@ -196,12 +199,13 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props:
<Input
id="workspaceUrl"
type="text"
value={value.toLocaleLowerCase().trim().replace(/ /g, "-")}
value={createWorkspaceSlug(value)}
onChange={(e) => {
const validation = validateSlug(e.target.value);
const nextSlug = createWorkspaceSlug(e.target.value);
const validation = validateSlug(nextSlug);
if (validation === true) setInvalidSlug(false);
else setInvalidSlug(true);
onChange(e.target.value.toLowerCase());
onChange(nextSlug);
}}
ref={ref}
hasError={Boolean(errors.slug)}

View File

@ -0,0 +1,743 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { type ChangeEvent, useMemo, useRef, useState } from "react";
import { observer } from "mobx-react";
import { Bot, Check, KeyRound, Route, ShieldCheck } from "lucide-react";
import useSWR from "swr";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { getFileURL } from "@plane/utils";
// components
import { SettingsHeading } from "@/components/settings/heading";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
// services
import { ProjectService } from "@/services/project/project.service";
import {
WorkspaceCodexAgentService,
type TCodexAgent,
type TCodexAgentSetupPacket,
type TCodexAgentToken,
} from "@/services/workspace-codex-agent.service";
import { WorkspaceService } from "@/services/workspace.service";
const TASK_AUTHOR_SCOPES = [
"workspace:read",
"project:read",
"project:member:add_existing",
"issue:read",
"issue:create",
"issue:update",
"issue:move",
"issue:comment",
"issue:label",
"issue:assign",
"issue:structured_blocks:write",
];
const AGENT_AVATAR_ACCEPT = "image/png,image/jpeg,image/webp,image/gif";
const MAX_AGENT_AVATAR_BYTES = 256 * 1024;
const OPS_AGENT_FILENAME = "OPS_AGENT.md";
const codexAgentService = new WorkspaceCodexAgentService();
const projectService = new ProjectService();
const workspaceService = new WorkspaceService();
type TProps = {
showHeading?: boolean;
workspaceSlug: string;
};
type TAgentSetupCard = {
agent: TCodexAgent;
setup?: TCodexAgentSetupPacket;
tokens: TCodexAgentToken[];
};
export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSettingsContent(props: TProps) {
const { showHeading = true, workspaceSlug } = props;
const createAvatarInputRef = useRef<HTMLInputElement | null>(null);
const agentAvatarInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
const [newAgentName, setNewAgentName] = useState("Local Codex");
const [newAgentAvatarUrl, setNewAgentAvatarUrl] = useState<string | null>(null);
const [selectedProjectId, setSelectedProjectId] = useState("");
const [agentDraftNames, setAgentDraftNames] = useState<Record<string, string>>({});
const [createdSetupCards, setCreatedSetupCards] = useState<TAgentSetupCard[]>([]);
const [revealedTokens, setRevealedTokens] = useState<Record<string, string>>({});
const [updatingAgentIds, setUpdatingAgentIds] = useState<Record<string, boolean>>({});
const [creatingTokenAgentIds, setCreatingTokenAgentIds] = useState<Record<string, boolean>>({});
const { currentWorkspace } = useWorkspace();
const { data: nodedcWorkspacePolicy, isLoading } = useSWR(
workspaceSlug ? `NODEDC_WORKSPACE_POLICY_${workspaceSlug}` : null,
() => workspaceService.getNodeDCWorkspacePolicy(workspaceSlug)
);
const isCodexAgentEntitled = nodedcWorkspacePolicy?.service_modules?.codex_agents === true;
const {
data: codexAgentsPayload,
error: codexAgentsError,
isLoading: areAgentsLoading,
mutate: mutateCodexAgents,
} = useSWR(isCodexAgentEntitled ? `CODEX_AGENT_API_AGENTS_${workspaceSlug}` : null, () =>
codexAgentService.listAgents(workspaceSlug)
);
const { data: projects } = useSWR(isCodexAgentEntitled ? `CODEX_AGENT_API_PROJECTS_${workspaceSlug}` : null, () =>
projectService.getProjectsLite(workspaceSlug)
);
const activeAgents = useMemo(
() => (codexAgentsPayload?.agents ?? []).filter((agent) => agent.status !== "revoked"),
[codexAgentsPayload?.agents]
);
const activeAgentIds = useMemo(() => activeAgents.map((agent) => agent.id).join(","), [activeAgents]);
const {
data: persistedSetupCards,
isLoading: areSetupCardsLoading,
mutate: mutateSetupCards,
} = useSWR(
isCodexAgentEntitled && activeAgentIds
? `CODEX_AGENT_API_AGENT_SETUP_CARDS_${workspaceSlug}_${activeAgentIds}`
: null,
async () =>
Promise.all(
activeAgents.map(async (agent) => {
const [tokensPayload, setupPayload] = await Promise.all([
codexAgentService.listTokens(workspaceSlug, agent.id),
codexAgentService.getSetup(workspaceSlug, agent.id),
]);
return {
agent,
setup: setupPayload.setup,
tokens: tokensPayload.tokens.filter((token) => token.status === "active"),
};
})
)
);
const projectOptions = projects ?? [];
const effectiveSelectedProjectId = selectedProjectId || projectOptions[0]?.id || "";
const setupCards = useMemo(
() => mergeSetupCards(persistedSetupCards ?? [], createdSetupCards),
[createdSetupCards, persistedSetupCards]
);
const handleCopy = async (value: string, label: string) => {
await navigator.clipboard.writeText(value);
setToast({
type: TOAST_TYPE.SUCCESS,
title: `${label} скопирован`,
message: "Секрет не хранится в Ops Agent.md. Token нужно сохранить в локальном Codex отдельно.",
});
};
const handleDownload = (value: string, fileName: string) => {
const blob = new Blob([value], { type: "text/markdown;charset=utf-8" });
const objectUrl = URL.createObjectURL(blob);
const linkElement = document.createElement("a");
linkElement.href = objectUrl;
linkElement.download = fileName;
document.body.appendChild(linkElement);
linkElement.click();
linkElement.remove();
URL.revokeObjectURL(objectUrl);
};
const handleCreateAvatarChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
try {
setNewAgentAvatarUrl(await readAvatarDataUrl(file));
} catch (error: any) {
showAvatarError(error?.message);
}
};
const handleAgentAvatarChange = async (agentId: string, event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
try {
const avatarUrl = await readAvatarDataUrl(file);
setUpdatingAgentIds((current) => ({ ...current, [agentId]: true }));
const response = await codexAgentService.updateAgent(workspaceSlug, agentId, { avatar_url: avatarUrl });
setCreatedSetupCards((currentCards) =>
currentCards.map((card) => (card.agent.id === agentId ? { ...card, agent: response.agent } : card))
);
await mutateCodexAgents();
await mutateSetupCards();
} catch (error: any) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Не удалось обновить аватар агента",
message: error?.message ?? error?.error ?? "Проверьте формат и размер изображения.",
});
} finally {
setUpdatingAgentIds((current) => ({ ...current, [agentId]: false }));
}
};
const handleCreateAgent = async () => {
const displayName = newAgentName.trim();
if (!displayName || !effectiveSelectedProjectId) return;
setIsCreatingAgent(true);
try {
const createResponse = await codexAgentService.createAgent(workspaceSlug, {
display_name: displayName,
avatar_url: newAgentAvatarUrl,
});
await codexAgentService.upsertGrant(workspaceSlug, createResponse.agent.id, {
project_id: effectiveSelectedProjectId,
scopes: TASK_AUTHOR_SCOPES,
mode: "voluntary",
});
const tokenResponse = await codexAgentService.createToken(workspaceSlug, createResponse.agent.id, {
name: `${displayName} local token`,
});
setRevealedTokens((currentTokens) => ({
...currentTokens,
[tokenResponse.token_record.id]: tokenResponse.token,
}));
setCreatedSetupCards((currentCards) =>
upsertSetupCardToken(currentCards, createResponse.agent, tokenResponse.token_record, tokenResponse.setup)
);
await mutateCodexAgents();
await mutateSetupCards();
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Codex agent создан",
message: "Полный token показан только в текущем открытии раздела. После перезахода останется masked suffix.",
});
} catch (error: any) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Не удалось создать Codex agent",
message: error?.message ?? error?.error ?? "Проверьте entitlement, Gateway URL/token и выбранный project.",
});
} finally {
setIsCreatingAgent(false);
}
};
const handleCreateToken = async (agent: TCodexAgent) => {
setCreatingTokenAgentIds((current) => ({ ...current, [agent.id]: true }));
try {
const tokenResponse = await codexAgentService.createToken(workspaceSlug, agent.id, {
name: `${agent.display_name} local token`,
});
setRevealedTokens((currentTokens) => ({
...currentTokens,
[tokenResponse.token_record.id]: tokenResponse.token,
}));
setCreatedSetupCards((currentCards) =>
upsertSetupCardToken(currentCards, agent, tokenResponse.token_record, tokenResponse.setup)
);
await mutateSetupCards();
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Новый token выпущен",
message: "Скопируйте token сейчас. После перезахода backend вернет только masked suffix.",
});
} catch (error: any) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Не удалось выпустить token",
message: error?.message ?? error?.error ?? "Проверьте Gateway и права workspace.",
});
} finally {
setCreatingTokenAgentIds((current) => ({ ...current, [agent.id]: false }));
}
};
const handleSaveAgentName = async (agent: TCodexAgent) => {
const displayName = getAgentDraftName(agentDraftNames, agent).trim();
if (!displayName) return;
setUpdatingAgentIds((current) => ({ ...current, [agent.id]: true }));
try {
const response = await codexAgentService.updateAgent(workspaceSlug, agent.id, { display_name: displayName });
setAgentDraftNames((currentDrafts) => {
const nextDrafts = { ...currentDrafts };
delete nextDrafts[agent.id];
return nextDrafts;
});
setCreatedSetupCards((currentCards) =>
currentCards.map((card) => (card.agent.id === agent.id ? { ...card, agent: response.agent } : card))
);
await mutateCodexAgents();
await mutateSetupCards();
} catch (error: any) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Не удалось сохранить агента",
message: error?.message ?? error?.error ?? "Проверьте имя агента и Gateway.",
});
} finally {
setUpdatingAgentIds((current) => ({ ...current, [agent.id]: false }));
}
};
const handleRevokeAgent = async (agentId: string) => {
await codexAgentService.revokeAgent(workspaceSlug, agentId);
setCreatedSetupCards((currentCards) => currentCards.filter((card) => card.agent.id !== agentId));
await mutateCodexAgents();
await mutateSetupCards();
};
return (
<div className="flex w-full flex-col gap-7">
{showHeading && (
<SettingsHeading
title="Codex Agent API"
description="Workspace-level вход в отдельный NODE.DC Agent Gateway. Внешний Codex получает только ограниченные agent grants, а не Plane session cookies или прямой Tasker API."
/>
)}
{isLoading ? (
<div className="nodedc-settings-card text-sm px-5 py-5 text-secondary">Загрузка статуса модуля...</div>
) : (
<>
<section className="nodedc-settings-card px-5 py-5">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-start">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2 text-16 font-semibold text-primary">
<Bot className="size-5 shrink-0 text-tertiary" />
<span className="min-w-0 truncate">Agent Gateway для {currentWorkspace?.name ?? workspaceSlug}</span>
</div>
<p className="mt-2 max-w-[56rem] text-13 leading-5 text-secondary">
Доступ к модулю приходит из Launcher entitlement Operational Core Codex Agent API. Если entitlement
снят, этот раздел исчезает из настроек workspace и backend policy больше не возвращает активный
модуль.
</p>
</div>
<div className="nodedc-settings-chip inline-flex h-11 w-fit items-center gap-2 text-13 font-medium text-primary">
<span className="grid size-5 shrink-0 place-items-center rounded-full bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]">
<Check className="size-3.5" />
</span>
<span>{isCodexAgentEntitled ? "Доступ выдан" : "Доступ не выдан"}</span>
</div>
</div>
</section>
<section className="grid gap-4 md:grid-cols-3">
<CapabilityCard
icon={ShieldCheck}
title="Граница прав"
description="Агент работает только в workspace/project grants и не получает права на удаление карточек, проектов, участников или состояний."
/>
<CapabilityCard
icon={Route}
title="Маршрутизация"
description="Все write-действия идут через отдельный Gateway и Tasker internal adapter, с audit trail и idempotency key."
/>
<CapabilityCard
icon={KeyRound}
title="Локальный Codex"
description="Пользовательский Codex подключается по MCP endpoint с agent token; token хранится только на стороне Gateway."
/>
</section>
{isCodexAgentEntitled && (
<>
<section className="nodedc-settings-card flex flex-col gap-5 px-5 py-5">
<div className="flex flex-col gap-1.5">
<div className="text-15 font-semibold text-primary">Создать агента workspace</div>
<p className="max-w-3xl text-12 leading-5 text-tertiary">
Задайте имя, выберите project grant и выпустите agent token. Аватар меняется кликом по кругу.
</p>
</div>
<div className="grid gap-4 xl:grid-cols-[auto_minmax(12rem,1fr)_minmax(12rem,1fr)_auto] xl:items-end">
<div className="grid gap-2.5">
<span className="text-body-sm-medium text-tertiary">Аватар</span>
<input
ref={createAvatarInputRef}
type="file"
accept={AGENT_AVATAR_ACCEPT}
className="hidden"
onChange={(event) => void handleCreateAvatarChange(event)}
/>
<AgentAvatarButton
avatarUrl={newAgentAvatarUrl}
name={newAgentName}
onClick={() => createAvatarInputRef.current?.click()}
/>
</div>
<label className="flex min-w-0 flex-col gap-2.5">
<span className="text-body-sm-medium text-tertiary">Задайте имя</span>
<input
className="nodedc-settings-input h-12 w-full px-4 text-13"
value={newAgentName}
onChange={(event) => setNewAgentName(event.target.value)}
placeholder="Имя агента"
/>
</label>
<label className="flex min-w-0 flex-col gap-2.5">
<span className="text-body-sm-medium text-tertiary">Выберите project</span>
<select
className="nodedc-settings-select h-12 w-full px-4 text-13"
value={effectiveSelectedProjectId}
onChange={(event) => setSelectedProjectId(event.target.value)}
>
{projectOptions.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
</label>
<Button
variant="primary"
size="lg"
className="nodedc-settings-save-button h-12 min-w-[11rem] self-end px-5"
disabled={!newAgentName.trim() || !effectiveSelectedProjectId}
loading={isCreatingAgent}
onClick={handleCreateAgent}
>
Создать агента
</Button>
</div>
</section>
{codexAgentsError && (
<div className="bg-red-500/10 text-red-300 rounded-xl px-4 py-3 text-13">
Gateway недоступен или не настроен. Проверьте `PLANE_NODEDC_AGENT_GATEWAY_URL` и
`PLANE_NODEDC_AGENT_GATEWAY_TOKEN` в Tasker API runtime.
</div>
)}
{areAgentsLoading ? (
<div className="nodedc-settings-card px-5 py-5 text-13 text-secondary">Загрузка агентов...</div>
) : activeAgents.length > 0 ? (
activeAgents.map((agent) => {
const draftName = getAgentDraftName(agentDraftNames, agent);
const isUpdatingAgent = updatingAgentIds[agent.id] === true;
const isCreatingToken = creatingTokenAgentIds[agent.id] === true;
const isAgentDirty = draftName.trim() !== agent.display_name;
const setupCard = setupCards.find((card) => card.agent.id === agent.id);
const agentTokens = setupCard?.tokens ?? [];
const setup = setupCard?.setup;
return (
<section key={agent.id} className="nodedc-settings-card flex flex-col gap-5 px-5 py-5">
<div className="grid gap-4 xl:grid-cols-[auto_minmax(12rem,1fr)_minmax(12rem,0.8fr)_auto] xl:items-end">
<div className="grid gap-2.5">
<span className="text-body-sm-medium text-tertiary">Аватар</span>
<input
ref={(element) => {
agentAvatarInputRefs.current[agent.id] = element;
}}
type="file"
accept={AGENT_AVATAR_ACCEPT}
className="hidden"
onChange={(event) => void handleAgentAvatarChange(agent.id, event)}
/>
<AgentAvatarButton
avatarUrl={agent.avatar_url}
disabled={isUpdatingAgent}
name={draftName}
onClick={() => agentAvatarInputRefs.current[agent.id]?.click()}
/>
</div>
<label className="flex min-w-0 flex-col gap-2.5">
<span className="text-body-sm-medium text-tertiary">Имя агента</span>
<input
className="nodedc-settings-input h-12 w-full px-4 text-13"
value={draftName}
onChange={(event) =>
setAgentDraftNames((currentDrafts) => ({
...currentDrafts,
[agent.id]: event.target.value,
}))
}
placeholder="Имя агента"
/>
</label>
<div className="flex min-w-0 flex-col gap-2.5">
<span className="text-body-sm-medium text-tertiary">Состояние</span>
<div className="nodedc-settings-input flex h-12 items-center px-4 text-13 text-secondary">
active · {new Date(agent.created_at).toLocaleString()}
</div>
</div>
<div className="flex flex-wrap gap-2 self-end">
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip h-12"
disabled={!isAgentDirty}
loading={isUpdatingAgent}
onClick={() => void handleSaveAgentName(agent)}
>
Сохранить
</Button>
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip h-12"
loading={isCreatingToken}
onClick={() => void handleCreateToken(agent)}
>
Новый token
</Button>
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip h-12"
onClick={() => void handleRevokeAgent(agent.id)}
>
Отозвать
</Button>
</div>
</div>
{areSetupCardsLoading && agentTokens.length === 0 ? (
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">
Загрузка token и Ops Agent.md...
</div>
) : agentTokens.length > 0 ? (
<div className="grid gap-4">
{agentTokens.map((token) => {
const revealedToken = revealedTokens[token.id];
const tokenValue = revealedToken ?? maskToken(token);
const isTokenRevealed = Boolean(revealedToken);
return (
<div key={token.id} className="grid gap-4 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.4fr)]">
<div className="nodedc-settings-field p-4">
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">
Agent token
</div>
<code className="nodedc-settings-input block min-h-12 px-3 py-3 text-12 break-all text-primary">
{tokenValue}
</code>
{isTokenRevealed && (
<div className="mt-3 flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip"
onClick={() => void handleCopy(revealedToken, "Токен")}
>
Скопировать токен
</Button>
</div>
)}
</div>
<div className="nodedc-settings-field p-4">
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">
Ops Agent.md
</div>
{setup?.agents_md ? (
<>
<textarea
readOnly
className="nodedc-settings-input font-mono h-64 w-full resize-y px-3 py-3 text-12"
value={setup.agents_md}
/>
<div className="mt-3 flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip"
onClick={() => void handleCopy(setup.agents_md ?? "", "Ops Agent.md")}
>
Скопировать Ops Agent.md
</Button>
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip"
onClick={() => handleDownload(setup.agents_md ?? "", OPS_AGENT_FILENAME)}
>
Скачать
</Button>
</div>
</>
) : (
<div className="nodedc-settings-input flex min-h-24 items-center px-4 text-13 text-secondary">
Setup packet пока недоступен. Проверьте Gateway и grants агента.
</div>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">
Token ещё не выпущен. Нажмите «Новый token», чтобы получить token и Ops Agent.md.
</div>
)}
</section>
);
})
) : (
<div className="nodedc-settings-card px-5 py-5 text-center text-13 text-secondary">
Агентов пока нет. Создайте агента, выберите project и сразу получите token + Ops Agent.md.
</div>
)}
</>
)}
</>
)}
</div>
);
});
function getAgentDraftName(agentDraftNames: Record<string, string>, agent: TCodexAgent): string {
return agentDraftNames[agent.id] ?? agent.display_name;
}
function getInitials(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean).slice(0, 2);
const initials = words.map((word) => word[0]?.toUpperCase()).join("");
return initials || "A";
}
function maskToken(token: TCodexAgentToken): string {
return `${"×".repeat(16)}${token.token_suffix ?? token.id.slice(-8)}`;
}
function getAvatarSrc(avatarUrl?: string | null): string | null {
if (!avatarUrl) return null;
if (/^(data:|blob:|https?:\/\/)/.test(avatarUrl)) return avatarUrl;
return getFileURL(avatarUrl);
}
function AgentAvatar(props: { avatarUrl?: string | null; name: string; size?: "md" | "lg" }) {
const sizeClassName = props.size === "lg" ? "size-12 text-16" : "size-10 text-13";
const commonClassName = `${sizeClassName} shrink-0 overflow-hidden rounded-full bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]`;
const avatarSrc = getAvatarSrc(props.avatarUrl);
if (avatarSrc) {
return <img src={avatarSrc} alt={props.name} className={`${commonClassName} object-cover`} />;
}
return <div className={`${commonClassName} grid place-items-center font-semibold`}>{getInitials(props.name)}</div>;
}
function AgentAvatarButton(props: {
avatarUrl?: string | null;
disabled?: boolean;
name: string;
onClick: () => void;
}) {
return (
<button
type="button"
className="relative size-12 rounded-full transition outline-none hover:opacity-90 disabled:cursor-wait disabled:opacity-70"
disabled={props.disabled}
onClick={props.onClick}
title="Изменить аватар"
>
<AgentAvatar avatarUrl={props.avatarUrl} name={props.name} size="lg" />
</button>
);
}
function mergeSetupCards(persistedCards: TAgentSetupCard[], createdCards: TAgentSetupCard[]): TAgentSetupCard[] {
const cardsByAgentId = new Map<string, TAgentSetupCard>();
for (const card of persistedCards) {
cardsByAgentId.set(card.agent.id, card);
}
for (const card of createdCards) {
const persistedCard = cardsByAgentId.get(card.agent.id);
if (!persistedCard) {
cardsByAgentId.set(card.agent.id, card);
continue;
}
cardsByAgentId.set(card.agent.id, {
agent: persistedCard.agent,
setup: persistedCard.setup ?? card.setup,
tokens: mergeTokens(persistedCard.tokens, card.tokens),
});
}
return Array.from(cardsByAgentId.values()).filter((card) => card.tokens.length > 0);
}
function mergeTokens(primaryTokens: TCodexAgentToken[], secondaryTokens: TCodexAgentToken[]): TCodexAgentToken[] {
const tokensById = new Map<string, TCodexAgentToken>();
for (const token of primaryTokens) tokensById.set(token.id, token);
for (const token of secondaryTokens) tokensById.set(token.id, tokensById.get(token.id) ?? token);
return Array.from(tokensById.values()).sort(
(leftToken, rightToken) => new Date(rightToken.created_at).getTime() - new Date(leftToken.created_at).getTime()
);
}
function upsertSetupCardToken(
cards: TAgentSetupCard[],
agent: TCodexAgent,
token: TCodexAgentToken,
setup?: TCodexAgentSetupPacket
): TAgentSetupCard[] {
const existingCard = cards.find((card) => card.agent.id === agent.id);
if (!existingCard) {
return [{ agent, setup, tokens: [token] }, ...cards];
}
return cards.map((card) =>
card.agent.id === agent.id
? {
agent,
setup: setup ?? card.setup,
tokens: mergeTokens([token], card.tokens),
}
: card
);
}
function readAvatarDataUrl(file: File): Promise<string> {
if (!file.type.startsWith("image/")) {
return Promise.reject(new Error("Поддерживаются только изображения PNG, JPG, WEBP или GIF."));
}
if (file.size > MAX_AGENT_AVATAR_BYTES) {
return Promise.reject(new Error("Аватар агента должен быть не больше 256 КБ."));
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error("Не удалось прочитать файл аватара."));
reader.readAsDataURL(file);
});
}
function showAvatarError(message?: string) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Не удалось загрузить аватар",
message: message ?? "Проверьте формат и размер изображения.",
});
}
type TCapabilityCardProps = {
description: string;
icon: typeof ShieldCheck;
title: string;
};
function CapabilityCard(props: TCapabilityCardProps) {
const Icon = props.icon;
return (
<div className="nodedc-settings-card px-5 py-5">
<div className="flex items-center gap-2 text-14 font-semibold text-primary">
<Icon className="size-4 text-tertiary" />
<span>{props.title}</span>
</div>
<p className="mt-3 text-13 leading-5 text-secondary">{props.description}</p>
</div>
);
}

View File

@ -91,7 +91,7 @@ export const WorkspaceMembersListItem = observer(function WorkspaceMembersListIt
if (isEmpty(columns)) return <MembersLayoutLoader />;
return (
<div className="horizontal-scrollbar scrollbar-sm w-full overflow-x-auto overflow-y-hidden rounded-[1.2rem]">
<div className="w-full overflow-visible rounded-[1.2rem]">
{removeMemberModal && (
<ConfirmWorkspaceMemberRemove
isOpen={removeMemberModal.member.id.length > 0}
@ -109,7 +109,7 @@ export const WorkspaceMembersListItem = observer(function WorkspaceMembersListIt
(memberDetails?.filter((member): member is IWorkspaceMember => member !== null) ?? []) as unknown as RowData[]
}
keyExtractor={(rowData) => rowData?.member.id ?? ""}
tableClassName="nodedc-settings-table-surface w-max table-auto border-separate border-spacing-0 overflow-visible"
tableClassName="nodedc-settings-table-surface min-w-full table-auto border-separate border-spacing-0 overflow-visible"
tHeadClassName="border-b border-white/6"
thClassName="text-left font-medium divide-x-0 text-placeholder"
tBodyClassName="divide-y-0"

View File

@ -25,6 +25,7 @@ import { SettingsSidebarItem } from "@/components/settings/sidebar/item";
import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon";
import { WorkspaceSettingsSidebarHeader } from "@/components/settings/workspace/sidebar/header";
import { AIVoiceTaskerSettingsContent } from "@/components/workspace/settings/ai-voice-tasker-settings";
import { CodexAgentApiSettingsContent } from "@/components/workspace/settings/codex-agent-api-settings";
import { WorkspaceExportsSettingsContent } from "@/components/workspace/settings/exports-settings";
import { WorkspaceMembersSettingsContent } from "@/components/workspace/settings/members-settings";
import { StorageSettingsContent } from "@/components/workspace/settings/storage-settings";
@ -48,8 +49,16 @@ import {
const HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["billing-and-plans"]);
const LAUNCHER_MANAGED_HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["members"]);
const WORKSPACE_FEATURE_GATED_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["ai-voice-tasker"]);
const MODAL_TABS = new Set<TWorkspaceSettingsTabs>(["general", "members", "export", "storage", "webhooks", "ai-voice-tasker"]);
const WORKSPACE_FEATURE_GATED_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["ai-voice-tasker", "codex-agent-api"]);
const MODAL_TABS = new Set<TWorkspaceSettingsTabs>([
"general",
"members",
"export",
"storage",
"webhooks",
"ai-voice-tasker",
"codex-agent-api",
]);
const workspaceAIService = new WorkspaceAIService();
const workspaceService = new WorkspaceService();
@ -77,7 +86,11 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
const { t } = useTranslation();
const canLoadVoiceTaskerEntitlement =
!!currentWorkspace?.slug &&
allowPermissions(WORKSPACE_SETTINGS["ai-voice-tasker"].access, EUserPermissionsLevel.WORKSPACE, currentWorkspace.slug);
allowPermissions(
WORKSPACE_SETTINGS["ai-voice-tasker"].access,
EUserPermissionsLevel.WORKSPACE,
currentWorkspace.slug
);
const { data: aiSettings, isLoading: isVoiceTaskerEntitlementLoading } = useSWR(
canLoadVoiceTaskerEntitlement ? `WORKSPACE_AI_SETTINGS_${currentWorkspace.slug}` : null,
() => workspaceAIService.retrieveSettings(currentWorkspace?.slug as string)
@ -88,13 +101,16 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
);
const isVoiceTaskerEntitled = aiSettings?.feature_entitlement_enabled === true;
const isLauncherManagedWorkspace = nodedcWorkspacePolicy?.managed_by === "launcher";
const isCodexAgentEntitled = nodedcWorkspacePolicy?.service_modules?.codex_agents === true;
useEffect(() => {
const syncFromLocation = () => {
const tab = getWorkspaceSettingsModalTabFromSearch(window.location.search);
setIsOpen(Boolean(tab));
if (tab) setActiveTab(tab);
setActiveWebhookId(tab === "webhooks" ? getWorkspaceSettingsWebhookIdFromSearch(window.location.search) : undefined);
setActiveWebhookId(
tab === "webhooks" ? getWorkspaceSettingsWebhookIdFromSearch(window.location.search) : undefined
);
};
const handleModalEvent = (event: Event) => {
@ -123,6 +139,11 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
if (!isVoiceTaskerEntitled) openWorkspaceSettingsModal("general", true);
}, [activeTab, isOpen, isVoiceTaskerEntitlementLoading, isVoiceTaskerEntitled]);
useEffect(() => {
if (!isOpen || activeTab !== "codex-agent-api" || !nodedcWorkspacePolicy) return;
if (!isCodexAgentEntitled) openWorkspaceSettingsModal("general", true);
}, [activeTab, isCodexAgentEntitled, isOpen, nodedcWorkspacePolicy]);
useEffect(() => {
if (!isOpen || activeTab !== "members" || !isLauncherManagedWorkspace) return;
openWorkspaceSettingsModal("general", true);
@ -149,6 +170,11 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
return <AIVoiceTaskerSettingsContent workspaceSlug={currentWorkspace.slug} />;
}
if (activeTab === "codex-agent-api" && currentWorkspace?.slug) {
if (!isCodexAgentEntitled) return <WorkspaceDetails />;
return <CodexAgentApiSettingsContent workspaceSlug={currentWorkspace.slug} />;
}
if (activeTab === "members" && currentWorkspace?.slug) {
return <WorkspaceMembersSettingsContent workspaceSlug={currentWorkspace.slug} />;
}
@ -162,7 +188,9 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
}
if (activeTab === "webhooks" && currentWorkspace?.slug) {
return <WorkspaceWebhooksSettingsContent selectedWebhookId={activeWebhookId} workspaceSlug={currentWorkspace.slug} />;
return (
<WorkspaceWebhooksSettingsContent selectedWebhookId={activeWebhookId} workspaceSlug={currentWorkspace.slug} />
);
}
return <WorkspaceDetails />;
@ -178,7 +206,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
handleClose={handleClose}
position={EModalPosition.CENTER}
width={EModalWidth.VIIXL}
className="h-[88vh] max-h-[920px] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)]"
className="h-[88vh] max-h-[920px] !max-w-[calc(100vw-1.5rem)] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)] sm:!max-w-[calc(100vw-2rem)] xl:!max-w-[88rem]"
>
<div className="flex h-full min-h-0">
<div className="hidden h-full w-[296px] shrink-0 md:block">
@ -189,6 +217,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
allowPermissions={allowPermissions}
isVoiceTaskerEntitled={isVoiceTaskerEntitled}
isLauncherManagedWorkspace={isLauncherManagedWorkspace}
isCodexAgentEntitled={isCodexAgentEntitled}
workspaceSlug={currentWorkspace?.slug}
/>
</div>
@ -212,7 +241,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
</div>
<ScrollArea scrollType="hover" orientation="vertical" size="sm" className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-[74rem] px-5 pb-7 lg:px-8">{renderContent()}</div>
<div className="mx-auto w-full max-w-[78rem] px-5 pb-7 lg:px-8">{renderContent()}</div>
</ScrollArea>
</div>
</div>
@ -223,6 +252,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
type TWorkspaceModalSidebarProps = {
activeTab: TWorkspaceSettingsModalTab;
allowPermissions: ReturnType<typeof useUserPermissions>["allowPermissions"];
isCodexAgentEntitled: boolean;
isLauncherManagedWorkspace: boolean;
isVoiceTaskerEntitled: boolean;
onSelectItem: (itemKey: TWorkspaceSettingsTabs, itemHref: string) => void;
@ -232,6 +262,7 @@ type TWorkspaceModalSidebarProps = {
function WorkspaceModalSidebar({
activeTab,
allowPermissions,
isCodexAgentEntitled,
isLauncherManagedWorkspace,
isVoiceTaskerEntitled,
onSelectItem,
@ -252,7 +283,11 @@ function WorkspaceModalSidebar({
(item) =>
!HIDDEN_WORKSPACE_SETTINGS_KEYS.has(item.key) &&
(!isLauncherManagedWorkspace || !LAUNCHER_MANAGED_HIDDEN_WORKSPACE_SETTINGS_KEYS.has(item.key)) &&
(!WORKSPACE_FEATURE_GATED_SETTINGS_KEYS.has(item.key) || isVoiceTaskerEntitled) &&
(!WORKSPACE_FEATURE_GATED_SETTINGS_KEYS.has(item.key) ||
isWorkspaceFeatureSettingsEntitled(item.key, {
isCodexAgentEntitled,
isVoiceTaskerEntitled,
})) &&
allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug)
);
@ -260,7 +295,7 @@ function WorkspaceModalSidebar({
return (
<div key={category} className="shrink-0 py-3.5 first:pt-0 last:pb-0">
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-tertiary">
<div className="px-3 py-1.5 text-[11px] font-semibold tracking-[0.18em] text-tertiary uppercase">
{t(category)}
</div>
<div className="flex flex-col">
@ -287,3 +322,16 @@ function WorkspaceModalSidebar({
</ScrollArea>
);
}
function isWorkspaceFeatureSettingsEntitled(
itemKey: TWorkspaceSettingsTabs,
entitlements: {
isCodexAgentEntitled: boolean;
isVoiceTaskerEntitled: boolean;
}
) {
if (itemKey === "ai-voice-tasker") return entitlements.isVoiceTaskerEntitled;
if (itemKey === "codex-agent-api") return entitlements.isCodexAgentEntitled;
return true;
}

View File

@ -3,7 +3,14 @@ export const WORKSPACE_SETTINGS_MODAL_EVENT = "nodedc:workspace-settings-modal";
export const WORKSPACE_SETTINGS_WEBHOOK_QUERY_KEY = "webhookId";
export type TWorkspaceSettingsModalTab = "general" | "members" | "export" | "storage" | "webhooks" | "ai-voice-tasker";
export type TWorkspaceSettingsModalTab =
| "general"
| "members"
| "export"
| "storage"
| "webhooks"
| "ai-voice-tasker"
| "codex-agent-api";
type TWorkspaceSettingsModalEventDetail = {
isOpen: boolean;
@ -23,7 +30,8 @@ export const getWorkspaceSettingsModalTabFromSearch = (search: string): TWorkspa
value === "export" ||
value === "storage" ||
value === "webhooks" ||
value === "ai-voice-tasker"
value === "ai-voice-tasker" ||
value === "codex-agent-api"
)
return value;

View File

@ -55,7 +55,7 @@ export const SidebarProjectsList = observer(function SidebarProjectsList() {
// auth
const isAuthorizedUser = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
[EUserPermissions.ADMIN],
EUserPermissionsLevel.WORKSPACE
);

View File

@ -44,10 +44,11 @@ type WorkspaceMenuStateSyncProps = {
sidebarPanelButtonRef: RefObject<HTMLButtonElement | null>;
onSidebarDropdownToggle: (value: boolean) => void;
onSidebarPanelPositionChange: (position: { left: number; top: number; width: number } | null) => void;
onOpen?: () => void;
};
function WorkspaceMenuStateSync(props: WorkspaceMenuStateSyncProps) {
const { open, variant, sidebarPanelButtonRef, onSidebarDropdownToggle, onSidebarPanelPositionChange } = props;
const { open, variant, sidebarPanelButtonRef, onOpen, onSidebarDropdownToggle, onSidebarPanelPositionChange } = props;
const updateSidebarPanelMenuPosition = useCallback(() => {
if (
@ -72,6 +73,10 @@ function WorkspaceMenuStateSync(props: WorkspaceMenuStateSyncProps) {
onSidebarDropdownToggle(open);
}, [onSidebarDropdownToggle, open]);
useEffect(() => {
if (open) onOpen?.();
}, [onOpen, open]);
useLayoutEffect(() => {
if (!open || !["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant)) {
onSidebarPanelPositionChange(null);
@ -102,7 +107,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
const { signOut } = useUser();
const { updateUserProfile } = useUserProfile();
const { currentWorkspace: activeWorkspace, workspaces } = useWorkspace();
const { data: nodedcWorkspacePolicy } = useSWR(currentUser ? "NODEDC_WORKSPACE_POLICY" : null, () =>
const { data: nodedcWorkspacePolicy, mutate: mutateNodeDCWorkspacePolicy } = useSWR(currentUser ? "NODEDC_WORKSPACE_POLICY" : null, () =>
workspaceService.getNodeDCWorkspacePolicy()
);
// derived values
@ -118,6 +123,9 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
} | null>(null);
const sidebarPanelButtonRef = useRef<HTMLButtonElement>(null);
const handleWorkspaceMenuOpen = useCallback(() => {
void mutateNodeDCWorkspacePolicy();
}, [mutateNodeDCWorkspacePolicy]);
const handleWorkspaceNavigation = (workspace: IWorkspace) => updateUserProfile({ last_workspace_id: workspace?.id });
@ -157,6 +165,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
variant={variant}
sidebarPanelButtonRef={sidebarPanelButtonRef}
onSidebarDropdownToggle={toggleAnySidebarDropdown}
onOpen={handleWorkspaceMenuOpen}
onSidebarPanelPositionChange={setSidebarPanelMenuPosition}
/>
{variant === "sidebar" && (

Some files were not shown because too many files have changed in this diff Show More