Compare commits

..

15 Commits

Author SHA1 Message Date
DCCONSTRUCTIONS 4ba3aab02e ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: блокировка и удаление участников воркспейса в God Mode 2026-04-29 01:15:17 +03:00
DCCONSTRUCTIONS 7bf416ec1f ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: администрирование воркспейсов и feature-gates в God Mode 2026-04-29 00:38:11 +03:00
DCCONSTRUCTIONS 7f47b85c36 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: дизайн и русификация God Mode 2026-04-29 00:38:04 +03:00
DCCONSTRUCTIONS 5d336039ba АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: переезд God Mode на nodedcsudo 2026-04-29 00:37:39 +03:00
DCCONSTRUCTIONS 3b0c75bee6 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: модальное окно уведомлений workspace 2026-04-28 20:50:58 +03:00
DCCONSTRUCTIONS be7929deec UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: выравнивание чипов очереди Voice Tasker 2026-04-28 20:31:47 +03:00
DCCONSTRUCTIONS 0cda486b05 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: backlog-очередь Voice Tasker 2026-04-28 20:10:05 +03:00
DCCONSTRUCTIONS 5e57786d39 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: вкладки enterprise-настроек Voice Tasker 2026-04-28 18:33:26 +03:00
DCCONSTRUCTIONS b796a21852 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: retention и стабильная загрузка аудио Voice Tasker 2026-04-28 17:54:11 +03:00
DCCONSTRUCTIONS d60c28ec04 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: мониторинг очереди Voice Tasker 2026-04-28 16:31:47 +03:00
DCCONSTRUCTIONS 3b295e33f3 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: очередь Voice Tasker и concurrency control 2026-04-28 15:43:01 +03:00
DCCONSTRUCTIONS a0213db2fc ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: суточные квоты Voice Tasker по workspace и контурам 2026-04-28 14:39:39 +03:00
DCCONSTRUCTIONS a0c0db27f3 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: лимиты Voice Tasker до OpenAI pipeline 2026-04-28 14:06:25 +03:00
DCCONSTRUCTIONS 46e27a326c ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: очистка хранилища и проектные квоты 2026-04-28 13:25:37 +03:00
DCCONSTRUCTIONS 02d79da6f9 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: ручное назначение Voice Tasker 2026-04-28 11:04:38 +03:00
131 changed files with 6551 additions and 1009 deletions

View File

@ -81,27 +81,35 @@ UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: переименован
## Ведение карточек задач Codex ## Ведение карточек задач Codex
Карточка задачи должна разделять постановку и ход работ. Карточка задачи должна разделять постановку, план этапов и фактическую реализацию.
Заголовок карточки:
- передает основную суть задачи
- должен быть коротким, читаемым в списке и без служебного шума
Основное тело карточки: Основное тело карточки:
- хранит общее описание задачи, цель, контекст, ограничения и критерии приемки - хранит концептуальное описание задачи, цель, контекст, ограничения и критерии приемки
- не превращается в журнал работ - описывает, зачем делается изменение и как система должна работать на среднем уровне детализации
- не превращается в журнал работ и не дублирует чекеры
- остается читаемым входом в задачу после нескольких итераций - остается читаемым входом в задачу после нескольких итераций
Подэлемент `Текущий статус работ`: Подэлементы-чекеры:
- создается как текстовый блок через `Добавить подэлемент` - создаются через `Добавить подэлемент` как отдельный чекер на каждый смысловой этап
- хранит фактический отчет по реализации - заголовок чекера должен называться как этап, например `Этап 1. Backend enforcement лимитов`
- обновляется после каждого осмысленного этапа - пункты внутри чекера должны быть короткими проверяемыми действиями по этапу
- фиксирует, что сделано, что проверено, какие файлы/модули затронуты, что осталось и какой следующий шаг - пункт закрывается только после реализации и проверки, а не по намерению
- чекеры используются как рабочий план, а не как место для длинных объяснений
Подэлемент-чекер: Текстовые блоки фактической реализации:
- используется для подзадач, которые можно проверить отдельно - создаются через `Добавить подэлемент` под соответствующим чекером этапа
- содержит короткие конкретные пункты без дублирования основного описания - заголовок текстового блока должен явно связывать его с этапом, например `Реализация этапа 1`
- закрывается по факту реализации и проверки, а не по намерению - блок фиксирует, что реально сделано, какие файлы/модули затронуты, какие проверки прошли и какие ограничения остались
- важные нюансы для дальнейшего масштабирования записываются именно сюда, а не теряются в чате
- после каждого осмысленного этапа соответствующий текстовый блок обновляется
Статус карточки: Статус карточки:
- `В работе` ставится только когда задача реально взята в исполнение - `В работе` ставится только когда задача реально взята в исполнение
- `Готово` ставится после проверки результата - `Готово` ставится после проверки результата и закрытия рабочих чекеров
- `Отложено` используется для задач, которые больше не входят в текущий рабочий план - `Отложено` используется для задач, которые больше не входят в текущий рабочий план
- backlog не должен хранить уже сделанные или сознательно отложенные задачи как активные - backlog не должен хранить уже сделанные или сознательно отложенные задачи как активные

View File

@ -27,11 +27,11 @@ x-proxy-env: &proxy-env
APP_DOMAIN: ${APP_DOMAIN:-localhost} APP_DOMAIN: ${APP_DOMAIN:-localhost}
FILE_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824} FILE_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824}
PROXY_BODY_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824} PROXY_BODY_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824}
CERT_EMAIL: ${CERT_EMAIL} CERT_EMAIL: ${CERT_EMAIL:-}
CERT_ACME_CA: ${CERT_ACME_CA} CERT_ACME_CA: ${CERT_ACME_CA:-https://acme-v02.api.letsencrypt.org/directory}
CERT_ACME_DNS: ${CERT_ACME_DNS} CERT_ACME_DNS: ${CERT_ACME_DNS:-}
LISTEN_HTTP_PORT: ${LISTEN_HTTP_PORT:-80} LISTEN_HTTP_PORT: ${LISTEN_HTTP_PORT:-8090}
LISTEN_HTTPS_PORT: ${LISTEN_HTTPS_PORT:-443} LISTEN_HTTPS_PORT: ${LISTEN_HTTPS_PORT:-8443}
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
SITE_ADDRESS: ${SITE_ADDRESS:-:80} SITE_ADDRESS: ${SITE_ADDRESS:-:80}
@ -57,6 +57,7 @@ x-app-env: &app-env
INSTANCE_CHANGELOG_URL: ${INSTANCE_CHANGELOG_URL:-} INSTANCE_CHANGELOG_URL: ${INSTANCE_CHANGELOG_URL:-}
IS_INTERCOM_ENABLED: ${IS_INTERCOM_ENABLED:-0} IS_INTERCOM_ENABLED: ${IS_INTERCOM_ENABLED:-0}
INTERCOM_APP_ID: ${INTERCOM_APP_ID:-} INTERCOM_APP_ID: ${INTERCOM_APP_ID:-}
ADMIN_BASE_PATH: ${ADMIN_BASE_PATH:-/nodedcsudo/}
USE_MINIO: ${USE_MINIO:-1} USE_MINIO: ${USE_MINIO:-1}
DATABASE_URL: ${DATABASE_URL:-postgresql://plane:plane@plane-db/plane} DATABASE_URL: ${DATABASE_URL:-postgresql://plane:plane@plane-db/plane}
SECRET_KEY: ${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} SECRET_KEY: ${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
@ -222,7 +223,7 @@ services:
# Comment this if you already have a reverse proxy running # Comment this if you already have a reverse proxy running
proxy: proxy:
image: makeplane/plane-proxy:${APP_RELEASE:-v1.3.0} image: nodedc/plane-proxy:ru
deploy: deploy:
replicas: 1 replicas: 1
restart_policy: restart_policy:
@ -231,11 +232,11 @@ services:
<<: *proxy-env <<: *proxy-env
ports: ports:
- target: 80 - target: 80
published: ${LISTEN_HTTP_PORT:-80} published: ${LISTEN_HTTP_PORT:-8090}
protocol: tcp protocol: tcp
mode: host mode: host
- target: 443 - target: 443
published: ${LISTEN_HTTPS_PORT:-443} published: ${LISTEN_HTTPS_PORT:-8443}
protocol: tcp protocol: tcp
mode: host mode: host
volumes: volumes:

View File

@ -27,11 +27,11 @@ x-proxy-env: &proxy-env
APP_DOMAIN: ${APP_DOMAIN:-localhost} APP_DOMAIN: ${APP_DOMAIN:-localhost}
FILE_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824} FILE_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824}
PROXY_BODY_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824} PROXY_BODY_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824}
CERT_EMAIL: ${CERT_EMAIL} CERT_EMAIL: ${CERT_EMAIL:-}
CERT_ACME_CA: ${CERT_ACME_CA} CERT_ACME_CA: ${CERT_ACME_CA:-https://acme-v02.api.letsencrypt.org/directory}
CERT_ACME_DNS: ${CERT_ACME_DNS} CERT_ACME_DNS: ${CERT_ACME_DNS:-}
LISTEN_HTTP_PORT: ${LISTEN_HTTP_PORT:-80} LISTEN_HTTP_PORT: ${LISTEN_HTTP_PORT:-8090}
LISTEN_HTTPS_PORT: ${LISTEN_HTTPS_PORT:-443} LISTEN_HTTPS_PORT: ${LISTEN_HTTPS_PORT:-8443}
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
SITE_ADDRESS: ${SITE_ADDRESS:-:80} SITE_ADDRESS: ${SITE_ADDRESS:-:80}
@ -57,6 +57,7 @@ x-app-env: &app-env
INSTANCE_CHANGELOG_URL: ${INSTANCE_CHANGELOG_URL:-} INSTANCE_CHANGELOG_URL: ${INSTANCE_CHANGELOG_URL:-}
IS_INTERCOM_ENABLED: ${IS_INTERCOM_ENABLED:-0} IS_INTERCOM_ENABLED: ${IS_INTERCOM_ENABLED:-0}
INTERCOM_APP_ID: ${INTERCOM_APP_ID:-} INTERCOM_APP_ID: ${INTERCOM_APP_ID:-}
ADMIN_BASE_PATH: ${ADMIN_BASE_PATH:-/nodedcsudo/}
USE_MINIO: ${USE_MINIO:-1} USE_MINIO: ${USE_MINIO:-1}
DATABASE_URL: ${DATABASE_URL:-postgresql://plane:plane@plane-db/plane} DATABASE_URL: ${DATABASE_URL:-postgresql://plane:plane@plane-db/plane}
SECRET_KEY: ${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} SECRET_KEY: ${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
@ -222,7 +223,7 @@ services:
# Comment this if you already have a reverse proxy running # Comment this if you already have a reverse proxy running
proxy: proxy:
image: makeplane/plane-proxy:${APP_RELEASE:-v1.3.0} image: nodedc/plane-proxy:ru
deploy: deploy:
replicas: 1 replicas: 1
restart_policy: restart_policy:
@ -231,11 +232,11 @@ services:
<<: *proxy-env <<: *proxy-env
ports: ports:
- target: 80 - target: 80
published: ${LISTEN_HTTP_PORT:-80} published: ${LISTEN_HTTP_PORT:-8090}
protocol: tcp protocol: tcp
mode: host mode: host
- target: 443 - target: 443
published: ${LISTEN_HTTPS_PORT:-443} published: ${LISTEN_HTTPS_PORT:-8443}
protocol: tcp protocol: tcp
mode: host mode: host
volumes: volumes:

View File

@ -76,7 +76,7 @@ docker compose -f docker-compose-local.yml up
pnpm dev pnpm dev
``` ```
5. Open your browser to http://localhost:3001/god-mode/ and register yourself as instance admin 5. Open your browser to http://localhost:3001/nodedcsudo/ and register yourself as instance admin
6. Open up your browser to http://localhost:3000 then log in using the same credentials from the previous step 6. Open up your browser to http://localhost:3000 then log in using the same credentials from the previous step
Thats it! Youre all set to begin coding. Remember to refresh your browser if changes dont auto-reload. Happy contributing! 🎉 Thats it! Youre all set to begin coding. Remember to refresh your browser if changes dont auto-reload. Happy contributing! 🎉

View File

@ -35,7 +35,7 @@ ENV VITE_API_BASE_PATH=$VITE_API_BASE_PATH
ARG VITE_ADMIN_BASE_URL="" ARG VITE_ADMIN_BASE_URL=""
ENV VITE_ADMIN_BASE_URL=$VITE_ADMIN_BASE_URL ENV VITE_ADMIN_BASE_URL=$VITE_ADMIN_BASE_URL
ARG VITE_ADMIN_BASE_PATH="/god-mode" ARG VITE_ADMIN_BASE_PATH="/nodedcsudo"
ENV VITE_ADMIN_BASE_PATH=$VITE_ADMIN_BASE_PATH ENV VITE_ADMIN_BASE_PATH=$VITE_ADMIN_BASE_PATH
ARG VITE_SPACE_BASE_URL="" ARG VITE_SPACE_BASE_URL=""
@ -78,11 +78,11 @@ RUN pnpm turbo run build --filter=admin
FROM nginx:1.29-alpine AS production FROM nginx:1.29-alpine AS production
COPY apps/admin/nginx/nginx.conf /etc/nginx/nginx.conf COPY apps/admin/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=installer /app/apps/admin/build/client /usr/share/nginx/html/god-mode COPY --from=installer /app/apps/admin/build/client /usr/share/nginx/html/nodedcsudo
EXPOSE 3000 EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -fsS http://127.0.0.1:3000/ >/dev/null || exit 1 CMD curl -fsS http://127.0.0.1:3000/ >/dev/null || exit 1
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

@ -8,7 +8,7 @@ COPY . .
RUN corepack enable pnpm && pnpm add -g turbo RUN corepack enable pnpm && pnpm add -g turbo
RUN pnpm install RUN pnpm install
ENV VITE_ADMIN_BASE_PATH="/god-mode" ENV VITE_ADMIN_BASE_PATH="/nodedcsudo"
EXPOSE 3000 EXPOSE 3000

View File

@ -41,17 +41,17 @@ export function InstanceAIForm(props: IInstanceAIForm) {
{ {
key: "LLM_MODEL", key: "LLM_MODEL",
type: "text", type: "text",
label: "LLM Model", label: "LLM-модель",
description: ( description: (
<> <>
Choose an OpenAI engine.{" "} Выберите модель OpenAI.{" "}
<a <a
href="https://platform.openai.com/docs/models/overview" href="https://platform.openai.com/docs/models/overview"
target="_blank" target="_blank"
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
Learn more Подробнее
</a> </a>
</> </>
), ),
@ -62,17 +62,17 @@ export function InstanceAIForm(props: IInstanceAIForm) {
{ {
key: "LLM_API_KEY", key: "LLM_API_KEY",
type: "password", type: "password",
label: "API key", label: "API-ключ",
description: ( description: (
<> <>
You will find your API key{" "} API-ключ находится{" "}
<a <a
href="https://platform.openai.com/api-keys" href="https://platform.openai.com/api-keys"
target="_blank" target="_blank"
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
here. здесь.
</a> </a>
</> </>
), ),
@ -89,8 +89,8 @@ export function InstanceAIForm(props: IInstanceAIForm) {
.then(() => .then(() =>
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Success", title: "Сохранено",
message: "AI Settings updated successfully", message: "ИИ-настройки обновлены",
}) })
) )
.catch((err) => console.error(err)); .catch((err) => console.error(err));
@ -101,7 +101,7 @@ export function InstanceAIForm(props: IInstanceAIForm) {
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<div className="pb-1 text-18 font-medium text-primary">OpenAI</div> <div className="pb-1 text-18 font-medium text-primary">OpenAI</div>
<div className="text-13 font-regular text-tertiary">If you use ChatGPT, this is for you.</div> <div className="text-13 font-regular text-tertiary">Используется для встроенных функций на базе OpenAI.</div>
</div> </div>
<div className="grid-col grid w-full grid-cols-1 items-center justify-between gap-x-12 gap-y-8 lg:grid-cols-3"> <div className="grid-col grid w-full grid-cols-1 items-center justify-between gap-x-12 gap-y-8 lg:grid-cols-3">
{aiFormFields.map((field) => ( {aiFormFields.map((field) => (
@ -122,15 +122,15 @@ export function InstanceAIForm(props: IInstanceAIForm) {
<div className="flex flex-col items-start gap-4"> <div className="flex flex-col items-start gap-4">
<Button variant="primary" size="lg" onClick={handleSubmit(onSubmit)} loading={isSubmitting}> <Button variant="primary" size="lg" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving" : "Save changes"} {isSubmitting ? "Сохранение" : "Сохранить изменения"}
</Button> </Button>
<div className="relative inline-flex items-center gap-1.5 rounded-sm border border-accent-subtle bg-accent-subtle px-4 py-2 text-caption-sm-regular text-accent-secondary"> <div className="nodedc-settings-note relative inline-flex max-w-2xl items-center gap-2 px-4 py-3 text-12 font-regular">
<Lightbulb className="size-4" /> <Lightbulb className="size-4 shrink-0 text-tertiary" />
<div> <div>
If you have a preferred AI models vendor, please get in{" "} Если нужен другой провайдер ИИ-моделей, свяжитесь{" "}
<a className="font-medium underline" href="https://plane.so/contact"> <a className="font-medium underline" href="https://plane.so/contact">
touch with us. с командой поддержки.
</a> </a>
</div> </div>
</div> </div>

View File

@ -25,8 +25,8 @@ const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentP
return ( return (
<PageWrapper <PageWrapper
header={{ header={{
title: "AI features for all your workspaces", title: "ИИ-функции для всех воркспейсов",
description: "Configure your AI API credentials so Plane AI features are turned on for all your workspaces.", description: "Настройте API-ключ и модель, чтобы включить ИИ-возможности во всех рабочих пространствах.",
}} }}
> >
{formattedConfig ? ( {formattedConfig ? (
@ -45,6 +45,6 @@ const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentP
); );
}); });
export const meta: Route.MetaFunction = () => [{ title: "Artificial Intelligence Settings - God Mode" }]; export const meta: Route.MetaFunction = () => [{ title: "ИИ-настройки - NODE.DC" }];
export default InstanceAIPage; export default InstanceAIPage;

View File

@ -58,9 +58,9 @@ export function InstanceGiteaConfigForm(props: Props) {
{ {
key: "GITEA_HOST", key: "GITEA_HOST",
type: "text", type: "text",
label: "Gitea Host", label: "Хост Gitea",
description: ( description: (
<>Use the URL of your Gitea instance. For the official Gitea instance, use &quot;https://gitea.com&quot;.</> <>Укажите URL вашего Gitea-инстанса. Для официального сервиса используйте &quot;https://gitea.com&quot;.</>
), ),
placeholder: "https://gitea.com", placeholder: "https://gitea.com",
error: Boolean(errors.GITEA_HOST), error: Boolean(errors.GITEA_HOST),
@ -72,7 +72,7 @@ export function InstanceGiteaConfigForm(props: Props) {
label: "Client ID", label: "Client ID",
description: ( description: (
<> <>
You will get this from your{" "} Возьмите значение в настройках{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://gitea.com/user/settings/applications" href="https://gitea.com/user/settings/applications"
@ -80,7 +80,7 @@ export function InstanceGiteaConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
Gitea OAuth application settings. Gitea OAuth-приложения.
</a> </a>
</> </>
), ),
@ -94,7 +94,7 @@ export function InstanceGiteaConfigForm(props: Props) {
label: "Client secret", label: "Client secret",
description: ( description: (
<> <>
Your client secret is also found in your{" "} Секрет клиента находится в настройках{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://gitea.com/user/settings/applications" href="https://gitea.com/user/settings/applications"
@ -102,7 +102,7 @@ export function InstanceGiteaConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
Gitea OAuth application settings. Gitea OAuth-приложения.
</a> </a>
</> </>
), ),
@ -124,8 +124,8 @@ export function InstanceGiteaConfigForm(props: Props) {
url: `${originURL}/auth/gitea/callback/`, url: `${originURL}/auth/gitea/callback/`,
description: ( description: (
<> <>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Callback URI</CodeBlock>{" "} Значение сформировано автоматически. Вставьте его в поле{" "}
field{" "} <CodeBlock darkerShade>Authorized Callback URI</CodeBlock>{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href={`${control._formValues.GITEA_HOST || "https://gitea.com"}/user/settings/applications`} href={`${control._formValues.GITEA_HOST || "https://gitea.com"}/user/settings/applications`}
@ -133,7 +133,7 @@ export function InstanceGiteaConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
here. здесь.
</a> </a>
</> </>
), ),
@ -147,8 +147,8 @@ export function InstanceGiteaConfigForm(props: Props) {
const response = await updateInstanceConfigurations(payload); const response = await updateInstanceConfigurations(payload);
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Done!", title: "Готово",
message: "Your Gitea authentication is configured. You should test it now.", message: "Аутентификация через Gitea настроена. Проверьте вход перед включением в проде.",
}); });
reset({ reset({
GITEA_HOST: response.find((item) => item.key === "GITEA_HOST")?.value, GITEA_HOST: response.find((item) => item.key === "GITEA_HOST")?.value,
@ -178,7 +178,7 @@ export function InstanceGiteaConfigForm(props: Props) {
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<div className="grid w-full grid-cols-2 gap-x-12 gap-y-8"> <div className="grid w-full grid-cols-2 gap-x-12 gap-y-8">
<div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1"> <div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1">
<div className="pt-2.5 text-18 font-medium">Gitea-provided details for Plane</div> <div className="pt-2.5 text-18 font-medium">Данные Gitea для NODE.DC</div>
{GITEA_FORM_FIELDS.map((field) => ( {GITEA_FORM_FIELDS.map((field) => (
<ControllerInput <ControllerInput
key={field.key} key={field.key}
@ -202,17 +202,17 @@ export function InstanceGiteaConfigForm(props: Props) {
loading={isSubmitting} loading={isSubmitting}
disabled={!isDirty} disabled={!isDirty}
> >
{isSubmitting ? "Saving" : "Save changes"} {isSubmitting ? "Сохранение" : "Сохранить изменения"}
</Button> </Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}> <Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
Go back Назад
</Link> </Link>
</div> </div>
</div> </div>
</div> </div>
<div className="col-span-2 md:col-span-1"> <div className="col-span-2 md:col-span-1">
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-1 px-6 pt-1.5 pb-4"> <div className="nodedc-settings-helper-card flex flex-col gap-y-4 px-6 pt-1.5 pb-4">
<div className="pt-2 text-18 font-medium">Plane-provided details for Gitea</div> <div className="pt-2 text-18 font-medium">Данные NODE.DC для Gitea</div>
{GITEA_SERVICE_FIELD.map((field) => ( {GITEA_SERVICE_FIELD.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} /> <CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))} ))}

View File

@ -41,14 +41,14 @@ const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthentic
const updateConfigPromise = updateInstanceConfigurations(payload); const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, { setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration", loading: "Сохранение конфигурации",
success: { success: {
title: "Configuration saved", title: "Конфигурация сохранена",
message: () => `Gitea authentication is now ${value === "1" ? "active" : "disabled"}.`, message: () => `Вход через Gitea ${value === "1" ? "включен" : "отключен"}.`,
}, },
error: { error: {
title: "Error", title: "Ошибка",
message: () => "Failed to save configuration", message: () => "Не удалось сохранить конфигурацию",
}, },
}); });
@ -69,7 +69,7 @@ const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthentic
customHeader={ customHeader={
<AuthenticationMethodCard <AuthenticationMethodCard
name="Gitea" name="Gitea"
description="Allow members to login or sign up to plane with their Gitea accounts." description="Вход и регистрация пользователей через аккаунты Gitea."
icon={<img src={giteaLogo} height={24} width={24} alt="Gitea Logo" />} icon={<img src={giteaLogo} height={24} width={24} alt="Gitea Logo" />}
config={ config={
<ToggleSwitch <ToggleSwitch
@ -100,6 +100,6 @@ const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthentic
</PageWrapper> </PageWrapper>
); );
}); });
export const meta: Route.MetaFunction = () => [{ title: "Gitea Authentication - God Mode" }]; export const meta: Route.MetaFunction = () => [{ title: "Gitea OAuth - NODE.DC" }];
export default InstanceGiteaAuthenticationPage; export default InstanceGiteaAuthenticationPage;

View File

@ -62,7 +62,7 @@ export function InstanceGithubConfigForm(props: Props) {
label: "Client ID", label: "Client ID",
description: ( description: (
<> <>
You will get this from your{" "} Возьмите значение в настройках{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://github.com/settings/applications/new" href="https://github.com/settings/applications/new"
@ -70,7 +70,7 @@ export function InstanceGithubConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
GitHub OAuth application settings. GitHub OAuth-приложения.
</a> </a>
</> </>
), ),
@ -84,7 +84,7 @@ export function InstanceGithubConfigForm(props: Props) {
label: "Client secret", label: "Client secret",
description: ( description: (
<> <>
Your client secret is also found in your{" "} Секрет клиента находится в настройках{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://github.com/settings/applications/new" href="https://github.com/settings/applications/new"
@ -92,7 +92,7 @@ export function InstanceGithubConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
GitHub OAuth application settings. GitHub OAuth-приложения.
</a> </a>
</> </>
), ),
@ -103,8 +103,8 @@ export function InstanceGithubConfigForm(props: Props) {
{ {
key: "GITHUB_ORGANIZATION_ID", key: "GITHUB_ORGANIZATION_ID",
type: "text", type: "text",
label: "Organization ID", label: "ID организации",
description: <>The organization github ID.</>, description: <>ID организации GitHub.</>,
placeholder: "123456789", placeholder: "123456789",
error: Boolean(errors.GITHUB_ORGANIZATION_ID), error: Boolean(errors.GITHUB_ORGANIZATION_ID),
required: false, required: false,
@ -123,7 +123,8 @@ export function InstanceGithubConfigForm(props: Props) {
url: originURL, url: originURL,
description: ( description: (
<> <>
We will auto-generate this. Paste this into the <CodeBlock darkerShade>Authorized origin URL</CodeBlock> field{" "} Значение сформировано автоматически. Вставьте его в поле{" "}
<CodeBlock darkerShade>Authorized origin URL</CodeBlock>{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://github.com/settings/applications/new" href="https://github.com/settings/applications/new"
@ -131,7 +132,7 @@ export function InstanceGithubConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
here. здесь.
</a> </a>
</> </>
), ),
@ -145,8 +146,8 @@ export function InstanceGithubConfigForm(props: Props) {
url: `${originURL}/auth/github/callback/`, url: `${originURL}/auth/github/callback/`,
description: ( description: (
<> <>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Callback URI</CodeBlock>{" "} Значение сформировано автоматически. Вставьте его в поле{" "}
field{" "} <CodeBlock darkerShade>Authorized Callback URI</CodeBlock>{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://github.com/settings/applications/new" href="https://github.com/settings/applications/new"
@ -154,7 +155,7 @@ export function InstanceGithubConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
here. здесь.
</a> </a>
</> </>
), ),
@ -168,8 +169,8 @@ export function InstanceGithubConfigForm(props: Props) {
const response = await updateInstanceConfigurations(payload); const response = await updateInstanceConfigurations(payload);
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Done!", title: "Готово",
message: "Your GitHub authentication is configured. You should test it now.", message: "Аутентификация через GitHub настроена. Проверьте вход перед включением в проде.",
}); });
reset({ reset({
GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value, GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value,
@ -199,7 +200,7 @@ export function InstanceGithubConfigForm(props: Props) {
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<div className="grid w-full grid-cols-2 gap-x-12 gap-y-8"> <div className="grid w-full grid-cols-2 gap-x-12 gap-y-8">
<div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1"> <div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1">
<div className="pt-2.5 text-18 font-medium">GitHub-provided details for Plane</div> <div className="pt-2.5 text-18 font-medium">Данные GitHub для NODE.DC</div>
{GITHUB_FORM_FIELDS.map((field) => ( {GITHUB_FORM_FIELDS.map((field) => (
<ControllerInput <ControllerInput
key={field.key} key={field.key}
@ -223,32 +224,32 @@ export function InstanceGithubConfigForm(props: Props) {
loading={isSubmitting} loading={isSubmitting}
disabled={!isDirty} disabled={!isDirty}
> >
{isSubmitting ? "Saving" : "Save changes"} {isSubmitting ? "Сохранение" : "Сохранить изменения"}
</Button> </Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}> <Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
Go back Назад
</Link> </Link>
</div> </div>
</div> </div>
</div> </div>
<div className="col-span-2 flex flex-col gap-y-6 md:col-span-1"> <div className="col-span-2 flex flex-col gap-y-6 md:col-span-1">
<div className="pt-2 text-18 font-medium">Plane-provided details for GitHub</div> <div className="pt-2 text-18 font-medium">Данные NODE.DC для GitHub</div>
<div className="flex flex-col gap-y-4"> <div className="flex flex-col gap-y-4">
{/* common service details */} {/* common service details */}
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-1 px-6 py-4"> <div className="nodedc-settings-helper-card flex flex-col gap-y-4 px-6 py-4">
{GITHUB_COMMON_SERVICE_DETAILS.map((field) => ( {GITHUB_COMMON_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} /> <CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))} ))}
</div> </div>
{/* web service details */} {/* web service details */}
<div className="flex flex-col overflow-hidden rounded-lg"> <div className="nodedc-settings-helper-card flex flex-col overflow-hidden">
<div className="flex items-center gap-x-3 bg-layer-3 px-6 py-3 text-11 font-medium text-secondary uppercase"> <div className="nodedc-settings-helper-card-header flex items-center gap-x-3 px-6 py-3 text-11 font-medium uppercase">
<Monitor className="h-3 w-3" /> <Monitor className="h-3 w-3" />
Web Веб
</div> </div>
<div className="flex flex-col gap-y-4 bg-layer-1 px-6 py-4"> <div className="flex flex-col gap-y-4 px-6 py-4">
{GITHUB_SERVICE_DETAILS.map((field) => ( {GITHUB_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} /> <CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))} ))}

View File

@ -49,14 +49,14 @@ const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthent
const updateConfigPromise = updateInstanceConfigurations(payload); const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, { setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration", loading: "Сохранение конфигурации",
success: { success: {
title: "Configuration saved", title: "Конфигурация сохранена",
message: () => `GitHub authentication is now ${value === "1" ? "active" : "disabled"}.`, message: () => `Вход через GitHub ${value === "1" ? "включен" : "отключен"}.`,
}, },
error: { error: {
title: "Error", title: "Ошибка",
message: () => "Failed to save configuration", message: () => "Не удалось сохранить конфигурацию",
}, },
}); });
@ -77,7 +77,7 @@ const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthent
customHeader={ customHeader={
<AuthenticationMethodCard <AuthenticationMethodCard
name="GitHub" name="GitHub"
description="Allow members to login or sign up to plane with their GitHub accounts." description="Вход и регистрация пользователей через аккаунты GitHub."
icon={ icon={
<img <img
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage} src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
@ -116,6 +116,6 @@ const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthent
); );
}); });
export const meta: Route.MetaFunction = () => [{ title: "GitHub Authentication - God Mode" }]; export const meta: Route.MetaFunction = () => [{ title: "GitHub OAuth - NODE.DC" }];
export default InstanceGithubAuthenticationPage; export default InstanceGithubAuthenticationPage;

View File

@ -58,10 +58,10 @@ export function InstanceGitlabConfigForm(props: Props) {
{ {
key: "GITLAB_HOST", key: "GITLAB_HOST",
type: "text", type: "text",
label: "Host", label: "Хост",
description: ( description: (
<> <>
This is either https://gitlab.com or the <CodeBlock>domain.tld</CodeBlock> where you host GitLab. Укажите https://gitlab.com или <CodeBlock>domain.tld</CodeBlock>, если GitLab развернут у вас.
</> </>
), ),
placeholder: "https://gitlab.com", placeholder: "https://gitlab.com",
@ -74,7 +74,7 @@ export function InstanceGitlabConfigForm(props: Props) {
label: "Application ID", label: "Application ID",
description: ( description: (
<> <>
Get this from your{" "} Возьмите значение в настройках{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html" href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
@ -82,7 +82,7 @@ export function InstanceGitlabConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
GitLab OAuth application settings GitLab OAuth-приложения
</a> </a>
. .
</> </>
@ -97,7 +97,7 @@ export function InstanceGitlabConfigForm(props: Props) {
label: "Secret", label: "Secret",
description: ( description: (
<> <>
The client secret is also found in your{" "} Секрет клиента находится в настройках{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html" href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
@ -105,7 +105,7 @@ export function InstanceGitlabConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
GitLab OAuth application settings GitLab OAuth-приложения
</a> </a>
. .
</> </>
@ -128,7 +128,7 @@ export function InstanceGitlabConfigForm(props: Props) {
url: `${originURL}/auth/gitlab/callback/`, url: `${originURL}/auth/gitlab/callback/`,
description: ( description: (
<> <>
We will auto-generate this. Paste this into the <CodeBlock darkerShade>Redirect URI</CodeBlock> field of your{" "} Значение сформировано автоматически. Вставьте его в поле <CodeBlock darkerShade>Redirect URI</CodeBlock>{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html" href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
@ -136,7 +136,7 @@ export function InstanceGitlabConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
GitLab OAuth application GitLab OAuth-приложения
</a> </a>
. .
</> </>
@ -151,8 +151,8 @@ export function InstanceGitlabConfigForm(props: Props) {
const response = await updateInstanceConfigurations(payload); const response = await updateInstanceConfigurations(payload);
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Done!", title: "Готово",
message: "Your GitLab authentication is configured. You should test it now.", message: "Аутентификация через GitLab настроена. Проверьте вход перед включением в проде.",
}); });
reset({ reset({
GITLAB_HOST: response.find((item) => item.key === "GITLAB_HOST")?.value, GITLAB_HOST: response.find((item) => item.key === "GITLAB_HOST")?.value,
@ -182,7 +182,7 @@ export function InstanceGitlabConfigForm(props: Props) {
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<div className="grid w-full grid-cols-2 gap-x-12 gap-y-8"> <div className="grid w-full grid-cols-2 gap-x-12 gap-y-8">
<div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1"> <div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1">
<div className="pt-2.5 text-18 font-medium">GitLab-provided details for Plane</div> <div className="pt-2.5 text-18 font-medium">Данные GitLab для NODE.DC</div>
{GITLAB_FORM_FIELDS.map((field) => ( {GITLAB_FORM_FIELDS.map((field) => (
<ControllerInput <ControllerInput
key={field.key} key={field.key}
@ -206,17 +206,17 @@ export function InstanceGitlabConfigForm(props: Props) {
loading={isSubmitting} loading={isSubmitting}
disabled={!isDirty} disabled={!isDirty}
> >
{isSubmitting ? "Saving" : "Save changes"} {isSubmitting ? "Сохранение" : "Сохранить изменения"}
</Button> </Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}> <Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
Go back Назад
</Link> </Link>
</div> </div>
</div> </div>
</div> </div>
<div className="col-span-2 md:col-span-1"> <div className="col-span-2 md:col-span-1">
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-3 px-6 pt-1.5 pb-4"> <div className="nodedc-settings-helper-card flex flex-col gap-y-4 px-6 pt-1.5 pb-4">
<div className="pt-2 text-18 font-medium">Plane-provided details for GitLab</div> <div className="pt-2 text-18 font-medium">Данные NODE.DC для GitLab</div>
{GITLAB_SERVICE_FIELD.map((field) => ( {GITLAB_SERVICE_FIELD.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} /> <CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))} ))}

View File

@ -43,14 +43,14 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent
const updateConfigPromise = updateInstanceConfigurations(payload); const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, { setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration", loading: "Сохранение конфигурации",
success: { success: {
title: "Configuration saved", title: "Конфигурация сохранена",
message: () => `GitLab authentication is now ${value === "1" ? "active" : "disabled"}.`, message: () => `Вход через GitLab ${value === "1" ? "включен" : "отключен"}.`,
}, },
error: { error: {
title: "Error", title: "Ошибка",
message: () => "Failed to save configuration", message: () => "Не удалось сохранить конфигурацию",
}, },
}); });
@ -68,7 +68,7 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent
customHeader={ customHeader={
<AuthenticationMethodCard <AuthenticationMethodCard
name="GitLab" name="GitLab"
description="Allow members to login or sign up to plane with their GitLab accounts." description="Вход и регистрация пользователей через аккаунты GitLab."
icon={<img src={GitlabLogo} height={24} width={24} alt="GitLab Logo" />} icon={<img src={GitlabLogo} height={24} width={24} alt="GitLab Logo" />}
config={ config={
<ToggleSwitch <ToggleSwitch
@ -104,6 +104,6 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent
); );
}); });
export const meta: Route.MetaFunction = () => [{ title: "GitLab Authentication - God Mode" }]; export const meta: Route.MetaFunction = () => [{ title: "GitLab OAuth - NODE.DC" }];
export default InstanceGitlabAuthenticationPage; export default InstanceGitlabAuthenticationPage;

View File

@ -61,7 +61,7 @@ export function InstanceGoogleConfigForm(props: Props) {
label: "Client ID", label: "Client ID",
description: ( description: (
<> <>
Your client ID lives in your Google API Console.{" "} Client ID находится в Google API Console.{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://developers.google.com/identity/protocols/oauth2/javascript-implicit-flow#creatingcred" href="https://developers.google.com/identity/protocols/oauth2/javascript-implicit-flow#creatingcred"
@ -69,7 +69,7 @@ export function InstanceGoogleConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
Learn more Подробнее
</a> </a>
</> </>
), ),
@ -83,7 +83,7 @@ export function InstanceGoogleConfigForm(props: Props) {
label: "Client secret", label: "Client secret",
description: ( description: (
<> <>
Your client secret should also be in your Google API Console.{" "} Client secret также находится в Google API Console.{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://developers.google.com/identity/oauth2/web/guides/get-google-api-clientid" href="https://developers.google.com/identity/oauth2/web/guides/get-google-api-clientid"
@ -91,7 +91,7 @@ export function InstanceGoogleConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
Learn more Подробнее
</a> </a>
</> </>
), ),
@ -113,15 +113,15 @@ export function InstanceGoogleConfigForm(props: Props) {
url: originURL, url: originURL,
description: ( description: (
<p> <p>
We will auto-generate this. Paste this into your{" "} Значение сформировано автоматически. Вставьте его в поле{" "}
<CodeBlock darkerShade>Authorized JavaScript origins</CodeBlock> field. For this OAuth client{" "} <CodeBlock darkerShade>Authorized JavaScript origins</CodeBlock> для OAuth-клиента{" "}
<a <a
href="https://console.cloud.google.com/apis/credentials/oauthclient" href="https://console.cloud.google.com/apis/credentials/oauthclient"
target="_blank" target="_blank"
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
here. здесь.
</a> </a>
</p> </p>
), ),
@ -135,15 +135,15 @@ export function InstanceGoogleConfigForm(props: Props) {
url: `${originURL}/auth/google/callback/`, url: `${originURL}/auth/google/callback/`,
description: ( description: (
<p> <p>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Redirect URI</CodeBlock>{" "} Значение сформировано автоматически. Вставьте его в поле{" "}
field. For this OAuth client{" "} <CodeBlock darkerShade>Authorized Redirect URI</CodeBlock> для OAuth-клиента{" "}
<a <a
href="https://console.cloud.google.com/apis/credentials/oauthclient" href="https://console.cloud.google.com/apis/credentials/oauthclient"
target="_blank" target="_blank"
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
here. здесь.
</a> </a>
</p> </p>
), ),
@ -157,8 +157,8 @@ export function InstanceGoogleConfigForm(props: Props) {
const response = await updateInstanceConfigurations(payload); const response = await updateInstanceConfigurations(payload);
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Done!", title: "Готово",
message: "Your Google authentication is configured. You should test it now.", message: "Аутентификация через Google настроена. Проверьте вход перед включением в проде.",
}); });
reset({ reset({
GOOGLE_CLIENT_ID: response.find((item) => item.key === "GOOGLE_CLIENT_ID")?.value, GOOGLE_CLIENT_ID: response.find((item) => item.key === "GOOGLE_CLIENT_ID")?.value,
@ -187,7 +187,7 @@ export function InstanceGoogleConfigForm(props: Props) {
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<div className="grid w-full grid-cols-2 gap-x-12 gap-y-8"> <div className="grid w-full grid-cols-2 gap-x-12 gap-y-8">
<div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1"> <div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1">
<div className="pt-2.5 text-18 font-medium">Google-provided details for Plane</div> <div className="pt-2.5 text-18 font-medium">Данные Google для NODE.DC</div>
{GOOGLE_FORM_FIELDS.map((field) => ( {GOOGLE_FORM_FIELDS.map((field) => (
<ControllerInput <ControllerInput
key={field.key} key={field.key}
@ -211,32 +211,32 @@ export function InstanceGoogleConfigForm(props: Props) {
loading={isSubmitting} loading={isSubmitting}
disabled={!isDirty} disabled={!isDirty}
> >
{isSubmitting ? "Saving" : "Save changes"} {isSubmitting ? "Сохранение" : "Сохранить изменения"}
</Button> </Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}> <Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
Go back Назад
</Link> </Link>
</div> </div>
</div> </div>
</div> </div>
<div className="col-span-2 flex flex-col gap-y-6 md:col-span-1"> <div className="col-span-2 flex flex-col gap-y-6 md:col-span-1">
<div className="pt-2 text-18 font-medium">Plane-provided details for Google</div> <div className="pt-2 text-18 font-medium">Данные NODE.DC для Google</div>
<div className="flex flex-col gap-y-4"> <div className="flex flex-col gap-y-4">
{/* common service details */} {/* common service details */}
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-1 px-6 py-4"> <div className="nodedc-settings-helper-card flex flex-col gap-y-4 px-6 py-4">
{GOOGLE_COMMON_SERVICE_DETAILS.map((field) => ( {GOOGLE_COMMON_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} /> <CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))} ))}
</div> </div>
{/* web service details */} {/* web service details */}
<div className="flex flex-col overflow-hidden rounded-lg"> <div className="nodedc-settings-helper-card flex flex-col overflow-hidden">
<div className="flex items-center gap-x-3 bg-layer-3 px-6 py-3 text-11 font-medium text-secondary uppercase"> <div className="nodedc-settings-helper-card-header flex items-center gap-x-3 px-6 py-3 text-11 font-medium uppercase">
<Monitor className="h-3 w-3" /> <Monitor className="h-3 w-3" />
Web Веб
</div> </div>
<div className="flex flex-col gap-y-4 bg-layer-1 px-6 py-4"> <div className="flex flex-col gap-y-4 px-6 py-4">
{GOOGLE_SERVICE_DETAILS.map((field) => ( {GOOGLE_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} /> <CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))} ))}

View File

@ -43,14 +43,14 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent
const updateConfigPromise = updateInstanceConfigurations(payload); const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, { setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration", loading: "Сохранение конфигурации",
success: { success: {
title: "Configuration saved", title: "Конфигурация сохранена",
message: () => `Google authentication is now ${value === "1" ? "active" : "disabled"}.`, message: () => `Вход через Google ${value === "1" ? "включен" : "отключен"}.`,
}, },
error: { error: {
title: "Error", title: "Ошибка",
message: () => "Failed to save configuration", message: () => "Не удалось сохранить конфигурацию",
}, },
}); });
@ -68,8 +68,7 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent
customHeader={ customHeader={
<AuthenticationMethodCard <AuthenticationMethodCard
name="Google" name="Google"
description="Allow members to login or sign up to plane with their Google description="Вход и регистрация пользователей через аккаунты Google."
accounts."
icon={<img src={GoogleLogo} height={24} width={24} alt="Google Logo" />} icon={<img src={GoogleLogo} height={24} width={24} alt="Google Logo" />}
config={ config={
<ToggleSwitch <ToggleSwitch
@ -105,6 +104,6 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent
); );
}); });
export const meta: Route.MetaFunction = () => [{ title: "Google Authentication - God Mode" }]; export const meta: Route.MetaFunction = () => [{ title: "Google OAuth - NODE.DC" }];
export default InstanceGoogleAuthenticationPage; export default InstanceGoogleAuthenticationPage;

View File

@ -55,9 +55,8 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
if (!canDisable) { if (!canDisable) {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Cannot disable authentication", title: "Нельзя отключить вход",
message: message: "Должен остаться хотя бы один способ входа. Сначала включите другой способ аутентификации.",
"At least one authentication method must remain enabled. Please enable another method before disabling this one.",
}); });
return; return;
} }
@ -74,14 +73,14 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
const updateConfigPromise = updateInstanceConfigurations(payload); const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, { setPromiseToast(updateConfigPromise, {
loading: "Saving configuration", loading: "Сохранение конфигурации",
success: { success: {
title: "Success", title: "Сохранено",
message: () => "Configuration saved successfully", message: () => "Конфигурация обновлена",
}, },
error: { error: {
title: "Error", title: "Ошибка",
message: () => "Failed to save configuration", message: () => "Не удалось сохранить конфигурацию",
}, },
}); });
@ -111,8 +110,8 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
return ( return (
<PageWrapper <PageWrapper
header={{ header={{
title: "Manage authentication modes for your instance", title: "Способы входа в инстанс",
description: "Configure authentication modes for your team and restrict sign-ups to be invite only.", description: "Настройте email, пароль, OAuth-провайдеры и правила регистрации пользователей.",
}} }}
> >
{formattedConfig ? ( {formattedConfig ? (
@ -120,9 +119,9 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
<div className={cn("flex w-full items-center gap-14 rounded-sm")}> <div className={cn("flex w-full items-center gap-14 rounded-sm")}>
<div className="flex grow items-center gap-4"> <div className="flex grow items-center gap-4">
<div className="grow"> <div className="grow">
<div className="pb-1 text-16 font-medium">Allow anyone to sign up even without an invite</div> <div className="pb-1 text-16 font-medium">Разрешить регистрацию без приглашения</div>
<div className={cn("text-11 leading-5 font-regular text-tertiary")}> <div className={cn("text-11 leading-5 font-regular text-tertiary")}>
Toggling this off will only let users sign up when they are invited. Если выключить, новые пользователи смогут зарегистрироваться только по приглашению.
</div> </div>
</div> </div>
</div> </div>
@ -143,7 +142,7 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
</div> </div>
</div> </div>
</div> </div>
<div className="text-lg pt-6 font-medium">Available authentication modes</div> <div className="text-lg pt-6 font-medium">Доступные способы входа</div>
{authenticationModes.map((method) => ( {authenticationModes.map((method) => (
<AuthenticationMethodCard <AuthenticationMethodCard
key={method.key} key={method.key}
@ -169,6 +168,6 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
); );
}); });
export const meta: Route.MetaFunction = () => [{ title: "Authentication Settings - Plane Web" }]; export const meta: Route.MetaFunction = () => [{ title: "Аутентификация - NODE.DC" }];
export default InstanceAuthenticationPage; export default InstanceAuthenticationPage;

View File

@ -31,7 +31,7 @@ type TEmailSecurityKeys = "EMAIL_USE_TLS" | "EMAIL_USE_SSL" | "NONE";
const EMAIL_SECURITY_OPTIONS: { [key in TEmailSecurityKeys]: string } = { const EMAIL_SECURITY_OPTIONS: { [key in TEmailSecurityKeys]: string } = {
EMAIL_USE_TLS: "TLS", EMAIL_USE_TLS: "TLS",
EMAIL_USE_SSL: "SSL", EMAIL_USE_SSL: "SSL",
NONE: "No email security", NONE: "Без шифрования",
}; };
export function InstanceEmailForm(props: IInstanceEmailForm) { export function InstanceEmailForm(props: IInstanceEmailForm) {
@ -63,7 +63,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
{ {
key: "EMAIL_HOST", key: "EMAIL_HOST",
type: "text", type: "text",
label: "Host", label: "Хост",
placeholder: "email.google.com", placeholder: "email.google.com",
error: Boolean(errors.EMAIL_HOST), error: Boolean(errors.EMAIL_HOST),
required: true, required: true,
@ -71,7 +71,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
{ {
key: "EMAIL_PORT", key: "EMAIL_PORT",
type: "text", type: "text",
label: "Port", label: "Порт",
placeholder: "8080", placeholder: "8080",
error: Boolean(errors.EMAIL_PORT), error: Boolean(errors.EMAIL_PORT),
required: true, required: true,
@ -79,9 +79,9 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
{ {
key: "EMAIL_FROM", key: "EMAIL_FROM",
type: "text", type: "text",
label: "Sender's email address", label: "Email отправителя",
description: description:
"This is the email address your users will see when getting emails from this instance. You will need to verify this address.", "Этот адрес будут видеть пользователи в письмах от инстанса. Адрес нужно подтвердить на стороне SMTP.",
placeholder: "no-reply@projectplane.so", placeholder: "no-reply@projectplane.so",
error: Boolean(errors.EMAIL_FROM), error: Boolean(errors.EMAIL_FROM),
required: true, required: true,
@ -92,7 +92,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
{ {
key: "EMAIL_HOST_USER", key: "EMAIL_HOST_USER",
type: "text", type: "text",
label: "Username", label: "Имя пользователя",
placeholder: "getitdone@projectplane.so", placeholder: "getitdone@projectplane.so",
error: Boolean(errors.EMAIL_HOST_USER), error: Boolean(errors.EMAIL_HOST_USER),
required: false, required: false,
@ -100,8 +100,8 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
{ {
key: "EMAIL_HOST_PASSWORD", key: "EMAIL_HOST_PASSWORD",
type: "password", type: "password",
label: "Password", label: "Пароль",
placeholder: "Password", placeholder: "Пароль",
error: Boolean(errors.EMAIL_HOST_PASSWORD), error: Boolean(errors.EMAIL_HOST_PASSWORD),
required: false, required: false,
}, },
@ -114,8 +114,8 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
.then(() => .then(() =>
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Success", title: "Сохранено",
message: "Email Settings updated successfully", message: "Настройки почты обновлены",
}) })
) )
.catch((err) => console.error(err)); .catch((err) => console.error(err));
@ -163,12 +163,12 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
/> />
))} ))}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">Email security</h4> <h4 className="text-13 text-tertiary">Защита соединения</h4>
<CustomSelect <CustomSelect
value={emailSecurityKey} value={emailSecurityKey}
label={EMAIL_SECURITY_OPTIONS[emailSecurityKey]} label={EMAIL_SECURITY_OPTIONS[emailSecurityKey]}
onChange={handleEmailSecurityChange} onChange={handleEmailSecurityChange}
buttonClassName="rounded-md border-subtle" buttonClassName="nodedc-settings-select rounded-md border-subtle"
input input
> >
{Object.entries(EMAIL_SECURITY_OPTIONS).map(([key, value]) => ( {Object.entries(EMAIL_SECURITY_OPTIONS).map(([key, value]) => (
@ -183,9 +183,9 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
<div className="flex w-full max-w-xl flex-col gap-y-10 px-1"> <div className="flex w-full max-w-xl flex-col gap-y-10 px-1">
<div className="mr-8 flex items-center gap-10 pt-4"> <div className="mr-8 flex items-center gap-10 pt-4">
<div className="grow"> <div className="grow">
<div className="text-13 font-medium text-primary">Authentication</div> <div className="text-13 font-medium text-primary">Аутентификация</div>
<div className="text-11 font-regular text-tertiary"> <div className="text-11 font-regular text-tertiary">
This is optional, but we recommend setting up a username and a password for your SMTP server. Необязательно, но для SMTP-сервера обычно нужны имя пользователя и пароль.
</div> </div>
</div> </div>
</div> </div>
@ -215,7 +215,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
loading={isSubmitting} loading={isSubmitting}
disabled={!isValid || !isDirty} disabled={!isValid || !isDirty}
> >
{isSubmitting ? "Saving" : "Save changes"} {isSubmitting ? "Сохранение" : "Сохранить изменения"}
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
@ -224,7 +224,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
loading={isSubmitting} loading={isSubmitting}
disabled={!isValid} disabled={!isValid}
> >
Send test email Отправить тестовое письмо
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -34,14 +34,14 @@ const InstanceEmailPage = observer(function InstanceEmailPage(_props: Route.Comp
await disableEmail(); await disableEmail();
setIsSMTPEnabled(false); setIsSMTPEnabled(false);
setToast({ setToast({
title: "Email feature disabled", title: "Почта отключена",
message: "Email feature has been disabled", message: "Отправка писем через SMTP отключена",
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
}); });
} catch (_error) { } catch (_error) {
setToast({ setToast({
title: "Error disabling email", title: "Не удалось отключить почту",
message: "Failed to disable email feature. Please try again.", message: "Повторите попытку.",
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
}); });
} finally { } finally {
@ -60,13 +60,13 @@ const InstanceEmailPage = observer(function InstanceEmailPage(_props: Route.Comp
return ( return (
<PageWrapper <PageWrapper
header={{ header={{
title: "Secure emails from your own instance", title: "Письма от вашего инстанса",
description: ( description: (
<> <>
Plane can send useful emails to you and your users from your own instance without talking to the Internet. NODE.DC может отправлять системные письма пользователям через ваш SMTP-сервер.
<div className="text-13 font-regular text-tertiary"> <div className="text-13 font-regular text-tertiary">
Set it up below and please test your settings before you save them.&nbsp; Заполните параметры ниже и проверьте отправку перед сохранением.&nbsp;
<span className="text-danger-primary">Misconfigs can lead to email bounces and errors.</span> <span className="text-danger-primary">Ошибки в конфигурации приводят к отказам доставки.</span>
</div> </div>
</> </>
), ),
@ -98,6 +98,6 @@ const InstanceEmailPage = observer(function InstanceEmailPage(_props: Route.Comp
); );
}); });
export const meta: Route.MetaFunction = () => [{ title: "Email Settings - God Mode" }]; export const meta: Route.MetaFunction = () => [{ title: "Настройки почты - NODE.DC" }];
export default InstanceEmailPage; export default InstanceEmailPage;

View File

@ -58,7 +58,7 @@ export function SendTestEmailModal(props: Props) {
setSendEmailStep(ESendEmailSteps.SUCCESS); setSendEmailStep(ESendEmailSteps.SUCCESS);
}) })
.catch((error) => { .catch((error) => {
setError(error?.error || "Failed to send email"); setError(error?.error || "Не удалось отправить письмо");
setSendEmailStep(ESendEmailSteps.FAILED); setSendEmailStep(ESendEmailSteps.FAILED);
}) })
.finally(() => { .finally(() => {
@ -91,13 +91,13 @@ export function SendTestEmailModal(props: Props) {
leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
> >
<Dialog.Panel className="relative w-full transform rounded-lg bg-surface-1 p-5 px-4 text-left shadow-raised-200 transition-all sm:max-w-xl"> <Dialog.Panel className="nodedc-glass-modal relative w-full transform rounded-[1.75rem] bg-surface-1 p-5 px-4 text-left shadow-raised-200 transition-all sm:max-w-xl">
<h3 className="text-16 leading-6 font-medium text-primary"> <h3 className="text-16 leading-6 font-medium text-primary">
{sendEmailStep === ESendEmailSteps.SEND_EMAIL {sendEmailStep === ESendEmailSteps.SEND_EMAIL
? "Send test email" ? "Отправить тестовое письмо"
: sendEmailStep === ESendEmailSteps.SUCCESS : sendEmailStep === ESendEmailSteps.SUCCESS
? "Email send" ? "Письмо отправлено"
: "Failed"}{" "} : "Ошибка отправки"}{" "}
</h3> </h3>
<div className="pt-6 pb-2"> <div className="pt-6 pb-2">
{sendEmailStep === ESendEmailSteps.SEND_EMAIL && ( {sendEmailStep === ESendEmailSteps.SEND_EMAIL && (
@ -106,28 +106,25 @@ export function SendTestEmailModal(props: Props) {
type="email" type="email"
value={receiverEmail} value={receiverEmail}
onChange={(e) => setReceiverEmail(e.target.value)} onChange={(e) => setReceiverEmail(e.target.value)}
placeholder="Receiver email" placeholder="Email получателя"
className="w-full resize-none text-16" className="nodedc-settings-input w-full resize-none text-16"
tabIndex={1} tabIndex={1}
/> />
)} )}
{sendEmailStep === ESendEmailSteps.SUCCESS && ( {sendEmailStep === ESendEmailSteps.SUCCESS && (
<div className="flex flex-col gap-y-4 text-13"> <div className="flex flex-col gap-y-4 text-13">
<p> <p>Тестовое письмо отправлено на {receiverEmail}. Если письма нет во входящих, проверьте спам.</p>
We have sent the test email to {receiverEmail}. Please check your spam folder if you cannot find <p>Если письмо не пришло, проверьте SMTP-настройки и отправьте тест заново.</p>
it.
</p>
<p>If you still cannot find it, recheck your SMTP configuration and trigger a new test email.</p>
</div> </div>
)} )}
{sendEmailStep === ESendEmailSteps.FAILED && <div className="text-13">{error}</div>} {sendEmailStep === ESendEmailSteps.FAILED && <div className="text-13">{error}</div>}
<div className="mt-5 flex items-center justify-end gap-2"> <div className="mt-5 flex items-center justify-end gap-2">
<Button variant="secondary" size="lg" onClick={handleClose} tabIndex={2}> <Button variant="secondary" size="lg" onClick={handleClose} tabIndex={2}>
{sendEmailStep === ESendEmailSteps.SEND_EMAIL ? "Cancel" : "Close"} {sendEmailStep === ESendEmailSteps.SEND_EMAIL ? "Отмена" : "Закрыть"}
</Button> </Button>
{sendEmailStep === ESendEmailSteps.SEND_EMAIL && ( {sendEmailStep === ESendEmailSteps.SEND_EMAIL && (
<Button variant="primary" size="lg" loading={isLoading} onClick={handleSubmit} tabIndex={3}> <Button variant="primary" size="lg" loading={isLoading} onClick={handleSubmit} tabIndex={3}>
{isLoading ? "Sending email" : "Send email"} {isLoading ? "Отправка" : "Отправить"}
</Button> </Button>
)} )}
</div> </div>

View File

@ -60,8 +60,8 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
.then(() => .then(() =>
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Success", title: "Сохранено",
message: "Settings updated successfully", message: "Основные настройки обновлены",
}) })
) )
.catch((err) => console.error(err)); .catch((err) => console.error(err));
@ -70,41 +70,41 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<div className="space-y-4"> <div className="space-y-4">
<div className="text-16 font-medium text-primary">Instance details</div> <div className="text-16 font-medium text-primary">Данные инстанса</div>
<div className="grid-col grid w-full grid-cols-1 items-center justify-between gap-8 md:grid-cols-2 lg:grid-cols-3"> <div className="grid-col grid w-full grid-cols-1 items-center justify-between gap-8 md:grid-cols-2 lg:grid-cols-3">
<ControllerInput <ControllerInput
key="instance_name" key="instance_name"
name="instance_name" name="instance_name"
control={control} control={control}
type="text" type="text"
label="Name of instance" label="Название инстанса"
placeholder="Instance name" placeholder="Название инстанса"
error={Boolean(errors.instance_name)} error={Boolean(errors.instance_name)}
required required
/> />
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">Email</h4> <h4 className="text-13 text-tertiary">Email администратора</h4>
<Input <Input
id="email" id="email"
name="email" name="email"
type="email" type="email"
value={instanceAdmins[0]?.user_detail?.email ?? ""} value={instanceAdmins[0]?.user_detail?.email ?? ""}
placeholder="Admin email" placeholder="Email администратора"
className="w-full cursor-not-allowed !text-placeholder" className="nodedc-settings-input w-full cursor-not-allowed !text-placeholder"
autoComplete="on" autoComplete="on"
disabled disabled
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">Instance ID</h4> <h4 className="text-13 text-tertiary">ID инстанса</h4>
<Input <Input
id="instance_id" id="instance_id"
name="instance_id" name="instance_id"
type="text" type="text"
value={instance.instance_id} value={instance.instance_id}
className="w-full cursor-not-allowed rounded-md font-medium !text-placeholder" className="nodedc-settings-input w-full cursor-not-allowed rounded-md font-medium !text-placeholder"
disabled disabled
/> />
</div> </div>
@ -112,7 +112,7 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<div className="border-b border-subtle pb-1.5 text-16 font-medium text-primary">Chat + telemetry</div> <div className="border-b border-subtle pb-1.5 text-16 font-medium text-primary">Чат и телеметрия</div>
<IntercomConfig isTelemetryEnabled={watch("is_telemetry_enabled") ?? false} /> <IntercomConfig isTelemetryEnabled={watch("is_telemetry_enabled") ?? false} />
<div className="flex items-center gap-14"> <div className="flex items-center gap-14">
<div className="flex grow items-center gap-4"> <div className="flex grow items-center gap-4">
@ -122,17 +122,17 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
</div> </div>
</div> </div>
<div className="grow"> <div className="grow">
<div className="text-13 leading-5 font-medium text-primary">Let Plane collect anonymous usage data</div> <div className="text-13 leading-5 font-medium text-primary">Разрешить анонимную телеметрию</div>
<div className="text-11 leading-5 font-regular text-tertiary"> <div className="text-11 leading-5 font-regular text-tertiary">
No PII is collected.This anonymized data is used to understand how you use Plane and build new features Персональные данные не собираются. Анонимные события помогают понимать, как используется система, с
in line with{" "} учетом{" "}
<a <a
href="https://developers.plane.so/self-hosting/telemetry" href="https://developers.plane.so/self-hosting/telemetry"
target="_blank" target="_blank"
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
our Telemetry Policy. политики телеметрии.
</a> </a>
</div> </div>
</div> </div>
@ -158,7 +158,7 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
}} }}
loading={isSubmitting} loading={isSubmitting}
> >
{isSubmitting ? "Saving" : "Save changes"} {isSubmitting ? "Сохранение" : "Сохранить изменения"}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -64,10 +64,10 @@ export const IntercomConfig = observer(function IntercomConfig(props: TIntercomC
</div> </div>
<div className="grow"> <div className="grow">
<div className="text-13 leading-5 font-medium text-primary">Chat with us</div> <div className="text-13 leading-5 font-medium text-primary">Встроенный чат поддержки</div>
<div className="text-11 leading-5 font-regular text-tertiary"> <div className="text-11 leading-5 font-regular text-tertiary">
Let your users chat with us via Intercom or another service. Toggling Telemetry off turns this off Разрешает пользователям писать в поддержку через Intercom или аналогичный сервис. При отключении
automatically. телеметрии чат отключается автоматически.
</div> </div>
</div> </div>

View File

@ -20,9 +20,8 @@ function GeneralPage() {
return ( return (
<PageWrapper <PageWrapper
header={{ header={{
title: "General settings", title: "Основные настройки",
description: description: "Имя инстанса, служебные идентификаторы, email администратора и режимы телеметрии.",
"Change the name of your instance and instance admin e-mail addresses. Enable or disable telemetry in your instance.",
}} }}
> >
{instance && instanceAdmins && <GeneralConfigurationForm instance={instance} instanceAdmins={instanceAdmins} />} {instance && instanceAdmins && <GeneralConfigurationForm instance={instance} instanceAdmins={instanceAdmins} />}
@ -30,6 +29,6 @@ function GeneralPage() {
); );
} }
export const meta: Route.MetaFunction = () => [{ title: "General Settings - God Mode" }]; export const meta: Route.MetaFunction = () => [{ title: "Основные настройки - NODE.DC" }];
export default observer(GeneralPage); export default observer(GeneralPage);

View File

@ -41,8 +41,8 @@ export function InstanceImageConfigForm(props: IInstanceImageConfigForm) {
.then(() => .then(() =>
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Success", title: "Сохранено",
message: "Image Configuration Settings updated successfully", message: "Настройки изображений обновлены",
}) })
) )
.catch((err) => console.error(err)); .catch((err) => console.error(err));
@ -55,17 +55,17 @@ export function InstanceImageConfigForm(props: IInstanceImageConfigForm) {
control={control} control={control}
type="password" type="password"
name="UNSPLASH_ACCESS_KEY" name="UNSPLASH_ACCESS_KEY"
label="Access key from your Unsplash account" label="Access key аккаунта Unsplash"
description={ description={
<> <>
You will find your access key in your Unsplash developer console.&nbsp; Ключ доступа находится в консоли разработчика Unsplash.&nbsp;
<a <a
href="https://unsplash.com/documentation#creating-a-developer-account" href="https://unsplash.com/documentation#creating-a-developer-account"
target="_blank" target="_blank"
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
Learn more. Подробнее.
</a> </a>
</> </>
} }
@ -77,7 +77,7 @@ export function InstanceImageConfigForm(props: IInstanceImageConfigForm) {
<div> <div>
<Button variant="primary" size="lg" onClick={handleSubmit(onSubmit)} loading={isSubmitting}> <Button variant="primary" size="lg" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving" : "Save changes"} {isSubmitting ? "Сохранение" : "Сохранить изменения"}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -25,8 +25,8 @@ const InstanceImagePage = observer(function InstanceImagePage(_props: Route.Comp
return ( return (
<PageWrapper <PageWrapper
header={{ header={{
title: "Third-party image libraries", title: "Внешние библиотеки изображений",
description: "Let your users search and choose images from third-party libraries", description: "Разрешите пользователям искать и выбирать изображения из внешних библиотек.",
}} }}
> >
{formattedConfig ? ( {formattedConfig ? (
@ -41,6 +41,6 @@ const InstanceImagePage = observer(function InstanceImagePage(_props: Route.Comp
); );
}); });
export const meta: Route.MetaFunction = () => [{ title: "Images Settings - God Mode" }]; export const meta: Route.MetaFunction = () => [{ title: "Изображения - NODE.DC" }];
export default InstanceImagePage; export default InstanceImagePage;

View File

@ -38,9 +38,9 @@ function AdminLayout(_props: Route.ComponentProps) {
if (isUserLoggedIn) { if (isUserLoggedIn) {
return ( return (
<div className="relative flex h-screen w-screen overflow-hidden"> <div className="nodedc-admin-shell relative flex h-screen w-screen overflow-hidden">
<AdminSidebar /> <AdminSidebar />
<main className="relative flex h-full w-full flex-col overflow-hidden bg-surface-1"> <main className="nodedc-admin-main relative flex h-full w-full flex-col overflow-hidden bg-surface-1">
<AdminHeader /> <AdminHeader />
<div className="vertical-scrollbar scrollbar-md h-full w-full overflow-hidden overflow-y-scroll"> <div className="vertical-scrollbar scrollbar-md h-full w-full overflow-hidden overflow-y-scroll">
<Outlet /> <Outlet />

View File

@ -36,11 +36,13 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
const handleSignOut = () => signOut(); const handleSignOut = () => signOut();
const getSidebarMenuItems = () => ( const getSidebarMenuItems = (align: "left" | "right" = "left") => (
<Menu.Items <Menu.Items
className={cn( className={cn(
"shadow-lg absolute left-0 z-20 mt-1.5 flex w-52 flex-col divide-y divide-subtle rounded-md border border-subtle bg-surface-1 px-1 py-2 text-11 outline-none", "nodedc-glass-popup-surface absolute top-full z-[100] mt-2 flex w-56 flex-col divide-y divide-white/6 px-2 py-2 text-11 outline-none",
{ {
"left-0": align === "left",
"right-0": align === "right",
"left-4": isSidebarCollapsed, "left-4": isSidebarCollapsed,
} }
)} )}
@ -52,11 +54,11 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
<Menu.Item <Menu.Item
as="button" as="button"
type="button" type="button"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1 hover:bg-layer-1-hover" className="nodedc-settings-sidebar-item flex w-full items-center gap-2 px-2 py-1 text-left"
onClick={handleThemeSwitch} onClick={handleThemeSwitch}
> >
<Palette className="h-4 w-4 stroke-[1.5]" /> <Palette className="h-4 w-4 stroke-[1.5]" />
Switch to {resolvedTheme === "dark" ? "light" : "dark"} mode {resolvedTheme === "dark" ? "Светлая тема" : "Темная тема"}
</Menu.Item> </Menu.Item>
</div> </div>
<div className="py-2"> <div className="py-2">
@ -65,10 +67,10 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
<Menu.Item <Menu.Item
as="button" as="button"
type="submit" type="submit"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1 hover:bg-layer-1-hover" className="nodedc-settings-sidebar-item flex w-full items-center gap-2 px-2 py-1 text-left"
> >
<LogOut className="h-4 w-4 stroke-[1.5]" /> <LogOut className="h-4 w-4 stroke-[1.5]" />
Sign out Выйти
</Menu.Item> </Menu.Item>
</form> </form>
</div> </div>
@ -81,21 +83,21 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
}, [csrfToken]); }, [csrfToken]);
return ( return (
<div className="flex max-h-header items-center gap-x-5 gap-y-2 border-b border-subtle px-4 py-2.5"> <div className="px-3 pt-4 pb-2">
<div className="h-full w-full truncate"> <div className="relative h-full w-full overflow-visible">
<div <div
className={`flex flex-grow items-center gap-x-2 truncate rounded-sm ${ className={`nodedc-admin-sidebar-profile relative z-[80] flex flex-grow items-center gap-x-3 px-3 py-3 ${
isSidebarCollapsed ? "justify-center" : "" isSidebarCollapsed ? "justify-center" : ""
}`} }`}
> >
<Menu as="div" className="flex-shrink-0"> <Menu as="div" className="relative z-[100] flex-shrink-0">
<Menu.Button <Menu.Button
className={cn("grid place-items-center outline-none", { className={cn("grid place-items-center outline-none", {
"cursor-default": !isSidebarCollapsed, "cursor-default": !isSidebarCollapsed,
})} })}
> >
<div className="flex size-8 flex-shrink-0 items-center justify-center rounded-sm bg-layer-1"> <div className="nodedc-admin-sidebar-avatar-button flex size-10 flex-shrink-0 items-center justify-center">
<UserCog2 className="size-5 text-primary" /> <UserCog2 className="size-5 text-[rgb(var(--nodedc-card-active-rgb))]" />
</div> </div>
</Menu.Button> </Menu.Button>
{isSidebarCollapsed && ( {isSidebarCollapsed && (
@ -114,38 +116,39 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
</Menu> </Menu>
{!isSidebarCollapsed && ( {!isSidebarCollapsed && (
<div className="flex w-full gap-2"> <div className="min-w-0 flex-1">
<h4 className="grow truncate text-body-md-medium text-primary">Instance admin</h4> <h4 className="truncate text-15 font-medium text-primary">Глобальный админ</h4>
<div className="truncate text-11 font-medium text-tertiary">Супер-администратор</div>
</div> </div>
)} )}
{!isSidebarCollapsed && currentUser && (
<Menu as="div" className="relative z-[100] flex-shrink-0">
<Menu.Button className="nodedc-admin-sidebar-action grid size-8 place-items-center outline-none">
<Avatar
name={currentUser.display_name}
src={getFileURL(currentUser.avatar_url)}
size={24}
shape="square"
className="!text-body-sm-medium"
/>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
{getSidebarMenuItems("right")}
</Transition>
</Menu>
)}
</div> </div>
</div> </div>
{!isSidebarCollapsed && currentUser && (
<Menu as="div" className="relative flex-shrink-0">
<Menu.Button className="grid place-items-center outline-none">
<Avatar
name={currentUser.display_name}
src={getFileURL(currentUser.avatar_url)}
size={24}
shape="square"
className="!text-body-sm-medium"
/>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
{getSidebarMenuItems()}
</Transition>
</Menu>
)}
</div> </div>
); );
}); });

View File

@ -20,17 +20,17 @@ import { useInstance, useTheme } from "@/hooks/store";
const helpOptions = [ const helpOptions = [
{ {
name: "Documentation", name: "Документация",
href: "https://docs.plane.so/", href: "https://docs.plane.so/",
Icon: PageIcon, Icon: PageIcon,
}, },
{ {
name: "Join our Forum", name: "Форум Plane",
href: "https://forum.plane.so", href: "https://forum.plane.so",
Icon: MessageSquare, Icon: MessageSquare,
}, },
{ {
name: "Report a bug", name: "Сообщить об ошибке",
href: "https://github.com/makeplane/plane/issues/new/choose", href: "https://github.com/makeplane/plane/issues/new/choose",
Icon: GithubIcon, Icon: GithubIcon,
}, },
@ -50,26 +50,26 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
return ( return (
<div <div
className={cn( className={cn(
"flex h-14 w-full flex-shrink-0 items-center justify-between gap-1 self-baseline border-t border-subtle bg-surface-1 px-4", "flex h-16 w-full flex-shrink-0 items-center justify-between gap-1 self-baseline border-t border-white/6 px-3",
{ {
"h-auto flex-col py-1.5": isSidebarCollapsed, "h-auto flex-col py-1.5": isSidebarCollapsed,
} }
)} )}
> >
<div className={`flex items-center gap-1 ${isSidebarCollapsed ? "flex-col justify-center" : "w-full"}`}> <div className={`flex items-center gap-1 ${isSidebarCollapsed ? "flex-col justify-center" : "w-full"}`}>
<Tooltip tooltipContent="Redirect to Plane" position="right" className="ml-4" disabled={!isSidebarCollapsed}> <Tooltip tooltipContent="Перейти в приложение" position="right" className="ml-4" disabled={!isSidebarCollapsed}>
<a <a
href={redirectionLink} href={redirectionLink}
className={`relative flex items-center gap-1 rounded-sm bg-layer-1 px-2 py-1 text-body-xs-medium whitespace-nowrap text-secondary`} className="nodedc-admin-sidebar-action relative flex items-center gap-1 px-3 py-1 text-12 font-medium whitespace-nowrap"
> >
<NewTabIcon width={14} height={14} /> <NewTabIcon width={14} height={14} />
{!isSidebarCollapsed && "Redirect to Plane"} {!isSidebarCollapsed && "В приложение"}
</a> </a>
</Tooltip> </Tooltip>
<Tooltip tooltipContent="Help" position={isSidebarCollapsed ? "right" : "top"} className="ml-4"> <Tooltip tooltipContent="Помощь" position={isSidebarCollapsed ? "right" : "top"} className="ml-4">
<button <button
type="button" type="button"
className={`ml-auto grid place-items-center rounded-md p-1.5 text-secondary outline-none hover:bg-layer-1-hover hover:text-primary ${ className={`nodedc-admin-sidebar-action ml-auto grid place-items-center p-1.5 outline-none ${
isSidebarCollapsed ? "w-full" : "" isSidebarCollapsed ? "w-full" : ""
}`} }`}
onClick={() => setIsNeedHelpOpen((prev) => !prev)} onClick={() => setIsNeedHelpOpen((prev) => !prev)}
@ -77,10 +77,10 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
<HelpCircle className="size-4" /> <HelpCircle className="size-4" />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip tooltipContent="Toggle sidebar" position={isSidebarCollapsed ? "right" : "top"} className="ml-4"> <Tooltip tooltipContent="Свернуть меню" position={isSidebarCollapsed ? "right" : "top"} className="ml-4">
<button <button
type="button" type="button"
className={`grid place-items-center rounded-md p-1.5 text-secondary outline-none hover:bg-layer-1-hover hover:text-primary ${ className={`nodedc-admin-sidebar-action grid place-items-center p-1.5 outline-none ${
isSidebarCollapsed ? "w-full" : "" isSidebarCollapsed ? "w-full" : ""
}`} }`}
onClick={() => toggleSidebar(!isSidebarCollapsed)} onClick={() => toggleSidebar(!isSidebarCollapsed)}
@ -103,7 +103,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
<div <div
className={`absolute bottom-2 z-[15] min-w-[10rem] ${ className={`absolute bottom-2 z-[15] min-w-[10rem] ${
isSidebarCollapsed ? "left-full" : "-left-[75px]" isSidebarCollapsed ? "left-full" : "-left-[75px]"
} divide-y divide-subtle-1 rounded-sm bg-surface-1 p-1 whitespace-nowrap shadow-raised-100`} } nodedc-glass-popup-surface divide-y divide-subtle-1 rounded-sm bg-surface-1 p-1 whitespace-nowrap shadow-raised-100`}
ref={helpOptionsRef} ref={helpOptionsRef}
> >
<div className="space-y-1 pb-2"> <div className="space-y-1 pb-2">
@ -134,7 +134,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
); );
})} })}
</div> </div>
<div className="px-2 pt-2 pb-1 text-10">Version: v{instance?.current_version}</div> <div className="px-2 pt-2 pb-1 text-10">Версия: v{instance?.current_version}</div>
</div> </div>
</Transition> </Transition>
</div> </div>

View File

@ -21,6 +21,10 @@ export const AdminSidebarMenu = observer(function AdminSidebarMenu() {
const { isSidebarCollapsed, toggleSidebar } = useTheme(); const { isSidebarCollapsed, toggleSidebar } = useTheme();
// derived values // derived values
const sidebarMenu = useSidebarMenu(); const sidebarMenu = useSidebarMenu();
const sidebarMenuGroups = [
{ label: "ИНСТАНС", items: sidebarMenu.slice(0, 4) },
{ label: "ВОЗМОЖНОСТИ", items: sidebarMenu.slice(4) },
];
const handleItemClick = () => { const handleItemClick = () => {
if (window.innerWidth < 768) { if (window.innerWidth < 768) {
@ -29,36 +33,33 @@ export const AdminSidebarMenu = observer(function AdminSidebarMenu() {
}; };
return ( return (
<div className="vertical-scrollbar flex scrollbar-sm h-full w-full flex-col gap-2.5 overflow-y-scroll px-4 py-4"> <div className="vertical-scrollbar flex scrollbar-sm h-full w-full flex-col overflow-y-scroll px-3 py-4">
{sidebarMenu.map((item, index) => { {sidebarMenuGroups.map((group) => (
const isActive = item.href === pathName || pathName?.includes(item.href); <div key={group.label} className="shrink-0 border-b border-white/6 py-3 first:pt-0 last:border-b-0 last:pb-0">
return ( {!isSidebarCollapsed && <div className="nodedc-admin-sidebar-section-label">{group.label}</div>}
<Link key={index} href={item.href} onClick={handleItemClick}> <div className="flex flex-col gap-1">
<div> {group.items.map((item) => {
<Tooltip tooltipContent={item.name} position="right" className="ml-2" disabled={!isSidebarCollapsed}> const isActive = item.href === pathName || Boolean(pathName?.startsWith(item.href));
<div return (
className={cn( <Link key={item.href} href={item.href} onClick={handleItemClick}>
"group flex w-full items-center gap-3 rounded-md px-3 py-2 transition-colors outline-none", <Tooltip tooltipContent={item.name} position="right" className="ml-2" disabled={!isSidebarCollapsed}>
{ <div
"!bg-layer-transparent-active text-primary": isActive, className={cn(
"text-secondary hover:bg-layer-transparent-hover active:bg-layer-transparent-active": !isActive, "nodedc-settings-sidebar-item group flex w-full items-center gap-3 px-3 py-2 text-14 font-medium transition-colors outline-none",
}, isSidebarCollapsed ? "justify-center" : "w-full"
isSidebarCollapsed ? "justify-center" : "w-[260px]" )}
)} data-active={isActive}
> >
{<item.Icon className="h-4 w-4 flex-shrink-0" />} {<item.Icon className="h-4 w-4 flex-shrink-0" />}
{!isSidebarCollapsed && ( {!isSidebarCollapsed && <div className="min-w-0 truncate transition-colors">{item.name}</div>}
<div className="w-full">
<div className={cn(`text-body-xs-medium transition-colors`)}>{item.name}</div>
<div className={cn(`text-caption-sm-regular transition-colors`)}>{item.description}</div>
</div> </div>
)} </Tooltip>
</div> </Link>
</Tooltip> );
</div> })}
</Link> </div>
); </div>
})} ))}
</div> </div>
); );
}); });

View File

@ -44,7 +44,7 @@ export const AdminSidebar = observer(function AdminSidebar() {
return ( return (
<div <div
className={`fixed inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-subtle bg-surface-1 duration-300 md:relative ${isSidebarCollapsed ? "-ml-[290px]" : ""} sm:${isSidebarCollapsed ? "-ml-[290px]" : ""} md:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"} lg:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"} `} className={`nodedc-glass-sidebar fixed inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-subtle bg-surface-1 duration-300 md:relative ${isSidebarCollapsed ? "-ml-[290px]" : ""} sm:${isSidebarCollapsed ? "-ml-[290px]" : ""} md:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"} lg:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"} `}
> >
<div ref={ref} className="flex h-full w-full flex-1 flex-col"> <div ref={ref} className="flex h-full w-full flex-1 flex-col">
<AdminSidebarDropdown /> <AdminSidebarDropdown />

View File

@ -56,16 +56,16 @@ export function WorkspaceCreateForm() {
.then(async () => { .then(async () => {
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Success!", title: "Готово",
message: "Workspace created successfully.", message: "Воркспейс создан.",
}); });
router.push(`/workspace`); router.push(`/workspace`);
}) })
.catch(() => { .catch(() => {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: "Ошибка",
message: "Workspace could not be created. Please try again.", message: "Не удалось создать воркспейс. Попробуйте еще раз.",
}); });
}); });
} else setSlugError(true); } else setSlugError(true);
@ -73,8 +73,8 @@ export function WorkspaceCreateForm() {
.catch(() => { .catch(() => {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: "Ошибка",
message: "Some error occurred while creating workspace. Please try again.", message: "При создании воркспейса произошла ошибка. Попробуйте еще раз.",
}); });
}); });
}; };
@ -91,7 +91,7 @@ export function WorkspaceCreateForm() {
<div className="space-y-8"> <div className="space-y-8">
<div className="grid-col grid w-full max-w-4xl grid-cols-1 items-start justify-between gap-x-10 gap-y-6 lg:grid-cols-2"> <div className="grid-col grid w-full max-w-4xl grid-cols-1 items-start justify-between gap-x-10 gap-y-6 lg:grid-cols-2">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">Name your workspace</h4> <h4 className="text-13 text-tertiary">Название воркспейса</h4>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Controller <Controller
control={control} control={control}
@ -113,8 +113,8 @@ export function WorkspaceCreateForm() {
}} }}
ref={ref} ref={ref}
hasError={Boolean(errors.name)} hasError={Boolean(errors.name)}
placeholder="Something familiar and recognizable is always best." placeholder="Короткое понятное название"
className="w-full" className="nodedc-settings-input w-full"
/> />
)} )}
/> />
@ -122,8 +122,8 @@ export function WorkspaceCreateForm() {
</div> </div>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">Set your workspace&apos;s URL</h4> <h4 className="text-13 text-tertiary">URL воркспейса</h4>
<div className="flex w-full items-center gap-0.5 rounded-md border-[0.5px] border-subtle px-3"> <div className="nodedc-settings-input flex w-full items-center gap-0.5 rounded-md border-[0.5px] border-subtle px-3">
<span className="text-13 whitespace-nowrap text-secondary">{workspaceBaseURL}</span> <span className="text-13 whitespace-nowrap text-secondary">{workspaceBaseURL}</span>
<Controller <Controller
control={control} control={control}
@ -149,29 +149,29 @@ export function WorkspaceCreateForm() {
)} )}
/> />
</div> </div>
{slugError && <p className="text-13 text-danger-primary">This URL is taken. Try something else.</p>} {slugError && <p className="text-13 text-danger-primary">Этот URL уже занят. Выберите другой.</p>}
{invalidSlug && ( {invalidSlug && (
<p className="text-13 text-danger-primary">{`URLs can contain only ( - ), ( _ ) and alphanumeric characters.`}</p> <p className="text-13 text-danger-primary">{`URL может содержать только латинские буквы, цифры, "-" и "_".`}</p>
)} )}
{errors.slug && <span className="text-11 text-danger-primary">{errors.slug.message}</span>} {errors.slug && <span className="text-11 text-danger-primary">{errors.slug.message}</span>}
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">How many people will use this workspace?</h4> <h4 className="text-13 text-tertiary">Сколько людей будет работать в воркспейсе?</h4>
<div className="w-full"> <div className="w-full">
<Controller <Controller
name="organization_size" name="organization_size"
control={control} control={control}
rules={{ required: "This is a required field." }} rules={{ required: "Это обязательное поле." }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<CustomSelect <CustomSelect
value={value} value={value}
onChange={onChange} onChange={onChange}
label={ label={
ORGANIZATION_SIZE.find((c) => c === value) ?? ( ORGANIZATION_SIZE.find((c) => c === value) ?? (
<span className="text-placeholder">Select a range</span> <span className="text-placeholder">Выберите диапазон</span>
) )
} }
buttonClassName="!border-[0.5px] !border-subtle !shadow-none" buttonClassName="nodedc-settings-select !border-[0.5px] !border-subtle !shadow-none"
input input
> >
{ORGANIZATION_SIZE.map((item) => ( {ORGANIZATION_SIZE.map((item) => (
@ -196,10 +196,10 @@ export function WorkspaceCreateForm() {
disabled={!isValid} disabled={!isValid}
loading={isSubmitting} loading={isSubmitting}
> >
{isSubmitting ? "Creating workspace" : "Create workspace"} {isSubmitting ? "Создание" : "Создать воркспейс"}
</Button> </Button>
<Link className={getButtonStyling("secondary", "lg")} href="/workspace"> <Link className={getButtonStyling("secondary", "lg")} href="/workspace">
Go back Назад
</Link> </Link>
</div> </div>
</div> </div>

View File

@ -16,8 +16,8 @@ const WorkspaceCreatePage = observer(function WorkspaceCreatePage(_props: Route.
return ( return (
<PageWrapper <PageWrapper
header={{ header={{
title: "Create a new workspace on this instance.", title: "Создать новый воркспейс",
description: "You will need to invite users from Workspace Settings after you create this workspace.", description: "После создания пригласите пользователей в настройках рабочего пространства.",
}} }}
> >
<WorkspaceCreateForm /> <WorkspaceCreateForm />
@ -25,6 +25,6 @@ const WorkspaceCreatePage = observer(function WorkspaceCreatePage(_props: Route.
); );
}); });
export const meta: Route.MetaFunction = () => [{ title: "Create Workspace - God Mode" }]; export const meta: Route.MetaFunction = () => [{ title: "Создание воркспейса - NODE.DC" }];
export default WorkspaceCreatePage; export default WorkspaceCreatePage;

View File

@ -17,6 +17,7 @@ import { Loader, ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// components // components
import { PageWrapper } from "@/components/common/page-wrapper"; import { PageWrapper } from "@/components/common/page-wrapper";
import { WorkspaceFeaturesModal, WorkspaceMembersModal } from "@/components/workspace/admin-modals";
import { WorkspaceListItem } from "@/components/workspace/list-item"; import { WorkspaceListItem } from "@/components/workspace/list-item";
// hooks // hooks
import { useInstance, useWorkspace } from "@/hooks/store"; import { useInstance, useWorkspace } from "@/hooks/store";
@ -26,6 +27,8 @@ import type { Route } from "./+types/page";
const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props: Route.ComponentProps) { const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props: Route.ComponentProps) {
// states // states
const [isSubmitting, setIsSubmitting] = useState<boolean>(false); const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [membersWorkspaceId, setMembersWorkspaceId] = useState<string | null>(null);
const [featuresWorkspaceId, setFeaturesWorkspaceId] = useState<string | null>(null);
// store // store
const { formattedConfig, fetchInstanceConfigurations, updateInstanceConfigurations } = useInstance(); const { formattedConfig, fetchInstanceConfigurations, updateInstanceConfigurations } = useInstance();
const { const {
@ -53,14 +56,14 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
const updateConfigPromise = updateInstanceConfigurations(payload); const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, { setPromiseToast(updateConfigPromise, {
loading: "Saving configuration", loading: "Сохранение конфигурации",
success: { success: {
title: "Success", title: "Сохранено",
message: () => "Configuration saved successfully", message: () => "Конфигурация обновлена",
}, },
error: { error: {
title: "Error", title: "Ошибка",
message: () => "Failed to save configuration", message: () => "Не удалось сохранить конфигурацию",
}, },
}); });
@ -77,8 +80,8 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
return ( return (
<PageWrapper <PageWrapper
header={{ header={{
title: "Workspaces on this instance", title: "Воркспейсы инстанса",
description: "See all workspaces and control who can create them.", description: "Просматривайте все рабочие пространства и управляйте правом создания новых.",
}} }}
> >
<div className="space-y-3"> <div className="space-y-3">
@ -86,9 +89,9 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
<div className={cn("flex w-full items-center gap-14 rounded-sm")}> <div className={cn("flex w-full items-center gap-14 rounded-sm")}>
<div className="flex grow items-center gap-4"> <div className="flex grow items-center gap-4">
<div className="grow"> <div className="grow">
<div className="pb-1 text-16 font-medium">Prevent anyone else from creating a workspace.</div> <div className="pb-1 text-16 font-medium">Запретить пользователям создавать воркспейсы</div>
<div className={cn("text-11 leading-5 font-regular text-tertiary")}> <div className={cn("text-11 leading-5 font-regular text-tertiary")}>
Toggling this on will let only you create workspaces. You will have to invite users to new workspaces. Если включить, создавать рабочие пространства сможет только администратор инстанса.
</div> </div>
</div> </div>
</div> </div>
@ -119,25 +122,30 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
<div className="flex items-center justify-between gap-2 pt-6"> <div className="flex items-center justify-between gap-2 pt-6">
<div className="flex flex-col items-start gap-x-2"> <div className="flex flex-col items-start gap-x-2">
<div className="flex items-center gap-2 text-16 font-medium"> <div className="flex items-center gap-2 text-16 font-medium">
All workspaces on this instance <span className="text-tertiary"> {workspaceIds.length}</span> Все воркспейсы инстанса <span className="text-tertiary"> {workspaceIds.length}</span>
{workspaceLoader && ["mutation", "pagination"].includes(workspaceLoader) && ( {workspaceLoader && ["mutation", "pagination"].includes(workspaceLoader) && (
<LoaderIcon className="h-4 w-4 animate-spin" /> <LoaderIcon className="h-4 w-4 animate-spin" />
)} )}
</div> </div>
<div className={cn("text-11 leading-5 font-regular text-tertiary")}> <div className={cn("text-11 leading-5 font-regular text-tertiary")}>
You can&apos;t yet delete workspaces and you can only go to the workspace if you are an Admin or a Удаление пока недоступно. Открыть воркспейс можно только при наличии роли администратора или
Member. участника.
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href="/workspace/create" className={getButtonStyling("primary", "base")}> <Link href="/workspace/create" className={getButtonStyling("primary", "base")}>
Create workspace Создать воркспейс
</Link> </Link>
</div> </div>
</div> </div>
<div className="flex flex-col gap-4 py-2"> <div className="flex flex-col gap-4 py-2">
{workspaceIds.map((workspaceId) => ( {workspaceIds.map((workspaceId) => (
<WorkspaceListItem key={workspaceId} workspaceId={workspaceId} /> <WorkspaceListItem
key={workspaceId}
workspaceId={workspaceId}
onMembersClick={setMembersWorkspaceId}
onFeaturesClick={setFeaturesWorkspaceId}
/>
))} ))}
</div> </div>
{hasNextPage && ( {hasNextPage && (
@ -148,7 +156,7 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
onClick={() => fetchNextWorkspaces()} onClick={() => fetchNextWorkspaces()}
disabled={workspaceLoader === "pagination"} disabled={workspaceLoader === "pagination"}
> >
Load more Загрузить еще
{workspaceLoader === "pagination" && <LoaderIcon className="h-3 w-3 animate-spin" />} {workspaceLoader === "pagination" && <LoaderIcon className="h-3 w-3 animate-spin" />}
</Button> </Button>
</div> </div>
@ -162,11 +170,21 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
<Loader.Item height="92px" width="100%" /> <Loader.Item height="92px" width="100%" />
</Loader> </Loader>
)} )}
<WorkspaceMembersModal
isOpen={!!membersWorkspaceId}
workspaceId={membersWorkspaceId}
onClose={() => setMembersWorkspaceId(null)}
/>
<WorkspaceFeaturesModal
isOpen={!!featuresWorkspaceId}
workspaceId={featuresWorkspaceId}
onClose={() => setFeaturesWorkspaceId(null)}
/>
</div> </div>
</PageWrapper> </PageWrapper>
); );
}); });
export const meta: Route.MetaFunction = () => [{ title: "Workspace Management - God Mode" }]; export const meta: Route.MetaFunction = () => [{ title: "Воркспейсы - NODE.DC" }];
export default WorkspaceManagementPage; export default WorkspaceManagementPage;

View File

@ -5,14 +5,16 @@
*/ */
import Link from "next/link"; import Link from "next/link";
import { PlaneLockup } from "@plane/propel/icons";
export function AuthHeader() { export function AuthHeader() {
return ( return (
<div className="sticky top-0 flex w-full flex-shrink-0 items-center justify-between gap-6"> <div className="sticky top-0 flex w-full flex-shrink-0 items-center justify-between gap-6">
<Link href="/"> <Link href="/">
<PlaneLockup height={20} width={95} className="text-primary" /> <span className="tracking-normal text-16 font-semibold text-primary">NODE.DC</span>
</Link> </Link>
<span className="rounded-full bg-white/6 px-3 py-1 text-11 font-medium text-secondary">
Глобальное администрирование
</span>
</div> </div>
); );
} }

View File

@ -22,56 +22,56 @@ const errorCodeMessages: {
} = { } = {
// admin // admin
[EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST]: { [EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST]: {
title: `Admin already exists`, title: `Администратор уже существует`,
message: () => `Admin already exists. Please try again.`, message: () => `Администратор уже существует. Попробуйте еще раз.`,
}, },
[EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: { [EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: {
title: `Email, password and first name required`, title: `Нужны email, пароль и имя`,
message: () => `Email, password and first name required. Please try again.`, message: () => `Укажите email, пароль и имя. Попробуйте еще раз.`,
}, },
[EAdminAuthErrorCodes.INVALID_ADMIN_EMAIL]: { [EAdminAuthErrorCodes.INVALID_ADMIN_EMAIL]: {
title: `Invalid admin email`, title: `Некорректный email администратора`,
message: () => `Invalid admin email. Please try again.`, message: () => `Некорректный email администратора. Попробуйте еще раз.`,
}, },
[EAdminAuthErrorCodes.INVALID_ADMIN_PASSWORD]: { [EAdminAuthErrorCodes.INVALID_ADMIN_PASSWORD]: {
title: `Invalid admin password`, title: `Некорректный пароль администратора`,
message: () => `Invalid admin password. Please try again.`, message: () => `Некорректный пароль администратора. Попробуйте еще раз.`,
}, },
[EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: { [EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: {
title: `Email and password required`, title: `Нужны email и пароль`,
message: () => `Email and password required. Please try again.`, message: () => `Укажите email и пароль. Попробуйте еще раз.`,
}, },
[EAdminAuthErrorCodes.ADMIN_AUTHENTICATION_FAILED]: { [EAdminAuthErrorCodes.ADMIN_AUTHENTICATION_FAILED]: {
title: `Authentication failed`, title: `Ошибка входа`,
message: () => `Authentication failed. Please try again.`, message: () => `Не удалось войти. Проверьте данные и попробуйте еще раз.`,
}, },
[EAdminAuthErrorCodes.ADMIN_USER_ALREADY_EXIST]: { [EAdminAuthErrorCodes.ADMIN_USER_ALREADY_EXIST]: {
title: `Admin user already exists`, title: `Администратор уже существует`,
message: () => ( message: () => (
<div> <div>
Admin user already exists.&nbsp; Администратор уже существует.&nbsp;
<Link className="font-medium underline underline-offset-4 transition-all hover:font-bold" href={`/admin`}> <Link className="font-medium underline underline-offset-4 transition-all hover:font-bold" href={`/admin`}>
Sign In Войти
</Link> </Link>
&nbsp;now. &nbsp;сейчас.
</div> </div>
), ),
}, },
[EAdminAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: { [EAdminAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: {
title: `Admin user does not exist`, title: `Администратор не найден`,
message: () => ( message: () => (
<div> <div>
Admin user does not exist.&nbsp; Администратор не найден.&nbsp;
<Link className="font-medium underline underline-offset-4 transition-all hover:font-bold" href={`/admin`}> <Link className="font-medium underline underline-offset-4 transition-all hover:font-bold" href={`/admin`}>
Sign In Войти
</Link> </Link>
&nbsp;now. &nbsp;сейчас.
</div> </div>
), ),
}, },
[EAdminAuthErrorCodes.ADMIN_USER_DEACTIVATED]: { [EAdminAuthErrorCodes.ADMIN_USER_DEACTIVATED]: {
title: `User account deactivated`, title: `Аккаунт деактивирован`,
message: () => `User account deactivated. Please contact ${SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`, message: () => `Аккаунт деактивирован. Свяжитесь с ${SUPPORT_EMAIL ? SUPPORT_EMAIL : "администратором"}.`,
}, },
}; };
@ -92,8 +92,8 @@ export const authErrorHandler = (errorCode: EAdminAuthErrorCodes, email?: string
return { return {
type: EErrorAlertType.BANNER_ALERT, type: EErrorAlertType.BANNER_ALERT,
code: errorCode, code: errorCode,
title: errorCodeMessages[errorCode]?.title || "Error", title: errorCodeMessages[errorCode]?.title || "Ошибка",
message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.", message: errorCodeMessages[errorCode]?.message(email) || "Что-то пошло не так. Попробуйте еще раз.",
}; };
return undefined; return undefined;

View File

@ -22,7 +22,7 @@ function RootLayout() {
}, [replace, isUserLoggedIn]); }, [replace, isUserLoggedIn]);
return ( return (
<div className="relative z-10 flex h-screen w-screen flex-col items-center overflow-hidden overflow-y-auto bg-surface-1 px-8 pt-6 pb-10"> <div className="nodedc-auth-shell relative z-10 flex h-screen w-screen flex-col items-center overflow-hidden overflow-y-auto bg-surface-1 px-8 pt-6 pb-10">
<Outlet /> <Outlet />
</div> </div>
); );

View File

@ -45,6 +45,6 @@ function HomePage() {
export default observer(HomePage); export default observer(HomePage);
export const meta: Route.MetaFunction = () => [ export const meta: Route.MetaFunction = () => [
{ title: "Admin Instance Setup & Sign-In" }, { title: "NODE.DC - вход в админ-панель" },
{ name: "description", content: "Configure your Plane instance or sign in to the admin portal." }, { name: "description", content: "Настройка инстанса NODE.DC и вход в глобальную админ-панель." },
]; ];

View File

@ -112,10 +112,10 @@ export function InstanceSignInForm() {
<> <>
<AuthHeader /> <AuthHeader />
<div className="mt-10 flex w-full flex-grow flex-col items-center justify-center py-6"> <div className="mt-10 flex w-full flex-grow flex-col items-center justify-center py-6">
<div className="relative flex w-full max-w-[22.5rem] flex-col gap-6"> <div className="nodedc-auth-card relative flex w-full max-w-[22.5rem] flex-col gap-6">
<FormHeader <FormHeader
heading="Manage your Plane instance" heading="Управление инстансом NODE.DC"
subHeading="Configure instance-wide settings to secure your instance" subHeading="Войдите, чтобы менять глобальные настройки системы"
/> />
<form <form
className="space-y-4" className="space-y-4"
@ -135,10 +135,10 @@ export function InstanceSignInForm() {
<div className="w-full space-y-1"> <div className="w-full space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="email"> <label className="text-13 font-medium text-tertiary" htmlFor="email">
Email <span className="text-danger-primary">*</span> Электронная почта <span className="text-danger-primary">*</span>
</label> </label>
<Input <Input
className="w-full border border-subtle !bg-surface-1 placeholder:text-placeholder" className="nodedc-settings-input w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
id="email" id="email"
name="email" name="email"
type="email" type="email"
@ -153,16 +153,16 @@ export function InstanceSignInForm() {
<div className="w-full space-y-1"> <div className="w-full space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="password"> <label className="text-13 font-medium text-tertiary" htmlFor="password">
Password <span className="text-danger-primary">*</span> Пароль <span className="text-danger-primary">*</span>
</label> </label>
<div className="relative"> <div className="relative">
<Input <Input
className="w-full border border-subtle !bg-surface-1 placeholder:text-placeholder" className="nodedc-settings-input w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
id="password" id="password"
name="password" name="password"
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
inputSize="md" inputSize="md"
placeholder="Enter your password" placeholder="Введите пароль"
value={formData.password} value={formData.password}
onChange={(e) => handleFormChange("password", e.target.value)} onChange={(e) => handleFormChange("password", e.target.value)}
autoComplete="off" autoComplete="off"
@ -188,7 +188,7 @@ export function InstanceSignInForm() {
</div> </div>
<div className="py-2"> <div className="py-2">
<Button type="submit" size="xl" className="w-full" disabled={isButtonDisabled}> <Button type="submit" size="xl" className="w-full" disabled={isButtonDisabled}>
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Sign in"} {isSubmitting ? <Spinner height="20px" width="20px" /> : "Войти"}
</Button> </Button>
</div> </div>
</form> </form>

View File

@ -17,19 +17,16 @@ function PageNotFound() {
<div className="grid h-full place-items-center p-4"> <div className="grid h-full place-items-center p-4">
<div className="space-y-8 text-center"> <div className="space-y-8 text-center">
<div className="relative mx-auto h-60 w-60 lg:h-80 lg:w-80"> <div className="relative mx-auto h-60 w-60 lg:h-80 lg:w-80">
<img src={Image404} alt="404 - Page not found" className="h-full w-full object-contain" /> <img src={Image404} alt="404 - страница не найдена" className="h-full w-full object-contain" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-16 font-semibold">Oops! Something went wrong.</h3> <h3 className="text-16 font-semibold">Страница не найдена</h3>
<p className="text-13 text-secondary"> <p className="text-13 text-secondary">Похоже, раздел был удален, переименован или временно недоступен.</p>
Sorry, the page you are looking for cannot be found. It may have been removed, had its name changed, or is
temporarily unavailable.
</p>
</div> </div>
<Link to="/general/"> <Link to="/general/">
<span className="flex justify-center py-4"> <span className="flex justify-center py-4">
<Button variant="secondary" size="lg"> <Button variant="secondary" size="lg">
Go to general settings Перейти в основные настройки
</Button> </Button>
</span> </span>
</Link> </Link>

View File

@ -21,9 +21,8 @@ import interVariableWoff2 from "@fontsource-variable/inter/files/inter-latin-wgh
import "@fontsource/material-symbols-rounded"; import "@fontsource/material-symbols-rounded";
import "@fontsource/ibm-plex-mono"; import "@fontsource/ibm-plex-mono";
const APP_TITLE = "Plane | Simple, extensible, open-source project management tool."; const APP_TITLE = "NODE.DC | Глобальное администрирование";
const APP_DESCRIPTION = const APP_DESCRIPTION = "Панель глобального администрирования инстанса NODE.DC.";
"Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.";
export const links: LinksFunction = () => [ export const links: LinksFunction = () => [
{ rel: "apple-touch-icon", sizes: "180x180", href: appleTouchIcon }, { rel: "apple-touch-icon", sizes: "180x180", href: appleTouchIcon },
@ -43,7 +42,7 @@ export const links: LinksFunction = () => [
export function Layout({ children }: { children: ReactNode }) { export function Layout({ children }: { children: ReactNode }) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="ru" suppressHydrationWarning>
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
@ -66,15 +65,14 @@ export const meta: Route.MetaFunction = () => [
{ property: "og:url", content: "https://plane.so/" }, { property: "og:url", content: "https://plane.so/" },
{ {
name: "keywords", name: "keywords",
content: content: "NODE.DC, администрирование, рабочие пространства, проекты, пользователи, настройки инстанса",
"software development, customer feedback, software, accelerate, code management, release management, project management, work items tracking, agile, scrum, kanban, collaboration",
}, },
{ name: "twitter:site", content: "@planepowers" }, { name: "twitter:site", content: "@nodedc" },
]; ];
export default function Root() { export default function Root() {
return ( return (
<div className="min-h-screen bg-canvas"> <div className="nodedc-admin-root min-h-screen bg-canvas">
<Outlet /> <Outlet />
</div> </div>
); );
@ -91,7 +89,7 @@ export function HydrateFallback() {
export function ErrorBoundary({ error: _error }: Route.ErrorBoundaryProps) { export function ErrorBoundary({ error: _error }: Route.ErrorBoundaryProps) {
return ( return (
<div> <div>
<p>Something went wrong.</p> <p>Что-то пошло не так.</p>
</div> </div>
); );
} }

View File

@ -22,7 +22,7 @@ export function AuthenticationMethodCard(props: Props) {
return ( return (
<div <div
className={cn("flex w-full items-center gap-14 rounded-lg bg-layer-2", { className={cn("nodedc-settings-card flex w-full items-center gap-14 rounded-lg bg-layer-2", {
"border border-subtle px-4 py-3": withBorder, "border border-subtle px-4 py-3": withBorder,
})} })}
> >

View File

@ -35,7 +35,7 @@ export const GiteaConfiguration = observer(function GiteaConfiguration(props: Pr
{GiteaConfigured ? ( {GiteaConfigured ? (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link href="/authentication/gitea" className={cn(getButtonStyling("link", "base"), "font-medium")}> <Link href="/authentication/gitea" className={cn(getButtonStyling("link", "base"), "font-medium")}>
Edit Изменить
</Link> </Link>
<ToggleSwitch <ToggleSwitch
value={Boolean(parseInt(GiteaConfig))} value={Boolean(parseInt(GiteaConfig))}
@ -51,7 +51,7 @@ export const GiteaConfiguration = observer(function GiteaConfiguration(props: Pr
) : ( ) : (
<Link href="/authentication/gitea" className={cn(getButtonStyling("secondary", "base"), "text-tertiary")}> <Link href="/authentication/gitea" className={cn(getButtonStyling("secondary", "base"), "text-tertiary")}>
<Settings2 className="h-4 w-4 p-0.5 text-tertiary" /> <Settings2 className="h-4 w-4 p-0.5 text-tertiary" />
Configure Настроить
</Link> </Link>
)} )}
</> </>

View File

@ -34,7 +34,7 @@ export const GithubConfiguration = observer(function GithubConfiguration(props:
{isGithubConfigured ? ( {isGithubConfigured ? (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link href="/authentication/github" className={cn(getButtonStyling("link", "base"), "font-medium")}> <Link href="/authentication/github" className={cn(getButtonStyling("link", "base"), "font-medium")}>
Edit Изменить
</Link> </Link>
<ToggleSwitch <ToggleSwitch
value={Boolean(parseInt(enableGithubConfig))} value={Boolean(parseInt(enableGithubConfig))}
@ -49,7 +49,7 @@ export const GithubConfiguration = observer(function GithubConfiguration(props:
) : ( ) : (
<Link href="/authentication/github" className={cn(getButtonStyling("secondary", "base"), "text-tertiary")}> <Link href="/authentication/github" className={cn(getButtonStyling("secondary", "base"), "text-tertiary")}>
<Settings2 className="h-4 w-4 p-0.5 text-tertiary" /> <Settings2 className="h-4 w-4 p-0.5 text-tertiary" />
Configure Настроить
</Link> </Link>
)} )}
</> </>

View File

@ -34,7 +34,7 @@ export const GitlabConfiguration = observer(function GitlabConfiguration(props:
{isGitlabConfigured ? ( {isGitlabConfigured ? (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link href="/authentication/gitlab" className={cn(getButtonStyling("link", "base"), "font-medium")}> <Link href="/authentication/gitlab" className={cn(getButtonStyling("link", "base"), "font-medium")}>
Edit Изменить
</Link> </Link>
<ToggleSwitch <ToggleSwitch
value={Boolean(parseInt(enableGitlabConfig))} value={Boolean(parseInt(enableGitlabConfig))}
@ -49,7 +49,7 @@ export const GitlabConfiguration = observer(function GitlabConfiguration(props:
) : ( ) : (
<Link href="/authentication/gitlab" className={cn(getButtonStyling("secondary", "base"), "text-tertiary")}> <Link href="/authentication/gitlab" className={cn(getButtonStyling("secondary", "base"), "text-tertiary")}>
<Settings2 className="h-4 w-4 p-0.5 text-tertiary" /> <Settings2 className="h-4 w-4 p-0.5 text-tertiary" />
Configure Настроить
</Link> </Link>
)} )}
</> </>

View File

@ -34,7 +34,7 @@ export const GoogleConfiguration = observer(function GoogleConfiguration(props:
{isGoogleConfigured ? ( {isGoogleConfigured ? (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link href="/authentication/google" className={cn(getButtonStyling("link", "base"), "font-medium")}> <Link href="/authentication/google" className={cn(getButtonStyling("link", "base"), "font-medium")}>
Edit Изменить
</Link> </Link>
<ToggleSwitch <ToggleSwitch
value={Boolean(parseInt(enableGoogleConfig))} value={Boolean(parseInt(enableGoogleConfig))}
@ -49,7 +49,7 @@ export const GoogleConfiguration = observer(function GoogleConfiguration(props:
) : ( ) : (
<Link href="/authentication/google" className={cn(getButtonStyling("secondary", "base"), "text-tertiary")}> <Link href="/authentication/google" className={cn(getButtonStyling("secondary", "base"), "text-tertiary")}>
<Settings2 className="h-4 w-4 p-0.5 text-tertiary" /> <Settings2 className="h-4 w-4 p-0.5 text-tertiary" />
Configure Настроить
</Link> </Link>
)} )}
</> </>

View File

@ -11,26 +11,35 @@ type Props = {
label?: string; label?: string;
href?: string; href?: string;
icon?: React.ReactNode | undefined; icon?: React.ReactNode | undefined;
isCurrent?: boolean;
}; };
export function BreadcrumbLink(props: Props) { export function BreadcrumbLink(props: Props) {
const { href, label, icon } = props; const { href, label, icon, isCurrent = false } = props;
const content = (
<>
{icon && <div className="flex h-5 w-5 items-center justify-center overflow-hidden !text-16">{icon}</div>}
<div className="relative line-clamp-1 block max-w-[150px] truncate overflow-hidden">{label}</div>
</>
);
return ( return (
<Tooltip tooltipContent={label} position="bottom"> <Tooltip tooltipContent={label} position="bottom">
<li className="flex items-center space-x-2" tabIndex={-1}> <li className="flex items-center" tabIndex={-1}>
<div className="flex flex-wrap items-center gap-2.5"> {href && !isCurrent ? (
{href ? ( <Link className="nodedc-admin-breadcrumb-pill flex items-center gap-1.5 text-13 font-medium" href={href}>
<Link className="flex items-center gap-1 text-13 font-medium text-tertiary hover:text-primary" href={href}> {content}
{icon && <div className="flex h-5 w-5 items-center justify-center overflow-hidden !text-16">{icon}</div>} </Link>
<div className="relative line-clamp-1 block max-w-[150px] truncate overflow-hidden">{label}</div> ) : (
</Link> <div
) : ( className="nodedc-admin-breadcrumb-pill flex cursor-default items-center gap-1.5 text-13 font-medium"
<div className="flex cursor-default items-center gap-1 text-13 font-medium text-primary"> data-current={isCurrent}
{icon && <div className="flex h-5 w-5 items-center justify-center overflow-hidden">{icon}</div>} aria-current={isCurrent ? "page" : undefined}
<div className="relative line-clamp-1 block max-w-[150px] truncate overflow-hidden">{label}</div> >
</div> {content}
)} </div>
</div> )}
</li> </li>
</Tooltip> </Tooltip>
); );

View File

@ -16,7 +16,7 @@ export function CodeBlock({ children, className, darkerShade }: TProps) {
return ( return (
<span <span
className={cn( className={cn(
"rounded-md border border-subtle bg-surface-2 px-0.5 text-11 font-semibold text-tertiary", "nodedc-code-chip rounded-md border border-subtle bg-surface-2 px-0.5 text-11 font-semibold text-tertiary",
{ {
"border-subtle bg-layer-1 text-secondary": darkerShade, "border-subtle bg-layer-1 text-secondary": darkerShade,
}, },

View File

@ -45,27 +45,25 @@ export function ConfirmDiscardModal(props: Props) {
leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
> >
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-surface-1 text-left shadow-raised-200 transition-all sm:my-8 sm:w-[30rem]"> <Dialog.Panel className="nodedc-glass-modal relative transform overflow-hidden rounded-[1.75rem] bg-surface-1 text-left shadow-raised-200 transition-all sm:my-8 sm:w-[30rem]">
<div className="px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> <div className="px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start"> <div className="sm:flex sm:items-start">
<div className="mt-3 text-center sm:mt-0 sm:text-left"> <div className="mt-3 text-center sm:mt-0 sm:text-left">
<Dialog.Title as="h3" className="text-16 leading-6 font-medium text-tertiary"> <Dialog.Title as="h3" className="text-16 leading-6 font-medium text-tertiary">
You have unsaved changes Есть несохраненные изменения
</Dialog.Title> </Dialog.Title>
<div className="mt-2"> <div className="mt-2">
<p className="text-13 text-placeholder"> <p className="text-13 text-placeholder">Если уйти назад, текущие правки будут потеряны.</p>
Changes you made will be lost if you go back. Do you wish to go back?
</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center justify-end gap-2 p-4 sm:px-6"> <div className="flex items-center justify-end gap-2 p-4 sm:px-6">
<Button variant="secondary" size="lg" onClick={handleClose}> <Button variant="secondary" size="lg" onClick={handleClose}>
Keep editing Продолжить редактирование
</Button> </Button>
<Link href={onDiscardHref} className={getButtonStyling("primary", "base")}> <Link href={onDiscardHref} className={getButtonStyling("primary", "base")}>
Go back Уйти назад
</Link> </Link>
</div> </div>
</Dialog.Panel> </Dialog.Panel>

View File

@ -46,7 +46,7 @@ export function ControllerInput(props: Props) {
<Controller <Controller
control={control} control={control}
name={name} name={name}
rules={{ required: required ? `${label} is required.` : false }} rules={{ required: required ? `Поле "${label}" обязательно.` : false }}
render={({ field: { value, onChange, ref } }) => ( render={({ field: { value, onChange, ref } }) => (
<Input <Input
id={name} id={name}
@ -57,7 +57,7 @@ export function ControllerInput(props: Props) {
ref={ref} ref={ref}
hasError={error} hasError={error}
placeholder={placeholder} placeholder={placeholder}
className={cn("w-full rounded-md font-medium", { className={cn("nodedc-settings-input w-full rounded-md font-medium", {
"pr-10": type === "password", "pr-10": type === "password",
})} })}
/> />

View File

@ -27,7 +27,7 @@ export function ControllerSwitch<T extends FieldValues>(props: Props<T>) {
return ( return (
<div className="flex items-center justify-between gap-1"> <div className="flex items-center justify-between gap-1">
<h4 className="text-sm text-custom-text-300">Refresh user attributes from {label} during sign in</h4> <h4 className="text-sm text-custom-text-300">Обновлять атрибуты пользователя из {label} при входе</h4>
<div className="relative"> <div className="relative">
<Controller <Controller
control={control} control={control}

View File

@ -32,18 +32,18 @@ export function CopyField(props: Props) {
<Button <Button
variant="secondary" variant="secondary"
size="lg" size="lg"
className="flex items-center justify-between py-2" className="nodedc-settings-secondary-button flex w-full items-center justify-between gap-3 py-2 text-left"
onClick={() => { onClick={() => {
navigator.clipboard.writeText(url); navigator.clipboard.writeText(url);
setToast({ setToast({
type: TOAST_TYPE.INFO, type: TOAST_TYPE.INFO,
title: "Copied to clipboard", title: "Скопировано",
message: `The ${label} has been successfully copied to your clipboard`, message: `${label} скопировано в буфер обмена`,
}); });
}} }}
> >
<p className="text-13 font-medium">{url}</p> <p className="min-w-0 truncate text-13 font-medium">{url}</p>
<CopyIcon width={18} height={18} color="#B9B9B9" /> <CopyIcon width={18} height={18} color="#B9B9B9" className="shrink-0" />
</Button> </Button>
<div className="text-11 text-tertiary">{description}</div> <div className="text-11 text-tertiary">{description}</div>
</div> </div>

View File

@ -5,15 +5,15 @@
*/ */
export const CORE_HEADER_SEGMENT_LABELS: Record<string, string> = { export const CORE_HEADER_SEGMENT_LABELS: Record<string, string> = {
general: "General", general: "Основное",
ai: "Artificial Intelligence", ai: "Искусственный интеллект",
email: "Email", email: "Почта",
authentication: "Authentication", authentication: "Аутентификация",
image: "Image", image: "Изображения",
google: "Google", google: "Google",
github: "GitHub", github: "GitHub",
gitlab: "GitLab", gitlab: "GitLab",
gitea: "Gitea", gitea: "Gitea",
workspace: "Workspace", workspace: "Воркспейсы",
create: "Create", create: "Создание",
}; };

View File

@ -4,11 +4,10 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { Fragment } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Menu, Settings } from "lucide-react"; import { ChevronRight, Menu, Settings } from "lucide-react";
// icons
import { Breadcrumbs } from "@plane/ui";
// components // components
import { BreadcrumbLink } from "../breadcrumb-link"; import { BreadcrumbLink } from "../breadcrumb-link";
// hooks // hooks
@ -22,6 +21,7 @@ export const HamburgerToggle = observer(function HamburgerToggle() {
return ( return (
<button <button
className="group flex size-7 cursor-pointer items-center justify-center rounded-sm bg-layer-1 transition-all hover:bg-layer-1-hover md:hidden" className="group flex size-7 cursor-pointer items-center justify-center rounded-sm bg-layer-1 transition-all hover:bg-layer-1-hover md:hidden"
aria-label="Открыть меню"
onClick={() => toggleSidebar(!isSidebarCollapsed)} onClick={() => toggleSidebar(!isSidebarCollapsed)}
> >
<Menu size={14} className="text-secondary transition-all group-hover:text-primary" /> <Menu size={14} className="text-secondary transition-all group-hover:text-primary" />
@ -56,32 +56,28 @@ export const AdminHeader = observer(function AdminHeader() {
const breadcrumbItems = generateBreadcrumbItems(pathName || ""); const breadcrumbItems = generateBreadcrumbItems(pathName || "");
return ( return (
<div className="relative z-10 flex h-header w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-subtle bg-surface-1 p-4"> <div className="nodedc-admin-header nodedc-glass-modal relative z-10 flex h-header w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-subtle bg-surface-1 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap"> <div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<HamburgerToggle /> <HamburgerToggle />
{breadcrumbItems.length >= 0 && ( {breadcrumbItems.length >= 0 && (
<div> <nav className="min-w-0" aria-label="Навигация God Mode">
<Breadcrumbs> <ol className="nodedc-admin-breadcrumbs">
<Breadcrumbs.Item <BreadcrumbLink href="/general/" label="Настройки" icon={<Settings className="h-4 w-4" />} />
component={ {breadcrumbItems.map((item, index) => {
<BreadcrumbLink if (!item.title) return null;
href="/general/" const isCurrent = index === breadcrumbItems.length - 1;
label="Settings"
icon={<Settings className="h-4 w-4 text-tertiary" />} return (
/> <Fragment key={`${item.href}-${item.title}`}>
} <li className="nodedc-admin-breadcrumb-separator" aria-hidden="true">
/> <ChevronRight className="size-4" />
{breadcrumbItems.map( </li>
(item) => <BreadcrumbLink href={item.href} label={item.title} isCurrent={isCurrent} />
item.title && ( </Fragment>
<Breadcrumbs.Item );
key={item.title} })}
component={<BreadcrumbLink href={item.href} label={item.title} />} </ol>
/> </nav>
)
)}
</Breadcrumbs>
</div>
)} )}
</div> </div>
</div> </div>

View File

@ -24,20 +24,19 @@ export const NewUserPopup = observer(function NewUserPopup() {
if (!isNewUserPopup) return <></>; if (!isNewUserPopup) return <></>;
return ( return (
<div className="shadow-md absolute right-8 bottom-8 w-96 rounded-lg border border-subtle bg-surface-1 p-6"> <div className="nodedc-glass-modal shadow-md absolute right-8 bottom-8 w-96 rounded-[1.75rem] border border-subtle bg-surface-1 p-6">
<div className="flex gap-4"> <div className="flex gap-4">
<div className="grow"> <div className="grow">
<div className="text-14 font-semibold">Create workspace</div> <div className="text-14 font-semibold">Создать воркспейс</div>
<div className="py-2 text-13 font-medium text-tertiary"> <div className="py-2 text-13 font-medium text-tertiary">
Instance setup done! Welcome to Plane instance portal. Start your journey with by creating your first Настройка инстанса завершена. Создайте первое рабочее пространство, чтобы начать работу.
workspace.
</div> </div>
<div className="flex items-center gap-4 pt-2"> <div className="flex items-center gap-4 pt-2">
<Link href="/workspace/create" className={getButtonStyling("primary", "lg")}> <Link href="/workspace/create" className={getButtonStyling("primary", "lg")}>
Create workspace Создать воркспейс
</Link> </Link>
<Button variant="secondary" size="lg" onClick={toggleNewUserPopup}> <Button variant="secondary" size="lg" onClick={toggleNewUserPopup}>
Close Закрыть
</Button> </Button>
</div> </div>
</div> </div>
@ -46,7 +45,7 @@ export const NewUserPopup = observer(function NewUserPopup() {
src={resolveGeneralTheme(resolvedTheme) === "dark" ? TakeoffIconDark : TakeoffIconLight} src={resolveGeneralTheme(resolvedTheme) === "dark" ? TakeoffIconDark : TakeoffIconLight}
height={80} height={80}
width={80} width={80}
alt="Plane icon" alt="NODE.DC"
/> />
</div> </div>
</div> </div>

View File

@ -10,7 +10,8 @@ type TPageHeader = {
}; };
export function PageHeader(props: TPageHeader) { export function PageHeader(props: TPageHeader) {
const { title = "God Mode - Plane", description = "Plane god mode" } = props; const { title = "NODE.DC - глобальное администрирование", description = "Глобальное администрирование NODE.DC" } =
props;
return ( return (
<> <>

View File

@ -24,16 +24,16 @@ export const PageWrapper = (props: TPageWrapperProps) => {
return ( return (
<div <div
className={cn("mx-auto h-full w-full space-y-6 py-4", { className={cn("nodedc-page mx-auto h-full w-full space-y-6 py-4", {
"max-w-[1000px] md:px-4 2xl:max-w-[1200px]": size === "md", "max-w-[1000px] md:px-4 2xl:max-w-[1200px]": size === "md",
"px-4 lg:px-12": size === "lg", "px-4 lg:px-12": size === "lg",
})} })}
> >
{customHeader ? ( {customHeader ? (
<div className="mx-4 shrink-0 space-y-1 border-b border-subtle py-4">{customHeader}</div> <div className="nodedc-page-header mx-4 shrink-0 space-y-1 border-b border-subtle py-4">{customHeader}</div>
) : ( ) : (
header && ( header && (
<div className="mx-4 flex shrink-0 items-center justify-between gap-4 space-y-1 border-b border-subtle py-4"> <div className="nodedc-page-header mx-4 flex shrink-0 items-center justify-between gap-4 space-y-1 border-b border-subtle py-4">
<div className={header.actions ? "flex flex-col gap-1" : "space-y-1"}> <div className={header.actions ? "flex flex-col gap-1" : "space-y-1"}>
<div className="text-h5-semibold text-primary">{header.title}</div> <div className="text-h5-semibold text-primary">{header.title}</div>
<div className="text-body-sm-regular text-secondary">{header.description}</div> <div className="text-body-sm-regular text-secondary">{header.description}</div>
@ -42,7 +42,7 @@ export const PageWrapper = (props: TPageWrapperProps) => {
</div> </div>
) )
)} )}
<div className="vertical-scrollbar scrollbar-sm flex-grow overflow-hidden overflow-y-scroll px-4 pb-4"> <div className="nodedc-page-body vertical-scrollbar scrollbar-sm flex-grow overflow-hidden overflow-y-scroll px-4 pb-4">
{children} {children}
</div> </div>
</div> </div>

View File

@ -27,15 +27,15 @@ export const InstanceFailureView = observer(function InstanceFailureView() {
<div className="mt-10 flex w-full flex-grow flex-col items-center justify-center py-6"> <div className="mt-10 flex w-full flex-grow flex-col items-center justify-center py-6">
<div className="relative flex w-full max-w-[22.5rem] flex-col gap-6"> <div className="relative flex w-full max-w-[22.5rem] flex-col gap-6">
<div className="relative flex flex-col items-center justify-center space-y-4"> <div className="relative flex flex-col items-center justify-center space-y-4">
<img src={instanceImage} alt="Instance failure illustration" /> <img src={instanceImage} alt="Ошибка загрузки инстанса" />
<h3 className="text-center text-20 font-medium text-on-color">Unable to fetch instance details.</h3> <h3 className="text-center text-20 font-medium text-on-color">Не удалось загрузить данные инстанса.</h3>
<p className="text-center text-14 font-medium"> <p className="text-center text-14 font-medium">
We were unable to fetch the details of the instance. Fret not, it might just be a connectivity issue. Проверьте соединение с API и попробуйте обновить страницу.
</p> </p>
</div> </div>
<div className="flex justify-center"> <div className="flex justify-center">
<Button size="lg" onClick={handleRetry}> <Button size="lg" onClick={handleRetry}>
Retry Повторить
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -14,15 +14,15 @@ export function InstanceNotReady() {
<div className="relative container mx-auto flex h-full w-full items-center justify-center px-5"> <div className="relative container mx-auto flex h-full w-full items-center justify-center px-5">
<div className="relative w-auto max-w-2xl space-y-8 py-10"> <div className="relative w-auto max-w-2xl space-y-8 py-10">
<div className="relative flex flex-col items-center justify-center space-y-4"> <div className="relative flex flex-col items-center justify-center space-y-4">
<h1 className="pb-3 text-24 font-bold">Welcome aboard Plane!</h1> <h1 className="pb-3 text-24 font-bold">Добро пожаловать в NODE.DC</h1>
<img src={PlaneTakeOffImage} alt="Plane Logo" /> <img src={PlaneTakeOffImage} alt="NODE.DC" />
<p className="text-14 font-medium text-placeholder">Get started by setting up your instance and workspace</p> <p className="text-14 font-medium text-placeholder">Начните с настройки инстанса и первого воркспейса</p>
</div> </div>
<div> <div>
<Link href={"/setup/?auth_enabled=0"}> <Link href={"/setup/?auth_enabled=0"}>
<Button size="xl" className="w-full"> <Button size="xl" className="w-full">
Get started Начать
</Button> </Button>
</Link> </Link>
</div> </div>

View File

@ -140,10 +140,10 @@ export function InstanceSetupForm() {
<> <>
<AuthHeader /> <AuthHeader />
<div className="mt-10 flex w-full flex-grow flex-col items-center justify-center py-6"> <div className="mt-10 flex w-full flex-grow flex-col items-center justify-center py-6">
<div className="relative flex w-full max-w-[22.5rem] flex-col gap-6"> <div className="nodedc-auth-card relative flex w-full max-w-[22.5rem] flex-col gap-6">
<FormHeader <FormHeader
heading="Setup your Plane Instance" heading="Настройка инстанса NODE.DC"
subHeading="Post setup you will be able to manage this Plane instance." subHeading="После настройки вы получите доступ к глобальному администрированию."
/> />
{errorData.type && {errorData.type &&
errorData?.message && errorData?.message &&
@ -163,15 +163,15 @@ export function InstanceSetupForm() {
<div className="flex flex-col items-center gap-4 sm:flex-row"> <div className="flex flex-col items-center gap-4 sm:flex-row">
<div className="w-full space-y-1"> <div className="w-full space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="first_name"> <label className="text-13 font-medium text-tertiary" htmlFor="first_name">
First name <span className="text-danger-primary">*</span> Имя <span className="text-danger-primary">*</span>
</label> </label>
<Input <Input
className="w-full border border-subtle !bg-surface-1 placeholder:text-placeholder" className="nodedc-settings-input w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
id="first_name" id="first_name"
name="first_name" name="first_name"
type="text" type="text"
inputSize="md" inputSize="md"
placeholder="Wilber" placeholder="Иван"
value={formData.first_name} value={formData.first_name}
onChange={(e) => { onChange={(e) => {
const validation = validatePersonName(e.target.value); const validation = validatePersonName(e.target.value);
@ -186,15 +186,15 @@ export function InstanceSetupForm() {
</div> </div>
<div className="w-full space-y-1"> <div className="w-full space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="last_name"> <label className="text-13 font-medium text-tertiary" htmlFor="last_name">
Last name <span className="text-danger-primary">*</span> Фамилия <span className="text-danger-primary">*</span>
</label> </label>
<Input <Input
className="w-full border border-subtle !bg-surface-1 placeholder:text-placeholder" className="nodedc-settings-input w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
id="last_name" id="last_name"
name="last_name" name="last_name"
type="text" type="text"
inputSize="md" inputSize="md"
placeholder="Wright" placeholder="Иванов"
value={formData.last_name} value={formData.last_name}
onChange={(e) => { onChange={(e) => {
const validation = validatePersonName(e.target.value); const validation = validatePersonName(e.target.value);
@ -210,10 +210,10 @@ export function InstanceSetupForm() {
<div className="w-full space-y-1"> <div className="w-full space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="email"> <label className="text-13 font-medium text-tertiary" htmlFor="email">
Email <span className="text-danger-primary">*</span> Электронная почта <span className="text-danger-primary">*</span>
</label> </label>
<Input <Input
className="w-full border border-subtle !bg-surface-1 placeholder:text-placeholder" className="nodedc-settings-input w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
id="email" id="email"
name="email" name="email"
type="email" type="email"
@ -231,15 +231,15 @@ export function InstanceSetupForm() {
<div className="w-full space-y-1"> <div className="w-full space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="company_name"> <label className="text-13 font-medium text-tertiary" htmlFor="company_name">
Company name <span className="text-danger-primary">*</span> Название компании <span className="text-danger-primary">*</span>
</label> </label>
<Input <Input
className="w-full border border-subtle !bg-surface-1 placeholder:text-placeholder" className="nodedc-settings-input w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
id="company_name" id="company_name"
name="company_name" name="company_name"
type="text" type="text"
inputSize="md" inputSize="md"
placeholder="Company name" placeholder="Название компании"
value={formData.company_name} value={formData.company_name}
onChange={(e) => { onChange={(e) => {
const validation = validateCompanyName(e.target.value, false); const validation = validateCompanyName(e.target.value, false);
@ -253,16 +253,16 @@ export function InstanceSetupForm() {
<div className="w-full space-y-1"> <div className="w-full space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="password"> <label className="text-13 font-medium text-tertiary" htmlFor="password">
Set a password <span className="text-danger-primary">*</span> Задайте пароль <span className="text-danger-primary">*</span>
</label> </label>
<div className="relative"> <div className="relative">
<Input <Input
className="w-full border border-subtle !bg-surface-1 placeholder:text-placeholder" className="nodedc-settings-input w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
id="password" id="password"
name="password" name="password"
type={showPassword.password ? "text" : "password"} type={showPassword.password ? "text" : "password"}
inputSize="md" inputSize="md"
placeholder="New password" placeholder="Новый пароль"
value={formData.password} value={formData.password}
onChange={(e) => handleFormChange("password", e.target.value)} onChange={(e) => handleFormChange("password", e.target.value)}
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false} hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false}
@ -298,7 +298,7 @@ export function InstanceSetupForm() {
<div className="w-full space-y-1"> <div className="w-full space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="confirm_password"> <label className="text-13 font-medium text-tertiary" htmlFor="confirm_password">
Confirm password <span className="text-danger-primary">*</span> Подтвердите пароль <span className="text-danger-primary">*</span>
</label> </label>
<div className="relative"> <div className="relative">
<Input <Input
@ -308,8 +308,8 @@ export function InstanceSetupForm() {
inputSize="md" inputSize="md"
value={formData.confirm_password} value={formData.confirm_password}
onChange={(e) => handleFormChange("confirm_password", e.target.value)} onChange={(e) => handleFormChange("confirm_password", e.target.value)}
placeholder="Confirm password" placeholder="Повторите пароль"
className="w-full border border-subtle !bg-surface-1 pr-12 placeholder:text-placeholder" className="nodedc-settings-input w-full border border-subtle !bg-surface-1 pr-12 placeholder:text-placeholder"
onFocus={() => setIsRetryPasswordInputFocused(true)} onFocus={() => setIsRetryPasswordInputFocused(true)}
onBlur={() => setIsRetryPasswordInputFocused(false)} onBlur={() => setIsRetryPasswordInputFocused(false)}
autoComplete="new-password" autoComplete="new-password"
@ -336,9 +336,7 @@ export function InstanceSetupForm() {
</div> </div>
{!!formData.confirm_password && {!!formData.confirm_password &&
formData.password !== formData.confirm_password && formData.password !== formData.confirm_password &&
renderPasswordMatchError && ( renderPasswordMatchError && <span className="text-13 text-danger-primary">Пароли не совпадают</span>}
<span className="text-13 text-danger-primary">Passwords don{"'"}t match</span>
)}
</div> </div>
<div className="relative flex gap-2"> <div className="relative flex gap-2">
@ -352,7 +350,7 @@ export function InstanceSetupForm() {
/> />
</div> </div>
<label className="cursor-pointer text-13 font-medium text-tertiary" htmlFor="is_telemetry_enabled"> <label className="cursor-pointer text-13 font-medium text-tertiary" htmlFor="is_telemetry_enabled">
Allow Plane to anonymously collect usage events.{" "} Разрешить NODE.DC анонимно собирать события использования.{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://developers.plane.so/self-hosting/telemetry" href="https://developers.plane.so/self-hosting/telemetry"
@ -360,14 +358,14 @@ export function InstanceSetupForm() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 flex-shrink-0 text-13 font-medium" className="text-blue-500 hover:text-blue-600 flex-shrink-0 text-13 font-medium"
> >
See More Подробнее
</a> </a>
</label> </label>
</div> </div>
<div className="py-2"> <div className="py-2">
<Button type="submit" size="xl" className="w-full" disabled={isButtonDisabled}> <Button type="submit" size="xl" className="w-full" disabled={isButtonDisabled}>
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Continue"} {isSubmitting ? <Spinner height="20px" width="20px" /> : "Продолжить"}
</Button> </Button>
</div> </div>
</form> </form>

View File

@ -0,0 +1,536 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Fragment, useState } from "react";
import type { ReactNode } from "react";
import { Dialog, Transition } from "@headlessui/react";
import useSWR from "swr";
import { Ban, CheckCircle2, Loader, ShieldCheck, Trash2, UsersRound, X } from "lucide-react";
// plane imports
import { Button } from "@plane/propel/button";
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
import { InstanceWorkspaceService } from "@plane/services";
import type { TInstanceWorkspaceFeature, TInstanceWorkspaceMember } from "@plane/types";
import { CustomSelect, ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { useWorkspace } from "@/hooks/store";
type TWorkspaceAdminModalProps = {
isOpen: boolean;
onClose: () => void;
workspaceId: string | null;
};
const instanceWorkspaceService = new InstanceWorkspaceService();
const ROLE_OPTIONS = [
{ label: "Гость", value: 5 },
{ label: "Участник", value: 15 },
{ label: "Администратор", value: 20 },
];
const ROLE_LABELS: Record<number, string> = {
5: "Гость",
15: "Участник",
20: "Администратор",
};
const BAN_DURATION_OPTIONS = [
{ label: "На 24 часа", value: "1d", hours: 24 },
{ label: "На 7 дней", value: "7d", hours: 24 * 7 },
{ label: "На 30 дней", value: "30d", hours: 24 * 30 },
{ label: "До ручного разбана", value: "manual", hours: null },
];
const ACCESS_MODE_LABELS: Record<TInstanceWorkspaceFeature["access_mode"], string> = {
all_workspace_members: "Весь воркспейс",
admins_only: "Только админы",
selected_projects: "По контурам",
selected_members: "По людям",
};
function getMemberName(member: TInstanceWorkspaceMember) {
return member.member.display_name || member.member.email || "Пользователь";
}
function getErrorMessage(error: unknown, fallback: string) {
if (error && typeof error === "object" && "error" in error && typeof error.error === "string") return error.error;
return fallback;
}
function getBanUntilIso(duration: string) {
const option = BAN_DURATION_OPTIONS.find((item) => item.value === duration);
if (!option?.hours) return null;
return new Date(Date.now() + option.hours * 60 * 60 * 1000).toISOString();
}
function formatBanUntil(bannedUntil?: string | null) {
if (!bannedUntil) return "до ручного разбана";
return `до ${new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(bannedUntil))}`;
}
function RoleSelect(props: { disabled: boolean; onChange: (role: number) => void; value: number }) {
const { disabled, onChange, value } = props;
return (
<CustomSelect
value={value}
label={<span className="truncate">{ROLE_LABELS[value] ?? "Роль"}</span>}
onChange={(role: number) => onChange(Number(role))}
buttonClassName="nodedc-settings-select h-10 min-h-10 w-full justify-between px-3 text-12"
className="w-full"
disabled={disabled}
input
maxHeight="sm"
optionsClassName="z-[80] min-w-[12rem]"
placement="bottom-start"
>
{ROLE_OPTIONS.map((role) => (
<CustomSelect.Option key={role.value} value={role.value} className="w-full">
<span className="text-12 font-medium">{role.label}</span>
</CustomSelect.Option>
))}
</CustomSelect>
);
}
type TPendingMemberAction = {
type: "ban" | "remove" | "unban";
workspaceMember: TInstanceWorkspaceMember;
};
function MemberActionConfirmModal(props: {
action: TPendingMemberAction | null;
banDuration: string;
isLoading: boolean;
onBanDurationChange: (duration: string) => void;
onClose: () => void;
onConfirm: () => void;
}) {
const { action, banDuration, isLoading, onBanDurationChange, onClose, onConfirm } = props;
const memberName = action ? getMemberName(action.workspaceMember) : "";
const title =
action?.type === "ban"
? "Заблокировать участника"
: action?.type === "unban"
? "Разблокировать участника"
: "Удалить участника";
const description =
action?.type === "ban"
? `${memberName} не сможет открыть этот воркспейс и принять новые приглашения до снятия блокировки.`
: action?.type === "unban"
? `${memberName} снова сможет войти в воркспейс. Проектные доступы после блокировки выдаются отдельно.`
: `${memberName} будет удален из воркспейса и всех его проектов. Это крайний вариант удаления доступа.`;
const confirmLabel = action?.type === "ban" ? "Заблокировать" : action?.type === "unban" ? "Разблокировать" : "Удалить";
return (
<Transition.Root show={Boolean(action)} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={isLoading ? () => undefined : onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/45 backdrop-blur-xl" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<Transition.Child
as={Fragment}
enter="ease-out duration-200"
enterFrom="translate-y-2 opacity-0 scale-95"
enterTo="translate-y-0 opacity-100 scale-100"
leave="ease-in duration-150"
leaveFrom="translate-y-0 opacity-100 scale-100"
leaveTo="translate-y-2 opacity-0 scale-95"
>
<Dialog.Panel className="nodedc-glass-modal nodedc-technical-confirm-modal w-full max-w-[33rem] rounded-[1.75rem] p-6 text-left">
<Dialog.Title as="h3" className="text-17 font-semibold text-primary">
{title}
</Dialog.Title>
<p className="mt-2 text-13 leading-5 text-secondary">{description}</p>
{action?.type === "ban" && (
<div className="mt-5">
<div className="mb-2 text-12 font-medium text-tertiary">Срок блокировки</div>
<CustomSelect
value={banDuration}
label={BAN_DURATION_OPTIONS.find((option) => option.value === banDuration)?.label}
onChange={(duration: string) => onBanDurationChange(duration)}
buttonClassName="nodedc-settings-select h-11 min-h-11 w-full justify-between px-4 text-13"
className="w-full"
disabled={isLoading}
input
maxHeight="sm"
optionsClassName="z-[90]"
>
{BAN_DURATION_OPTIONS.map((option) => (
<CustomSelect.Option key={option.value} value={option.value} className="w-full">
{option.label}
</CustomSelect.Option>
))}
</CustomSelect>
</div>
)}
<div className="mt-6 flex justify-end gap-2.5">
<Button
variant="primary"
size="lg"
onClick={onClose}
disabled={isLoading}
className="nodedc-settings-save-button min-w-[8.5rem] justify-center"
>
Отмена
</Button>
<Button
variant="secondary"
size="lg"
onClick={onConfirm}
loading={isLoading}
className="nodedc-settings-secondary-button min-w-[8.5rem] justify-center"
>
{confirmLabel}
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}
function WorkspaceModalShell(props: TWorkspaceAdminModalProps & { children: ReactNode; title: string; icon: ReactNode }) {
const { children, icon, isOpen, onClose, title, workspaceId } = props;
const { getWorkspaceById } = useWorkspace();
const workspace = workspaceId ? getWorkspaceById(workspaceId) : undefined;
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-40" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<Transition.Child
as={Fragment}
enter="ease-out duration-200"
enterFrom="translate-y-2 opacity-0 scale-95"
enterTo="translate-y-0 opacity-100 scale-100"
leave="ease-in duration-150"
leaveFrom="translate-y-0 opacity-100 scale-100"
leaveTo="translate-y-2 opacity-0 scale-95"
>
<Dialog.Panel className="nodedc-glass-modal relative w-full max-w-[80rem] overflow-hidden rounded-[1.75rem] bg-surface-1 text-left shadow-raised-200 transition-all">
<div className="flex items-start justify-between gap-4 p-5 pr-16">
<div className="flex min-w-0 items-start gap-3">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-layer-2 text-accent-primary">
{icon}
</span>
<div className="min-w-0">
<Dialog.Title as="h3" className="truncate text-18 font-semibold text-primary">
{title}
</Dialog.Title>
<div className="mt-1 truncate text-13 text-secondary">
{workspace ? `${workspace.name} / [${workspace.slug}]` : "Воркспейс"}
</div>
</div>
</div>
<button
type="button"
onClick={onClose}
className="absolute top-2 right-2 flex h-10 min-h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/6 p-0 text-primary transition hover:bg-white/10 hover:text-primary focus-visible:outline-none"
aria-label="Закрыть"
>
<X className="h-5 w-5 stroke-[2.2]" />
</button>
</div>
<div className="max-h-[72vh] overflow-y-auto p-5">{children}</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}
export function WorkspaceMembersModal(props: TWorkspaceAdminModalProps) {
const { isOpen, workspaceId } = props;
const [banDuration, setBanDuration] = useState("7d");
const [mutatingMemberId, setMutatingMemberId] = useState<string | null>(null);
const [pendingAction, setPendingAction] = useState<TPendingMemberAction | null>(null);
const { data, isLoading, mutate } = useSWR(
isOpen && workspaceId ? ["INSTANCE_WORKSPACE_MEMBERS", workspaceId] : null,
() => instanceWorkspaceService.listMembers(workspaceId as string)
);
const handleRoleChange = async (workspaceMemberId: string, role: number) => {
if (!workspaceId) return;
setMutatingMemberId(workspaceMemberId);
try {
await instanceWorkspaceService.updateMemberRole(workspaceId, workspaceMemberId, role);
await mutate();
setToast({ type: TOAST_TYPE.SUCCESS, title: "Роль участника обновлена" });
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Не удалось обновить роль",
message: getErrorMessage(error, "Проверьте ограничения по администраторам воркспейса и проектов."),
});
} finally {
setMutatingMemberId(null);
}
};
const handleMemberBan = async (workspaceMember: TInstanceWorkspaceMember, isBanned: boolean) => {
if (!workspaceId) return;
setMutatingMemberId(workspaceMember.id);
try {
await instanceWorkspaceService.updateMemberBan(workspaceId, workspaceMember.id, {
is_banned: isBanned,
banned_until: isBanned ? getBanUntilIso(banDuration) : null,
});
await mutate();
setToast({ type: TOAST_TYPE.SUCCESS, title: isBanned ? "Участник заблокирован" : "Участник разблокирован" });
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: isBanned ? "Не удалось заблокировать участника" : "Не удалось разблокировать участника",
message: getErrorMessage(error, "Пользователь может быть единственным администратором."),
});
} finally {
setMutatingMemberId(null);
setPendingAction(null);
}
};
const handleRemove = async (workspaceMember: TInstanceWorkspaceMember) => {
if (!workspaceId) return;
setMutatingMemberId(workspaceMember.id);
try {
await instanceWorkspaceService.removeMember(workspaceId, workspaceMember.id);
await mutate();
setToast({ type: TOAST_TYPE.SUCCESS, title: "Участник удален из воркспейса" });
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Не удалось удалить участника",
message: getErrorMessage(error, "Пользователь может быть единственным администратором."),
});
} finally {
setMutatingMemberId(null);
setPendingAction(null);
}
};
const handleConfirmAction = () => {
if (!pendingAction) return;
if (pendingAction.type === "ban") return handleMemberBan(pendingAction.workspaceMember, true);
if (pendingAction.type === "unban") return handleMemberBan(pendingAction.workspaceMember, false);
return handleRemove(pendingAction.workspaceMember);
};
return (
<WorkspaceModalShell {...props} title="Участники воркспейса" icon={<UsersRound className="h-5 w-5" />}>
<MemberActionConfirmModal
action={pendingAction}
banDuration={banDuration}
isLoading={Boolean(mutatingMemberId)}
onBanDurationChange={setBanDuration}
onClose={() => setPendingAction(null)}
onConfirm={handleConfirmAction}
/>
{isLoading ? (
<div className="flex min-h-40 items-center justify-center text-secondary">
<Loader className="h-5 w-5 animate-spin" />
</div>
) : (
<div className="overflow-hidden rounded-[1.35rem] bg-layer-1">
<div className="grid grid-cols-[minmax(14rem,1.45fr)_13rem_6rem_8rem_20rem] gap-3 border-b border-subtle px-4 py-3 text-11 font-semibold uppercase text-tertiary">
<div>Пользователь</div>
<div>Роль</div>
<div>Проекты</div>
<div>Админ проектов</div>
<div className="text-right">Доступ</div>
</div>
<div className="divide-y divide-subtle">
{(data ?? []).map((workspaceMember) => (
<div
key={workspaceMember.id}
className="grid grid-cols-[minmax(14rem,1.45fr)_13rem_6rem_8rem_20rem] items-center gap-3 px-4 py-3 text-13"
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate font-medium text-primary">{getMemberName(workspaceMember)}</div>
{workspaceMember.is_banned && (
<span className="shrink-0 rounded-full bg-red-500/12 px-2 py-0.5 text-10 font-semibold uppercase text-red-200">
Блокировка {formatBanUntil(workspaceMember.banned_until)}
</span>
)}
</div>
<div className="truncate text-12 text-tertiary">{workspaceMember.member.email}</div>
</div>
<RoleSelect
value={Number(workspaceMember.role)}
disabled={mutatingMemberId === workspaceMember.id}
onChange={(role) => handleRoleChange(workspaceMember.id, role)}
/>
<div className="text-secondary">{workspaceMember.active_project_count}</div>
<div className="text-secondary">{workspaceMember.admin_project_count}</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setPendingAction({ type: workspaceMember.is_banned ? "unban" : "ban", workspaceMember })}
disabled={mutatingMemberId === workspaceMember.id}
className="nodedc-settings-secondary-button flex h-9 min-h-9 min-w-[9.5rem] items-center justify-center gap-2 whitespace-nowrap px-3 text-12"
aria-label={`${workspaceMember.is_banned ? "Разблокировать" : "Заблокировать"}: ${getMemberName(workspaceMember)}`}
>
{mutatingMemberId === workspaceMember.id ? (
<Loader className="h-3.5 w-3.5 animate-spin" />
) : workspaceMember.is_banned ? (
<CheckCircle2 className="h-3.5 w-3.5" />
) : (
<Ban className="h-3.5 w-3.5" />
)}
{workspaceMember.is_banned ? "Разблокировать" : "Заблокировать"}
</button>
<button
type="button"
onClick={() => setPendingAction({ type: "remove", workspaceMember })}
disabled={mutatingMemberId === workspaceMember.id}
className="nodedc-settings-secondary-button flex h-9 min-h-9 min-w-[7.5rem] items-center justify-center gap-2 whitespace-nowrap px-3 text-12"
aria-label={`Удалить участника: ${getMemberName(workspaceMember)}`}
>
{mutatingMemberId === workspaceMember.id ? (
<Loader className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
Удалить
</button>
</div>
</div>
))}
</div>
{(data ?? []).length === 0 && <div className="px-4 py-8 text-center text-13 text-tertiary">Активных участников нет</div>}
</div>
)}
</WorkspaceModalShell>
);
}
export function WorkspaceFeaturesModal(props: TWorkspaceAdminModalProps) {
const { isOpen, workspaceId } = props;
const [mutatingFeatureKey, setMutatingFeatureKey] = useState<string | null>(null);
const { data, isLoading, mutate } = useSWR(
isOpen && workspaceId ? ["INSTANCE_WORKSPACE_FEATURES", workspaceId] : null,
() => instanceWorkspaceService.retrieveFeatures(workspaceId as string)
);
const handleToggle = async (feature: TInstanceWorkspaceFeature) => {
if (!workspaceId) return;
setMutatingFeatureKey(feature.key);
try {
await instanceWorkspaceService.updateFeature(workspaceId, feature.key, !feature.is_enabled);
await mutate();
setToast({
type: TOAST_TYPE.SUCCESS,
title: feature.is_enabled ? "Фича отключена для воркспейса" : "Фича выдана воркспейсу",
});
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Не удалось обновить доступ",
message: getErrorMessage(error, "Попробуйте еще раз."),
});
} finally {
setMutatingFeatureKey(null);
}
};
return (
<WorkspaceModalShell {...props} title="Функции воркспейса" icon={<ShieldCheck className="h-5 w-5" />}>
{isLoading ? (
<div className="flex min-h-40 items-center justify-center text-secondary">
<Loader className="h-5 w-5 animate-spin" />
</div>
) : (
<div className="space-y-3">
{(data?.features ?? []).map((feature) => (
<div key={feature.key} className="nodedc-settings-card flex items-center justify-between gap-5 p-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="text-15 font-semibold text-primary">{feature.title}</div>
<span
className={cn(
"rounded-full px-2 py-0.5 text-11 font-medium",
feature.is_enabled ? "bg-accent-primary text-on-color" : "bg-layer-2 text-tertiary"
)}
>
{feature.is_enabled ? "Доступ выдан" : "Не выдано"}
</span>
</div>
<div className="mt-1 text-13 text-secondary">{feature.description}</div>
<div className="mt-3 flex flex-wrap gap-2 text-11 text-tertiary">
<span className="rounded-full bg-layer-2 px-2 py-1">
Workspace: {feature.workspace_setting_enabled ? "включено" : "выключено"}
</span>
<span className="rounded-full bg-layer-2 px-2 py-1">Доступ: {ACCESS_MODE_LABELS[feature.access_mode]}</span>
<span className="rounded-full bg-layer-2 px-2 py-1">
OpenAI key: {feature.has_workspace_key ? "есть" : "нет"}
</span>
</div>
</div>
<div className="shrink-0">
<ToggleSwitch
value={feature.is_enabled}
onChange={() => handleToggle(feature)}
size="sm"
disabled={mutatingFeatureKey === feature.key}
/>
</div>
</div>
))}
{(data?.features ?? []).length === 0 && <div className="px-4 py-8 text-center text-13 text-tertiary">Функции не найдены</div>}
</div>
)}
<div className="mt-5 flex justify-end">
<Button variant="secondary" size="lg" onClick={props.onClose} className="nodedc-settings-secondary-button">
Закрыть
</Button>
</div>
</WorkspaceModalShell>
);
}

View File

@ -5,20 +5,26 @@
*/ */
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { ExternalLink, Sparkles, UsersRound } from "lucide-react";
// plane internal packages // plane internal packages
import { WEB_BASE_URL } from "@plane/constants"; import { WEB_BASE_URL } from "@plane/constants";
import { NewTabIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip"; import { Tooltip } from "@plane/propel/tooltip";
import { getFileURL } from "@plane/utils"; import { getFileURL } from "@plane/utils";
// hooks // hooks
import { useWorkspace } from "@/hooks/store"; import { useWorkspace } from "@/hooks/store";
type TWorkspaceListItemProps = { type TWorkspaceListItemProps = {
onFeaturesClick: (workspaceId: string) => void;
onMembersClick: (workspaceId: string) => void;
workspaceId: string; workspaceId: string;
}; };
export const WorkspaceListItem = observer(function WorkspaceListItem({ workspaceId }: TWorkspaceListItemProps) { export const WorkspaceListItem = observer(function WorkspaceListItem({
onFeaturesClick,
onMembersClick,
workspaceId,
}: TWorkspaceListItemProps) {
// store hooks // store hooks
const { getWorkspaceById } = useWorkspace(); const { getWorkspaceById } = useWorkspace();
// derived values // derived values
@ -26,14 +32,11 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
if (!workspace) return null; if (!workspace) return null;
return ( return (
<a <div
key={workspaceId} key={workspaceId}
href={`${WEB_BASE_URL}/${encodeURIComponent(workspace.slug)}`} className="nodedc-settings-card group flex items-center justify-between gap-3 rounded-lg border border-subtle bg-layer-1 p-3 hover:border-subtle-1 hover:bg-layer-1-hover hover:shadow-raised-100"
target="_blank"
className="group flex items-center justify-between gap-2.5 truncate rounded-lg border border-subtle bg-layer-1 p-3 hover:border-subtle-1 hover:bg-layer-1-hover hover:shadow-raised-100"
rel="noreferrer"
> >
<div className="flex items-start gap-4"> <div className="flex min-w-0 items-start gap-4">
<span <span
className={`relative mt-1 flex h-8 w-8 flex-shrink-0 items-center justify-center p-2 text-11 uppercase ${ className={`relative mt-1 flex h-8 w-8 flex-shrink-0 items-center justify-center p-2 text-11 uppercase ${
!workspace?.logo_url && "rounded-lg bg-accent-primary text-on-color" !workspace?.logo_url && "rounded-lg bg-accent-primary text-on-color"
@ -43,29 +46,29 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
<img <img
src={getFileURL(workspace.logo_url)} src={getFileURL(workspace.logo_url)}
className="absolute top-0 left-0 h-full w-full rounded-sm object-cover" className="absolute top-0 left-0 h-full w-full rounded-sm object-cover"
alt="Workspace Logo" alt="Логотип воркспейса"
/> />
) : ( ) : (
(workspace?.name?.[0] ?? "...") (workspace?.name?.[0] ?? "...")
)} )}
</span> </span>
<div className="flex flex-col items-start gap-1"> <div className="flex min-w-0 flex-col items-start gap-1">
<div className="flex w-full flex-wrap items-center gap-2.5"> <div className="flex w-full flex-wrap items-center gap-2.5">
<h3 className={`text-14 font-medium capitalize`}>{workspace.name}</h3>/ <h3 className={`truncate text-14 font-medium capitalize`}>{workspace.name}</h3>/
<Tooltip tooltipContent="The unique URL of your workspace"> <Tooltip tooltipContent="Уникальный URL воркспейса">
<h4 className="text-13 text-tertiary">[{workspace.slug}]</h4> <h4 className="text-13 text-tertiary">[{workspace.slug}]</h4>
</Tooltip> </Tooltip>
</div> </div>
{workspace.owner.email && ( {workspace.owner.email && (
<div className="flex items-center gap-1 text-11"> <div className="flex items-center gap-1 text-11">
<h3 className="font-medium text-secondary">Owned by:</h3> <h3 className="font-medium text-secondary">Владелец:</h3>
<h4 className="text-tertiary">{workspace.owner.email}</h4> <h4 className="text-tertiary">{workspace.owner.email}</h4>
</div> </div>
)} )}
<div className="flex items-center gap-2.5 text-11"> <div className="flex items-center gap-2.5 text-11">
{workspace.total_projects !== null && ( {workspace.total_projects !== null && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<h3 className="font-medium text-secondary">Total projects:</h3> <h3 className="font-medium text-secondary">Проектов:</h3>
<h4 className="text-tertiary">{workspace.total_projects}</h4> <h4 className="text-tertiary">{workspace.total_projects}</h4>
</span> </span>
)} )}
@ -73,7 +76,7 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
<> <>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<h3 className="font-medium text-secondary">Total members:</h3> <h3 className="font-medium text-secondary">Участников:</h3>
<h4 className="text-tertiary">{workspace.total_members}</h4> <h4 className="text-tertiary">{workspace.total_members}</h4>
</span> </span>
</> </>
@ -81,9 +84,39 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
</div> </div>
</div> </div>
</div> </div>
<div className="flex-shrink-0"> <div className="flex shrink-0 items-center gap-1.5">
<NewTabIcon width={14} height={16} className="text-placeholder group-hover:text-secondary" /> <Tooltip tooltipContent="Доступный функционал">
<button
type="button"
onClick={() => onFeaturesClick(workspaceId)}
className="flex size-9 items-center justify-center rounded-xl p-0 text-tertiary transition hover:text-primary focus-visible:outline-none focus-visible:text-primary"
aria-label="Доступный функционал"
>
<Sparkles className="size-4" />
</button>
</Tooltip>
<Tooltip tooltipContent="Участники воркспейса">
<button
type="button"
onClick={() => onMembersClick(workspaceId)}
className="flex size-9 items-center justify-center rounded-xl p-0 text-tertiary transition hover:text-primary focus-visible:outline-none focus-visible:text-primary"
aria-label="Участники воркспейса"
>
<UsersRound className="size-4" />
</button>
</Tooltip>
<Tooltip tooltipContent="Открыть воркспейс">
<a
href={`${WEB_BASE_URL}/${encodeURIComponent(workspace.slug)}`}
target="_blank"
className="flex size-9 items-center justify-center rounded-xl p-0 text-tertiary transition hover:text-primary focus-visible:outline-none focus-visible:text-primary"
rel="noreferrer"
aria-label="Открыть воркспейс"
>
<ExternalLink className="size-4" />
</a>
</Tooltip>
</div> </div>
</a> </div>
); );
}); });

View File

@ -35,17 +35,16 @@ export const getCoreAuthenticationModesMap: (
}) => ({ }) => ({
"unique-codes": { "unique-codes": {
key: "unique-codes", key: "unique-codes",
name: "Unique codes", name: "Одноразовые коды",
description: description: "Вход и регистрация по кодам из email. Для этого способа нужен настроенный SMTP.",
"Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.",
icon: <Mails className="h-6 w-6 p-0.5 text-tertiary" />, icon: <Mails className="h-6 w-6 p-0.5 text-tertiary" />,
config: <EmailCodesConfiguration disabled={disabled} updateConfig={updateConfig} />, config: <EmailCodesConfiguration disabled={disabled} updateConfig={updateConfig} />,
enabledConfigKey: "ENABLE_MAGIC_LINK_LOGIN", enabledConfigKey: "ENABLE_MAGIC_LINK_LOGIN",
}, },
"passwords-login": { "passwords-login": {
key: "passwords-login", key: "passwords-login",
name: "Passwords", name: "Пароли",
description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.", description: "Пользователи создают аккаунты с паролем и входят по email.",
icon: <KeyRound className="h-6 w-6 p-0.5 text-tertiary" />, icon: <KeyRound className="h-6 w-6 p-0.5 text-tertiary" />,
config: <PasswordLoginConfiguration disabled={disabled} updateConfig={updateConfig} />, config: <PasswordLoginConfiguration disabled={disabled} updateConfig={updateConfig} />,
enabledConfigKey: "ENABLE_EMAIL_PASSWORD", enabledConfigKey: "ENABLE_EMAIL_PASSWORD",
@ -53,7 +52,7 @@ export const getCoreAuthenticationModesMap: (
google: { google: {
key: "google", key: "google",
name: "Google", name: "Google",
description: "Allow members to log in or sign up for Plane with their Google accounts.", description: "Вход и регистрация через аккаунты Google.",
icon: <img src={googleLogo} height={20} width={20} alt="Google Logo" />, icon: <img src={googleLogo} height={20} width={20} alt="Google Logo" />,
config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />, config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />,
enabledConfigKey: "IS_GOOGLE_ENABLED", enabledConfigKey: "IS_GOOGLE_ENABLED",
@ -61,7 +60,7 @@ export const getCoreAuthenticationModesMap: (
github: { github: {
key: "github", key: "github",
name: "GitHub", name: "GitHub",
description: "Allow members to log in or sign up for Plane with their GitHub accounts.", description: "Вход и регистрация через аккаунты GitHub.",
icon: ( icon: (
<img <img
src={resolvedTheme === "dark" ? githubDarkModeImage : githubLightModeImage} src={resolvedTheme === "dark" ? githubDarkModeImage : githubLightModeImage}
@ -76,7 +75,7 @@ export const getCoreAuthenticationModesMap: (
gitlab: { gitlab: {
key: "gitlab", key: "gitlab",
name: "GitLab", name: "GitLab",
description: "Allow members to log in or sign up to plane with their GitLab accounts.", description: "Вход и регистрация через аккаунты GitLab.",
icon: <img src={gitlabLogo} height={20} width={20} alt="GitLab Logo" />, icon: <img src={gitlabLogo} height={20} width={20} alt="GitLab Logo" />,
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />, config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
enabledConfigKey: "IS_GITLAB_ENABLED", enabledConfigKey: "IS_GITLAB_ENABLED",
@ -84,7 +83,7 @@ export const getCoreAuthenticationModesMap: (
gitea: { gitea: {
key: "gitea", key: "gitea",
name: "Gitea", name: "Gitea",
description: "Allow members to log in or sign up to plane with their Gitea accounts.", description: "Вход и регистрация через аккаунты Gitea.",
icon: <img src={giteaLogo} height={20} width={20} alt="Gitea Logo" />, icon: <img src={giteaLogo} height={20} width={20} alt="Gitea Logo" />,
config: <GiteaConfiguration disabled={disabled} updateConfig={updateConfig} />, config: <GiteaConfiguration disabled={disabled} updateConfig={updateConfig} />,
enabledConfigKey: "IS_GITEA_ENABLED", enabledConfigKey: "IS_GITEA_ENABLED",

View File

@ -15,38 +15,38 @@ export type TCoreSidebarMenuKey = "general" | "email" | "workspace" | "authentic
export const coreSidebarMenuLinks: Record<TCoreSidebarMenuKey, TSidebarMenuItem> = { export const coreSidebarMenuLinks: Record<TCoreSidebarMenuKey, TSidebarMenuItem> = {
general: { general: {
Icon: Cog, Icon: Cog,
name: "General", name: "Основное",
description: "Identify your instances and get key details.", description: "Имя инстанса, ID и телеметрия.",
href: `/general/`, href: `/general/`,
}, },
email: { email: {
Icon: Mail, Icon: Mail,
name: "Email", name: "Почта",
description: "Configure your SMTP controls.", description: "SMTP и тестовая отправка.",
href: `/email/`, href: `/email/`,
}, },
workspace: { workspace: {
Icon: WorkspaceIcon, Icon: WorkspaceIcon,
name: "Workspaces", name: "Воркспейсы",
description: "Manage all workspaces on this instance.", description: "Все рабочие пространства инстанса.",
href: `/workspace/`, href: `/workspace/`,
}, },
authentication: { authentication: {
Icon: LockIcon, Icon: LockIcon,
name: "Authentication", name: "Аутентификация",
description: "Configure authentication modes.", description: "Вход, регистрация и OAuth.",
href: `/authentication/`, href: `/authentication/`,
}, },
ai: { ai: {
Icon: BrainCog, Icon: BrainCog,
name: "Artificial intelligence", name: "ИИ",
description: "Configure your OpenAI creds.", description: "OpenAI модель и ключ API.",
href: `/ai/`, href: `/ai/`,
}, },
image: { image: {
Icon: Image, Icon: Image,
name: "Images in Plane", name: "Изображения",
description: "Allow third-party image libraries.", description: "Внешние библиотеки изображений.",
href: `/image/`, href: `/image/`,
}, },
}; };

View File

@ -29,7 +29,7 @@ http {
location / { location / {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html index.htm; index index.html index.htm;
try_files $uri $uri/ /god-mode/index.html; try_files $uri $uri/ /nodedcsudo/index.html;
} }
} }
} }

View File

@ -24,7 +24,7 @@ const DEFAULT_SWR_CONFIG = {
export function CoreProviders({ children }: { children: React.ReactNode }) { export function CoreProviders({ children }: { children: React.ReactNode }) {
return ( return (
<ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem> <ThemeProvider themes={["light", "dark"]} defaultTheme="dark" enableSystem>
<AppProgressBar /> <AppProgressBar />
<ToastWithTheme /> <ToastWithTheme />
<SWRConfig value={DEFAULT_SWR_CONFIG}> <SWRConfig value={DEFAULT_SWR_CONFIG}>

View File

@ -1,5 +1,27 @@
@import "@plane/tailwind-config/index.css"; @import "@plane/tailwind-config/index.css";
:root {
--nodedc-accent-rgb: 195 255 102;
--nodedc-on-accent-rgb: 11 17 23;
--nodedc-card-passive-rgb: 42 43 46;
--nodedc-on-card-passive-rgb: 245 247 251;
--nodedc-card-active-rgb: 195 255 102;
--nodedc-on-card-active-rgb: 11 17 23;
--brand-default: rgb(var(--nodedc-accent-rgb));
--brand-300: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 65%, white);
--brand-700: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 75%, black);
--bg-accent-primary: rgb(var(--nodedc-accent-rgb));
--bg-accent-primary-hover: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 82%, white);
--bg-accent-primary-active: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 90%, black);
--txt-on-color: rgb(var(--nodedc-on-accent-rgb));
--txt-icon-on-color: rgb(var(--nodedc-on-accent-rgb));
}
html,
body {
background: #050506;
}
.shadow-custom { .shadow-custom {
box-shadow: 2px 2px 8px 2px rgba(234, 231, 250, 0.3); /* Convert #EAE7FA4D to rgba */ box-shadow: 2px 2px 8px 2px rgba(234, 231, 250, 0.3); /* Convert #EAE7FA4D to rgba */
} }
@ -40,3 +62,510 @@
0 0 4px --alpha(var(--background-color-accent-primary) / 40%) !important; 0 0 4px --alpha(var(--background-color-accent-primary) / 40%) !important;
will-change: transform, opacity; will-change: transform, opacity;
} }
@layer components {
.nodedc-admin-root {
min-height: 100vh;
background:
radial-gradient(circle at 12% -8%, rgba(var(--nodedc-accent-rgb), 0.18), transparent 32rem),
radial-gradient(circle at 88% 0%, rgba(var(--nodedc-card-active-rgb), 0.1), transparent 30rem), #050506;
color: var(--text-color-primary);
}
.nodedc-admin-shell {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.018) 0%, rgba(255, 255, 255, 0.006) 100%), rgba(5, 5, 7, 0.94);
}
.nodedc-admin-main {
background: transparent !important;
}
.nodedc-auth-shell {
background:
radial-gradient(circle at 20% 0%, rgba(var(--nodedc-accent-rgb), 0.2), transparent 30rem),
radial-gradient(circle at 78% 10%, rgba(var(--nodedc-card-active-rgb), 0.11), transparent 26rem), #050506 !important;
}
.nodedc-auth-card {
border-radius: 1.75rem;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.036) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(8, 8, 11, 0.78);
padding: 1.5rem;
-webkit-backdrop-filter: blur(34px);
backdrop-filter: blur(34px);
box-shadow:
0 24px 64px rgba(0, 0, 0, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.035);
}
.nodedc-glass-sidebar {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.008) 100%), rgba(8, 8, 11, 0.9) !important;
border-right: 1px solid rgba(255, 255, 255, 0.07) !important;
-webkit-backdrop-filter: blur(28px);
backdrop-filter: blur(28px);
box-shadow:
inset -1px 0 0 rgba(255, 255, 255, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.015),
0 18px 48px rgba(0, 0, 0, 0.26);
}
.nodedc-glass-modal,
.nodedc-glass-surface {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.036) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(8, 8, 11, 0.88) !important;
border: 0 !important;
outline: none !important;
-webkit-backdrop-filter: blur(38px);
backdrop-filter: blur(38px);
box-shadow:
0 22px 58px rgba(0, 0, 0, 0.34),
inset 0 1px 0 rgba(255, 255, 255, 0.025);
}
.nodedc-technical-confirm-modal {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.048) 0%, rgba(255, 255, 255, 0.014) 100%),
rgba(6, 6, 8, 0.76) !important;
-webkit-backdrop-filter: blur(54px) saturate(130%);
backdrop-filter: blur(54px) saturate(130%);
box-shadow:
0 28px 76px rgba(0, 0, 0, 0.5),
0 8px 24px rgba(0, 0, 0, 0.26),
inset 0 1px 0 rgba(255, 255, 255, 0.036);
}
.nodedc-glass-popup-surface {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(8, 8, 11, 0.93) !important;
border: 0 !important;
outline: none !important;
border-radius: 1.25rem !important;
-webkit-backdrop-filter: blur(42px);
backdrop-filter: blur(42px);
box-shadow: 0 22px 60px rgba(0, 0, 0, 0.36);
}
.nodedc-dropdown-surface {
border: 0 !important;
border-radius: 1.25rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%),
rgba(8, 8, 11, 0.92) !important;
padding: 0.75rem !important;
-webkit-backdrop-filter: blur(44px);
backdrop-filter: blur(44px);
box-shadow:
0 22px 60px rgba(0, 0, 0, 0.36),
0 6px 18px rgba(0, 0, 0, 0.2);
}
.nodedc-dropdown-option {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
border-radius: 0.9rem !important;
padding: 0.5rem !important;
color: rgba(255, 255, 255, 0.72) !important;
outline: none !important;
user-select: none;
transition:
background-color 160ms ease,
color 160ms ease;
}
.nodedc-dropdown-option:hover {
background: rgba(255, 255, 255, 0.06) !important;
color: var(--text-color-primary) !important;
}
.nodedc-admin-header {
min-height: 4.25rem;
border: 0 !important;
border-radius: 0 0 1.35rem 1.35rem;
margin: 0.65rem 0.75rem 0;
width: calc(100% - 1.5rem) !important;
}
.nodedc-admin-breadcrumbs {
display: flex;
min-width: 0;
align-items: center;
gap: 0.35rem;
}
.nodedc-admin-breadcrumb-pill {
min-height: 2.5rem;
border: 0 !important;
border-radius: 1.25rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.036) 0%, rgba(255, 255, 255, 0.014) 100%),
rgba(255, 255, 255, 0.04) !important;
color: rgba(255, 255, 255, 0.72) !important;
padding: 0.55rem 0.9rem !important;
box-shadow: none !important;
transition:
background-color 160ms ease,
color 160ms ease;
}
.nodedc-admin-breadcrumb-pill:hover {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.018) 100%),
rgba(255, 255, 255, 0.065) !important;
color: var(--text-color-primary) !important;
}
.nodedc-admin-breadcrumb-pill[data-current="true"] {
color: rgb(var(--nodedc-accent-rgb)) !important;
}
.nodedc-admin-breadcrumb-pill[data-current="true"] * {
color: rgb(var(--nodedc-accent-rgb)) !important;
}
.nodedc-admin-breadcrumb-separator {
color: rgba(255, 255, 255, 0.32);
}
.nodedc-page {
padding: 1rem 0 1.5rem;
}
.nodedc-page-header {
border: 0 !important;
border-radius: 1.35rem;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.025) 0%, rgba(255, 255, 255, 0.01) 100%), rgba(255, 255, 255, 0.025);
padding: 1.1rem 1.25rem !important;
}
.nodedc-page-body {
padding-top: 0.15rem;
}
.nodedc-settings-card {
border: 0 !important;
outline: none !important;
border-radius: 1.35rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%),
rgba(255, 255, 255, 0.032) !important;
box-shadow: none !important;
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
}
.nodedc-settings-card:hover {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.018) 100%), rgba(255, 255, 255, 0.046) !important;
}
.nodedc-admin-sidebar-profile {
border: 0 !important;
border-radius: 1.35rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.044) 0%, rgba(255, 255, 255, 0.016) 100%),
rgba(255, 255, 255, 0.04) !important;
box-shadow: none !important;
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
}
.nodedc-admin-sidebar-avatar-button {
border: 0 !important;
border-radius: 0.65rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.014) 100%),
rgba(0, 0, 0, 0.32) !important;
color: rgb(var(--nodedc-accent-rgb)) !important;
box-shadow: none !important;
}
.nodedc-admin-sidebar-section-label {
padding: 0.55rem 0.9rem 0.45rem;
color: rgba(255, 255, 255, 0.54);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.nodedc-settings-sidebar-item {
min-height: 2.75rem;
border: 0 !important;
outline: none !important;
border-radius: 1.1rem !important;
background: transparent !important;
color: rgba(255, 255, 255, 0.76) !important;
padding-inline: 0.95rem !important;
box-shadow: none !important;
}
.nodedc-settings-sidebar-item:hover {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.014) 100%), rgba(255, 255, 255, 0.03) !important;
color: var(--text-color-primary) !important;
}
.nodedc-settings-sidebar-item[data-active="true"] {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.016) 100%), rgba(255, 255, 255, 0.045) !important;
color: rgb(var(--nodedc-accent-rgb)) !important;
box-shadow: none !important;
}
.nodedc-settings-sidebar-item[data-active="true"] * {
color: rgb(var(--nodedc-accent-rgb)) !important;
}
.nodedc-admin-sidebar-action {
min-height: 2.25rem;
border: 0 !important;
border-radius: 999px !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.036) 0%, rgba(255, 255, 255, 0.014) 100%),
rgba(255, 255, 255, 0.045) !important;
color: rgba(255, 255, 255, 0.7) !important;
box-shadow: none !important;
}
.nodedc-admin-sidebar-action:hover {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.052) 0%, rgba(255, 255, 255, 0.018) 100%),
rgba(255, 255, 255, 0.07) !important;
color: var(--text-color-primary) !important;
}
.nodedc-settings-input,
.nodedc-admin-root input:not([type="checkbox"]) {
min-height: 3rem;
border: 0 !important;
outline: none !important;
border-radius: 1.25rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%),
rgba(255, 255, 255, 0.032) !important;
color: var(--text-color-primary) !important;
box-shadow: none !important;
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
}
.nodedc-settings-input:focus,
.nodedc-settings-input:focus-visible,
.nodedc-admin-root input:not([type="checkbox"]):focus,
.nodedc-admin-root input:not([type="checkbox"]):focus-visible {
outline: none !important;
box-shadow: inset 0 0 0 1px rgba(var(--nodedc-accent-rgb), 0.28) !important;
}
.nodedc-settings-input input {
min-height: auto !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
.nodedc-settings-select {
min-height: 3rem !important;
border: 0 !important;
outline: none !important;
border-radius: 1.25rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%),
rgba(255, 255, 255, 0.032) !important;
color: var(--text-color-primary) !important;
box-shadow: none !important;
}
.nodedc-admin-root [data-slot="button"],
.nodedc-admin-root button,
.nodedc-admin-root a[class*="rounded"] {
outline: none !important;
}
.nodedc-settings-save-button,
.nodedc-admin-root [data-slot="button"].bg-accent-primary,
.nodedc-admin-root button.bg-accent-primary:not([role="switch"]),
.nodedc-admin-root a.bg-accent-primary {
min-height: 2.75rem;
border: 0 !important;
border-radius: 1.25rem !important;
background: rgb(var(--nodedc-card-active-rgb)) !important;
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
box-shadow: none !important;
padding-inline: 1.35rem !important;
}
.nodedc-settings-save-button:hover,
.nodedc-admin-root [data-slot="button"].bg-accent-primary:hover,
.nodedc-admin-root button.bg-accent-primary:not([role="switch"]):hover,
.nodedc-admin-root a.bg-accent-primary:hover {
background: color-mix(in srgb, rgb(var(--nodedc-card-active-rgb)) 82%, white) !important;
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
}
.nodedc-settings-save-button *,
.nodedc-admin-root [data-slot="button"].bg-accent-primary *,
.nodedc-admin-root button.bg-accent-primary:not([role="switch"]) *,
.nodedc-admin-root a.bg-accent-primary * {
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
}
.nodedc-admin-root button[role="switch"] {
min-height: auto !important;
padding: 0 !important;
border-radius: 9999px !important;
border-color: rgba(255, 255, 255, 0.1) !important;
background: rgba(255, 255, 255, 0.13) !important;
box-shadow: none !important;
}
.nodedc-admin-root button[role="switch"][aria-checked="true"] {
border-color: transparent !important;
background: rgb(var(--nodedc-card-active-rgb)) !important;
}
.nodedc-admin-root button[role="switch"]:hover {
background: rgba(255, 255, 255, 0.17) !important;
}
.nodedc-admin-root button[role="switch"][aria-checked="true"]:hover {
background: color-mix(in srgb, rgb(var(--nodedc-card-active-rgb)) 88%, white) !important;
}
.nodedc-admin-root button[role="switch"] > span[aria-hidden="true"] {
background: rgba(255, 255, 255, 0.72) !important;
}
.nodedc-admin-root button[role="switch"][aria-checked="true"] > span[aria-hidden="true"] {
background: rgba(9, 10, 9, 0.94) !important;
}
.nodedc-admin-root input[type="checkbox"] {
min-height: 1rem !important;
width: 1rem !important;
height: 1rem !important;
padding: 0 !important;
border-radius: 0.35rem !important;
border-color: rgba(255, 255, 255, 0.22) !important;
background: rgba(255, 255, 255, 0.06) !important;
box-shadow: none !important;
}
.nodedc-admin-root input[type="checkbox"]:hover {
border-color: rgba(255, 255, 255, 0.36) !important;
background: rgba(255, 255, 255, 0.09) !important;
}
.nodedc-admin-root input[type="checkbox"]:checked,
.nodedc-admin-root input[type="checkbox"]:indeterminate {
border-color: transparent !important;
background: rgb(var(--nodedc-card-active-rgb)) !important;
}
.nodedc-admin-root input[type="checkbox"]:focus-visible {
outline: none !important;
box-shadow: 0 0 0 4px rgba(var(--nodedc-accent-rgb), 0.18) !important;
}
.nodedc-settings-secondary-button {
min-height: 2.75rem;
border: 0 !important;
border-radius: 1.25rem !important;
background: rgba(255, 255, 255, 0.06) !important;
color: var(--text-color-primary) !important;
box-shadow: none !important;
padding-inline: 1.25rem !important;
}
.nodedc-admin-root [data-slot="button"].border-strong,
.nodedc-admin-root a.border-strong,
.nodedc-admin-root button.border-strong:not([role="switch"]) {
min-height: 2.75rem;
border: 0 !important;
border-radius: 1.25rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.022) 100%),
rgba(255, 255, 255, 0.055) !important;
color: rgba(255, 255, 255, 0.76) !important;
box-shadow: none !important;
padding-inline: 1.25rem !important;
}
.nodedc-settings-secondary-button:hover,
.nodedc-admin-root [data-slot="button"].border-strong:hover,
.nodedc-admin-root a.border-strong:hover,
.nodedc-admin-root button.border-strong:not([role="switch"]):hover {
background: rgba(255, 255, 255, 0.1) !important;
color: var(--text-color-primary) !important;
}
.nodedc-admin-root [data-slot="button"]:disabled,
.nodedc-admin-root button:disabled:not([role="switch"]),
.nodedc-admin-root [aria-disabled="true"] {
border: 0 !important;
background: rgba(255, 255, 255, 0.035) !important;
color: rgba(255, 255, 255, 0.34) !important;
opacity: 1 !important;
box-shadow: none !important;
}
.nodedc-settings-note {
border: 0 !important;
border-radius: 1.25rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.045) 0%, rgba(255, 255, 255, 0.018) 100%),
rgba(255, 255, 255, 0.045) !important;
color: rgba(255, 255, 255, 0.72) !important;
box-shadow: none !important;
}
.nodedc-settings-note a {
color: rgb(var(--nodedc-card-active-rgb)) !important;
text-decoration-color: rgba(var(--nodedc-card-active-rgb), 0.48) !important;
}
.nodedc-settings-helper-card {
border: 0 !important;
border-radius: 1.35rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.048) 0%, rgba(255, 255, 255, 0.018) 100%),
rgba(255, 255, 255, 0.045) !important;
box-shadow: none !important;
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
}
.nodedc-settings-helper-card-header {
border: 0 !important;
background: rgba(255, 255, 255, 0.035) !important;
color: rgba(255, 255, 255, 0.72) !important;
}
.nodedc-admin-root .bg-layer-1,
.nodedc-admin-root .bg-layer-2,
.nodedc-admin-root .bg-layer-3 {
background-color: rgba(255, 255, 255, 0.04) !important;
}
.nodedc-admin-root .border-subtle,
.nodedc-admin-root .border-subtle-1 {
border-color: rgba(255, 255, 255, 0.07) !important;
}
.nodedc-admin-root :focus-visible {
outline: none !important;
}
.nodedc-code-chip {
border: 0 !important;
border-radius: 0.65rem !important;
background: rgba(255, 255, 255, 0.08) !important;
color: rgb(var(--nodedc-card-active-rgb)) !important;
}
}

View File

@ -9,6 +9,8 @@ from rest_framework import status
from enum import Enum from enum import Enum
from plane.utils.workspace_bans import release_expired_workspace_bans
class ROLE(Enum): class ROLE(Enum):
ADMIN = 20 ADMIN = 20
@ -20,6 +22,9 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
def decorator(view_func): def decorator(view_func):
@wraps(view_func) @wraps(view_func)
def _wrapped_view(instance, request, *args, **kwargs): def _wrapped_view(instance, request, *args, **kwargs):
if not request.user.is_anonymous and kwargs.get("slug"):
release_expired_workspace_bans(member=request.user, workspace_slug=kwargs["slug"])
# Check for creator if required # Check for creator if required
if creator and model: if creator and model:
# check if the user is part of the workspace or not # check if the user is part of the workspace or not

View File

@ -8,6 +8,12 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission
# Module import # Module import
from plane.db.models import ProjectMember, WorkspaceMember from plane.db.models import ProjectMember, WorkspaceMember
from plane.db.models.project import ROLE from plane.db.models.project import ROLE
from plane.utils.workspace_bans import release_expired_workspace_bans
def release_request_workspace_ban(request, view):
if hasattr(view, "workspace_slug") and view.workspace_slug:
release_expired_workspace_bans(member=request.user, workspace_slug=view.workspace_slug)
class ProjectBasePermission(BasePermission): class ProjectBasePermission(BasePermission):
@ -15,6 +21,8 @@ class ProjectBasePermission(BasePermission):
if request.user.is_anonymous: if request.user.is_anonymous:
return False return False
release_request_workspace_ban(request, view)
## Safe Methods -> Handle the filtering logic in queryset ## Safe Methods -> Handle the filtering logic in queryset
if request.method in SAFE_METHODS: if request.method in SAFE_METHODS:
return WorkspaceMember.objects.filter( return WorkspaceMember.objects.filter(
@ -58,6 +66,8 @@ class ProjectMemberPermission(BasePermission):
if request.user.is_anonymous: if request.user.is_anonymous:
return False return False
release_request_workspace_ban(request, view)
## Safe Methods -> Handle the filtering logic in queryset ## Safe Methods -> Handle the filtering logic in queryset
if request.method in SAFE_METHODS: if request.method in SAFE_METHODS:
return ProjectMember.objects.filter( return ProjectMember.objects.filter(
@ -87,6 +97,8 @@ class ProjectEntityPermission(BasePermission):
if request.user.is_anonymous: if request.user.is_anonymous:
return False return False
release_request_workspace_ban(request, view)
# Handle requests based on project__identifier # Handle requests based on project__identifier
if hasattr(view, "project_identifier") and view.project_identifier: if hasattr(view, "project_identifier") and view.project_identifier:
if request.method in SAFE_METHODS: if request.method in SAFE_METHODS:
@ -121,6 +133,8 @@ class ProjectAdminPermission(BasePermission):
if request.user.is_anonymous: if request.user.is_anonymous:
return False return False
release_request_workspace_ban(request, view)
return ProjectMember.objects.filter( return ProjectMember.objects.filter(
workspace__slug=view.workspace_slug, workspace__slug=view.workspace_slug,
member=request.user, member=request.user,
@ -135,6 +149,8 @@ class ProjectLitePermission(BasePermission):
if request.user.is_anonymous: if request.user.is_anonymous:
return False return False
release_request_workspace_ban(request, view)
return ProjectMember.objects.filter( return ProjectMember.objects.filter(
workspace__slug=view.workspace_slug, workspace__slug=view.workspace_slug,
member=request.user, member=request.user,

View File

@ -7,6 +7,7 @@ from rest_framework.permissions import BasePermission, SAFE_METHODS
# Module imports # Module imports
from plane.db.models import WorkspaceMember from plane.db.models import WorkspaceMember
from plane.utils.workspace_bans import release_expired_workspace_bans
# Permission Mappings # Permission Mappings
@ -15,6 +16,11 @@ Member = 15
Guest = 5 Guest = 5
def release_request_workspace_ban(request, view):
if hasattr(view, "workspace_slug") and view.workspace_slug:
release_expired_workspace_bans(member=request.user, workspace_slug=view.workspace_slug)
# TODO: Move the below logic to python match - python v3.10 # TODO: Move the below logic to python match - python v3.10
class WorkSpaceBasePermission(BasePermission): class WorkSpaceBasePermission(BasePermission):
def has_permission(self, request, view): def has_permission(self, request, view):
@ -25,6 +31,8 @@ class WorkSpaceBasePermission(BasePermission):
if request.method == "POST": if request.method == "POST":
return True return True
release_request_workspace_ban(request, view)
## Safe Methods ## Safe Methods
if request.method in SAFE_METHODS: if request.method in SAFE_METHODS:
return True return True
@ -53,6 +61,8 @@ class WorkspaceOwnerPermission(BasePermission):
if request.user.is_anonymous: if request.user.is_anonymous:
return False return False
release_request_workspace_ban(request, view)
return WorkspaceMember.objects.filter( return WorkspaceMember.objects.filter(
workspace__slug=view.workspace_slug, member=request.user, role=Admin workspace__slug=view.workspace_slug, member=request.user, role=Admin
).exists() ).exists()
@ -63,6 +73,8 @@ class WorkSpaceAdminPermission(BasePermission):
if request.user.is_anonymous: if request.user.is_anonymous:
return False return False
release_request_workspace_ban(request, view)
return WorkspaceMember.objects.filter( return WorkspaceMember.objects.filter(
member=request.user, member=request.user,
workspace__slug=view.workspace_slug, workspace__slug=view.workspace_slug,
@ -76,6 +88,8 @@ class WorkspaceEntityPermission(BasePermission):
if request.user.is_anonymous: if request.user.is_anonymous:
return False return False
release_request_workspace_ban(request, view)
## Safe Methods -> Handle the filtering logic in queryset ## Safe Methods -> Handle the filtering logic in queryset
if request.method in SAFE_METHODS: if request.method in SAFE_METHODS:
return WorkspaceMember.objects.filter( return WorkspaceMember.objects.filter(
@ -95,6 +109,8 @@ class WorkspaceViewerPermission(BasePermission):
if request.user.is_anonymous: if request.user.is_anonymous:
return False return False
release_request_workspace_ban(request, view)
return WorkspaceMember.objects.filter( return WorkspaceMember.objects.filter(
member=request.user, workspace__slug=view.workspace_slug, is_active=True member=request.user, workspace__slug=view.workspace_slug, is_active=True
).exists() ).exists()
@ -105,6 +121,8 @@ class WorkspaceUserPermission(BasePermission):
if request.user.is_anonymous: if request.user.is_anonymous:
return False return False
release_request_workspace_ban(request, view)
return WorkspaceMember.objects.filter( return WorkspaceMember.objects.filter(
member=request.user, workspace__slug=view.workspace_slug, is_active=True member=request.user, workspace__slug=view.workspace_slug, is_active=True
).exists() ).exists()

View File

@ -4,7 +4,7 @@
from rest_framework import serializers from rest_framework import serializers
from plane.db.models import Project, WorkspaceAICredential, WorkspaceAISettings from plane.db.models import Project, WorkspaceAICredential, WorkspaceAISettings, WorkspaceMember
from plane.license.utils.encryption import encrypt_data from plane.license.utils.encryption import encrypt_data
from .base import BaseSerializer from .base import BaseSerializer
@ -12,6 +12,8 @@ from .base import BaseSerializer
class WorkspaceAISettingsSerializer(BaseSerializer): class WorkspaceAISettingsSerializer(BaseSerializer):
default_project_id = serializers.UUIDField(required=False, allow_null=True) default_project_id = serializers.UUIDField(required=False, allow_null=True)
enabled_project_ids = serializers.ListField(child=serializers.UUIDField(), required=False, write_only=True)
enabled_member_ids = serializers.ListField(child=serializers.UUIDField(), required=False, write_only=True)
openai_api_key = serializers.CharField(required=False, allow_blank=True, write_only=True, trim_whitespace=False) openai_api_key = serializers.CharField(required=False, allow_blank=True, write_only=True, trim_whitespace=False)
credential = serializers.SerializerMethodField(read_only=True) credential = serializers.SerializerMethodField(read_only=True)
@ -26,9 +28,16 @@ class WorkspaceAISettingsSerializer(BaseSerializer):
"structuring_model", "structuring_model",
"default_project_id", "default_project_id",
"access_mode", "access_mode",
"enabled_project_ids",
"enabled_member_ids",
"max_audio_duration_seconds", "max_audio_duration_seconds",
"per_user_hourly_limit", "per_user_hourly_limit",
"workspace_hourly_limit", "workspace_hourly_limit",
"per_user_daily_limit",
"workspace_daily_limit",
"project_daily_limit",
"workspace_concurrency_limit",
"sensitive_data_retention_days",
"credential", "credential",
"openai_api_key", "openai_api_key",
"created_at", "created_at",
@ -45,6 +54,12 @@ class WorkspaceAISettingsSerializer(BaseSerializer):
"updated_at": credential.updated_at if credential else None, "updated_at": credential.updated_at if credential else None,
} }
def to_representation(self, instance):
data = super().to_representation(instance)
data["enabled_project_ids"] = [str(project_id) for project_id in instance.enabled_projects.values_list("id", flat=True)]
data["enabled_member_ids"] = [str(member_id) for member_id in instance.enabled_members.values_list("id", flat=True)]
return data
def validate_default_project_id(self, value): def validate_default_project_id(self, value):
if value is None: if value is None:
return None return None
@ -54,6 +69,32 @@ class WorkspaceAISettingsSerializer(BaseSerializer):
raise serializers.ValidationError("Default project must belong to this workspace.") raise serializers.ValidationError("Default project must belong to this workspace.")
return value return value
def validate_enabled_project_ids(self, value):
workspace = self.context["workspace"]
project_ids = list(dict.fromkeys(value))
existing_ids = set(
Project.objects.filter(workspace=workspace, id__in=project_ids, archived_at__isnull=True).values_list(
"id", flat=True
)
)
if len(existing_ids) != len(project_ids):
raise serializers.ValidationError("All selected projects must belong to this workspace.")
return project_ids
def validate_enabled_member_ids(self, value):
workspace = self.context["workspace"]
member_ids = list(dict.fromkeys(value))
existing_ids = set(
WorkspaceMember.objects.filter(
workspace=workspace,
member_id__in=member_ids,
is_active=True,
).values_list("member_id", flat=True)
)
if len(existing_ids) != len(member_ids):
raise serializers.ValidationError("All selected members must be active workspace members.")
return member_ids
def validate_max_audio_duration_seconds(self, value): def validate_max_audio_duration_seconds(self, value):
if value < 10 or value > 600: if value < 10 or value > 600:
raise serializers.ValidationError("Max audio duration must be between 10 and 600 seconds.") raise serializers.ValidationError("Max audio duration must be between 10 and 600 seconds.")
@ -69,9 +110,36 @@ class WorkspaceAISettingsSerializer(BaseSerializer):
raise serializers.ValidationError("Workspace hourly limit must be between 1 and 10000.") raise serializers.ValidationError("Workspace hourly limit must be between 1 and 10000.")
return value return value
def validate_per_user_daily_limit(self, value):
if value < 1 or value > 10000:
raise serializers.ValidationError("Per-user daily limit must be between 1 and 10000.")
return value
def validate_workspace_daily_limit(self, value):
if value < 1 or value > 100000:
raise serializers.ValidationError("Workspace daily limit must be between 1 and 100000.")
return value
def validate_project_daily_limit(self, value):
if value < 1 or value > 50000:
raise serializers.ValidationError("Project daily limit must be between 1 and 50000.")
return value
def validate_workspace_concurrency_limit(self, value):
if value < 1 or value > 50:
raise serializers.ValidationError("Workspace concurrency limit must be between 1 and 50.")
return value
def validate_sensitive_data_retention_days(self, value):
if value < 1 or value > 365:
raise serializers.ValidationError("Sensitive data retention must be between 1 and 365 days.")
return value
def update(self, instance, validated_data): def update(self, instance, validated_data):
api_key = validated_data.pop("openai_api_key", None) api_key = validated_data.pop("openai_api_key", None)
default_project_id = validated_data.pop("default_project_id", serializers.empty) default_project_id = validated_data.pop("default_project_id", serializers.empty)
enabled_project_ids = validated_data.pop("enabled_project_ids", serializers.empty)
enabled_member_ids = validated_data.pop("enabled_member_ids", serializers.empty)
if default_project_id is not serializers.empty: if default_project_id is not serializers.empty:
instance.default_project_id = default_project_id instance.default_project_id = default_project_id
@ -81,6 +149,12 @@ class WorkspaceAISettingsSerializer(BaseSerializer):
instance.save() instance.save()
if enabled_project_ids is not serializers.empty:
instance.enabled_projects.set(enabled_project_ids)
if enabled_member_ids is not serializers.empty:
instance.enabled_members.set(enabled_member_ids)
if api_key: if api_key:
cleaned_api_key = api_key.strip() cleaned_api_key = api_key.strip()
credential, _ = WorkspaceAICredential.objects.get_or_create( credential, _ = WorkspaceAICredential.objects.get_or_create(

View File

@ -6,8 +6,10 @@ from django.urls import path
from plane.app.views import ( from plane.app.views import (
VoiceTaskCommitEndpoint, VoiceTaskCommitEndpoint,
VoiceTaskMonitorEndpoint,
VoiceTaskParseEndpoint, VoiceTaskParseEndpoint,
VoiceTaskPreflightEndpoint, VoiceTaskPreflightEndpoint,
VoiceTaskSessionEndpoint,
WorkspaceAISettingsEndpoint, WorkspaceAISettingsEndpoint,
WorkspaceAISettingsTestConnectionEndpoint, WorkspaceAISettingsTestConnectionEndpoint,
) )
@ -24,6 +26,11 @@ urlpatterns = [
WorkspaceAISettingsTestConnectionEndpoint.as_view(), WorkspaceAISettingsTestConnectionEndpoint.as_view(),
name="voice-tasker-settings-test-connection", name="voice-tasker-settings-test-connection",
), ),
path(
"workspaces/<str:slug>/voice-tasker/monitor/",
VoiceTaskMonitorEndpoint.as_view(),
name="voice-tasker-monitor",
),
path( path(
"workspaces/<str:slug>/voice-task/preflight/", "workspaces/<str:slug>/voice-task/preflight/",
VoiceTaskPreflightEndpoint.as_view(), VoiceTaskPreflightEndpoint.as_view(),
@ -34,6 +41,11 @@ urlpatterns = [
VoiceTaskParseEndpoint.as_view(), VoiceTaskParseEndpoint.as_view(),
name="voice-task-parse", name="voice-task-parse",
), ),
path(
"workspaces/<str:slug>/voice-task/sessions/<uuid:session_id>/",
VoiceTaskSessionEndpoint.as_view(),
name="voice-task-session",
),
path( path(
"workspaces/<str:slug>/voice-task/commit/", "workspaces/<str:slug>/voice-task/commit/",
VoiceTaskCommitEndpoint.as_view(), VoiceTaskCommitEndpoint.as_view(),

View File

@ -36,6 +36,8 @@ from plane.app.views import (
UserRecentVisitViewSet, UserRecentVisitViewSet,
WorkspaceHomePreferenceViewSet, WorkspaceHomePreferenceViewSet,
WorkspaceStickyViewSet, WorkspaceStickyViewSet,
WorkspaceStorageMaintenanceEndpoint,
WorkspaceStorageProjectQuotaEndpoint,
WorkspaceStorageSummaryEndpoint, WorkspaceStorageSummaryEndpoint,
WorkspaceUserPreferenceViewSet, WorkspaceUserPreferenceViewSet,
) )
@ -263,6 +265,16 @@ urlpatterns = [
WorkspaceStorageSummaryEndpoint.as_view(), WorkspaceStorageSummaryEndpoint.as_view(),
name="workspace-storage-summary", name="workspace-storage-summary",
), ),
path(
"workspaces/<str:slug>/storage/maintenance/",
WorkspaceStorageMaintenanceEndpoint.as_view(),
name="workspace-storage-maintenance",
),
path(
"workspaces/<str:slug>/storage/projects/<uuid:project_id>/quota/",
WorkspaceStorageProjectQuotaEndpoint.as_view(),
name="workspace-storage-project-quota",
),
# User Preference # User Preference
path( path(
"workspaces/<str:slug>/sidebar-preferences/", "workspaces/<str:slug>/sidebar-preferences/",

View File

@ -83,7 +83,11 @@ from .workspace.module import WorkspaceModulesEndpoint
from .workspace.cycle import WorkspaceCyclesEndpoint from .workspace.cycle import WorkspaceCyclesEndpoint
from .workspace.quick_link import QuickLinkViewSet from .workspace.quick_link import QuickLinkViewSet
from .workspace.sticky import WorkspaceStickyViewSet from .workspace.sticky import WorkspaceStickyViewSet
from .workspace.storage import WorkspaceStorageSummaryEndpoint from .workspace.storage import (
WorkspaceStorageMaintenanceEndpoint,
WorkspaceStorageProjectQuotaEndpoint,
WorkspaceStorageSummaryEndpoint,
)
from .state.base import StateViewSet, IntakeStateEndpoint from .state.base import StateViewSet, IntakeStateEndpoint
from .view.base import ( from .view.base import (
@ -247,8 +251,10 @@ from .webhook.base import (
from .voice_tasker import ( from .voice_tasker import (
VoiceTaskCommitEndpoint, VoiceTaskCommitEndpoint,
VoiceTaskMonitorEndpoint,
VoiceTaskParseEndpoint, VoiceTaskParseEndpoint,
VoiceTaskPreflightEndpoint, VoiceTaskPreflightEndpoint,
VoiceTaskSessionEndpoint,
WorkspaceAISettingsEndpoint, WorkspaceAISettingsEndpoint,
WorkspaceAISettingsTestConnectionEndpoint, WorkspaceAISettingsTestConnectionEndpoint,
) )

View File

@ -23,7 +23,7 @@ from plane.settings.storage import S3Storage
from plane.app.permissions import allow_permission, ROLE from plane.app.permissions import allow_permission, ROLE
from plane.utils.cache import invalidate_cache_directly from plane.utils.cache import invalidate_cache_directly
from plane.throttles.asset import AssetRateThrottle from plane.throttles.asset import AssetRateThrottle
from plane.utils.upload_limits import resolve_workspace_upload_size_limit from plane.utils.upload_limits import get_project_storage_quota_response, resolve_workspace_upload_size_limit
from plane.utils.file_dedup import ( from plane.utils.file_dedup import (
UploadedObjectMissing, UploadedObjectMissing,
attach_existing_blob_to_file_asset, attach_existing_blob_to_file_asset,
@ -361,6 +361,14 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
# Get the size limit # Get the size limit
size_limit = resolve_workspace_upload_size_limit(workspace, size) size_limit = resolve_workspace_upload_size_limit(workspace, size)
if entity_type == FileAsset.EntityTypeContext.PROJECT_COVER and entity_identifier:
quota_project = Project.objects.filter(id=entity_identifier, workspace=workspace).first()
else:
quota_project = None
if quota_project is not None:
quota_response = get_project_storage_quota_response(quota_project, requested_size=size)
if quota_response is not None:
return quota_response
# asset key # asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
@ -564,6 +572,10 @@ class ProjectAssetEndpoint(BaseAPIView):
# Get the size limit # Get the size limit
size_limit = resolve_workspace_upload_size_limit(workspace, size) size_limit = resolve_workspace_upload_size_limit(workspace, size)
project = Project.objects.get(id=project_id, workspace=workspace)
quota_response = get_project_storage_quota_response(project, requested_size=size)
if quota_response is not None:
return quota_response
# asset key # asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
@ -576,7 +588,7 @@ class ProjectAssetEndpoint(BaseAPIView):
workspace=workspace, workspace=workspace,
created_by=request.user, created_by=request.user,
entity_type=entity_type, entity_type=entity_type,
project_id=project_id, project_id=project.id,
**self.get_entity_id_field(entity_type, entity_identifier), **self.get_entity_id_field(entity_type, entity_identifier),
) )
@ -767,10 +779,6 @@ class DuplicateAssetEndpoint(BaseAPIView):
) )
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
if project_id:
# check if project exists in the workspace
if not Project.objects.filter(id=project_id, workspace=workspace).exists():
return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND)
storage = S3Storage(request=request) storage = S3Storage(request=request)
original_asset = FileAsset.objects.filter(id=asset_id, workspace=workspace, is_uploaded=True).first() original_asset = FileAsset.objects.filter(id=asset_id, workspace=workspace, is_uploaded=True).first()
@ -778,6 +786,14 @@ class DuplicateAssetEndpoint(BaseAPIView):
if not original_asset: if not original_asset:
return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND) return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND)
if project_id:
project = Project.objects.filter(id=project_id, workspace=workspace).first()
if not project:
return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND)
quota_response = get_project_storage_quota_response(project, requested_size=original_asset.size)
if quota_response is not None:
return quota_response
destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}" destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
duplicated_asset = FileAsset.objects.create( duplicated_asset = FileAsset.objects.create(
attributes={ attributes={

View File

@ -19,12 +19,12 @@ from rest_framework.parsers import MultiPartParser, FormParser
# Module imports # Module imports
from .. import BaseAPIView from .. import BaseAPIView
from plane.app.serializers import IssueAttachmentSerializer from plane.app.serializers import IssueAttachmentSerializer
from plane.db.models import FileAsset, Workspace from plane.db.models import FileAsset, Project, Workspace
from plane.bgtasks.issue_activities_task import issue_activity from plane.bgtasks.issue_activities_task import issue_activity
from plane.app.permissions import allow_permission, ROLE from plane.app.permissions import allow_permission, ROLE
from plane.settings.storage import S3Storage from plane.settings.storage import S3Storage
from plane.utils.host import base_host from plane.utils.host import base_host
from plane.utils.upload_limits import resolve_workspace_upload_size_limit from plane.utils.upload_limits import get_project_storage_quota_response, resolve_workspace_upload_size_limit
from plane.utils.attachment_preview import attachment_object_exists, get_attachment_preview_response from plane.utils.attachment_preview import attachment_object_exists, get_attachment_preview_response
from plane.utils.file_dedup import finalize_uploaded_file_asset, release_file_asset_blob, UploadedObjectMissing from plane.utils.file_dedup import finalize_uploaded_file_asset, release_file_asset_blob, UploadedObjectMissing
@ -118,12 +118,16 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
# Get the workspace # Get the workspace
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(id=project_id, workspace=workspace)
# asset key # asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
# Get the size limit # Get the size limit
size_limit = resolve_workspace_upload_size_limit(workspace, size) size_limit = resolve_workspace_upload_size_limit(workspace, size)
quota_response = get_project_storage_quota_response(project, requested_size=size)
if quota_response is not None:
return quota_response
# Create a File Asset # Create a File Asset
asset = FileAsset.objects.create( asset = FileAsset.objects.create(

View File

@ -32,6 +32,7 @@ from plane.db.models import (
) )
from plane.db.models.project import ProjectNetwork from plane.db.models.project import ProjectNetwork
from plane.utils.host import base_host from plane.utils.host import base_host
from plane.utils.workspace_bans import is_workspace_member_currently_banned, release_workspace_member_ban
class ProjectInvitationsViewset(BaseViewSet): class ProjectInvitationsViewset(BaseViewSet):
@ -195,7 +196,19 @@ class ProjectJoinEndpoint(BaseAPIView):
) )
if project_invite.responded_at is None: if project_invite.responded_at is None:
project_invite.accepted = request.data.get("accepted", False) accepted = request.data.get("accepted", False)
if accepted:
user = User.objects.filter(email=email).first()
workspace_member = WorkspaceMember.objects.filter(workspace__slug=slug, member=user).first()
if is_workspace_member_currently_banned(workspace_member):
return Response(
{"error": "You are banned from this workspace"},
status=status.HTTP_403_FORBIDDEN,
)
if workspace_member is not None and workspace_member.is_banned:
release_workspace_member_ban(workspace_member)
project_invite.accepted = accepted
project_invite.responded_at = timezone.now() project_invite.responded_at = timezone.now()
project_invite.save() project_invite.save()

File diff suppressed because it is too large Load Diff

View File

@ -50,6 +50,7 @@ from plane.bgtasks.event_tracking_task import track_event
from plane.utils.url import contains_url from plane.utils.url import contains_url
from plane.utils.analytics_events import WORKSPACE_CREATED, WORKSPACE_DELETED from plane.utils.analytics_events import WORKSPACE_CREATED, WORKSPACE_DELETED
from plane.utils.csv_utils import sanitize_csv_row from plane.utils.csv_utils import sanitize_csv_row
from plane.utils.workspace_bans import release_expired_workspace_bans
class WorkSpaceViewSet(BaseViewSet): class WorkSpaceViewSet(BaseViewSet):
@ -207,6 +208,8 @@ class UserWorkSpacesEndpoint(BaseAPIView):
use_read_replica = True use_read_replica = True
def get(self, request): def get(self, request):
release_expired_workspace_bans(member=request.user)
fields = [field for field in request.GET.get("fields", "").split(",") if field] fields = [field for field in request.GET.get("fields", "").split(",") if field]
member_count = ( member_count = (
WorkspaceMember.objects.filter(workspace=OuterRef("id"), member__is_bot=False, is_active=True) WorkspaceMember.objects.filter(workspace=OuterRef("id"), member__is_bot=False, is_active=True)

View File

@ -31,6 +31,7 @@ from plane.db.models import User, Workspace, WorkspaceMember, WorkspaceMemberInv
from plane.utils.cache import invalidate_cache, invalidate_cache_directly from plane.utils.cache import invalidate_cache, invalidate_cache_directly
from plane.utils.host import base_host from plane.utils.host import base_host
from plane.utils.analytics_events import USER_JOINED_WORKSPACE, USER_INVITED_TO_WORKSPACE from plane.utils.analytics_events import USER_JOINED_WORKSPACE, USER_INVITED_TO_WORKSPACE
from plane.utils.workspace_bans import is_workspace_member_currently_banned, release_workspace_member_ban
from .. import BaseViewSet from .. import BaseViewSet
@ -85,6 +86,17 @@ class WorkspaceInvitationsViewset(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
banned_workspace_members = WorkspaceMember.objects.filter(
workspace_id=workspace.id,
member__email__in=[email.get("email") for email in emails],
is_banned=True,
).select_related("member")
if any(is_workspace_member_currently_banned(workspace_member) for workspace_member in banned_workspace_members):
return Response(
{"error": "Some users are banned from this workspace"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace_invitations = [] workspace_invitations = []
for email in emails: for email in emails:
try: try:
@ -174,7 +186,23 @@ class WorkspaceJoinEndpoint(BaseAPIView):
# If already responded then return error # If already responded then return error
if workspace_invite.responded_at is None: if workspace_invite.responded_at is None:
workspace_invite.accepted = request.data.get("accepted", False) accepted = request.data.get("accepted", False)
if accepted:
user = User.objects.filter(email=workspace_invite.email).first()
workspace_member = (
WorkspaceMember.objects.filter(workspace=workspace_invite.workspace, member=user).first()
if user is not None
else None
)
if is_workspace_member_currently_banned(workspace_member):
return Response(
{"error": "You are banned from this workspace"},
status=status.HTTP_403_FORBIDDEN,
)
if workspace_member is not None and workspace_member.is_banned:
release_workspace_member_ban(workspace_member)
workspace_invite.accepted = accepted
workspace_invite.responded_at = timezone.now() workspace_invite.responded_at = timezone.now()
workspace_invite.save() workspace_invite.save()
@ -260,6 +288,17 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
# If the user is already a member of workspace and was deactivated then activate the user # If the user is already a member of workspace and was deactivated then activate the user
for invitation in workspace_invitations: for invitation in workspace_invitations:
workspace_member = WorkspaceMember.objects.filter(
workspace_id=invitation.workspace_id, member=request.user
).first()
if is_workspace_member_currently_banned(workspace_member):
return Response(
{"error": "You are banned from one or more invited workspaces"},
status=status.HTTP_403_FORBIDDEN,
)
if workspace_member is not None and workspace_member.is_banned:
release_workspace_member_ban(workspace_member)
invalidate_cache_directly( invalidate_cache_directly(
path=f"/api/workspaces/{invitation.workspace.slug}/members/", path=f"/api/workspaces/{invitation.workspace.slug}/members/",
user=False, user=False,

View File

@ -2,8 +2,10 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details. # See the LICENSE file for details.
import os
from datetime import timedelta from datetime import timedelta
from django.conf import settings
from django.db.models import Sum from django.db.models import Sum
from django.utils import timezone from django.utils import timezone
from rest_framework import status from rest_framework import status
@ -12,6 +14,8 @@ from rest_framework.response import Response
from plane.app.permissions import ROLE, allow_permission from plane.app.permissions import ROLE, allow_permission
from plane.app.views.base import BaseAPIView from plane.app.views.base import BaseAPIView
from plane.db.models import FileAsset, Project, StoredBlob, Workspace from plane.db.models import FileAsset, Project, StoredBlob, Workspace
from plane.settings.storage import S3Storage
from plane.utils.file_dedup import hard_delete_file_asset, release_file_asset_blob
def _int_size(value): def _int_size(value):
@ -33,24 +37,94 @@ def _dedup_savings(logical_size, physical_size):
return max(_int_size(logical_size) - _int_size(physical_size), 0) return max(_int_size(logical_size) - _int_size(physical_size), 0)
def _retention_days(env_key, default):
try:
return max(int(os.environ.get(env_key, default)), 0)
except (TypeError, ValueError):
return int(default)
def _storage_cutoffs():
now = timezone.now()
unuploaded_retention_days = _retention_days("UNUPLOADED_ASSET_DELETE_DAYS", 7)
soft_deleted_retention_days = _retention_days("FILE_ASSET_HARD_DELETE_AFTER_DAYS", settings.HARD_DELETE_AFTER_DAYS)
return {
"stale_upload_cutoff": now - timedelta(days=1),
"unuploaded_cleanup_cutoff": now - timedelta(days=unuploaded_retention_days),
"soft_deleted_cleanup_cutoff": now - timedelta(days=soft_deleted_retention_days),
"unuploaded_retention_days": unuploaded_retention_days,
"soft_deleted_retention_days": soft_deleted_retention_days,
}
def _blob_size_queryset(queryset):
return _int_size(queryset.aggregate(total=Sum("size")).get("total"))
def _blob_size_by_ids(blob_ids):
if not blob_ids:
return 0
return _blob_size_queryset(StoredBlob.objects.filter(id__in=blob_ids, status=StoredBlob.Status.ACTIVE))
def _cleanup_snapshot(workspace, failed_uploads, soft_deleted_assets, orphaned_blobs):
cutoffs = _storage_cutoffs()
unuploaded_ready = failed_uploads.filter(created_at__lt=cutoffs["unuploaded_cleanup_cutoff"])
soft_deleted_ready = soft_deleted_assets.filter(deleted_at__lt=cutoffs["soft_deleted_cleanup_cutoff"])
orphaned_ready = orphaned_blobs.filter(ref_count=0)
return {
"unuploaded_retention_days": cutoffs["unuploaded_retention_days"],
"soft_deleted_retention_days": cutoffs["soft_deleted_retention_days"],
"unuploaded_ready_count": unuploaded_ready.count(),
"unuploaded_ready_size": _sum_asset_size(unuploaded_ready),
"soft_deleted_ready_count": soft_deleted_ready.count(),
"soft_deleted_ready_size": _sum_asset_size(soft_deleted_ready),
"orphaned_blob_ready_count": orphaned_ready.count(),
"orphaned_blob_ready_size": _blob_size_queryset(orphaned_ready),
"workspace_slug": workspace.slug,
}
def _project_quota_snapshot(project, used_size):
enabled = bool(project.storage_quota_enabled and project.storage_quota > 0)
quota = int(project.storage_quota or 0) if enabled else 0
used = int(used_size or 0)
remaining = max(quota - used, 0) if enabled else None
percent = round((used / quota) * 100, 1) if quota > 0 else 0
return {
"quota_enabled": enabled,
"quota": quota,
"quota_used": used,
"quota_remaining": remaining,
"quota_percent": percent,
"quota_exceeded": enabled and used > quota,
"quota_warning": enabled and used <= quota and percent >= 80,
}
class WorkspaceStorageSummaryEndpoint(BaseAPIView): class WorkspaceStorageSummaryEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def get(self, request, slug): def get(self, request, slug):
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
stale_cutoff = timezone.now() - timedelta(days=1) cutoffs = _storage_cutoffs()
active_assets = FileAsset.objects.filter(workspace=workspace, is_uploaded=True) active_assets = FileAsset.objects.filter(workspace=workspace, is_uploaded=True)
failed_preview_assets = active_assets.filter(attributes__preview_status="failed")
active_blob_ids = list(active_assets.exclude(blob__isnull=True).values_list("blob_id", flat=True).distinct()) active_blob_ids = list(active_assets.exclude(blob__isnull=True).values_list("blob_id", flat=True).distinct())
workspace_logical_size = _sum_asset_size(active_assets) workspace_logical_size = _sum_asset_size(active_assets)
workspace_physical_size = _sum_blob_size(active_blob_ids) workspace_physical_size = _blob_size_by_ids(active_blob_ids)
failed_uploads = FileAsset.all_objects.filter( failed_uploads = FileAsset.all_objects.filter(
workspace=workspace, workspace=workspace,
is_uploaded=False, is_uploaded=False,
deleted_at__isnull=True, deleted_at__isnull=True,
) )
stale_unuploaded = failed_uploads.filter(created_at__lt=stale_cutoff) stale_unuploaded = failed_uploads.filter(created_at__lt=cutoffs["stale_upload_cutoff"])
soft_deleted_assets = FileAsset.all_objects.filter(workspace=workspace, deleted_at__isnull=False) soft_deleted_assets = FileAsset.all_objects.filter(workspace=workspace, deleted_at__isnull=False)
orphaned_blobs = StoredBlob.objects.filter(workspace=workspace, status=StoredBlob.Status.ORPHANED) orphaned_blobs = StoredBlob.objects.filter(workspace=workspace, status=StoredBlob.Status.ORPHANED)
missing_blobs = StoredBlob.objects.filter(workspace=workspace, status=StoredBlob.Status.MISSING) missing_blobs = StoredBlob.objects.filter(workspace=workspace, status=StoredBlob.Status.MISSING)
@ -64,8 +138,9 @@ class WorkspaceStorageSummaryEndpoint(BaseAPIView):
project_assets.exclude(blob__isnull=True).values_list("blob_id", flat=True).distinct() project_assets.exclude(blob__isnull=True).values_list("blob_id", flat=True).distinct()
) )
project_logical_size = _sum_asset_size(project_assets) project_logical_size = _sum_asset_size(project_assets)
project_physical_size = _sum_blob_size(project_blob_ids) project_physical_size = _blob_size_by_ids(project_blob_ids)
project_failed_uploads = failed_uploads.filter(project=project) project_failed_uploads = failed_uploads.filter(project=project)
project_failed_previews = failed_preview_assets.filter(project=project)
project_soft_deleted = soft_deleted_assets.filter(project=project) project_soft_deleted = soft_deleted_assets.filter(project=project)
project_uploaded_without_blob = uploaded_without_blob.filter(project=project) project_uploaded_without_blob = uploaded_without_blob.filter(project=project)
@ -81,9 +156,15 @@ class WorkspaceStorageSummaryEndpoint(BaseAPIView):
"dedup_savings": _dedup_savings(project_logical_size, project_physical_size), "dedup_savings": _dedup_savings(project_logical_size, project_physical_size),
"failed_upload_count": project_failed_uploads.count(), "failed_upload_count": project_failed_uploads.count(),
"failed_upload_size": _sum_asset_size(project_failed_uploads), "failed_upload_size": _sum_asset_size(project_failed_uploads),
"failed_preview_count": project_failed_previews.count(),
"failed_preview_size": _sum_asset_size(project_failed_previews),
"stale_unuploaded_count": project_failed_uploads.filter(
created_at__lt=cutoffs["stale_upload_cutoff"]
).count(),
"soft_deleted_count": project_soft_deleted.count(), "soft_deleted_count": project_soft_deleted.count(),
"soft_deleted_size": _sum_asset_size(project_soft_deleted), "soft_deleted_size": _sum_asset_size(project_soft_deleted),
"uploaded_without_blob_count": project_uploaded_without_blob.count(), "uploaded_without_blob_count": project_uploaded_without_blob.count(),
**_project_quota_snapshot(project, project_logical_size),
} }
) )
@ -114,8 +195,160 @@ class WorkspaceStorageSummaryEndpoint(BaseAPIView):
"orphaned_blob_size": _sum_blob_size(list(orphaned_blobs.values_list("id", flat=True))), "orphaned_blob_size": _sum_blob_size(list(orphaned_blobs.values_list("id", flat=True))),
"missing_blob_count": missing_blobs.count(), "missing_blob_count": missing_blobs.count(),
"missing_blob_size": _sum_blob_size(list(missing_blobs.values_list("id", flat=True))), "missing_blob_size": _sum_blob_size(list(missing_blobs.values_list("id", flat=True))),
"failed_preview_count": failed_preview_assets.count(),
"failed_preview_size": _sum_asset_size(failed_preview_assets),
"failed_preview_instrumented": True,
"quota_warning_project_count": sum(1 for project in project_rows if project["quota_warning"]),
"quota_exceeded_project_count": sum(1 for project in project_rows if project["quota_exceeded"]),
}, },
"cleanup": _cleanup_snapshot(workspace, failed_uploads, soft_deleted_assets, orphaned_blobs),
"projects": project_rows, "projects": project_rows,
} }
return Response(data, status=status.HTTP_200_OK) return Response(data, status=status.HTTP_200_OK)
class WorkspaceStorageProjectQuotaEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def patch(self, request, slug, project_id):
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(id=project_id, workspace=workspace)
enabled = bool(request.data.get("quota_enabled", False))
try:
quota = int(request.data.get("quota", 0) or 0)
except (TypeError, ValueError):
quota = 0
project.storage_quota_enabled = enabled and quota > 0
project.storage_quota = max(quota, 0)
project.save(update_fields=["storage_quota_enabled", "storage_quota", "updated_at"])
return Response(
{
"id": str(project.id),
**_project_quota_snapshot(
project,
_sum_asset_size(FileAsset.objects.filter(project=project, is_uploaded=True)),
),
},
status=status.HTTP_200_OK,
)
class WorkspaceStorageMaintenanceEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
action = request.data.get("action")
if action == "scan_missing_blobs":
return Response(self._scan_missing_blobs(request, workspace), status=status.HTTP_200_OK)
if action == "purge_stale_unuploaded":
return Response(self._purge_stale_unuploaded(request, workspace), status=status.HTTP_200_OK)
if action == "purge_expired_deleted":
return Response(self._purge_expired_deleted(request, workspace), status=status.HTTP_200_OK)
if action == "purge_orphaned_blobs":
return Response(self._purge_orphaned_blobs(request, workspace), status=status.HTTP_200_OK)
return Response({"error": "Unsupported storage maintenance action."}, status=status.HTTP_400_BAD_REQUEST)
def _scan_missing_blobs(self, request, workspace):
storage = S3Storage(request=request, is_server=True)
blobs = StoredBlob.objects.filter(
workspace=workspace,
status__in=[StoredBlob.Status.ACTIVE, StoredBlob.Status.MISSING],
)
scanned = 0
missing = 0
restored = 0
for blob in blobs.iterator():
scanned += 1
metadata = storage.get_object_metadata(object_name=blob.canonical_object_key)
if metadata:
if blob.status == StoredBlob.Status.MISSING:
restored += 1
blob.status = StoredBlob.Status.ACTIVE
blob.storage_metadata = metadata
blob.save(update_fields=["status", "storage_metadata", "updated_at"])
continue
if blob.status != StoredBlob.Status.MISSING:
missing += 1
blob.status = StoredBlob.Status.MISSING
blob.save(update_fields=["status", "updated_at"])
return {
"action": "scan_missing_blobs",
"scanned_count": scanned,
"missing_count": missing,
"restored_count": restored,
}
def _purge_stale_unuploaded(self, request, workspace):
cutoff = _storage_cutoffs()["unuploaded_cleanup_cutoff"]
assets = FileAsset.objects.filter(workspace=workspace, is_uploaded=False, created_at__lt=cutoff)
deleted_assets = 0
deleted_objects = 0
released_size = 0
for asset in assets.iterator():
released_size += _int_size(asset.size)
release_result = release_file_asset_blob(asset, request=request, delete_untracked_object=True)
if release_result.deleted_object:
deleted_objects += 1
asset.delete(soft=False)
deleted_assets += 1
return {
"action": "purge_stale_unuploaded",
"deleted_asset_count": deleted_assets,
"deleted_object_count": deleted_objects,
"released_size": released_size,
}
def _purge_expired_deleted(self, request, workspace):
cutoff = _storage_cutoffs()["soft_deleted_cleanup_cutoff"]
assets = FileAsset.all_objects.filter(workspace=workspace, deleted_at__lt=cutoff)
storage = S3Storage(request=request, is_server=True)
deleted_assets = 0
deleted_objects = 0
released_size = 0
for asset in assets.iterator():
released_size += _int_size(asset.size)
if hard_delete_file_asset(asset, request=request, storage=storage):
deleted_objects += 1
deleted_assets += 1
return {
"action": "purge_expired_deleted",
"deleted_asset_count": deleted_assets,
"deleted_object_count": deleted_objects,
"released_size": released_size,
}
def _purge_orphaned_blobs(self, request, workspace):
storage = S3Storage(request=request, is_server=True)
blobs = StoredBlob.objects.filter(workspace=workspace, status=StoredBlob.Status.ORPHANED, ref_count=0)
deleted_blobs = 0
deleted_objects = 0
released_size = 0
for blob in blobs.iterator():
released_size += _int_size(blob.size)
if storage.delete_files(object_names=[blob.canonical_object_key]):
deleted_objects += 1
blob.delete(soft=False)
deleted_blobs += 1
return {
"action": "purge_orphaned_blobs",
"deleted_blob_count": deleted_blobs,
"deleted_object_count": deleted_objects,
"released_size": released_size,
}

View File

@ -27,7 +27,7 @@ def base_host(
if is_admin: if is_admin:
admin_base_path = getattr(settings, "ADMIN_BASE_PATH", None) admin_base_path = getattr(settings, "ADMIN_BASE_PATH", None)
if not isinstance(admin_base_path, str): if not isinstance(admin_base_path, str):
admin_base_path = "/god-mode/" admin_base_path = "/nodedcsudo/"
if not admin_base_path.startswith("/"): if not admin_base_path.startswith("/"):
admin_base_path = "/" + admin_base_path admin_base_path = "/" + admin_base_path
if not admin_base_path.endswith("/"): if not admin_base_path.endswith("/"):

View File

@ -3,9 +3,12 @@
# See the LICENSE file for details. # See the LICENSE file for details.
from plane.db.models import Profile, Workspace, WorkspaceMemberInvite from plane.db.models import Profile, Workspace, WorkspaceMemberInvite
from plane.utils.workspace_bans import release_expired_workspace_bans
def get_redirection_path(user): def get_redirection_path(user):
release_expired_workspace_bans(member=user)
# Handle redirections # Handle redirections
profile, _ = Profile.objects.get_or_create(user=user) profile, _ = Profile.objects.get_or_create(user=user)

View File

@ -15,42 +15,9 @@ from django.db.models import Q
from celery import shared_task from celery import shared_task
# Module imports # Module imports
from plane.db.models import FileAsset, StoredBlob from plane.db.models import FileAsset
from plane.settings.storage import S3Storage from plane.settings.storage import S3Storage
from plane.utils.file_dedup import release_file_asset_blob from plane.utils.file_dedup import hard_delete_file_asset, release_file_asset_blob
def _asset_object_key(asset):
return str(asset.asset.name or asset.asset)
def _delete_legacy_asset_object(asset, storage):
object_key = _asset_object_key(asset)
if not object_key:
return False
has_other_reference = FileAsset.all_objects.filter(asset=object_key).exclude(pk=asset.pk).exists()
if has_other_reference:
return False
return storage.delete_files(object_names=[object_key])
def _hard_delete_file_asset(asset, storage=None):
blob_id = asset.blob_id
if blob_id:
release_file_asset_blob(asset)
StoredBlob.all_objects.filter(
pk=blob_id,
ref_count=0,
status=StoredBlob.Status.ORPHANED,
).delete()
else:
storage = storage or S3Storage()
_delete_legacy_asset_object(asset, storage)
asset.delete(soft=False)
@shared_task @shared_task
@ -74,4 +41,4 @@ def delete_expired_file_asset():
expired_assets = FileAsset.all_objects.filter(deleted_at__lt=cutoff) expired_assets = FileAsset.all_objects.filter(deleted_at__lt=cutoff)
for asset in expired_assets.iterator(): for asset in expired_assets.iterator():
_hard_delete_file_asset(asset, storage=storage) hard_delete_file_asset(asset, storage=storage)

View File

@ -0,0 +1,32 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from celery import shared_task
@shared_task(bind=True, max_retries=60)
def process_voice_task_session(self, voice_session_id):
from plane.app.views.voice_tasker import fail_voice_task_session, process_voice_task_session_pipeline
result = process_voice_task_session_pipeline(voice_session_id) or {}
if not result.get("retry"):
return result
if self.request.retries >= self.max_retries:
fail_voice_task_session(
voice_session_id,
"voice_task_queue_timeout",
"Voice Tasker session stayed in retry queue for too long.",
)
return {"ok": False, "code": "voice_task_queue_timeout"}
countdown = int(result.get("countdown") or 15)
raise self.retry(countdown=countdown, exc=Exception(result.get("code") or "voice_task_retry"))
@shared_task
def cleanup_voice_task_sessions():
from plane.app.views.voice_tasker import cleanup_voice_task_sessions as cleanup_voice_task_sessions_impl
return cleanup_voice_task_sessions_impl()

View File

@ -73,6 +73,10 @@ app.conf.beat_schedule = {
"task": "plane.bgtasks.cleanup_task.delete_webhook_logs", "task": "plane.bgtasks.cleanup_task.delete_webhook_logs",
"schedule": crontab(hour=3, minute=30), # UTC 03:30 "schedule": crontab(hour=3, minute=30), # UTC 03:30
}, },
"check-every-day-to-cleanup-voice-tasker": {
"task": "plane.bgtasks.voice_tasker_task.cleanup_voice_task_sessions",
"schedule": crontab(hour=3, minute=40), # UTC 03:40
},
"check-every-day-to-delete-exporter-history": { "check-every-day-to-delete-exporter-history": {
"task": "plane.bgtasks.exporter_expired_task.delete_old_s3_link", "task": "plane.bgtasks.exporter_expired_task.delete_old_s3_link",
"schedule": crontab(hour=3, minute=45), # UTC 03:45 "schedule": crontab(hour=3, minute=45), # UTC 03:45

View File

@ -0,0 +1,47 @@
# Generated by Codex on 2026-04-28
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("db", "0128_stored_blob_dedup"),
]
operations = [
migrations.AlterField(
model_name="workspaceaisettings",
name="access_mode",
field=models.CharField(
choices=[
("all_workspace_members", "All workspace members"),
("admins_only", "Admins only"),
("selected_projects", "Selected projects"),
("selected_members", "Selected members"),
],
default="all_workspace_members",
max_length=40,
),
),
migrations.AddField(
model_name="workspaceaisettings",
name="enabled_members",
field=models.ManyToManyField(
blank=True,
related_name="workspace_ai_feature_settings",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="workspaceaisettings",
name="enabled_projects",
field=models.ManyToManyField(
blank=True,
related_name="workspace_ai_feature_settings",
to="db.project",
),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Plane on 2026-04-28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("db", "0129_workspace_ai_access_scope"),
]
operations = [
migrations.AddField(
model_name="project",
name="storage_quota_enabled",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="project",
name="storage_quota",
field=models.PositiveBigIntegerField(default=0),
),
]

View File

@ -0,0 +1,56 @@
# Generated by Codex on 2026-04-28
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("db", "0130_project_storage_quotas"),
]
operations = [
migrations.AlterField(
model_name="workspaceaicredential",
name="deleted_at",
field=models.DateTimeField(blank=True, null=True, verbose_name="Deleted At"),
),
migrations.AlterField(
model_name="workspaceaisettings",
name="deleted_at",
field=models.DateTimeField(blank=True, null=True, verbose_name="Deleted At"),
),
migrations.AddField(
model_name="workspaceaisettings",
name="per_user_daily_limit",
field=models.PositiveIntegerField(default=100),
),
migrations.AddField(
model_name="workspaceaisettings",
name="workspace_daily_limit",
field=models.PositiveIntegerField(default=1000),
),
migrations.AddField(
model_name="workspaceaisettings",
name="project_daily_limit",
field=models.PositiveIntegerField(default=300),
),
migrations.AddField(
model_name="voicetasksession",
name="project",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="voice_task_sessions",
to="db.project",
),
),
migrations.AddIndex(
model_name="voicetasksession",
index=models.Index(
fields=["workspace", "project", "-created_at"],
name="voice_task_session_project_idx",
),
),
]

View File

@ -0,0 +1,40 @@
# Generated for NODEDC Voice Tasker queue lifecycle.
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("db", "0131_voice_tasker_daily_project_limits"),
]
operations = [
migrations.AddField(
model_name="workspaceaisettings",
name="workspace_concurrency_limit",
field=models.PositiveIntegerField(default=3),
),
migrations.AddField(
model_name="voicetasksession",
name="audio_file",
field=models.FileField(blank=True, null=True, upload_to="voice-task-sessions/%Y/%m/%d/"),
),
migrations.AlterField(
model_name="voicetasksession",
name="status",
field=models.CharField(
choices=[
("queued", "Queued"),
("processing", "Processing"),
("uploaded", "Uploaded"),
("transcribing", "Transcribing"),
("transcribed", "Transcribed"),
("parsing", "Parsing"),
("parsed", "Parsed"),
("failed", "Failed"),
],
default="uploaded",
max_length=32,
),
),
]

View File

@ -0,0 +1,68 @@
# Generated by Codex on 2026-04-28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("db", "0132_voice_tasker_queue_concurrency"),
]
operations = [
migrations.AddField(
model_name="voicetasksession",
name="completed_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="voicetasksession",
name="failed_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="voicetasksession",
name="parser_completion_tokens",
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="voicetasksession",
name="parser_prompt_tokens",
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="voicetasksession",
name="parser_total_tokens",
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="voicetasksession",
name="parsing_duration_ms",
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="voicetasksession",
name="processing_duration_ms",
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="voicetasksession",
name="processing_started_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="voicetasksession",
name="queued_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="voicetasksession",
name="transcribed_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="voicetasksession",
name="transcription_duration_ms",
field=models.PositiveIntegerField(blank=True, null=True),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Codex on 2026-04-28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("db", "0133_voice_tasker_observability"),
]
operations = [
migrations.AddField(
model_name="workspaceaisettings",
name="sensitive_data_retention_days",
field=models.PositiveIntegerField(default=30),
),
migrations.AddField(
model_name="voicetasksession",
name="sensitive_data_redacted_at",
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@ -0,0 +1,93 @@
# Generated by Codex on 2026-04-28
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("db", "0134_voice_tasker_retention"),
]
operations = [
migrations.CreateModel(
name="WorkspaceFeatureEntitlement",
fields=[
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Last Modified At"),
),
("deleted_at", models.DateTimeField(blank=True, editable=False, null=True)),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
(
"feature_key",
models.CharField(
choices=[("voice_tasker", "AI / Voice Tasker")],
max_length=80,
),
),
("is_enabled", models.BooleanField(default=False)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="feature_entitlements",
to="db.workspace",
),
),
],
options={
"verbose_name": "Workspace Feature Entitlement",
"verbose_name_plural": "Workspace Feature Entitlements",
"db_table": "workspace_feature_entitlements",
"ordering": ("-created_at",),
},
),
migrations.AddConstraint(
model_name="workspacefeatureentitlement",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("workspace", "feature_key"),
name="workspace_feature_entitlement_unique_active_feature",
),
),
]

View File

@ -0,0 +1,32 @@
# Generated by Codex on 2026-04-29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("db", "0135_workspace_feature_entitlements"),
]
operations = [
migrations.AddField(
model_name="workspacemember",
name="is_banned",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="workspacemember",
name="banned_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="workspacemember",
name="banned_until",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="workspacemember",
name="ban_project_member_ids",
field=models.JSONField(default=list),
),
]

View File

@ -69,6 +69,7 @@ from .voice_tasker import VoiceTaskSession, WorkspaceAICredential, WorkspaceAISe
from .workspace import ( from .workspace import (
Workspace, Workspace,
WorkspaceBaseModel, WorkspaceBaseModel,
WorkspaceFeatureEntitlement,
WorkspaceMember, WorkspaceMember,
WorkspaceMemberInvite, WorkspaceMemberInvite,
WorkspaceTheme, WorkspaceTheme,

View File

@ -98,6 +98,8 @@ class Project(BaseModel):
is_time_tracking_enabled = models.BooleanField(default=False) is_time_tracking_enabled = models.BooleanField(default=False)
is_issue_type_enabled = models.BooleanField(default=False) is_issue_type_enabled = models.BooleanField(default=False)
guest_view_all_features = models.BooleanField(default=False) guest_view_all_features = models.BooleanField(default=False)
storage_quota_enabled = models.BooleanField(default=False)
storage_quota = models.PositiveBigIntegerField(default=0)
cover_image = models.TextField(blank=True, null=True) cover_image = models.TextField(blank=True, null=True)
cover_image_asset = models.ForeignKey( cover_image_asset = models.ForeignKey(
"db.FileAsset", "db.FileAsset",

View File

@ -15,6 +15,8 @@ class WorkspaceAISettings(BaseModel):
class AccessMode(models.TextChoices): class AccessMode(models.TextChoices):
ALL_WORKSPACE_MEMBERS = "all_workspace_members", "All workspace members" ALL_WORKSPACE_MEMBERS = "all_workspace_members", "All workspace members"
ADMINS_ONLY = "admins_only", "Admins only" ADMINS_ONLY = "admins_only", "Admins only"
SELECTED_PROJECTS = "selected_projects", "Selected projects"
SELECTED_MEMBERS = "selected_members", "Selected members"
workspace = models.OneToOneField( workspace = models.OneToOneField(
"db.Workspace", "db.Workspace",
@ -37,9 +39,24 @@ class WorkspaceAISettings(BaseModel):
choices=AccessMode.choices, choices=AccessMode.choices,
default=AccessMode.ALL_WORKSPACE_MEMBERS, default=AccessMode.ALL_WORKSPACE_MEMBERS,
) )
enabled_projects = models.ManyToManyField(
"db.Project",
blank=True,
related_name="workspace_ai_feature_settings",
)
enabled_members = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="workspace_ai_feature_settings",
)
max_audio_duration_seconds = models.PositiveIntegerField(default=120) max_audio_duration_seconds = models.PositiveIntegerField(default=120)
per_user_hourly_limit = models.PositiveIntegerField(default=30) per_user_hourly_limit = models.PositiveIntegerField(default=30)
workspace_hourly_limit = models.PositiveIntegerField(default=300) workspace_hourly_limit = models.PositiveIntegerField(default=300)
per_user_daily_limit = models.PositiveIntegerField(default=100)
workspace_daily_limit = models.PositiveIntegerField(default=1000)
project_daily_limit = models.PositiveIntegerField(default=300)
workspace_concurrency_limit = models.PositiveIntegerField(default=3)
sensitive_data_retention_days = models.PositiveIntegerField(default=30)
class Meta: class Meta:
verbose_name = "Workspace AI Settings" verbose_name = "Workspace AI Settings"
@ -77,6 +94,8 @@ class WorkspaceAICredential(BaseModel):
class VoiceTaskSession(BaseModel): class VoiceTaskSession(BaseModel):
class Status(models.TextChoices): class Status(models.TextChoices):
QUEUED = "queued", "Queued"
PROCESSING = "processing", "Processing"
UPLOADED = "uploaded", "Uploaded" UPLOADED = "uploaded", "Uploaded"
TRANSCRIBING = "transcribing", "Transcribing" TRANSCRIBING = "transcribing", "Transcribing"
TRANSCRIBED = "transcribed", "Transcribed" TRANSCRIBED = "transcribed", "Transcribed"
@ -94,7 +113,15 @@ class VoiceTaskSession(BaseModel):
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="voice_task_sessions", related_name="voice_task_sessions",
) )
project = models.ForeignKey(
"db.Project",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="voice_task_sessions",
)
status = models.CharField(max_length=32, choices=Status.choices, default=Status.UPLOADED) status = models.CharField(max_length=32, choices=Status.choices, default=Status.UPLOADED)
audio_file = models.FileField(upload_to="voice-task-sessions/%Y/%m/%d/", null=True, blank=True)
audio_duration_seconds = models.FloatField(null=True, blank=True) audio_duration_seconds = models.FloatField(null=True, blank=True)
audio_content_type = models.CharField(max_length=100, blank=True) audio_content_type = models.CharField(max_length=100, blank=True)
audio_size = models.PositiveIntegerField(null=True, blank=True) audio_size = models.PositiveIntegerField(null=True, blank=True)
@ -102,6 +129,18 @@ class VoiceTaskSession(BaseModel):
intent = models.CharField(max_length=40, blank=True) intent = models.CharField(max_length=40, blank=True)
parsed_json = models.JSONField(blank=True, default=dict) parsed_json = models.JSONField(blank=True, default=dict)
client_context = models.JSONField(blank=True, default=dict) client_context = models.JSONField(blank=True, default=dict)
queued_at = models.DateTimeField(null=True, blank=True)
processing_started_at = models.DateTimeField(null=True, blank=True)
transcribed_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
failed_at = models.DateTimeField(null=True, blank=True)
transcription_duration_ms = models.PositiveIntegerField(null=True, blank=True)
parsing_duration_ms = models.PositiveIntegerField(null=True, blank=True)
processing_duration_ms = models.PositiveIntegerField(null=True, blank=True)
parser_prompt_tokens = models.PositiveIntegerField(null=True, blank=True)
parser_completion_tokens = models.PositiveIntegerField(null=True, blank=True)
parser_total_tokens = models.PositiveIntegerField(null=True, blank=True)
sensitive_data_redacted_at = models.DateTimeField(null=True, blank=True)
created_task = models.ForeignKey( created_task = models.ForeignKey(
"db.Issue", "db.Issue",
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -126,6 +165,7 @@ class VoiceTaskSession(BaseModel):
ordering = ("-created_at",) ordering = ("-created_at",)
indexes = [ indexes = [
models.Index(fields=["workspace", "user", "-created_at"], name="voice_task_session_user_idx"), models.Index(fields=["workspace", "user", "-created_at"], name="voice_task_session_user_idx"),
models.Index(fields=["workspace", "project", "-created_at"], name="voice_task_session_project_idx"),
models.Index(fields=["workspace", "status", "-created_at"], name="voice_task_session_status_idx"), models.Index(fields=["workspace", "status", "-created_at"], name="voice_task_session_status_idx"),
] ]

View File

@ -210,6 +210,10 @@ class WorkspaceMember(BaseModel):
default_props = models.JSONField(default=get_default_props) default_props = models.JSONField(default=get_default_props)
issue_props = models.JSONField(default=get_issue_props) issue_props = models.JSONField(default=get_issue_props)
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
is_banned = models.BooleanField(default=False)
banned_at = models.DateTimeField(null=True, blank=True)
banned_until = models.DateTimeField(null=True, blank=True)
ban_project_member_ids = models.JSONField(default=list)
getting_started_checklist = models.JSONField(default=dict) getting_started_checklist = models.JSONField(default=dict)
tips = models.JSONField(default=dict) tips = models.JSONField(default=dict)
explored_features = models.JSONField(default=dict) explored_features = models.JSONField(default=dict)
@ -260,6 +264,36 @@ class WorkspaceMemberInvite(BaseModel):
return f"{self.workspace.name} {self.email} {self.accepted}" return f"{self.workspace.name} {self.email} {self.accepted}"
class WorkspaceFeatureEntitlement(BaseModel):
class FeatureKey(models.TextChoices):
VOICE_TASKER = "voice_tasker", "AI / Voice Tasker"
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="feature_entitlements",
)
feature_key = models.CharField(max_length=80, choices=FeatureKey.choices)
is_enabled = models.BooleanField(default=False)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["workspace", "feature_key"],
condition=models.Q(deleted_at__isnull=True),
name="workspace_feature_entitlement_unique_active_feature",
)
]
verbose_name = "Workspace Feature Entitlement"
verbose_name_plural = "Workspace Feature Entitlements"
db_table = "workspace_feature_entitlements"
ordering = ("-created_at",)
def __str__(self):
return f"{self.workspace.slug} <{self.feature_key}>"
class Team(BaseModel): class Team(BaseModel):
name = models.CharField(max_length=255, verbose_name="Team Name") name = models.CharField(max_length=255, verbose_name="Team Name")
description = models.TextField(verbose_name="Team Description", blank=True) description = models.TextField(verbose_name="Team Description", blank=True)

View File

@ -25,4 +25,7 @@ from .admin import (
from .workspace import ( from .workspace import (
InstanceWorkSpaceAvailabilityCheckEndpoint, InstanceWorkSpaceAvailabilityCheckEndpoint,
InstanceWorkSpaceEndpoint, InstanceWorkSpaceEndpoint,
InstanceWorkSpaceFeatureEndpoint,
InstanceWorkSpaceMemberBanEndpoint,
InstanceWorkSpaceMemberEndpoint,
) )

View File

@ -6,14 +6,115 @@
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from django.db import IntegrityError from django.db import IntegrityError
from django.db.models import OuterRef, Func, F from django.db.models import Count, OuterRef, Func, F, Q
from django.utils import timezone
from django.utils.dateparse import parse_datetime
# Module imports # Module imports
from plane.app.views.base import BaseAPIView from plane.app.views.base import BaseAPIView
from plane.license.api.permissions import InstanceAdminPermission from plane.license.api.permissions import InstanceAdminPermission
from plane.db.models import Workspace, WorkspaceMember, Project from plane.db.models import (
Project,
ProjectMember,
Workspace,
WorkspaceAICredential,
WorkspaceAISettings,
WorkspaceFeatureEntitlement,
WorkspaceMember,
)
from plane.license.api.serializers import WorkspaceSerializer from plane.license.api.serializers import WorkspaceSerializer
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.utils.workspace_bans import release_expired_workspace_bans, release_workspace_member_ban
WORKSPACE_ROLES = {5, 15, 20}
VOICE_TASKER_FEATURE = WorkspaceFeatureEntitlement.FeatureKey.VOICE_TASKER
def serialize_instance_workspace_member(workspace_member):
member = workspace_member.member
return {
"id": str(workspace_member.id),
"workspace": str(workspace_member.workspace_id),
"member": {
"id": str(member.id),
"email": member.email,
"display_name": member.display_name,
"first_name": member.first_name,
"last_name": member.last_name,
"avatar_url": member.avatar_url,
"is_bot": member.is_bot,
"is_active": member.is_active,
},
"role": workspace_member.role,
"is_active": workspace_member.is_active,
"is_banned": workspace_member.is_banned,
"banned_at": workspace_member.banned_at,
"banned_until": workspace_member.banned_until,
"created_at": workspace_member.created_at,
"active_project_count": getattr(workspace_member, "active_project_count", 0),
"admin_project_count": getattr(workspace_member, "admin_project_count", 0),
}
def has_other_workspace_admin(workspace_member):
return WorkspaceMember.objects.filter(
workspace=workspace_member.workspace,
role=20,
is_active=True,
is_banned=False,
member__is_bot=False,
).exclude(pk=workspace_member.pk).exists()
def is_only_project_admin(workspace_member):
return (
Project.objects.filter(
workspace=workspace_member.workspace,
archived_at__isnull=True,
project_projectmember__member_id=workspace_member.member_id,
project_projectmember__role=20,
project_projectmember__is_active=True,
)
.annotate(
other_admin_count=Count(
"project_projectmember",
filter=(
Q(project_projectmember__role=20)
& Q(project_projectmember__is_active=True)
& ~Q(project_projectmember__member_id=workspace_member.member_id)
),
distinct=True,
)
)
.filter(other_admin_count=0)
.exists()
)
def get_workspace_member_queryset(workspace_id):
release_expired_workspace_bans(workspace_id=workspace_id)
return (
WorkspaceMember.objects.filter(workspace_id=workspace_id, member__is_bot=False)
.filter(Q(is_active=True) | Q(is_banned=True))
.select_related("workspace", "member", "member__avatar_asset")
.annotate(
active_project_count=Count(
"member__member_project",
filter=Q(member__member_project__workspace_id=workspace_id)
& Q(member__member_project__is_active=True),
distinct=True,
),
admin_project_count=Count(
"member__member_project",
filter=Q(member__member_project__workspace_id=workspace_id)
& Q(member__member_project__is_active=True)
& Q(member__member_project__role=20),
distinct=True,
),
)
.order_by("member__display_name", "member__email")
)
class InstanceWorkSpaceAvailabilityCheckEndpoint(BaseAPIView): class InstanceWorkSpaceAvailabilityCheckEndpoint(BaseAPIView):
@ -108,3 +209,202 @@ class InstanceWorkSpaceEndpoint(BaseAPIView):
{"slug": "The workspace with the slug already exists"}, {"slug": "The workspace with the slug already exists"},
status=status.HTTP_409_CONFLICT, status=status.HTTP_409_CONFLICT,
) )
class InstanceWorkSpaceMemberEndpoint(BaseAPIView):
permission_classes = [InstanceAdminPermission]
def get(self, request, workspace_id):
if not Workspace.objects.filter(id=workspace_id).exists():
return Response({"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND)
workspace_members = get_workspace_member_queryset(workspace_id)
return Response(
[serialize_instance_workspace_member(workspace_member) for workspace_member in workspace_members],
status=status.HTTP_200_OK,
)
def patch(self, request, workspace_id, member_id):
workspace_member = get_workspace_member_queryset(workspace_id).filter(pk=member_id).first()
if not workspace_member:
return Response({"error": "Workspace member not found"}, status=status.HTTP_404_NOT_FOUND)
try:
role = int(request.data.get("role"))
except (TypeError, ValueError):
return Response({"error": "Role must be one of 5, 15, 20"}, status=status.HTTP_400_BAD_REQUEST)
if role not in WORKSPACE_ROLES:
return Response({"error": "Role must be one of 5, 15, 20"}, status=status.HTTP_400_BAD_REQUEST)
if workspace_member.role == 20 and role != 20 and not has_other_workspace_admin(workspace_member):
return Response(
{"error": "Cannot demote the only workspace admin"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace_member.role = role
workspace_member.save()
if role == 5:
ProjectMember.objects.filter(
workspace_id=workspace_id,
member_id=workspace_member.member_id,
is_active=True,
).update(role=5, updated_at=timezone.now())
workspace_member = get_workspace_member_queryset(workspace_id).get(pk=member_id)
return Response(serialize_instance_workspace_member(workspace_member), status=status.HTTP_200_OK)
def delete(self, request, workspace_id, member_id):
workspace_member = get_workspace_member_queryset(workspace_id).filter(pk=member_id).first()
if not workspace_member:
return Response({"error": "Workspace member not found"}, status=status.HTTP_404_NOT_FOUND)
if workspace_member.role == 20 and not has_other_workspace_admin(workspace_member):
return Response(
{"error": "Cannot remove the only workspace admin"},
status=status.HTTP_400_BAD_REQUEST,
)
if is_only_project_admin(workspace_member):
return Response(
{"error": "Cannot remove a member who is the only admin in one or more projects"},
status=status.HTTP_400_BAD_REQUEST,
)
ProjectMember.objects.filter(
workspace_id=workspace_id,
member_id=workspace_member.member_id,
is_active=True,
).update(is_active=False, updated_at=timezone.now())
workspace_member.is_active = False
workspace_member.save()
return Response(status=status.HTTP_204_NO_CONTENT)
class InstanceWorkSpaceMemberBanEndpoint(BaseAPIView):
permission_classes = [InstanceAdminPermission]
def patch(self, request, workspace_id, member_id):
workspace_member = get_workspace_member_queryset(workspace_id).filter(pk=member_id).first()
if not workspace_member:
return Response({"error": "Workspace member not found"}, status=status.HTTP_404_NOT_FOUND)
is_banned = request.data.get("is_banned")
if not isinstance(is_banned, bool):
return Response({"error": "is_banned must be a boolean"}, status=status.HTTP_400_BAD_REQUEST)
banned_until = None
if is_banned:
raw_banned_until = request.data.get("banned_until")
if raw_banned_until not in (None, ""):
banned_until = parse_datetime(str(raw_banned_until))
if banned_until is None:
return Response({"error": "banned_until must be an ISO datetime"}, status=status.HTTP_400_BAD_REQUEST)
if timezone.is_naive(banned_until):
banned_until = timezone.make_aware(banned_until, timezone.get_current_timezone())
if banned_until <= timezone.now():
return Response({"error": "banned_until must be in the future"}, status=status.HTTP_400_BAD_REQUEST)
if workspace_member.role == 20 and not has_other_workspace_admin(workspace_member):
return Response(
{"error": "Cannot ban the only workspace admin"},
status=status.HTTP_400_BAD_REQUEST,
)
if is_only_project_admin(workspace_member):
return Response(
{"error": "Cannot ban a member who is the only admin in one or more projects"},
status=status.HTTP_400_BAD_REQUEST,
)
project_member_ids = list(
ProjectMember.objects.filter(
workspace_id=workspace_id,
member_id=workspace_member.member_id,
is_active=True,
).values_list("id", flat=True)
)
ProjectMember.objects.filter(
workspace_id=workspace_id,
member_id=workspace_member.member_id,
is_active=True,
).update(is_active=False, updated_at=timezone.now())
workspace_member.is_banned = True
workspace_member.banned_at = timezone.now()
workspace_member.banned_until = banned_until
workspace_member.ban_project_member_ids = [str(project_member_id) for project_member_id in project_member_ids]
workspace_member.is_active = False
workspace_member.save(
update_fields=[
"is_banned",
"banned_at",
"banned_until",
"ban_project_member_ids",
"is_active",
"updated_at",
]
)
else:
release_workspace_member_ban(workspace_member)
workspace_member = get_workspace_member_queryset(workspace_id).get(pk=member_id)
return Response(serialize_instance_workspace_member(workspace_member), status=status.HTTP_200_OK)
class InstanceWorkSpaceFeatureEndpoint(BaseAPIView):
permission_classes = [InstanceAdminPermission]
def serialize_voice_tasker_feature(self, workspace):
entitlement = WorkspaceFeatureEntitlement.objects.filter(
workspace=workspace,
feature_key=VOICE_TASKER_FEATURE,
).first()
ai_settings = WorkspaceAISettings.objects.filter(workspace=workspace).first()
credential = WorkspaceAICredential.objects.filter(
workspace=workspace,
provider=WorkspaceAICredential.Provider.OPENAI,
is_active=True,
).first()
return {
"key": VOICE_TASKER_FEATURE,
"title": "AI / Voice Tasker",
"description": "Голосовая постановка задач и встроенные AI-сценарии воркспейса.",
"is_enabled": bool(entitlement and entitlement.is_enabled),
"workspace_setting_enabled": bool(ai_settings and ai_settings.voice_tasker_enabled),
"access_mode": ai_settings.access_mode if ai_settings else WorkspaceAISettings.AccessMode.ALL_WORKSPACE_MEMBERS,
"has_workspace_key": bool(credential and credential.encrypted_api_key),
}
def get(self, request, workspace_id):
workspace = Workspace.objects.filter(id=workspace_id).first()
if not workspace:
return Response({"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND)
return Response({"features": [self.serialize_voice_tasker_feature(workspace)]}, status=status.HTTP_200_OK)
def patch(self, request, workspace_id):
workspace = Workspace.objects.filter(id=workspace_id).first()
if not workspace:
return Response({"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND)
feature_key = request.data.get("feature_key")
if feature_key != VOICE_TASKER_FEATURE:
return Response({"error": "Unsupported feature key"}, status=status.HTTP_400_BAD_REQUEST)
is_enabled = request.data.get("is_enabled")
if not isinstance(is_enabled, bool):
return Response({"error": "is_enabled must be a boolean"}, status=status.HTTP_400_BAD_REQUEST)
entitlement, _ = WorkspaceFeatureEntitlement.objects.get_or_create(
workspace=workspace,
feature_key=feature_key,
)
entitlement.is_enabled = is_enabled
entitlement.save()
return Response({"features": [self.serialize_voice_tasker_feature(workspace)]}, status=status.HTTP_200_OK)

View File

@ -18,6 +18,9 @@ from plane.license.api.views import (
InstanceAdminUserSessionEndpoint, InstanceAdminUserSessionEndpoint,
InstanceWorkSpaceAvailabilityCheckEndpoint, InstanceWorkSpaceAvailabilityCheckEndpoint,
InstanceWorkSpaceEndpoint, InstanceWorkSpaceEndpoint,
InstanceWorkSpaceFeatureEndpoint,
InstanceWorkSpaceMemberBanEndpoint,
InstanceWorkSpaceMemberEndpoint,
) )
urlpatterns = [ urlpatterns = [
@ -71,4 +74,24 @@ urlpatterns = [
name="instance-workspace-availability", name="instance-workspace-availability",
), ),
path("workspaces/", InstanceWorkSpaceEndpoint.as_view(), name="instance-workspace"), path("workspaces/", InstanceWorkSpaceEndpoint.as_view(), name="instance-workspace"),
path(
"workspaces/<uuid:workspace_id>/members/",
InstanceWorkSpaceMemberEndpoint.as_view(),
name="instance-workspace-members",
),
path(
"workspaces/<uuid:workspace_id>/members/<uuid:member_id>/",
InstanceWorkSpaceMemberEndpoint.as_view(),
name="instance-workspace-member",
),
path(
"workspaces/<uuid:workspace_id>/members/<uuid:member_id>/ban/",
InstanceWorkSpaceMemberBanEndpoint.as_view(),
name="instance-workspace-member-ban",
),
path(
"workspaces/<uuid:workspace_id>/features/",
InstanceWorkSpaceFeatureEndpoint.as_view(),
name="instance-workspace-features",
),
] ]

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