Compare commits

..

No commits in common. "4ba3aab02eb88ee8335d71816d53c9e81706f2af" and "9243924f78d81216426934ed3e7054ecd4e938f5" have entirely different histories.

131 changed files with 1003 additions and 6545 deletions

View File

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

View File

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

View File

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

View File

@ -76,7 +76,7 @@ docker compose -f docker-compose-local.yml up
pnpm dev
```
5. Open your browser to http://localhost:3001/nodedcsudo/ and register yourself as instance admin
5. Open your browser to http://localhost:3001/god-mode/ 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
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=""
ENV VITE_ADMIN_BASE_URL=$VITE_ADMIN_BASE_URL
ARG VITE_ADMIN_BASE_PATH="/nodedcsudo"
ARG VITE_ADMIN_BASE_PATH="/god-mode"
ENV VITE_ADMIN_BASE_PATH=$VITE_ADMIN_BASE_PATH
ARG VITE_SPACE_BASE_URL=""
@ -78,11 +78,11 @@ RUN pnpm turbo run build --filter=admin
FROM nginx:1.29-alpine AS production
COPY apps/admin/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=installer /app/apps/admin/build/client /usr/share/nginx/html/nodedcsudo
COPY --from=installer /app/apps/admin/build/client /usr/share/nginx/html/god-mode
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -fsS http://127.0.0.1:3000/ >/dev/null || exit 1
CMD ["nginx", "-g", "daemon off;"]
CMD ["nginx", "-g", "daemon off;"]

View File

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

View File

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

View File

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

View File

@ -58,9 +58,9 @@ export function InstanceGiteaConfigForm(props: Props) {
{
key: "GITEA_HOST",
type: "text",
label: "Хост Gitea",
label: "Gitea Host",
description: (
<>Укажите URL вашего Gitea-инстанса. Для официального сервиса используйте &quot;https://gitea.com&quot;.</>
<>Use the URL of your Gitea instance. For the official Gitea instance, use &quot;https://gitea.com&quot;.</>
),
placeholder: "https://gitea.com",
error: Boolean(errors.GITEA_HOST),
@ -72,7 +72,7 @@ export function InstanceGiteaConfigForm(props: Props) {
label: "Client ID",
description: (
<>
Возьмите значение в настройках{" "}
You will get this from your{" "}
<a
tabIndex={-1}
href="https://gitea.com/user/settings/applications"
@ -80,7 +80,7 @@ export function InstanceGiteaConfigForm(props: Props) {
className="text-accent-primary hover:underline"
rel="noreferrer"
>
Gitea OAuth-приложения.
Gitea OAuth application settings.
</a>
</>
),
@ -94,7 +94,7 @@ export function InstanceGiteaConfigForm(props: Props) {
label: "Client secret",
description: (
<>
Секрет клиента находится в настройках{" "}
Your client secret is also found in your{" "}
<a
tabIndex={-1}
href="https://gitea.com/user/settings/applications"
@ -102,7 +102,7 @@ export function InstanceGiteaConfigForm(props: Props) {
className="text-accent-primary hover:underline"
rel="noreferrer"
>
Gitea OAuth-приложения.
Gitea OAuth application settings.
</a>
</>
),
@ -124,8 +124,8 @@ export function InstanceGiteaConfigForm(props: Props) {
url: `${originURL}/auth/gitea/callback/`,
description: (
<>
Значение сформировано автоматически. Вставьте его в поле{" "}
<CodeBlock darkerShade>Authorized Callback URI</CodeBlock>{" "}
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Callback URI</CodeBlock>{" "}
field{" "}
<a
tabIndex={-1}
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"
rel="noreferrer"
>
здесь.
here.
</a>
</>
),
@ -147,8 +147,8 @@ export function InstanceGiteaConfigForm(props: Props) {
const response = await updateInstanceConfigurations(payload);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Готово",
message: "Аутентификация через Gitea настроена. Проверьте вход перед включением в проде.",
title: "Done!",
message: "Your Gitea authentication is configured. You should test it now.",
});
reset({
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="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="pt-2.5 text-18 font-medium">Данные Gitea для NODE.DC</div>
<div className="pt-2.5 text-18 font-medium">Gitea-provided details for Plane</div>
{GITEA_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
@ -202,17 +202,17 @@ export function InstanceGiteaConfigForm(props: Props) {
loading={isSubmitting}
disabled={!isDirty}
>
{isSubmitting ? "Сохранение" : "Сохранить изменения"}
{isSubmitting ? "Saving" : "Save changes"}
</Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
Назад
Go back
</Link>
</div>
</div>
</div>
<div className="col-span-2 md:col-span-1">
<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">Данные NODE.DC для Gitea</div>
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-1 px-6 pt-1.5 pb-4">
<div className="pt-2 text-18 font-medium">Plane-provided details for Gitea</div>
{GITEA_SERVICE_FIELD.map((field) => (
<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);
setPromiseToast(updateConfigPromise, {
loading: "Сохранение конфигурации",
loading: "Saving Configuration",
success: {
title: "Конфигурация сохранена",
message: () => `Вход через Gitea ${value === "1" ? "включен" : "отключен"}.`,
title: "Configuration saved",
message: () => `Gitea authentication is now ${value === "1" ? "active" : "disabled"}.`,
},
error: {
title: "Ошибка",
message: () => "Не удалось сохранить конфигурацию",
title: "Error",
message: () => "Failed to save configuration",
},
});
@ -69,7 +69,7 @@ const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthentic
customHeader={
<AuthenticationMethodCard
name="Gitea"
description="Вход и регистрация пользователей через аккаунты Gitea."
description="Allow members to login or sign up to plane with their Gitea accounts."
icon={<img src={giteaLogo} height={24} width={24} alt="Gitea Logo" />}
config={
<ToggleSwitch
@ -100,6 +100,6 @@ const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthentic
</PageWrapper>
);
});
export const meta: Route.MetaFunction = () => [{ title: "Gitea OAuth - NODE.DC" }];
export const meta: Route.MetaFunction = () => [{ title: "Gitea Authentication - God Mode" }];
export default InstanceGiteaAuthenticationPage;

View File

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

View File

@ -58,10 +58,10 @@ export function InstanceGitlabConfigForm(props: Props) {
{
key: "GITLAB_HOST",
type: "text",
label: "Хост",
label: "Host",
description: (
<>
Укажите https://gitlab.com или <CodeBlock>domain.tld</CodeBlock>, если GitLab развернут у вас.
This is either https://gitlab.com or the <CodeBlock>domain.tld</CodeBlock> where you host GitLab.
</>
),
placeholder: "https://gitlab.com",
@ -74,7 +74,7 @@ export function InstanceGitlabConfigForm(props: Props) {
label: "Application ID",
description: (
<>
Возьмите значение в настройках{" "}
Get this from your{" "}
<a
tabIndex={-1}
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"
rel="noreferrer"
>
GitLab OAuth-приложения
GitLab OAuth application settings
</a>
.
</>
@ -97,7 +97,7 @@ export function InstanceGitlabConfigForm(props: Props) {
label: "Secret",
description: (
<>
Секрет клиента находится в настройках{" "}
The client secret is also found in your{" "}
<a
tabIndex={-1}
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"
rel="noreferrer"
>
GitLab OAuth-приложения
GitLab OAuth application settings
</a>
.
</>
@ -128,7 +128,7 @@ export function InstanceGitlabConfigForm(props: Props) {
url: `${originURL}/auth/gitlab/callback/`,
description: (
<>
Значение сформировано автоматически. Вставьте его в поле <CodeBlock darkerShade>Redirect URI</CodeBlock>{" "}
We will auto-generate this. Paste this into the <CodeBlock darkerShade>Redirect URI</CodeBlock> field of your{" "}
<a
tabIndex={-1}
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"
rel="noreferrer"
>
GitLab OAuth-приложения
GitLab OAuth application
</a>
.
</>
@ -151,8 +151,8 @@ export function InstanceGitlabConfigForm(props: Props) {
const response = await updateInstanceConfigurations(payload);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Готово",
message: "Аутентификация через GitLab настроена. Проверьте вход перед включением в проде.",
title: "Done!",
message: "Your GitLab authentication is configured. You should test it now.",
});
reset({
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="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="pt-2.5 text-18 font-medium">Данные GitLab для NODE.DC</div>
<div className="pt-2.5 text-18 font-medium">GitLab-provided details for Plane</div>
{GITLAB_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
@ -206,17 +206,17 @@ export function InstanceGitlabConfigForm(props: Props) {
loading={isSubmitting}
disabled={!isDirty}
>
{isSubmitting ? "Сохранение" : "Сохранить изменения"}
{isSubmitting ? "Saving" : "Save changes"}
</Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
Назад
Go back
</Link>
</div>
</div>
</div>
<div className="col-span-2 md:col-span-1">
<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">Данные NODE.DC для GitLab</div>
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-3 px-6 pt-1.5 pb-4">
<div className="pt-2 text-18 font-medium">Plane-provided details for GitLab</div>
{GITLAB_SERVICE_FIELD.map((field) => (
<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);
setPromiseToast(updateConfigPromise, {
loading: "Сохранение конфигурации",
loading: "Saving Configuration",
success: {
title: "Конфигурация сохранена",
message: () => `Вход через GitLab ${value === "1" ? "включен" : "отключен"}.`,
title: "Configuration saved",
message: () => `GitLab authentication is now ${value === "1" ? "active" : "disabled"}.`,
},
error: {
title: "Ошибка",
message: () => "Не удалось сохранить конфигурацию",
title: "Error",
message: () => "Failed to save configuration",
},
});
@ -68,7 +68,7 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent
customHeader={
<AuthenticationMethodCard
name="GitLab"
description="Вход и регистрация пользователей через аккаунты GitLab."
description="Allow members to login or sign up to plane with their GitLab accounts."
icon={<img src={GitlabLogo} height={24} width={24} alt="GitLab Logo" />}
config={
<ToggleSwitch
@ -104,6 +104,6 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent
);
});
export const meta: Route.MetaFunction = () => [{ title: "GitLab OAuth - NODE.DC" }];
export const meta: Route.MetaFunction = () => [{ title: "GitLab Authentication - God Mode" }];
export default InstanceGitlabAuthenticationPage;

View File

@ -61,7 +61,7 @@ export function InstanceGoogleConfigForm(props: Props) {
label: "Client ID",
description: (
<>
Client ID находится в Google API Console.{" "}
Your client ID lives in your Google API Console.{" "}
<a
tabIndex={-1}
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"
rel="noreferrer"
>
Подробнее
Learn more
</a>
</>
),
@ -83,7 +83,7 @@ export function InstanceGoogleConfigForm(props: Props) {
label: "Client secret",
description: (
<>
Client secret также находится в Google API Console.{" "}
Your client secret should also be in your Google API Console.{" "}
<a
tabIndex={-1}
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"
rel="noreferrer"
>
Подробнее
Learn more
</a>
</>
),
@ -113,15 +113,15 @@ export function InstanceGoogleConfigForm(props: Props) {
url: originURL,
description: (
<p>
Значение сформировано автоматически. Вставьте его в поле{" "}
<CodeBlock darkerShade>Authorized JavaScript origins</CodeBlock> для OAuth-клиента{" "}
We will auto-generate this. Paste this into your{" "}
<CodeBlock darkerShade>Authorized JavaScript origins</CodeBlock> field. For this OAuth client{" "}
<a
href="https://console.cloud.google.com/apis/credentials/oauthclient"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
здесь.
here.
</a>
</p>
),
@ -135,15 +135,15 @@ export function InstanceGoogleConfigForm(props: Props) {
url: `${originURL}/auth/google/callback/`,
description: (
<p>
Значение сформировано автоматически. Вставьте его в поле{" "}
<CodeBlock darkerShade>Authorized Redirect URI</CodeBlock> для OAuth-клиента{" "}
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Redirect URI</CodeBlock>{" "}
field. For this OAuth client{" "}
<a
href="https://console.cloud.google.com/apis/credentials/oauthclient"
target="_blank"
className="text-accent-primary hover:underline"
rel="noreferrer"
>
здесь.
here.
</a>
</p>
),
@ -157,8 +157,8 @@ export function InstanceGoogleConfigForm(props: Props) {
const response = await updateInstanceConfigurations(payload);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Готово",
message: "Аутентификация через Google настроена. Проверьте вход перед включением в проде.",
title: "Done!",
message: "Your Google authentication is configured. You should test it now.",
});
reset({
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="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="pt-2.5 text-18 font-medium">Данные Google для NODE.DC</div>
<div className="pt-2.5 text-18 font-medium">Google-provided details for Plane</div>
{GOOGLE_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
@ -211,32 +211,32 @@ export function InstanceGoogleConfigForm(props: Props) {
loading={isSubmitting}
disabled={!isDirty}
>
{isSubmitting ? "Сохранение" : "Сохранить изменения"}
{isSubmitting ? "Saving" : "Save changes"}
</Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
Назад
Go back
</Link>
</div>
</div>
</div>
<div className="col-span-2 flex flex-col gap-y-6 md:col-span-1">
<div className="pt-2 text-18 font-medium">Данные NODE.DC для Google</div>
<div className="pt-2 text-18 font-medium">Plane-provided details for Google</div>
<div className="flex flex-col gap-y-4">
{/* common service details */}
<div className="nodedc-settings-helper-card flex flex-col gap-y-4 px-6 py-4">
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-1 px-6 py-4">
{GOOGLE_COMMON_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
{/* web service details */}
<div className="nodedc-settings-helper-card flex flex-col overflow-hidden">
<div className="nodedc-settings-helper-card-header flex items-center gap-x-3 px-6 py-3 text-11 font-medium uppercase">
<div className="flex flex-col overflow-hidden rounded-lg">
<div className="flex items-center gap-x-3 bg-layer-3 px-6 py-3 text-11 font-medium text-secondary uppercase">
<Monitor className="h-3 w-3" />
Веб
Web
</div>
<div className="flex flex-col gap-y-4 px-6 py-4">
<div className="flex flex-col gap-y-4 bg-layer-1 px-6 py-4">
{GOOGLE_SERVICE_DETAILS.map((field) => (
<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);
setPromiseToast(updateConfigPromise, {
loading: "Сохранение конфигурации",
loading: "Saving Configuration",
success: {
title: "Конфигурация сохранена",
message: () => `Вход через Google ${value === "1" ? "включен" : "отключен"}.`,
title: "Configuration saved",
message: () => `Google authentication is now ${value === "1" ? "active" : "disabled"}.`,
},
error: {
title: "Ошибка",
message: () => "Не удалось сохранить конфигурацию",
title: "Error",
message: () => "Failed to save configuration",
},
});
@ -68,7 +68,8 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent
customHeader={
<AuthenticationMethodCard
name="Google"
description="Вход и регистрация пользователей через аккаунты Google."
description="Allow members to login or sign up to plane with their Google
accounts."
icon={<img src={GoogleLogo} height={24} width={24} alt="Google Logo" />}
config={
<ToggleSwitch
@ -104,6 +105,6 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent
);
});
export const meta: Route.MetaFunction = () => [{ title: "Google OAuth - NODE.DC" }];
export const meta: Route.MetaFunction = () => [{ title: "Google Authentication - God Mode" }];
export default InstanceGoogleAuthenticationPage;

View File

@ -55,8 +55,9 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
if (!canDisable) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Нельзя отключить вход",
message: "Должен остаться хотя бы один способ входа. Сначала включите другой способ аутентификации.",
title: "Cannot disable authentication",
message:
"At least one authentication method must remain enabled. Please enable another method before disabling this one.",
});
return;
}
@ -73,14 +74,14 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Сохранение конфигурации",
loading: "Saving configuration",
success: {
title: "Сохранено",
message: () => "Конфигурация обновлена",
title: "Success",
message: () => "Configuration saved successfully",
},
error: {
title: "Ошибка",
message: () => "Не удалось сохранить конфигурацию",
title: "Error",
message: () => "Failed to save configuration",
},
});
@ -110,8 +111,8 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
return (
<PageWrapper
header={{
title: "Способы входа в инстанс",
description: "Настройте email, пароль, OAuth-провайдеры и правила регистрации пользователей.",
title: "Manage authentication modes for your instance",
description: "Configure authentication modes for your team and restrict sign-ups to be invite only.",
}}
>
{formattedConfig ? (
@ -119,9 +120,9 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
<div className={cn("flex w-full items-center gap-14 rounded-sm")}>
<div className="flex grow items-center gap-4">
<div className="grow">
<div className="pb-1 text-16 font-medium">Разрешить регистрацию без приглашения</div>
<div className="pb-1 text-16 font-medium">Allow anyone to sign up even without an invite</div>
<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>
@ -142,7 +143,7 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
</div>
</div>
</div>
<div className="text-lg pt-6 font-medium">Доступные способы входа</div>
<div className="text-lg pt-6 font-medium">Available authentication modes</div>
{authenticationModes.map((method) => (
<AuthenticationMethodCard
key={method.key}
@ -168,6 +169,6 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
);
});
export const meta: Route.MetaFunction = () => [{ title: "Аутентификация - NODE.DC" }];
export const meta: Route.MetaFunction = () => [{ title: "Authentication Settings - Plane Web" }];
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 } = {
EMAIL_USE_TLS: "TLS",
EMAIL_USE_SSL: "SSL",
NONE: "Без шифрования",
NONE: "No email security",
};
export function InstanceEmailForm(props: IInstanceEmailForm) {
@ -63,7 +63,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
{
key: "EMAIL_HOST",
type: "text",
label: "Хост",
label: "Host",
placeholder: "email.google.com",
error: Boolean(errors.EMAIL_HOST),
required: true,
@ -71,7 +71,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
{
key: "EMAIL_PORT",
type: "text",
label: "Порт",
label: "Port",
placeholder: "8080",
error: Boolean(errors.EMAIL_PORT),
required: true,
@ -79,9 +79,9 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
{
key: "EMAIL_FROM",
type: "text",
label: "Email отправителя",
label: "Sender's email address",
description:
"Этот адрес будут видеть пользователи в письмах от инстанса. Адрес нужно подтвердить на стороне SMTP.",
"This is the email address your users will see when getting emails from this instance. You will need to verify this address.",
placeholder: "no-reply@projectplane.so",
error: Boolean(errors.EMAIL_FROM),
required: true,
@ -92,7 +92,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
{
key: "EMAIL_HOST_USER",
type: "text",
label: "Имя пользователя",
label: "Username",
placeholder: "getitdone@projectplane.so",
error: Boolean(errors.EMAIL_HOST_USER),
required: false,
@ -100,8 +100,8 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
{
key: "EMAIL_HOST_PASSWORD",
type: "password",
label: "Пароль",
placeholder: "Пароль",
label: "Password",
placeholder: "Password",
error: Boolean(errors.EMAIL_HOST_PASSWORD),
required: false,
},
@ -114,8 +114,8 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
.then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Сохранено",
message: "Настройки почты обновлены",
title: "Success",
message: "Email Settings updated successfully",
})
)
.catch((err) => console.error(err));
@ -163,12 +163,12 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
/>
))}
<div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">Защита соединения</h4>
<h4 className="text-13 text-tertiary">Email security</h4>
<CustomSelect
value={emailSecurityKey}
label={EMAIL_SECURITY_OPTIONS[emailSecurityKey]}
onChange={handleEmailSecurityChange}
buttonClassName="nodedc-settings-select rounded-md border-subtle"
buttonClassName="rounded-md border-subtle"
input
>
{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="mr-8 flex items-center gap-10 pt-4">
<div className="grow">
<div className="text-13 font-medium text-primary">Аутентификация</div>
<div className="text-13 font-medium text-primary">Authentication</div>
<div className="text-11 font-regular text-tertiary">
Необязательно, но для SMTP-сервера обычно нужны имя пользователя и пароль.
This is optional, but we recommend setting up a username and a password for your SMTP server.
</div>
</div>
</div>
@ -215,7 +215,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
loading={isSubmitting}
disabled={!isValid || !isDirty}
>
{isSubmitting ? "Сохранение" : "Сохранить изменения"}
{isSubmitting ? "Saving" : "Save changes"}
</Button>
<Button
variant="secondary"
@ -224,7 +224,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
loading={isSubmitting}
disabled={!isValid}
>
Отправить тестовое письмо
Send test email
</Button>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

@ -20,8 +20,9 @@ function GeneralPage() {
return (
<PageWrapper
header={{
title: "Основные настройки",
description: "Имя инстанса, служебные идентификаторы, email администратора и режимы телеметрии.",
title: "General settings",
description:
"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} />}
@ -29,6 +30,6 @@ function GeneralPage() {
);
}
export const meta: Route.MetaFunction = () => [{ title: "Основные настройки - NODE.DC" }];
export const meta: Route.MetaFunction = () => [{ title: "General Settings - God Mode" }];
export default observer(GeneralPage);

View File

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

View File

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

View File

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

View File

@ -36,13 +36,11 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
const handleSignOut = () => signOut();
const getSidebarMenuItems = (align: "left" | "right" = "left") => (
const getSidebarMenuItems = () => (
<Menu.Items
className={cn(
"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",
"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",
{
"left-0": align === "left",
"right-0": align === "right",
"left-4": isSidebarCollapsed,
}
)}
@ -54,11 +52,11 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
<Menu.Item
as="button"
type="button"
className="nodedc-settings-sidebar-item flex w-full items-center gap-2 px-2 py-1 text-left"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1 hover:bg-layer-1-hover"
onClick={handleThemeSwitch}
>
<Palette className="h-4 w-4 stroke-[1.5]" />
{resolvedTheme === "dark" ? "Светлая тема" : "Темная тема"}
Switch to {resolvedTheme === "dark" ? "light" : "dark"} mode
</Menu.Item>
</div>
<div className="py-2">
@ -67,10 +65,10 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
<Menu.Item
as="button"
type="submit"
className="nodedc-settings-sidebar-item flex w-full items-center gap-2 px-2 py-1 text-left"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1 hover:bg-layer-1-hover"
>
<LogOut className="h-4 w-4 stroke-[1.5]" />
Выйти
Sign out
</Menu.Item>
</form>
</div>
@ -83,21 +81,21 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
}, [csrfToken]);
return (
<div className="px-3 pt-4 pb-2">
<div className="relative h-full w-full overflow-visible">
<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="h-full w-full truncate">
<div
className={`nodedc-admin-sidebar-profile relative z-[80] flex flex-grow items-center gap-x-3 px-3 py-3 ${
className={`flex flex-grow items-center gap-x-2 truncate rounded-sm ${
isSidebarCollapsed ? "justify-center" : ""
}`}
>
<Menu as="div" className="relative z-[100] flex-shrink-0">
<Menu as="div" className="flex-shrink-0">
<Menu.Button
className={cn("grid place-items-center outline-none", {
"cursor-default": !isSidebarCollapsed,
})}
>
<div className="nodedc-admin-sidebar-avatar-button flex size-10 flex-shrink-0 items-center justify-center">
<UserCog2 className="size-5 text-[rgb(var(--nodedc-card-active-rgb))]" />
<div className="flex size-8 flex-shrink-0 items-center justify-center rounded-sm bg-layer-1">
<UserCog2 className="size-5 text-primary" />
</div>
</Menu.Button>
{isSidebarCollapsed && (
@ -116,39 +114,38 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
</Menu>
{!isSidebarCollapsed && (
<div className="min-w-0 flex-1">
<h4 className="truncate text-15 font-medium text-primary">Глобальный админ</h4>
<div className="truncate text-11 font-medium text-tertiary">Супер-администратор</div>
<div className="flex w-full gap-2">
<h4 className="grow truncate text-body-md-medium text-primary">Instance admin</h4>
</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>
{!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>
);
});

View File

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

View File

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

View File

@ -44,7 +44,7 @@ export const AdminSidebar = observer(function AdminSidebar() {
return (
<div
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]"} `}
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]"} `}
>
<div ref={ref} className="flex h-full w-full flex-1 flex-col">
<AdminSidebarDropdown />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -22,7 +22,7 @@ function RootLayout() {
}, [replace, isUserLoggedIn]);
return (
<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">
<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">
<Outlet />
</div>
);

View File

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

View File

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

View File

@ -17,16 +17,19 @@ function PageNotFound() {
<div className="grid h-full place-items-center p-4">
<div className="space-y-8 text-center">
<div className="relative mx-auto h-60 w-60 lg:h-80 lg:w-80">
<img src={Image404} alt="404 - страница не найдена" className="h-full w-full object-contain" />
<img src={Image404} alt="404 - Page not found" className="h-full w-full object-contain" />
</div>
<div className="space-y-2">
<h3 className="text-16 font-semibold">Страница не найдена</h3>
<p className="text-13 text-secondary">Похоже, раздел был удален, переименован или временно недоступен.</p>
<h3 className="text-16 font-semibold">Oops! Something went wrong.</h3>
<p className="text-13 text-secondary">
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>
<Link to="/general/">
<span className="flex justify-center py-4">
<Button variant="secondary" size="lg">
Перейти в основные настройки
Go to general settings
</Button>
</span>
</Link>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -45,25 +45,27 @@ export function ConfirmDiscardModal(props: Props) {
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<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]">
<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]">
<div className="px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<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">
Есть несохраненные изменения
You have unsaved changes
</Dialog.Title>
<div className="mt-2">
<p className="text-13 text-placeholder">Если уйти назад, текущие правки будут потеряны.</p>
<p className="text-13 text-placeholder">
Changes you made will be lost if you go back. Do you wish to go back?
</p>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 p-4 sm:px-6">
<Button variant="secondary" size="lg" onClick={handleClose}>
Продолжить редактирование
Keep editing
</Button>
<Link href={onDiscardHref} className={getButtonStyling("primary", "base")}>
Уйти назад
Go back
</Link>
</div>
</Dialog.Panel>

View File

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

View File

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

View File

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

View File

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

View File

@ -4,10 +4,11 @@
* See the LICENSE file for details.
*/
import { Fragment } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { ChevronRight, Menu, Settings } from "lucide-react";
import { Menu, Settings } from "lucide-react";
// icons
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "../breadcrumb-link";
// hooks
@ -21,7 +22,6 @@ export const HamburgerToggle = observer(function HamburgerToggle() {
return (
<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"
aria-label="Открыть меню"
onClick={() => toggleSidebar(!isSidebarCollapsed)}
>
<Menu size={14} className="text-secondary transition-all group-hover:text-primary" />
@ -56,28 +56,32 @@ export const AdminHeader = observer(function AdminHeader() {
const breadcrumbItems = generateBreadcrumbItems(pathName || "");
return (
<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="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">
<HamburgerToggle />
{breadcrumbItems.length >= 0 && (
<nav className="min-w-0" aria-label="Навигация God Mode">
<ol className="nodedc-admin-breadcrumbs">
<BreadcrumbLink href="/general/" label="Настройки" icon={<Settings className="h-4 w-4" />} />
{breadcrumbItems.map((item, index) => {
if (!item.title) return null;
const isCurrent = index === breadcrumbItems.length - 1;
return (
<Fragment key={`${item.href}-${item.title}`}>
<li className="nodedc-admin-breadcrumb-separator" aria-hidden="true">
<ChevronRight className="size-4" />
</li>
<BreadcrumbLink href={item.href} label={item.title} isCurrent={isCurrent} />
</Fragment>
);
})}
</ol>
</nav>
<div>
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
href="/general/"
label="Settings"
icon={<Settings className="h-4 w-4 text-tertiary" />}
/>
}
/>
{breadcrumbItems.map(
(item) =>
item.title && (
<Breadcrumbs.Item
key={item.title}
component={<BreadcrumbLink href={item.href} label={item.title} />}
/>
)
)}
</Breadcrumbs>
</div>
)}
</div>
</div>

View File

@ -24,19 +24,20 @@ export const NewUserPopup = observer(function NewUserPopup() {
if (!isNewUserPopup) return <></>;
return (
<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="shadow-md absolute right-8 bottom-8 w-96 rounded-lg border border-subtle bg-surface-1 p-6">
<div className="flex gap-4">
<div className="grow">
<div className="text-14 font-semibold">Создать воркспейс</div>
<div className="text-14 font-semibold">Create workspace</div>
<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 className="flex items-center gap-4 pt-2">
<Link href="/workspace/create" className={getButtonStyling("primary", "lg")}>
Создать воркспейс
Create workspace
</Link>
<Button variant="secondary" size="lg" onClick={toggleNewUserPopup}>
Закрыть
Close
</Button>
</div>
</div>
@ -45,7 +46,7 @@ export const NewUserPopup = observer(function NewUserPopup() {
src={resolveGeneralTheme(resolvedTheme) === "dark" ? TakeoffIconDark : TakeoffIconLight}
height={80}
width={80}
alt="NODE.DC"
alt="Plane icon"
/>
</div>
</div>

View File

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

View File

@ -24,16 +24,16 @@ export const PageWrapper = (props: TPageWrapperProps) => {
return (
<div
className={cn("nodedc-page mx-auto h-full w-full space-y-6 py-4", {
className={cn("mx-auto h-full w-full space-y-6 py-4", {
"max-w-[1000px] md:px-4 2xl:max-w-[1200px]": size === "md",
"px-4 lg:px-12": size === "lg",
})}
>
{customHeader ? (
<div className="nodedc-page-header mx-4 shrink-0 space-y-1 border-b border-subtle py-4">{customHeader}</div>
<div className="mx-4 shrink-0 space-y-1 border-b border-subtle py-4">{customHeader}</div>
) : (
header && (
<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="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="text-h5-semibold text-primary">{header.title}</div>
<div className="text-body-sm-regular text-secondary">{header.description}</div>
@ -42,7 +42,7 @@ export const PageWrapper = (props: TPageWrapperProps) => {
</div>
)
)}
<div className="nodedc-page-body vertical-scrollbar scrollbar-sm flex-grow overflow-hidden overflow-y-scroll px-4 pb-4">
<div className="vertical-scrollbar scrollbar-sm flex-grow overflow-hidden overflow-y-scroll px-4 pb-4">
{children}
</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="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">
<img src={instanceImage} alt="Ошибка загрузки инстанса" />
<h3 className="text-center text-20 font-medium text-on-color">Не удалось загрузить данные инстанса.</h3>
<img src={instanceImage} alt="Instance failure illustration" />
<h3 className="text-center text-20 font-medium text-on-color">Unable to fetch instance details.</h3>
<p className="text-center text-14 font-medium">
Проверьте соединение с API и попробуйте обновить страницу.
We were unable to fetch the details of the instance. Fret not, it might just be a connectivity issue.
</p>
</div>
<div className="flex justify-center">
<Button size="lg" onClick={handleRetry}>
Повторить
Retry
</Button>
</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 w-auto max-w-2xl space-y-8 py-10">
<div className="relative flex flex-col items-center justify-center space-y-4">
<h1 className="pb-3 text-24 font-bold">Добро пожаловать в NODE.DC</h1>
<img src={PlaneTakeOffImage} alt="NODE.DC" />
<p className="text-14 font-medium text-placeholder">Начните с настройки инстанса и первого воркспейса</p>
<h1 className="pb-3 text-24 font-bold">Welcome aboard Plane!</h1>
<img src={PlaneTakeOffImage} alt="Plane Logo" />
<p className="text-14 font-medium text-placeholder">Get started by setting up your instance and workspace</p>
</div>
<div>
<Link href={"/setup/?auth_enabled=0"}>
<Button size="xl" className="w-full">
Начать
Get started
</Button>
</Link>
</div>

View File

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

View File

@ -1,536 +0,0 @@
/**
* 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,26 +5,20 @@
*/
import { observer } from "mobx-react";
import { ExternalLink, Sparkles, UsersRound } from "lucide-react";
// plane internal packages
import { WEB_BASE_URL } from "@plane/constants";
import { NewTabIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { getFileURL } from "@plane/utils";
// hooks
import { useWorkspace } from "@/hooks/store";
type TWorkspaceListItemProps = {
onFeaturesClick: (workspaceId: string) => void;
onMembersClick: (workspaceId: string) => void;
workspaceId: string;
};
export const WorkspaceListItem = observer(function WorkspaceListItem({
onFeaturesClick,
onMembersClick,
workspaceId,
}: TWorkspaceListItemProps) {
export const WorkspaceListItem = observer(function WorkspaceListItem({ workspaceId }: TWorkspaceListItemProps) {
// store hooks
const { getWorkspaceById } = useWorkspace();
// derived values
@ -32,11 +26,14 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({
if (!workspace) return null;
return (
<div
<a
key={workspaceId}
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"
href={`${WEB_BASE_URL}/${encodeURIComponent(workspace.slug)}`}
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 min-w-0 items-start gap-4">
<div className="flex items-start gap-4">
<span
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"
@ -46,29 +43,29 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({
<img
src={getFileURL(workspace.logo_url)}
className="absolute top-0 left-0 h-full w-full rounded-sm object-cover"
alt="Логотип воркспейса"
alt="Workspace Logo"
/>
) : (
(workspace?.name?.[0] ?? "...")
)}
</span>
<div className="flex min-w-0 flex-col items-start gap-1">
<div className="flex flex-col items-start gap-1">
<div className="flex w-full flex-wrap items-center gap-2.5">
<h3 className={`truncate text-14 font-medium capitalize`}>{workspace.name}</h3>/
<Tooltip tooltipContent="Уникальный URL воркспейса">
<h3 className={`text-14 font-medium capitalize`}>{workspace.name}</h3>/
<Tooltip tooltipContent="The unique URL of your workspace">
<h4 className="text-13 text-tertiary">[{workspace.slug}]</h4>
</Tooltip>
</div>
{workspace.owner.email && (
<div className="flex items-center gap-1 text-11">
<h3 className="font-medium text-secondary">Владелец:</h3>
<h3 className="font-medium text-secondary">Owned by:</h3>
<h4 className="text-tertiary">{workspace.owner.email}</h4>
</div>
)}
<div className="flex items-center gap-2.5 text-11">
{workspace.total_projects !== null && (
<span className="flex items-center gap-1">
<h3 className="font-medium text-secondary">Проектов:</h3>
<h3 className="font-medium text-secondary">Total projects:</h3>
<h4 className="text-tertiary">{workspace.total_projects}</h4>
</span>
)}
@ -76,7 +73,7 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({
<>
<span className="flex items-center gap-1">
<h3 className="font-medium text-secondary">Участников:</h3>
<h3 className="font-medium text-secondary">Total members:</h3>
<h4 className="text-tertiary">{workspace.total_members}</h4>
</span>
</>
@ -84,39 +81,9 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<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 className="flex-shrink-0">
<NewTabIcon width={14} height={16} className="text-placeholder group-hover:text-secondary" />
</div>
</div>
</a>
);
});

View File

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

View File

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

View File

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

View File

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

View File

@ -1,27 +1,5 @@
@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 {
box-shadow: 2px 2px 8px 2px rgba(234, 231, 250, 0.3); /* Convert #EAE7FA4D to rgba */
}
@ -62,510 +40,3 @@ body {
0 0 4px --alpha(var(--background-color-accent-primary) / 40%) !important;
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,8 +9,6 @@ from rest_framework import status
from enum import Enum
from plane.utils.workspace_bans import release_expired_workspace_bans
class ROLE(Enum):
ADMIN = 20
@ -22,9 +20,6 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
def decorator(view_func):
@wraps(view_func)
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
if creator and model:
# check if the user is part of the workspace or not

View File

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

View File

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

View File

@ -4,7 +4,7 @@
from rest_framework import serializers
from plane.db.models import Project, WorkspaceAICredential, WorkspaceAISettings, WorkspaceMember
from plane.db.models import Project, WorkspaceAICredential, WorkspaceAISettings
from plane.license.utils.encryption import encrypt_data
from .base import BaseSerializer
@ -12,8 +12,6 @@ from .base import BaseSerializer
class WorkspaceAISettingsSerializer(BaseSerializer):
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)
credential = serializers.SerializerMethodField(read_only=True)
@ -28,16 +26,9 @@ class WorkspaceAISettingsSerializer(BaseSerializer):
"structuring_model",
"default_project_id",
"access_mode",
"enabled_project_ids",
"enabled_member_ids",
"max_audio_duration_seconds",
"per_user_hourly_limit",
"workspace_hourly_limit",
"per_user_daily_limit",
"workspace_daily_limit",
"project_daily_limit",
"workspace_concurrency_limit",
"sensitive_data_retention_days",
"credential",
"openai_api_key",
"created_at",
@ -54,12 +45,6 @@ class WorkspaceAISettingsSerializer(BaseSerializer):
"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):
if value is None:
return None
@ -69,32 +54,6 @@ class WorkspaceAISettingsSerializer(BaseSerializer):
raise serializers.ValidationError("Default project must belong to this workspace.")
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):
if value < 10 or value > 600:
raise serializers.ValidationError("Max audio duration must be between 10 and 600 seconds.")
@ -110,36 +69,9 @@ class WorkspaceAISettingsSerializer(BaseSerializer):
raise serializers.ValidationError("Workspace hourly limit must be between 1 and 10000.")
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):
api_key = validated_data.pop("openai_api_key", None)
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:
instance.default_project_id = default_project_id
@ -149,12 +81,6 @@ class WorkspaceAISettingsSerializer(BaseSerializer):
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:
cleaned_api_key = api_key.strip()
credential, _ = WorkspaceAICredential.objects.get_or_create(

View File

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

View File

@ -36,8 +36,6 @@ from plane.app.views import (
UserRecentVisitViewSet,
WorkspaceHomePreferenceViewSet,
WorkspaceStickyViewSet,
WorkspaceStorageMaintenanceEndpoint,
WorkspaceStorageProjectQuotaEndpoint,
WorkspaceStorageSummaryEndpoint,
WorkspaceUserPreferenceViewSet,
)
@ -265,16 +263,6 @@ urlpatterns = [
WorkspaceStorageSummaryEndpoint.as_view(),
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
path(
"workspaces/<str:slug>/sidebar-preferences/",

View File

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

View File

@ -23,7 +23,7 @@ from plane.settings.storage import S3Storage
from plane.app.permissions import allow_permission, ROLE
from plane.utils.cache import invalidate_cache_directly
from plane.throttles.asset import AssetRateThrottle
from plane.utils.upload_limits import get_project_storage_quota_response, resolve_workspace_upload_size_limit
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
from plane.utils.file_dedup import (
UploadedObjectMissing,
attach_existing_blob_to_file_asset,
@ -361,14 +361,6 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
# Get the size limit
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 = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
@ -572,10 +564,6 @@ class ProjectAssetEndpoint(BaseAPIView):
# Get the size limit
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 = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
@ -588,7 +576,7 @@ class ProjectAssetEndpoint(BaseAPIView):
workspace=workspace,
created_by=request.user,
entity_type=entity_type,
project_id=project.id,
project_id=project_id,
**self.get_entity_id_field(entity_type, entity_identifier),
)
@ -779,6 +767,10 @@ class DuplicateAssetEndpoint(BaseAPIView):
)
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)
original_asset = FileAsset.objects.filter(id=asset_id, workspace=workspace, is_uploaded=True).first()
@ -786,14 +778,6 @@ class DuplicateAssetEndpoint(BaseAPIView):
if not original_asset:
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')}"
duplicated_asset = FileAsset.objects.create(
attributes={

View File

@ -19,12 +19,12 @@ from rest_framework.parsers import MultiPartParser, FormParser
# Module imports
from .. import BaseAPIView
from plane.app.serializers import IssueAttachmentSerializer
from plane.db.models import FileAsset, Project, Workspace
from plane.db.models import FileAsset, Workspace
from plane.bgtasks.issue_activities_task import issue_activity
from plane.app.permissions import allow_permission, ROLE
from plane.settings.storage import S3Storage
from plane.utils.host import base_host
from plane.utils.upload_limits import get_project_storage_quota_response, resolve_workspace_upload_size_limit
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
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
@ -118,16 +118,12 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(id=project_id, workspace=workspace)
# asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
# Get the size limit
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
asset = FileAsset.objects.create(

View File

@ -32,7 +32,6 @@ from plane.db.models import (
)
from plane.db.models.project import ProjectNetwork
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):
@ -196,19 +195,7 @@ class ProjectJoinEndpoint(BaseAPIView):
)
if project_invite.responded_at is None:
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.accepted = request.data.get("accepted", False)
project_invite.responded_at = timezone.now()
project_invite.save()

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -31,7 +31,6 @@ from plane.db.models import User, Workspace, WorkspaceMember, WorkspaceMemberInv
from plane.utils.cache import invalidate_cache, invalidate_cache_directly
from plane.utils.host import base_host
from plane.utils.analytics_events import USER_JOINED_WORKSPACE, USER_INVITED_TO_WORKSPACE
from plane.utils.workspace_bans import is_workspace_member_currently_banned, release_workspace_member_ban
from .. import BaseViewSet
@ -86,17 +85,6 @@ class WorkspaceInvitationsViewset(BaseViewSet):
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 = []
for email in emails:
try:
@ -186,23 +174,7 @@ class WorkspaceJoinEndpoint(BaseAPIView):
# If already responded then return error
if workspace_invite.responded_at is None:
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.accepted = request.data.get("accepted", False)
workspace_invite.responded_at = timezone.now()
workspace_invite.save()
@ -288,17 +260,6 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
# If the user is already a member of workspace and was deactivated then activate the user
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(
path=f"/api/workspaces/{invitation.workspace.slug}/members/",
user=False,

View File

@ -2,10 +2,8 @@
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import os
from datetime import timedelta
from django.conf import settings
from django.db.models import Sum
from django.utils import timezone
from rest_framework import status
@ -14,8 +12,6 @@ from rest_framework.response import Response
from plane.app.permissions import ROLE, allow_permission
from plane.app.views.base import BaseAPIView
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):
@ -37,94 +33,24 @@ def _dedup_savings(logical_size, physical_size):
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):
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def get(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
cutoffs = _storage_cutoffs()
stale_cutoff = timezone.now() - timedelta(days=1)
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())
workspace_logical_size = _sum_asset_size(active_assets)
workspace_physical_size = _blob_size_by_ids(active_blob_ids)
workspace_physical_size = _sum_blob_size(active_blob_ids)
failed_uploads = FileAsset.all_objects.filter(
workspace=workspace,
is_uploaded=False,
deleted_at__isnull=True,
)
stale_unuploaded = failed_uploads.filter(created_at__lt=cutoffs["stale_upload_cutoff"])
stale_unuploaded = failed_uploads.filter(created_at__lt=stale_cutoff)
soft_deleted_assets = FileAsset.all_objects.filter(workspace=workspace, deleted_at__isnull=False)
orphaned_blobs = StoredBlob.objects.filter(workspace=workspace, status=StoredBlob.Status.ORPHANED)
missing_blobs = StoredBlob.objects.filter(workspace=workspace, status=StoredBlob.Status.MISSING)
@ -138,9 +64,8 @@ class WorkspaceStorageSummaryEndpoint(BaseAPIView):
project_assets.exclude(blob__isnull=True).values_list("blob_id", flat=True).distinct()
)
project_logical_size = _sum_asset_size(project_assets)
project_physical_size = _blob_size_by_ids(project_blob_ids)
project_physical_size = _sum_blob_size(project_blob_ids)
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_uploaded_without_blob = uploaded_without_blob.filter(project=project)
@ -156,15 +81,9 @@ class WorkspaceStorageSummaryEndpoint(BaseAPIView):
"dedup_savings": _dedup_savings(project_logical_size, project_physical_size),
"failed_upload_count": project_failed_uploads.count(),
"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_size": _sum_asset_size(project_soft_deleted),
"uploaded_without_blob_count": project_uploaded_without_blob.count(),
**_project_quota_snapshot(project, project_logical_size),
}
)
@ -195,160 +114,8 @@ class WorkspaceStorageSummaryEndpoint(BaseAPIView):
"orphaned_blob_size": _sum_blob_size(list(orphaned_blobs.values_list("id", flat=True))),
"missing_blob_count": missing_blobs.count(),
"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,
}
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:
admin_base_path = getattr(settings, "ADMIN_BASE_PATH", None)
if not isinstance(admin_base_path, str):
admin_base_path = "/nodedcsudo/"
admin_base_path = "/god-mode/"
if not admin_base_path.startswith("/"):
admin_base_path = "/" + admin_base_path
if not admin_base_path.endswith("/"):

View File

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

View File

@ -15,9 +15,42 @@ from django.db.models import Q
from celery import shared_task
# Module imports
from plane.db.models import FileAsset
from plane.db.models import FileAsset, StoredBlob
from plane.settings.storage import S3Storage
from plane.utils.file_dedup import hard_delete_file_asset, release_file_asset_blob
from plane.utils.file_dedup import 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
@ -41,4 +74,4 @@ def delete_expired_file_asset():
expired_assets = FileAsset.all_objects.filter(deleted_at__lt=cutoff)
for asset in expired_assets.iterator():
hard_delete_file_asset(asset, storage=storage)
_hard_delete_file_asset(asset, storage=storage)

View File

@ -1,32 +0,0 @@
# 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,10 +73,6 @@ app.conf.beat_schedule = {
"task": "plane.bgtasks.cleanup_task.delete_webhook_logs",
"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": {
"task": "plane.bgtasks.exporter_expired_task.delete_old_s3_link",
"schedule": crontab(hour=3, minute=45), # UTC 03:45

View File

@ -1,47 +0,0 @@
# 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

@ -1,22 +0,0 @@
# 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

@ -1,56 +0,0 @@
# 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

@ -1,40 +0,0 @@
# 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

@ -1,68 +0,0 @@
# 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

@ -1,23 +0,0 @@
# 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

@ -1,93 +0,0 @@
# 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

@ -1,32 +0,0 @@
# 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,7 +69,6 @@ from .voice_tasker import VoiceTaskSession, WorkspaceAICredential, WorkspaceAISe
from .workspace import (
Workspace,
WorkspaceBaseModel,
WorkspaceFeatureEntitlement,
WorkspaceMember,
WorkspaceMemberInvite,
WorkspaceTheme,

View File

@ -98,8 +98,6 @@ class Project(BaseModel):
is_time_tracking_enabled = models.BooleanField(default=False)
is_issue_type_enabled = 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_asset = models.ForeignKey(
"db.FileAsset",

View File

@ -15,8 +15,6 @@ class WorkspaceAISettings(BaseModel):
class AccessMode(models.TextChoices):
ALL_WORKSPACE_MEMBERS = "all_workspace_members", "All workspace members"
ADMINS_ONLY = "admins_only", "Admins only"
SELECTED_PROJECTS = "selected_projects", "Selected projects"
SELECTED_MEMBERS = "selected_members", "Selected members"
workspace = models.OneToOneField(
"db.Workspace",
@ -39,24 +37,9 @@ class WorkspaceAISettings(BaseModel):
choices=AccessMode.choices,
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)
per_user_hourly_limit = models.PositiveIntegerField(default=30)
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:
verbose_name = "Workspace AI Settings"
@ -94,8 +77,6 @@ class WorkspaceAICredential(BaseModel):
class VoiceTaskSession(BaseModel):
class Status(models.TextChoices):
QUEUED = "queued", "Queued"
PROCESSING = "processing", "Processing"
UPLOADED = "uploaded", "Uploaded"
TRANSCRIBING = "transcribing", "Transcribing"
TRANSCRIBED = "transcribed", "Transcribed"
@ -113,15 +94,7 @@ class VoiceTaskSession(BaseModel):
on_delete=models.CASCADE,
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)
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_content_type = models.CharField(max_length=100, blank=True)
audio_size = models.PositiveIntegerField(null=True, blank=True)
@ -129,18 +102,6 @@ class VoiceTaskSession(BaseModel):
intent = models.CharField(max_length=40, blank=True)
parsed_json = 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(
"db.Issue",
on_delete=models.SET_NULL,
@ -165,7 +126,6 @@ class VoiceTaskSession(BaseModel):
ordering = ("-created_at",)
indexes = [
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"),
]

View File

@ -210,10 +210,6 @@ class WorkspaceMember(BaseModel):
default_props = models.JSONField(default=get_default_props)
issue_props = models.JSONField(default=get_issue_props)
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)
tips = models.JSONField(default=dict)
explored_features = models.JSONField(default=dict)
@ -264,36 +260,6 @@ class WorkspaceMemberInvite(BaseModel):
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):
name = models.CharField(max_length=255, verbose_name="Team Name")
description = models.TextField(verbose_name="Team Description", blank=True)

View File

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

View File

@ -6,115 +6,14 @@
from rest_framework.response import Response
from rest_framework import status
from django.db import IntegrityError
from django.db.models import Count, OuterRef, Func, F, Q
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from django.db.models import OuterRef, Func, F
# Module imports
from plane.app.views.base import BaseAPIView
from plane.license.api.permissions import InstanceAdminPermission
from plane.db.models import (
Project,
ProjectMember,
Workspace,
WorkspaceAICredential,
WorkspaceAISettings,
WorkspaceFeatureEntitlement,
WorkspaceMember,
)
from plane.db.models import Workspace, WorkspaceMember, Project
from plane.license.api.serializers import WorkspaceSerializer
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):
@ -209,202 +108,3 @@ class InstanceWorkSpaceEndpoint(BaseAPIView):
{"slug": "The workspace with the slug already exists"},
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,9 +18,6 @@ from plane.license.api.views import (
InstanceAdminUserSessionEndpoint,
InstanceWorkSpaceAvailabilityCheckEndpoint,
InstanceWorkSpaceEndpoint,
InstanceWorkSpaceFeatureEndpoint,
InstanceWorkSpaceMemberBanEndpoint,
InstanceWorkSpaceMemberEndpoint,
)
urlpatterns = [
@ -74,24 +71,4 @@ urlpatterns = [
name="instance-workspace-availability",
),
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