Compare commits
22 Commits
2224faa8f7
...
b0a682b63b
| Author | SHA1 | Date |
|---|---|---|
|
|
b0a682b63b | |
|
|
65ee15b86c | |
|
|
3b8e0ea594 | |
|
|
97566faba3 | |
|
|
2ae353c8d5 | |
|
|
533f8c6356 | |
|
|
c455ce3c34 | |
|
|
4eb6362565 | |
|
|
af01a205f0 | |
|
|
2717726440 | |
|
|
6f34c3cd34 | |
|
|
a86ed9f5f3 | |
|
|
268ab2c9b9 | |
|
|
5e7c9e08a0 | |
|
|
87e1857f53 | |
|
|
d0e2f423e6 | |
|
|
1f7ecc39a0 | |
|
|
480f85cce8 | |
|
|
fc59481703 | |
|
|
6737138ab7 | |
|
|
854a596149 | |
|
|
4ffcd64ddc |
24
AGENTS.md
24
AGENTS.md
|
|
@ -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: регламент этапов и именования коммитов
|
||||
```
|
||||
|
||||
## Дополнение
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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:-}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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/",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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"}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)})
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;"]
|
||||
|
|
@ -45,7 +45,7 @@ function AnalyticsPage({ params }: Route.ComponentProps) {
|
|||
|
||||
// permissions
|
||||
const canPerformEmptyStateActions = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
[EUserPermissions.ADMIN],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
|
|||
|
||||
// auth
|
||||
const isAuthorizedUser = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
[EUserPermissions.ADMIN],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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" }];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
),
|
||||
]),
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
]}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
? "У вас нет прав перемещать эту карточку"
|
||||
: "Перетаскивание отключено для текущей группировки",
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
? "У вас нет прав перемещать эту карточку"
|
||||
: "Перетаскивание отключено для текущей группировки",
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
]}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export const ProjectCardList = observer(function ProjectCardList(props: TProject
|
|||
|
||||
// permissions
|
||||
const canPerformEmptyStateActions = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
[EUserPermissions.ADMIN],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 can’t 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ?? []}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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")}`}
|
||||
|
|
|
|||
|
|
@ -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 can’t 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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
{notification?.data?.issue?.name}
|
||||
{isIssueNotification ? (
|
||||
<>
|
||||
{notification?.data?.issue?.identifier}-{notification?.data?.issue?.sequence_id}
|
||||
{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 ? (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? "Выход..."
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export const SidebarProjectsList = observer(function SidebarProjectsList() {
|
|||
|
||||
// auth
|
||||
const isAuthorizedUser = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
[EUserPermissions.ADMIN],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue