Compare commits
15 Commits
9243924f78
...
4ba3aab02e
| Author | SHA1 | Date |
|---|---|---|
|
|
4ba3aab02e | |
|
|
7bf416ec1f | |
|
|
7f47b85c36 | |
|
|
5d336039ba | |
|
|
3b0c75bee6 | |
|
|
be7929deec | |
|
|
0cda486b05 | |
|
|
5e57786d39 | |
|
|
b796a21852 | |
|
|
d60c28ec04 | |
|
|
3b295e33f3 | |
|
|
a0213db2fc | |
|
|
a0c0db27f3 | |
|
|
46e27a326c | |
|
|
02d79da6f9 |
34
AGENTS.md
34
AGENTS.md
|
|
@ -81,27 +81,35 @@ UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: переименован
|
|||
|
||||
## Ведение карточек задач Codex
|
||||
|
||||
Карточка задачи должна разделять постановку и ход работ.
|
||||
Карточка задачи должна разделять постановку, план этапов и фактическую реализацию.
|
||||
|
||||
Заголовок карточки:
|
||||
- передает основную суть задачи
|
||||
- должен быть коротким, читаемым в списке и без служебного шума
|
||||
|
||||
Основное тело карточки:
|
||||
- хранит общее описание задачи, цель, контекст, ограничения и критерии приемки
|
||||
- не превращается в журнал работ
|
||||
- хранит концептуальное описание задачи, цель, контекст, ограничения и критерии приемки
|
||||
- описывает, зачем делается изменение и как система должна работать на среднем уровне детализации
|
||||
- не превращается в журнал работ и не дублирует чекеры
|
||||
- остается читаемым входом в задачу после нескольких итераций
|
||||
|
||||
Подэлемент `Текущий статус работ`:
|
||||
- создается как текстовый блок через `Добавить подэлемент`
|
||||
- хранит фактический отчет по реализации
|
||||
- обновляется после каждого осмысленного этапа
|
||||
- фиксирует, что сделано, что проверено, какие файлы/модули затронуты, что осталось и какой следующий шаг
|
||||
Подэлементы-чекеры:
|
||||
- создаются через `Добавить подэлемент` как отдельный чекер на каждый смысловой этап
|
||||
- заголовок чекера должен называться как этап, например `Этап 1. Backend enforcement лимитов`
|
||||
- пункты внутри чекера должны быть короткими проверяемыми действиями по этапу
|
||||
- пункт закрывается только после реализации и проверки, а не по намерению
|
||||
- чекеры используются как рабочий план, а не как место для длинных объяснений
|
||||
|
||||
Подэлемент-чекер:
|
||||
- используется для подзадач, которые можно проверить отдельно
|
||||
- содержит короткие конкретные пункты без дублирования основного описания
|
||||
- закрывается по факту реализации и проверки, а не по намерению
|
||||
Текстовые блоки фактической реализации:
|
||||
- создаются через `Добавить подэлемент` под соответствующим чекером этапа
|
||||
- заголовок текстового блока должен явно связывать его с этапом, например `Реализация этапа 1`
|
||||
- блок фиксирует, что реально сделано, какие файлы/модули затронуты, какие проверки прошли и какие ограничения остались
|
||||
- важные нюансы для дальнейшего масштабирования записываются именно сюда, а не теряются в чате
|
||||
- после каждого осмысленного этапа соответствующий текстовый блок обновляется
|
||||
|
||||
Статус карточки:
|
||||
- `В работе` ставится только когда задача реально взята в исполнение
|
||||
- `Готово` ставится после проверки результата
|
||||
- `Готово` ставится после проверки результата и закрытия рабочих чекеров
|
||||
- `Отложено` используется для задач, которые больше не входят в текущий рабочий план
|
||||
- backlog не должен хранить уже сделанные или сознательно отложенные задачи как активные
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
CERT_ACME_DNS: ${CERT_ACME_DNS}
|
||||
LISTEN_HTTP_PORT: ${LISTEN_HTTP_PORT:-80}
|
||||
LISTEN_HTTPS_PORT: ${LISTEN_HTTPS_PORT:-443}
|
||||
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}
|
||||
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
||||
SITE_ADDRESS: ${SITE_ADDRESS:-:80}
|
||||
|
||||
|
|
@ -57,6 +57,7 @@ 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}
|
||||
|
|
@ -222,7 +223,7 @@ services:
|
|||
|
||||
# Comment this if you already have a reverse proxy running
|
||||
proxy:
|
||||
image: makeplane/plane-proxy:${APP_RELEASE:-v1.3.0}
|
||||
image: nodedc/plane-proxy:ru
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
|
|
@ -231,11 +232,11 @@ services:
|
|||
<<: *proxy-env
|
||||
ports:
|
||||
- target: 80
|
||||
published: ${LISTEN_HTTP_PORT:-80}
|
||||
published: ${LISTEN_HTTP_PORT:-8090}
|
||||
protocol: tcp
|
||||
mode: host
|
||||
- target: 443
|
||||
published: ${LISTEN_HTTPS_PORT:-443}
|
||||
published: ${LISTEN_HTTPS_PORT:-8443}
|
||||
protocol: tcp
|
||||
mode: host
|
||||
volumes:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
CERT_ACME_DNS: ${CERT_ACME_DNS}
|
||||
LISTEN_HTTP_PORT: ${LISTEN_HTTP_PORT:-80}
|
||||
LISTEN_HTTPS_PORT: ${LISTEN_HTTPS_PORT:-443}
|
||||
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}
|
||||
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
||||
SITE_ADDRESS: ${SITE_ADDRESS:-:80}
|
||||
|
||||
|
|
@ -57,6 +57,7 @@ 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}
|
||||
|
|
@ -222,7 +223,7 @@ services:
|
|||
|
||||
# Comment this if you already have a reverse proxy running
|
||||
proxy:
|
||||
image: makeplane/plane-proxy:${APP_RELEASE:-v1.3.0}
|
||||
image: nodedc/plane-proxy:ru
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
|
|
@ -231,11 +232,11 @@ services:
|
|||
<<: *proxy-env
|
||||
ports:
|
||||
- target: 80
|
||||
published: ${LISTEN_HTTP_PORT:-80}
|
||||
published: ${LISTEN_HTTP_PORT:-8090}
|
||||
protocol: tcp
|
||||
mode: host
|
||||
- target: 443
|
||||
published: ${LISTEN_HTTPS_PORT:-443}
|
||||
published: ${LISTEN_HTTPS_PORT:-8443}
|
||||
protocol: tcp
|
||||
mode: host
|
||||
volumes:
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ docker compose -f docker-compose-local.yml up
|
|||
pnpm dev
|
||||
```
|
||||
|
||||
5. Open your browser to http://localhost:3001/god-mode/ and register yourself as instance admin
|
||||
5. Open your browser to http://localhost:3001/nodedcsudo/ and register yourself as instance admin
|
||||
6. Open up your browser to http://localhost:3000 then log in using the same credentials from the previous step
|
||||
|
||||
That’s it! You’re all set to begin coding. Remember to refresh your browser if changes don’t auto-reload. Happy contributing! 🎉
|
||||
|
|
|
|||
|
|
@ -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="/god-mode"
|
||||
ARG VITE_ADMIN_BASE_PATH="/nodedcsudo"
|
||||
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/god-mode
|
||||
COPY --from=installer /app/apps/admin/build/client /usr/share/nginx/html/nodedcsudo
|
||||
|
||||
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;"]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ COPY . .
|
|||
RUN corepack enable pnpm && pnpm add -g turbo
|
||||
RUN pnpm install
|
||||
|
||||
ENV VITE_ADMIN_BASE_PATH="/god-mode"
|
||||
ENV VITE_ADMIN_BASE_PATH="/nodedcsudo"
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
|
|
|||
|
|
@ -41,17 +41,17 @@ export function InstanceAIForm(props: IInstanceAIForm) {
|
|||
{
|
||||
key: "LLM_MODEL",
|
||||
type: "text",
|
||||
label: "LLM Model",
|
||||
label: "LLM-модель",
|
||||
description: (
|
||||
<>
|
||||
Choose an OpenAI engine.{" "}
|
||||
Выберите модель OpenAI.{" "}
|
||||
<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 key",
|
||||
label: "API-ключ",
|
||||
description: (
|
||||
<>
|
||||
You will find your API key{" "}
|
||||
API-ключ находится{" "}
|
||||
<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: "Success",
|
||||
message: "AI Settings updated successfully",
|
||||
title: "Сохранено",
|
||||
message: "ИИ-настройки обновлены",
|
||||
})
|
||||
)
|
||||
.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">If you use ChatGPT, this is for you.</div>
|
||||
<div className="text-13 font-regular text-tertiary">Используется для встроенных функций на базе OpenAI.</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 ? "Saving" : "Save changes"}
|
||||
{isSubmitting ? "Сохранение" : "Сохранить изменения"}
|
||||
</Button>
|
||||
|
||||
<div className="relative inline-flex items-center gap-1.5 rounded-sm border border-accent-subtle bg-accent-subtle px-4 py-2 text-caption-sm-regular text-accent-secondary">
|
||||
<Lightbulb className="size-4" />
|
||||
<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>
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentP
|
|||
return (
|
||||
<PageWrapper
|
||||
header={{
|
||||
title: "AI features for all your workspaces",
|
||||
description: "Configure your AI API credentials so Plane AI features are turned on for all your workspaces.",
|
||||
title: "ИИ-функции для всех воркспейсов",
|
||||
description: "Настройте API-ключ и модель, чтобы включить ИИ-возможности во всех рабочих пространствах.",
|
||||
}}
|
||||
>
|
||||
{formattedConfig ? (
|
||||
|
|
@ -45,6 +45,6 @@ const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentP
|
|||
);
|
||||
});
|
||||
|
||||
export const meta: Route.MetaFunction = () => [{ title: "Artificial Intelligence Settings - God Mode" }];
|
||||
export const meta: Route.MetaFunction = () => [{ title: "ИИ-настройки - NODE.DC" }];
|
||||
|
||||
export default InstanceAIPage;
|
||||
|
|
|
|||
|
|
@ -58,9 +58,9 @@ export function InstanceGiteaConfigForm(props: Props) {
|
|||
{
|
||||
key: "GITEA_HOST",
|
||||
type: "text",
|
||||
label: "Gitea Host",
|
||||
label: "Хост Gitea",
|
||||
description: (
|
||||
<>Use the URL of your Gitea instance. For the official Gitea instance, use "https://gitea.com".</>
|
||||
<>Укажите URL вашего Gitea-инстанса. Для официального сервиса используйте "https://gitea.com".</>
|
||||
),
|
||||
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 application settings.
|
||||
Gitea OAuth-приложения.
|
||||
</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 application settings.
|
||||
Gitea OAuth-приложения.
|
||||
</a>
|
||||
</>
|
||||
),
|
||||
|
|
@ -124,8 +124,8 @@ export function InstanceGiteaConfigForm(props: Props) {
|
|||
url: `${originURL}/auth/gitea/callback/`,
|
||||
description: (
|
||||
<>
|
||||
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Callback URI</CodeBlock>{" "}
|
||||
field{" "}
|
||||
Значение сформировано автоматически. Вставьте его в поле{" "}
|
||||
<CodeBlock darkerShade>Authorized Callback URI</CodeBlock>{" "}
|
||||
<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: "Done!",
|
||||
message: "Your Gitea authentication is configured. You should test it now.",
|
||||
title: "Готово",
|
||||
message: "Аутентификация через Gitea настроена. Проверьте вход перед включением в проде.",
|
||||
});
|
||||
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-provided details for Plane</div>
|
||||
<div className="pt-2.5 text-18 font-medium">Данные Gitea для NODE.DC</div>
|
||||
{GITEA_FORM_FIELDS.map((field) => (
|
||||
<ControllerInput
|
||||
key={field.key}
|
||||
|
|
@ -202,17 +202,17 @@ export function InstanceGiteaConfigForm(props: Props) {
|
|||
loading={isSubmitting}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
{isSubmitting ? "Saving" : "Save changes"}
|
||||
{isSubmitting ? "Сохранение" : "Сохранить изменения"}
|
||||
</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="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>
|
||||
<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>
|
||||
{GITEA_SERVICE_FIELD.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -41,14 +41,14 @@ const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthentic
|
|||
const updateConfigPromise = updateInstanceConfigurations(payload);
|
||||
|
||||
setPromiseToast(updateConfigPromise, {
|
||||
loading: "Saving Configuration",
|
||||
loading: "Сохранение конфигурации",
|
||||
success: {
|
||||
title: "Configuration saved",
|
||||
message: () => `Gitea authentication is now ${value === "1" ? "active" : "disabled"}.`,
|
||||
title: "Конфигурация сохранена",
|
||||
message: () => `Вход через Gitea ${value === "1" ? "включен" : "отключен"}.`,
|
||||
},
|
||||
error: {
|
||||
title: "Error",
|
||||
message: () => "Failed to save configuration",
|
||||
title: "Ошибка",
|
||||
message: () => "Не удалось сохранить конфигурацию",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -69,7 +69,7 @@ const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthentic
|
|||
customHeader={
|
||||
<AuthenticationMethodCard
|
||||
name="Gitea"
|
||||
description="Allow members to login or sign up to plane with their Gitea accounts."
|
||||
description="Вход и регистрация пользователей через аккаунты Gitea."
|
||||
icon={<img src={giteaLogo} height={24} width={24} alt="Gitea Logo" />}
|
||||
config={
|
||||
<ToggleSwitch
|
||||
|
|
@ -100,6 +100,6 @@ const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthentic
|
|||
</PageWrapper>
|
||||
);
|
||||
});
|
||||
export const meta: Route.MetaFunction = () => [{ title: "Gitea Authentication - God Mode" }];
|
||||
export const meta: Route.MetaFunction = () => [{ title: "Gitea OAuth - NODE.DC" }];
|
||||
|
||||
export default InstanceGiteaAuthenticationPage;
|
||||
|
|
|
|||
|
|
@ -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 application settings.
|
||||
GitHub OAuth-приложения.
|
||||
</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 application settings.
|
||||
GitHub OAuth-приложения.
|
||||
</a>
|
||||
</>
|
||||
),
|
||||
|
|
@ -103,8 +103,8 @@ export function InstanceGithubConfigForm(props: Props) {
|
|||
{
|
||||
key: "GITHUB_ORGANIZATION_ID",
|
||||
type: "text",
|
||||
label: "Organization ID",
|
||||
description: <>The organization github ID.</>,
|
||||
label: "ID организации",
|
||||
description: <>ID организации GitHub.</>,
|
||||
placeholder: "123456789",
|
||||
error: Boolean(errors.GITHUB_ORGANIZATION_ID),
|
||||
required: false,
|
||||
|
|
@ -123,7 +123,8 @@ export function InstanceGithubConfigForm(props: Props) {
|
|||
url: originURL,
|
||||
description: (
|
||||
<>
|
||||
We will auto-generate this. Paste this into the <CodeBlock darkerShade>Authorized origin URL</CodeBlock> field{" "}
|
||||
Значение сформировано автоматически. Вставьте его в поле{" "}
|
||||
<CodeBlock darkerShade>Authorized origin URL</CodeBlock>{" "}
|
||||
<a
|
||||
tabIndex={-1}
|
||||
href="https://github.com/settings/applications/new"
|
||||
|
|
@ -131,7 +132,7 @@ export function InstanceGithubConfigForm(props: Props) {
|
|||
className="text-accent-primary hover:underline"
|
||||
rel="noreferrer"
|
||||
>
|
||||
here.
|
||||
здесь.
|
||||
</a>
|
||||
</>
|
||||
),
|
||||
|
|
@ -145,8 +146,8 @@ export function InstanceGithubConfigForm(props: Props) {
|
|||
url: `${originURL}/auth/github/callback/`,
|
||||
description: (
|
||||
<>
|
||||
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Callback URI</CodeBlock>{" "}
|
||||
field{" "}
|
||||
Значение сформировано автоматически. Вставьте его в поле{" "}
|
||||
<CodeBlock darkerShade>Authorized Callback URI</CodeBlock>{" "}
|
||||
<a
|
||||
tabIndex={-1}
|
||||
href="https://github.com/settings/applications/new"
|
||||
|
|
@ -154,7 +155,7 @@ export function InstanceGithubConfigForm(props: Props) {
|
|||
className="text-accent-primary hover:underline"
|
||||
rel="noreferrer"
|
||||
>
|
||||
here.
|
||||
здесь.
|
||||
</a>
|
||||
</>
|
||||
),
|
||||
|
|
@ -168,8 +169,8 @@ export function InstanceGithubConfigForm(props: Props) {
|
|||
const response = await updateInstanceConfigurations(payload);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Done!",
|
||||
message: "Your GitHub authentication is configured. You should test it now.",
|
||||
title: "Готово",
|
||||
message: "Аутентификация через GitHub настроена. Проверьте вход перед включением в проде.",
|
||||
});
|
||||
reset({
|
||||
GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value,
|
||||
|
|
@ -199,7 +200,7 @@ export function InstanceGithubConfigForm(props: Props) {
|
|||
<div className="flex flex-col gap-8">
|
||||
<div className="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-provided details for Plane</div>
|
||||
<div className="pt-2.5 text-18 font-medium">Данные GitHub для NODE.DC</div>
|
||||
{GITHUB_FORM_FIELDS.map((field) => (
|
||||
<ControllerInput
|
||||
key={field.key}
|
||||
|
|
@ -223,32 +224,32 @@ export function InstanceGithubConfigForm(props: Props) {
|
|||
loading={isSubmitting}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
{isSubmitting ? "Saving" : "Save changes"}
|
||||
{isSubmitting ? "Сохранение" : "Сохранить изменения"}
|
||||
</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">Plane-provided details for GitHub</div>
|
||||
<div className="pt-2 text-18 font-medium">Данные NODE.DC для GitHub</div>
|
||||
|
||||
<div className="flex flex-col gap-y-4">
|
||||
{/* common service details */}
|
||||
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-1 px-6 py-4">
|
||||
<div className="nodedc-settings-helper-card flex flex-col gap-y-4 px-6 py-4">
|
||||
{GITHUB_COMMON_SERVICE_DETAILS.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* web service details */}
|
||||
<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">
|
||||
<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">
|
||||
<Monitor className="h-3 w-3" />
|
||||
Web
|
||||
Веб
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-4 bg-layer-1 px-6 py-4">
|
||||
<div className="flex flex-col gap-y-4 px-6 py-4">
|
||||
{GITHUB_SERVICE_DETAILS.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -49,14 +49,14 @@ const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthent
|
|||
const updateConfigPromise = updateInstanceConfigurations(payload);
|
||||
|
||||
setPromiseToast(updateConfigPromise, {
|
||||
loading: "Saving Configuration",
|
||||
loading: "Сохранение конфигурации",
|
||||
success: {
|
||||
title: "Configuration saved",
|
||||
message: () => `GitHub authentication is now ${value === "1" ? "active" : "disabled"}.`,
|
||||
title: "Конфигурация сохранена",
|
||||
message: () => `Вход через GitHub ${value === "1" ? "включен" : "отключен"}.`,
|
||||
},
|
||||
error: {
|
||||
title: "Error",
|
||||
message: () => "Failed to save configuration",
|
||||
title: "Ошибка",
|
||||
message: () => "Не удалось сохранить конфигурацию",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -77,7 +77,7 @@ const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthent
|
|||
customHeader={
|
||||
<AuthenticationMethodCard
|
||||
name="GitHub"
|
||||
description="Allow members to login or sign up to plane with their GitHub accounts."
|
||||
description="Вход и регистрация пользователей через аккаунты GitHub."
|
||||
icon={
|
||||
<img
|
||||
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
|
||||
|
|
@ -116,6 +116,6 @@ const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthent
|
|||
);
|
||||
});
|
||||
|
||||
export const meta: Route.MetaFunction = () => [{ title: "GitHub Authentication - God Mode" }];
|
||||
export const meta: Route.MetaFunction = () => [{ title: "GitHub OAuth - NODE.DC" }];
|
||||
|
||||
export default InstanceGithubAuthenticationPage;
|
||||
|
|
|
|||
|
|
@ -58,10 +58,10 @@ export function InstanceGitlabConfigForm(props: Props) {
|
|||
{
|
||||
key: "GITLAB_HOST",
|
||||
type: "text",
|
||||
label: "Host",
|
||||
label: "Хост",
|
||||
description: (
|
||||
<>
|
||||
This is either https://gitlab.com or the <CodeBlock>domain.tld</CodeBlock> where you host GitLab.
|
||||
Укажите https://gitlab.com или <CodeBlock>domain.tld</CodeBlock>, если GitLab развернут у вас.
|
||||
</>
|
||||
),
|
||||
placeholder: "https://gitlab.com",
|
||||
|
|
@ -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 application settings
|
||||
GitLab OAuth-приложения
|
||||
</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 application settings
|
||||
GitLab OAuth-приложения
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
|
|
@ -128,7 +128,7 @@ export function InstanceGitlabConfigForm(props: Props) {
|
|||
url: `${originURL}/auth/gitlab/callback/`,
|
||||
description: (
|
||||
<>
|
||||
We will auto-generate this. Paste this into the <CodeBlock darkerShade>Redirect URI</CodeBlock> field of your{" "}
|
||||
Значение сформировано автоматически. Вставьте его в поле <CodeBlock darkerShade>Redirect URI</CodeBlock>{" "}
|
||||
<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 application
|
||||
GitLab OAuth-приложения
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
|
|
@ -151,8 +151,8 @@ export function InstanceGitlabConfigForm(props: Props) {
|
|||
const response = await updateInstanceConfigurations(payload);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Done!",
|
||||
message: "Your GitLab authentication is configured. You should test it now.",
|
||||
title: "Готово",
|
||||
message: "Аутентификация через GitLab настроена. Проверьте вход перед включением в проде.",
|
||||
});
|
||||
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-provided details for Plane</div>
|
||||
<div className="pt-2.5 text-18 font-medium">Данные GitLab для NODE.DC</div>
|
||||
{GITLAB_FORM_FIELDS.map((field) => (
|
||||
<ControllerInput
|
||||
key={field.key}
|
||||
|
|
@ -206,17 +206,17 @@ export function InstanceGitlabConfigForm(props: Props) {
|
|||
loading={isSubmitting}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
{isSubmitting ? "Saving" : "Save changes"}
|
||||
{isSubmitting ? "Сохранение" : "Сохранить изменения"}
|
||||
</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="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>
|
||||
<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>
|
||||
{GITLAB_SERVICE_FIELD.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -43,14 +43,14 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent
|
|||
const updateConfigPromise = updateInstanceConfigurations(payload);
|
||||
|
||||
setPromiseToast(updateConfigPromise, {
|
||||
loading: "Saving Configuration",
|
||||
loading: "Сохранение конфигурации",
|
||||
success: {
|
||||
title: "Configuration saved",
|
||||
message: () => `GitLab authentication is now ${value === "1" ? "active" : "disabled"}.`,
|
||||
title: "Конфигурация сохранена",
|
||||
message: () => `Вход через GitLab ${value === "1" ? "включен" : "отключен"}.`,
|
||||
},
|
||||
error: {
|
||||
title: "Error",
|
||||
message: () => "Failed to save configuration",
|
||||
title: "Ошибка",
|
||||
message: () => "Не удалось сохранить конфигурацию",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -68,7 +68,7 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent
|
|||
customHeader={
|
||||
<AuthenticationMethodCard
|
||||
name="GitLab"
|
||||
description="Allow members to login or sign up to plane with their GitLab accounts."
|
||||
description="Вход и регистрация пользователей через аккаунты GitLab."
|
||||
icon={<img src={GitlabLogo} height={24} width={24} alt="GitLab Logo" />}
|
||||
config={
|
||||
<ToggleSwitch
|
||||
|
|
@ -104,6 +104,6 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent
|
|||
);
|
||||
});
|
||||
|
||||
export const meta: Route.MetaFunction = () => [{ title: "GitLab Authentication - God Mode" }];
|
||||
export const meta: Route.MetaFunction = () => [{ title: "GitLab OAuth - NODE.DC" }];
|
||||
|
||||
export default InstanceGitlabAuthenticationPage;
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export function InstanceGoogleConfigForm(props: Props) {
|
|||
label: "Client ID",
|
||||
description: (
|
||||
<>
|
||||
Your client ID lives in your Google API Console.{" "}
|
||||
Client ID находится в 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: (
|
||||
<>
|
||||
Your client secret should also be in your Google API Console.{" "}
|
||||
Client secret также находится в 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>
|
||||
We will auto-generate this. Paste this into your{" "}
|
||||
<CodeBlock darkerShade>Authorized JavaScript origins</CodeBlock> field. For this OAuth client{" "}
|
||||
Значение сформировано автоматически. Вставьте его в поле{" "}
|
||||
<CodeBlock darkerShade>Authorized JavaScript origins</CodeBlock> для OAuth-клиента{" "}
|
||||
<a
|
||||
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>
|
||||
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Redirect URI</CodeBlock>{" "}
|
||||
field. For this OAuth client{" "}
|
||||
Значение сформировано автоматически. Вставьте его в поле{" "}
|
||||
<CodeBlock darkerShade>Authorized Redirect URI</CodeBlock> для OAuth-клиента{" "}
|
||||
<a
|
||||
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: "Done!",
|
||||
message: "Your Google authentication is configured. You should test it now.",
|
||||
title: "Готово",
|
||||
message: "Аутентификация через Google настроена. Проверьте вход перед включением в проде.",
|
||||
});
|
||||
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-provided details for Plane</div>
|
||||
<div className="pt-2.5 text-18 font-medium">Данные Google для NODE.DC</div>
|
||||
{GOOGLE_FORM_FIELDS.map((field) => (
|
||||
<ControllerInput
|
||||
key={field.key}
|
||||
|
|
@ -211,32 +211,32 @@ export function InstanceGoogleConfigForm(props: Props) {
|
|||
loading={isSubmitting}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
{isSubmitting ? "Saving" : "Save changes"}
|
||||
{isSubmitting ? "Сохранение" : "Сохранить изменения"}
|
||||
</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">Plane-provided details for Google</div>
|
||||
<div className="pt-2 text-18 font-medium">Данные NODE.DC для Google</div>
|
||||
|
||||
<div className="flex flex-col gap-y-4">
|
||||
{/* common service details */}
|
||||
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-1 px-6 py-4">
|
||||
<div className="nodedc-settings-helper-card flex flex-col gap-y-4 px-6 py-4">
|
||||
{GOOGLE_COMMON_SERVICE_DETAILS.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* web service details */}
|
||||
<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">
|
||||
<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">
|
||||
<Monitor className="h-3 w-3" />
|
||||
Web
|
||||
Веб
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-4 bg-layer-1 px-6 py-4">
|
||||
<div className="flex flex-col gap-y-4 px-6 py-4">
|
||||
{GOOGLE_SERVICE_DETAILS.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -43,14 +43,14 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent
|
|||
const updateConfigPromise = updateInstanceConfigurations(payload);
|
||||
|
||||
setPromiseToast(updateConfigPromise, {
|
||||
loading: "Saving Configuration",
|
||||
loading: "Сохранение конфигурации",
|
||||
success: {
|
||||
title: "Configuration saved",
|
||||
message: () => `Google authentication is now ${value === "1" ? "active" : "disabled"}.`,
|
||||
title: "Конфигурация сохранена",
|
||||
message: () => `Вход через Google ${value === "1" ? "включен" : "отключен"}.`,
|
||||
},
|
||||
error: {
|
||||
title: "Error",
|
||||
message: () => "Failed to save configuration",
|
||||
title: "Ошибка",
|
||||
message: () => "Не удалось сохранить конфигурацию",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -68,8 +68,7 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent
|
|||
customHeader={
|
||||
<AuthenticationMethodCard
|
||||
name="Google"
|
||||
description="Allow members to login or sign up to plane with their Google
|
||||
accounts."
|
||||
description="Вход и регистрация пользователей через аккаунты Google."
|
||||
icon={<img src={GoogleLogo} height={24} width={24} alt="Google Logo" />}
|
||||
config={
|
||||
<ToggleSwitch
|
||||
|
|
@ -105,6 +104,6 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent
|
|||
);
|
||||
});
|
||||
|
||||
export const meta: Route.MetaFunction = () => [{ title: "Google Authentication - God Mode" }];
|
||||
export const meta: Route.MetaFunction = () => [{ title: "Google OAuth - NODE.DC" }];
|
||||
|
||||
export default InstanceGoogleAuthenticationPage;
|
||||
|
|
|
|||
|
|
@ -55,9 +55,8 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
|
|||
if (!canDisable) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Cannot disable authentication",
|
||||
message:
|
||||
"At least one authentication method must remain enabled. Please enable another method before disabling this one.",
|
||||
title: "Нельзя отключить вход",
|
||||
message: "Должен остаться хотя бы один способ входа. Сначала включите другой способ аутентификации.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -74,14 +73,14 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
|
|||
const updateConfigPromise = updateInstanceConfigurations(payload);
|
||||
|
||||
setPromiseToast(updateConfigPromise, {
|
||||
loading: "Saving configuration",
|
||||
loading: "Сохранение конфигурации",
|
||||
success: {
|
||||
title: "Success",
|
||||
message: () => "Configuration saved successfully",
|
||||
title: "Сохранено",
|
||||
message: () => "Конфигурация обновлена",
|
||||
},
|
||||
error: {
|
||||
title: "Error",
|
||||
message: () => "Failed to save configuration",
|
||||
title: "Ошибка",
|
||||
message: () => "Не удалось сохранить конфигурацию",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -111,8 +110,8 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
|
|||
return (
|
||||
<PageWrapper
|
||||
header={{
|
||||
title: "Manage authentication modes for your instance",
|
||||
description: "Configure authentication modes for your team and restrict sign-ups to be invite only.",
|
||||
title: "Способы входа в инстанс",
|
||||
description: "Настройте email, пароль, OAuth-провайдеры и правила регистрации пользователей.",
|
||||
}}
|
||||
>
|
||||
{formattedConfig ? (
|
||||
|
|
@ -120,9 +119,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">Allow anyone to sign up even without an invite</div>
|
||||
<div className="pb-1 text-16 font-medium">Разрешить регистрацию без приглашения</div>
|
||||
<div className={cn("text-11 leading-5 font-regular text-tertiary")}>
|
||||
Toggling this off will only let users sign up when they are invited.
|
||||
Если выключить, новые пользователи смогут зарегистрироваться только по приглашению.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -143,7 +142,7 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-lg pt-6 font-medium">Available authentication modes</div>
|
||||
<div className="text-lg pt-6 font-medium">Доступные способы входа</div>
|
||||
{authenticationModes.map((method) => (
|
||||
<AuthenticationMethodCard
|
||||
key={method.key}
|
||||
|
|
@ -169,6 +168,6 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
|
|||
);
|
||||
});
|
||||
|
||||
export const meta: Route.MetaFunction = () => [{ title: "Authentication Settings - Plane Web" }];
|
||||
export const meta: Route.MetaFunction = () => [{ title: "Аутентификация - NODE.DC" }];
|
||||
|
||||
export default InstanceAuthenticationPage;
|
||||
|
|
|
|||
|
|
@ -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: "No email security",
|
||||
NONE: "Без шифрования",
|
||||
};
|
||||
|
||||
export function InstanceEmailForm(props: IInstanceEmailForm) {
|
||||
|
|
@ -63,7 +63,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
|
|||
{
|
||||
key: "EMAIL_HOST",
|
||||
type: "text",
|
||||
label: "Host",
|
||||
label: "Хост",
|
||||
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: "Port",
|
||||
label: "Порт",
|
||||
placeholder: "8080",
|
||||
error: Boolean(errors.EMAIL_PORT),
|
||||
required: true,
|
||||
|
|
@ -79,9 +79,9 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
|
|||
{
|
||||
key: "EMAIL_FROM",
|
||||
type: "text",
|
||||
label: "Sender's email address",
|
||||
label: "Email отправителя",
|
||||
description:
|
||||
"This is the email address your users will see when getting emails from this instance. You will need to verify this address.",
|
||||
"Этот адрес будут видеть пользователи в письмах от инстанса. Адрес нужно подтвердить на стороне SMTP.",
|
||||
placeholder: "no-reply@projectplane.so",
|
||||
error: Boolean(errors.EMAIL_FROM),
|
||||
required: true,
|
||||
|
|
@ -92,7 +92,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
|
|||
{
|
||||
key: "EMAIL_HOST_USER",
|
||||
type: "text",
|
||||
label: "Username",
|
||||
label: "Имя пользователя",
|
||||
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: "Password",
|
||||
placeholder: "Password",
|
||||
label: "Пароль",
|
||||
placeholder: "Пароль",
|
||||
error: Boolean(errors.EMAIL_HOST_PASSWORD),
|
||||
required: false,
|
||||
},
|
||||
|
|
@ -114,8 +114,8 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
|
|||
.then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success",
|
||||
message: "Email Settings updated successfully",
|
||||
title: "Сохранено",
|
||||
message: "Настройки почты обновлены",
|
||||
})
|
||||
)
|
||||
.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">Email security</h4>
|
||||
<h4 className="text-13 text-tertiary">Защита соединения</h4>
|
||||
<CustomSelect
|
||||
value={emailSecurityKey}
|
||||
label={EMAIL_SECURITY_OPTIONS[emailSecurityKey]}
|
||||
onChange={handleEmailSecurityChange}
|
||||
buttonClassName="rounded-md border-subtle"
|
||||
buttonClassName="nodedc-settings-select 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">Authentication</div>
|
||||
<div className="text-13 font-medium text-primary">Аутентификация</div>
|
||||
<div className="text-11 font-regular text-tertiary">
|
||||
This is optional, but we recommend setting up a username and a password for your SMTP server.
|
||||
Необязательно, но для SMTP-сервера обычно нужны имя пользователя и пароль.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -215,7 +215,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
|
|||
loading={isSubmitting}
|
||||
disabled={!isValid || !isDirty}
|
||||
>
|
||||
{isSubmitting ? "Saving" : "Save changes"}
|
||||
{isSubmitting ? "Сохранение" : "Сохранить изменения"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
|
@ -224,7 +224,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
|
|||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
>
|
||||
Send test email
|
||||
Отправить тестовое письмо
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -34,14 +34,14 @@ const InstanceEmailPage = observer(function InstanceEmailPage(_props: Route.Comp
|
|||
await disableEmail();
|
||||
setIsSMTPEnabled(false);
|
||||
setToast({
|
||||
title: "Email feature disabled",
|
||||
message: "Email feature has been disabled",
|
||||
title: "Почта отключена",
|
||||
message: "Отправка писем через SMTP отключена",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
});
|
||||
} catch (_error) {
|
||||
setToast({
|
||||
title: "Error disabling email",
|
||||
message: "Failed to disable email feature. Please try again.",
|
||||
title: "Не удалось отключить почту",
|
||||
message: "Повторите попытку.",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -60,13 +60,13 @@ const InstanceEmailPage = observer(function InstanceEmailPage(_props: Route.Comp
|
|||
return (
|
||||
<PageWrapper
|
||||
header={{
|
||||
title: "Secure emails from your own instance",
|
||||
title: "Письма от вашего инстанса",
|
||||
description: (
|
||||
<>
|
||||
Plane can send useful emails to you and your users from your own instance without talking to the Internet.
|
||||
NODE.DC может отправлять системные письма пользователям через ваш SMTP-сервер.
|
||||
<div className="text-13 font-regular text-tertiary">
|
||||
Set it up below and please test your settings before you save them.
|
||||
<span className="text-danger-primary">Misconfigs can lead to email bounces and errors.</span>
|
||||
Заполните параметры ниже и проверьте отправку перед сохранением.
|
||||
<span className="text-danger-primary">Ошибки в конфигурации приводят к отказам доставки.</span>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
|
|
@ -98,6 +98,6 @@ const InstanceEmailPage = observer(function InstanceEmailPage(_props: Route.Comp
|
|||
);
|
||||
});
|
||||
|
||||
export const meta: Route.MetaFunction = () => [{ title: "Email Settings - God Mode" }];
|
||||
export const meta: Route.MetaFunction = () => [{ title: "Настройки почты - NODE.DC" }];
|
||||
|
||||
export default InstanceEmailPage;
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export function SendTestEmailModal(props: Props) {
|
|||
setSendEmailStep(ESendEmailSteps.SUCCESS);
|
||||
})
|
||||
.catch((error) => {
|
||||
setError(error?.error || "Failed to send email");
|
||||
setError(error?.error || "Не удалось отправить письмо");
|
||||
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="relative w-full transform rounded-lg bg-surface-1 p-5 px-4 text-left shadow-raised-200 transition-all sm:max-w-xl">
|
||||
<Dialog.Panel className="nodedc-glass-modal relative w-full transform rounded-[1.75rem] bg-surface-1 p-5 px-4 text-left shadow-raised-200 transition-all sm:max-w-xl">
|
||||
<h3 className="text-16 leading-6 font-medium text-primary">
|
||||
{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,28 +106,25 @@ export function SendTestEmailModal(props: Props) {
|
|||
type="email"
|
||||
value={receiverEmail}
|
||||
onChange={(e) => setReceiverEmail(e.target.value)}
|
||||
placeholder="Receiver email"
|
||||
className="w-full resize-none text-16"
|
||||
placeholder="Email получателя"
|
||||
className="nodedc-settings-input w-full resize-none text-16"
|
||||
tabIndex={1}
|
||||
/>
|
||||
)}
|
||||
{sendEmailStep === ESendEmailSteps.SUCCESS && (
|
||||
<div className="flex flex-col gap-y-4 text-13">
|
||||
<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>
|
||||
<p>Тестовое письмо отправлено на {receiverEmail}. Если письма нет во входящих, проверьте спам.</p>
|
||||
<p>Если письмо не пришло, проверьте SMTP-настройки и отправьте тест заново.</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 ? "Cancel" : "Close"}
|
||||
{sendEmailStep === ESendEmailSteps.SEND_EMAIL ? "Отмена" : "Закрыть"}
|
||||
</Button>
|
||||
{sendEmailStep === ESendEmailSteps.SEND_EMAIL && (
|
||||
<Button variant="primary" size="lg" loading={isLoading} onClick={handleSubmit} tabIndex={3}>
|
||||
{isLoading ? "Sending email" : "Send email"}
|
||||
{isLoading ? "Отправка" : "Отправить"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -60,8 +60,8 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
|
|||
.then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success",
|
||||
message: "Settings updated successfully",
|
||||
title: "Сохранено",
|
||||
message: "Основные настройки обновлены",
|
||||
})
|
||||
)
|
||||
.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">Instance details</div>
|
||||
<div className="text-16 font-medium text-primary">Данные инстанса</div>
|
||||
<div className="grid-col grid w-full grid-cols-1 items-center justify-between gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
<ControllerInput
|
||||
key="instance_name"
|
||||
name="instance_name"
|
||||
control={control}
|
||||
type="text"
|
||||
label="Name of instance"
|
||||
placeholder="Instance name"
|
||||
label="Название инстанса"
|
||||
placeholder="Название инстанса"
|
||||
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="Admin email"
|
||||
className="w-full cursor-not-allowed !text-placeholder"
|
||||
placeholder="Email администратора"
|
||||
className="nodedc-settings-input w-full cursor-not-allowed !text-placeholder"
|
||||
autoComplete="on"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-13 text-tertiary">Instance ID</h4>
|
||||
<h4 className="text-13 text-tertiary">ID инстанса</h4>
|
||||
<Input
|
||||
id="instance_id"
|
||||
name="instance_id"
|
||||
type="text"
|
||||
value={instance.instance_id}
|
||||
className="w-full cursor-not-allowed rounded-md font-medium !text-placeholder"
|
||||
className="nodedc-settings-input w-full cursor-not-allowed rounded-md font-medium !text-placeholder"
|
||||
disabled
|
||||
/>
|
||||
</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">Chat + telemetry</div>
|
||||
<div className="border-b border-subtle pb-1.5 text-16 font-medium text-primary">Чат и телеметрия</div>
|
||||
<IntercomConfig isTelemetryEnabled={watch("is_telemetry_enabled") ?? false} />
|
||||
<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">Let Plane collect anonymous usage data</div>
|
||||
<div className="text-13 leading-5 font-medium text-primary">Разрешить анонимную телеметрию</div>
|
||||
<div className="text-11 leading-5 font-regular text-tertiary">
|
||||
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 ? "Saving" : "Save changes"}
|
||||
{isSubmitting ? "Сохранение" : "Сохранить изменения"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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">Chat with us</div>
|
||||
<div className="text-13 leading-5 font-medium text-primary">Встроенный чат поддержки</div>
|
||||
<div className="text-11 leading-5 font-regular text-tertiary">
|
||||
Let your users chat with us via Intercom or another service. Toggling Telemetry off turns this off
|
||||
automatically.
|
||||
Разрешает пользователям писать в поддержку через Intercom или аналогичный сервис. При отключении
|
||||
телеметрии чат отключается автоматически.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -20,9 +20,8 @@ function GeneralPage() {
|
|||
return (
|
||||
<PageWrapper
|
||||
header={{
|
||||
title: "General settings",
|
||||
description:
|
||||
"Change the name of your instance and instance admin e-mail addresses. Enable or disable telemetry in your instance.",
|
||||
title: "Основные настройки",
|
||||
description: "Имя инстанса, служебные идентификаторы, email администратора и режимы телеметрии.",
|
||||
}}
|
||||
>
|
||||
{instance && instanceAdmins && <GeneralConfigurationForm instance={instance} instanceAdmins={instanceAdmins} />}
|
||||
|
|
@ -30,6 +29,6 @@ function GeneralPage() {
|
|||
);
|
||||
}
|
||||
|
||||
export const meta: Route.MetaFunction = () => [{ title: "General Settings - God Mode" }];
|
||||
export const meta: Route.MetaFunction = () => [{ title: "Основные настройки - NODE.DC" }];
|
||||
|
||||
export default observer(GeneralPage);
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@ export function InstanceImageConfigForm(props: IInstanceImageConfigForm) {
|
|||
.then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success",
|
||||
message: "Image Configuration Settings updated successfully",
|
||||
title: "Сохранено",
|
||||
message: "Настройки изображений обновлены",
|
||||
})
|
||||
)
|
||||
.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 from your Unsplash account"
|
||||
label="Access key аккаунта Unsplash"
|
||||
description={
|
||||
<>
|
||||
You will find your access key in your Unsplash developer console.
|
||||
Ключ доступа находится в консоли разработчика Unsplash.
|
||||
<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 ? "Saving" : "Save changes"}
|
||||
{isSubmitting ? "Сохранение" : "Сохранить изменения"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ const InstanceImagePage = observer(function InstanceImagePage(_props: Route.Comp
|
|||
return (
|
||||
<PageWrapper
|
||||
header={{
|
||||
title: "Third-party image libraries",
|
||||
description: "Let your users search and choose images from third-party libraries",
|
||||
title: "Внешние библиотеки изображений",
|
||||
description: "Разрешите пользователям искать и выбирать изображения из внешних библиотек.",
|
||||
}}
|
||||
>
|
||||
{formattedConfig ? (
|
||||
|
|
@ -41,6 +41,6 @@ const InstanceImagePage = observer(function InstanceImagePage(_props: Route.Comp
|
|||
);
|
||||
});
|
||||
|
||||
export const meta: Route.MetaFunction = () => [{ title: "Images Settings - God Mode" }];
|
||||
export const meta: Route.MetaFunction = () => [{ title: "Изображения - NODE.DC" }];
|
||||
|
||||
export default InstanceImagePage;
|
||||
|
|
|
|||
|
|
@ -38,9 +38,9 @@ function AdminLayout(_props: Route.ComponentProps) {
|
|||
|
||||
if (isUserLoggedIn) {
|
||||
return (
|
||||
<div className="relative flex h-screen w-screen overflow-hidden">
|
||||
<div className="nodedc-admin-shell relative flex h-screen w-screen overflow-hidden">
|
||||
<AdminSidebar />
|
||||
<main className="relative flex h-full w-full flex-col overflow-hidden bg-surface-1">
|
||||
<main className="nodedc-admin-main relative flex h-full w-full flex-col overflow-hidden bg-surface-1">
|
||||
<AdminHeader />
|
||||
<div className="vertical-scrollbar scrollbar-md h-full w-full overflow-hidden overflow-y-scroll">
|
||||
<Outlet />
|
||||
|
|
|
|||
|
|
@ -36,11 +36,13 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
|
|||
|
||||
const handleSignOut = () => signOut();
|
||||
|
||||
const getSidebarMenuItems = () => (
|
||||
const getSidebarMenuItems = (align: "left" | "right" = "left") => (
|
||||
<Menu.Items
|
||||
className={cn(
|
||||
"shadow-lg absolute left-0 z-20 mt-1.5 flex w-52 flex-col divide-y divide-subtle rounded-md border border-subtle bg-surface-1 px-1 py-2 text-11 outline-none",
|
||||
"nodedc-glass-popup-surface absolute top-full z-[100] mt-2 flex w-56 flex-col divide-y divide-white/6 px-2 py-2 text-11 outline-none",
|
||||
{
|
||||
"left-0": align === "left",
|
||||
"right-0": align === "right",
|
||||
"left-4": isSidebarCollapsed,
|
||||
}
|
||||
)}
|
||||
|
|
@ -52,11 +54,11 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
|
|||
<Menu.Item
|
||||
as="button"
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1 hover:bg-layer-1-hover"
|
||||
className="nodedc-settings-sidebar-item flex w-full items-center gap-2 px-2 py-1 text-left"
|
||||
onClick={handleThemeSwitch}
|
||||
>
|
||||
<Palette className="h-4 w-4 stroke-[1.5]" />
|
||||
Switch to {resolvedTheme === "dark" ? "light" : "dark"} mode
|
||||
{resolvedTheme === "dark" ? "Светлая тема" : "Темная тема"}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
<div className="py-2">
|
||||
|
|
@ -65,10 +67,10 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
|
|||
<Menu.Item
|
||||
as="button"
|
||||
type="submit"
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1 hover:bg-layer-1-hover"
|
||||
className="nodedc-settings-sidebar-item flex w-full items-center gap-2 px-2 py-1 text-left"
|
||||
>
|
||||
<LogOut className="h-4 w-4 stroke-[1.5]" />
|
||||
Sign out
|
||||
Выйти
|
||||
</Menu.Item>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -81,21 +83,21 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
|
|||
}, [csrfToken]);
|
||||
|
||||
return (
|
||||
<div className="flex max-h-header items-center gap-x-5 gap-y-2 border-b border-subtle px-4 py-2.5">
|
||||
<div className="h-full w-full truncate">
|
||||
<div className="px-3 pt-4 pb-2">
|
||||
<div className="relative h-full w-full overflow-visible">
|
||||
<div
|
||||
className={`flex flex-grow items-center gap-x-2 truncate rounded-sm ${
|
||||
className={`nodedc-admin-sidebar-profile relative z-[80] flex flex-grow items-center gap-x-3 px-3 py-3 ${
|
||||
isSidebarCollapsed ? "justify-center" : ""
|
||||
}`}
|
||||
>
|
||||
<Menu as="div" className="flex-shrink-0">
|
||||
<Menu as="div" className="relative z-[100] flex-shrink-0">
|
||||
<Menu.Button
|
||||
className={cn("grid place-items-center outline-none", {
|
||||
"cursor-default": !isSidebarCollapsed,
|
||||
})}
|
||||
>
|
||||
<div className="flex size-8 flex-shrink-0 items-center justify-center rounded-sm bg-layer-1">
|
||||
<UserCog2 className="size-5 text-primary" />
|
||||
<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>
|
||||
</Menu.Button>
|
||||
{isSidebarCollapsed && (
|
||||
|
|
@ -114,38 +116,39 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
|
|||
</Menu>
|
||||
|
||||
{!isSidebarCollapsed && (
|
||||
<div className="flex w-full gap-2">
|
||||
<h4 className="grow truncate text-body-md-medium text-primary">Instance admin</h4>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,17 +20,17 @@ import { useInstance, useTheme } from "@/hooks/store";
|
|||
|
||||
const helpOptions = [
|
||||
{
|
||||
name: "Documentation",
|
||||
name: "Документация",
|
||||
href: "https://docs.plane.so/",
|
||||
Icon: PageIcon,
|
||||
},
|
||||
{
|
||||
name: "Join our Forum",
|
||||
name: "Форум Plane",
|
||||
href: "https://forum.plane.so",
|
||||
Icon: MessageSquare,
|
||||
},
|
||||
{
|
||||
name: "Report a bug",
|
||||
name: "Сообщить об ошибке",
|
||||
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-14 w-full flex-shrink-0 items-center justify-between gap-1 self-baseline border-t border-subtle bg-surface-1 px-4",
|
||||
"flex h-16 w-full flex-shrink-0 items-center justify-between gap-1 self-baseline border-t border-white/6 px-3",
|
||||
{
|
||||
"h-auto flex-col py-1.5": isSidebarCollapsed,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className={`flex items-center gap-1 ${isSidebarCollapsed ? "flex-col justify-center" : "w-full"}`}>
|
||||
<Tooltip tooltipContent="Redirect to Plane" position="right" className="ml-4" disabled={!isSidebarCollapsed}>
|
||||
<Tooltip tooltipContent="Перейти в приложение" position="right" className="ml-4" disabled={!isSidebarCollapsed}>
|
||||
<a
|
||||
href={redirectionLink}
|
||||
className={`relative flex items-center gap-1 rounded-sm bg-layer-1 px-2 py-1 text-body-xs-medium whitespace-nowrap text-secondary`}
|
||||
className="nodedc-admin-sidebar-action relative flex items-center gap-1 px-3 py-1 text-12 font-medium whitespace-nowrap"
|
||||
>
|
||||
<NewTabIcon width={14} height={14} />
|
||||
{!isSidebarCollapsed && "Redirect to Plane"}
|
||||
{!isSidebarCollapsed && "В приложение"}
|
||||
</a>
|
||||
</Tooltip>
|
||||
<Tooltip tooltipContent="Help" position={isSidebarCollapsed ? "right" : "top"} className="ml-4">
|
||||
<Tooltip tooltipContent="Помощь" position={isSidebarCollapsed ? "right" : "top"} className="ml-4">
|
||||
<button
|
||||
type="button"
|
||||
className={`ml-auto grid place-items-center rounded-md p-1.5 text-secondary outline-none hover:bg-layer-1-hover hover:text-primary ${
|
||||
className={`nodedc-admin-sidebar-action ml-auto grid place-items-center p-1.5 outline-none ${
|
||||
isSidebarCollapsed ? "w-full" : ""
|
||||
}`}
|
||||
onClick={() => setIsNeedHelpOpen((prev) => !prev)}
|
||||
|
|
@ -77,10 +77,10 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
|
|||
<HelpCircle className="size-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip tooltipContent="Toggle sidebar" position={isSidebarCollapsed ? "right" : "top"} className="ml-4">
|
||||
<Tooltip tooltipContent="Свернуть меню" position={isSidebarCollapsed ? "right" : "top"} className="ml-4">
|
||||
<button
|
||||
type="button"
|
||||
className={`grid place-items-center rounded-md p-1.5 text-secondary outline-none hover:bg-layer-1-hover hover:text-primary ${
|
||||
className={`nodedc-admin-sidebar-action grid place-items-center p-1.5 outline-none ${
|
||||
isSidebarCollapsed ? "w-full" : ""
|
||||
}`}
|
||||
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]"
|
||||
} divide-y divide-subtle-1 rounded-sm bg-surface-1 p-1 whitespace-nowrap shadow-raised-100`}
|
||||
} nodedc-glass-popup-surface divide-y divide-subtle-1 rounded-sm bg-surface-1 p-1 whitespace-nowrap shadow-raised-100`}
|
||||
ref={helpOptionsRef}
|
||||
>
|
||||
<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">Version: v{instance?.current_version}</div>
|
||||
<div className="px-2 pt-2 pb-1 text-10">Версия: v{instance?.current_version}</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ 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) {
|
||||
|
|
@ -29,36 +33,33 @@ export const AdminSidebarMenu = observer(function AdminSidebarMenu() {
|
|||
};
|
||||
|
||||
return (
|
||||
<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 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>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</Tooltip>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export const AdminSidebar = observer(function AdminSidebar() {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-subtle bg-surface-1 duration-300 md:relative ${isSidebarCollapsed ? "-ml-[290px]" : ""} sm:${isSidebarCollapsed ? "-ml-[290px]" : ""} md:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"} lg:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"} `}
|
||||
className={`nodedc-glass-sidebar fixed inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-subtle bg-surface-1 duration-300 md:relative ${isSidebarCollapsed ? "-ml-[290px]" : ""} sm:${isSidebarCollapsed ? "-ml-[290px]" : ""} md:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"} lg:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"} `}
|
||||
>
|
||||
<div ref={ref} className="flex h-full w-full flex-1 flex-col">
|
||||
<AdminSidebarDropdown />
|
||||
|
|
|
|||
|
|
@ -56,16 +56,16 @@ export function WorkspaceCreateForm() {
|
|||
.then(async () => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Workspace created successfully.",
|
||||
title: "Готово",
|
||||
message: "Воркспейс создан.",
|
||||
});
|
||||
router.push(`/workspace`);
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Workspace could not be created. Please try again.",
|
||||
title: "Ошибка",
|
||||
message: "Не удалось создать воркспейс. Попробуйте еще раз.",
|
||||
});
|
||||
});
|
||||
} else setSlugError(true);
|
||||
|
|
@ -73,8 +73,8 @@ export function WorkspaceCreateForm() {
|
|||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Some error occurred while creating workspace. Please try again.",
|
||||
title: "Ошибка",
|
||||
message: "При создании воркспейса произошла ошибка. Попробуйте еще раз.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
@ -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">Name your workspace</h4>
|
||||
<h4 className="text-13 text-tertiary">Название воркспейса</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="Something familiar and recognizable is always best."
|
||||
className="w-full"
|
||||
placeholder="Короткое понятное название"
|
||||
className="nodedc-settings-input w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -122,8 +122,8 @@ export function WorkspaceCreateForm() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-13 text-tertiary">Set your workspace's URL</h4>
|
||||
<div className="flex w-full items-center gap-0.5 rounded-md border-[0.5px] border-subtle px-3">
|
||||
<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">
|
||||
<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">This URL is taken. Try something else.</p>}
|
||||
{slugError && <p className="text-13 text-danger-primary">Этот URL уже занят. Выберите другой.</p>}
|
||||
{invalidSlug && (
|
||||
<p className="text-13 text-danger-primary">{`URLs can contain only ( - ), ( _ ) and alphanumeric characters.`}</p>
|
||||
<p className="text-13 text-danger-primary">{`URL может содержать только латинские буквы, цифры, "-" и "_".`}</p>
|
||||
)}
|
||||
{errors.slug && <span className="text-11 text-danger-primary">{errors.slug.message}</span>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-13 text-tertiary">How many people will use this workspace?</h4>
|
||||
<h4 className="text-13 text-tertiary">Сколько людей будет работать в воркспейсе?</h4>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
name="organization_size"
|
||||
control={control}
|
||||
rules={{ required: "This is a required field." }}
|
||||
rules={{ required: "Это обязательное поле." }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label={
|
||||
ORGANIZATION_SIZE.find((c) => c === value) ?? (
|
||||
<span className="text-placeholder">Select a range</span>
|
||||
<span className="text-placeholder">Выберите диапазон</span>
|
||||
)
|
||||
}
|
||||
buttonClassName="!border-[0.5px] !border-subtle !shadow-none"
|
||||
buttonClassName="nodedc-settings-select !border-[0.5px] !border-subtle !shadow-none"
|
||||
input
|
||||
>
|
||||
{ORGANIZATION_SIZE.map((item) => (
|
||||
|
|
@ -196,10 +196,10 @@ export function WorkspaceCreateForm() {
|
|||
disabled={!isValid}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Creating workspace" : "Create workspace"}
|
||||
{isSubmitting ? "Создание" : "Создать воркспейс"}
|
||||
</Button>
|
||||
<Link className={getButtonStyling("secondary", "lg")} href="/workspace">
|
||||
Go back
|
||||
Назад
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ const WorkspaceCreatePage = observer(function WorkspaceCreatePage(_props: Route.
|
|||
return (
|
||||
<PageWrapper
|
||||
header={{
|
||||
title: "Create a new workspace on this instance.",
|
||||
description: "You will need to invite users from Workspace Settings after you create this workspace.",
|
||||
title: "Создать новый воркспейс",
|
||||
description: "После создания пригласите пользователей в настройках рабочего пространства.",
|
||||
}}
|
||||
>
|
||||
<WorkspaceCreateForm />
|
||||
|
|
@ -25,6 +25,6 @@ const WorkspaceCreatePage = observer(function WorkspaceCreatePage(_props: Route.
|
|||
);
|
||||
});
|
||||
|
||||
export const meta: Route.MetaFunction = () => [{ title: "Create Workspace - God Mode" }];
|
||||
export const meta: Route.MetaFunction = () => [{ title: "Создание воркспейса - NODE.DC" }];
|
||||
|
||||
export default WorkspaceCreatePage;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ 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";
|
||||
|
|
@ -26,6 +27,8 @@ 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 {
|
||||
|
|
@ -53,14 +56,14 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
|
|||
const updateConfigPromise = updateInstanceConfigurations(payload);
|
||||
|
||||
setPromiseToast(updateConfigPromise, {
|
||||
loading: "Saving configuration",
|
||||
loading: "Сохранение конфигурации",
|
||||
success: {
|
||||
title: "Success",
|
||||
message: () => "Configuration saved successfully",
|
||||
title: "Сохранено",
|
||||
message: () => "Конфигурация обновлена",
|
||||
},
|
||||
error: {
|
||||
title: "Error",
|
||||
message: () => "Failed to save configuration",
|
||||
title: "Ошибка",
|
||||
message: () => "Не удалось сохранить конфигурацию",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -77,8 +80,8 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
|
|||
return (
|
||||
<PageWrapper
|
||||
header={{
|
||||
title: "Workspaces on this instance",
|
||||
description: "See all workspaces and control who can create them.",
|
||||
title: "Воркспейсы инстанса",
|
||||
description: "Просматривайте все рабочие пространства и управляйте правом создания новых.",
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
|
|
@ -86,9 +89,9 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
|
|||
<div className={cn("flex w-full items-center gap-14 rounded-sm")}>
|
||||
<div className="flex grow items-center gap-4">
|
||||
<div className="grow">
|
||||
<div className="pb-1 text-16 font-medium">Prevent anyone else from creating a workspace.</div>
|
||||
<div className="pb-1 text-16 font-medium">Запретить пользователям создавать воркспейсы</div>
|
||||
<div className={cn("text-11 leading-5 font-regular text-tertiary")}>
|
||||
Toggling this on will let only you create workspaces. You will have to invite users to new workspaces.
|
||||
Если включить, создавать рабочие пространства сможет только администратор инстанса.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -119,25 +122,30 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
|
|||
<div className="flex items-center justify-between gap-2 pt-6">
|
||||
<div className="flex flex-col items-start gap-x-2">
|
||||
<div className="flex items-center gap-2 text-16 font-medium">
|
||||
All workspaces on this instance <span className="text-tertiary">• {workspaceIds.length}</span>
|
||||
Все воркспейсы инстанса <span className="text-tertiary">• {workspaceIds.length}</span>
|
||||
{workspaceLoader && ["mutation", "pagination"].includes(workspaceLoader) && (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("text-11 leading-5 font-regular text-tertiary")}>
|
||||
You can'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} />
|
||||
<WorkspaceListItem
|
||||
key={workspaceId}
|
||||
workspaceId={workspaceId}
|
||||
onMembersClick={setMembersWorkspaceId}
|
||||
onFeaturesClick={setFeaturesWorkspaceId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasNextPage && (
|
||||
|
|
@ -148,7 +156,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>
|
||||
|
|
@ -162,11 +170,21 @@ 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: "Workspace Management - God Mode" }];
|
||||
export const meta: Route.MetaFunction = () => [{ title: "Воркспейсы - NODE.DC" }];
|
||||
|
||||
export default WorkspaceManagementPage;
|
||||
|
|
|
|||
|
|
@ -5,14 +5,16 @@
|
|||
*/
|
||||
|
||||
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="/">
|
||||
<PlaneLockup height={20} width={95} className="text-primary" />
|
||||
<span className="tracking-normal text-16 font-semibold text-primary">NODE.DC</span>
|
||||
</Link>
|
||||
<span className="rounded-full bg-white/6 px-3 py-1 text-11 font-medium text-secondary">
|
||||
Глобальное администрирование
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,56 +22,56 @@ const errorCodeMessages: {
|
|||
} = {
|
||||
// admin
|
||||
[EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST]: {
|
||||
title: `Admin already exists`,
|
||||
message: () => `Admin already exists. Please try again.`,
|
||||
title: `Администратор уже существует`,
|
||||
message: () => `Администратор уже существует. Попробуйте еще раз.`,
|
||||
},
|
||||
[EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: {
|
||||
title: `Email, password and first name required`,
|
||||
message: () => `Email, password and first name required. Please try again.`,
|
||||
title: `Нужны email, пароль и имя`,
|
||||
message: () => `Укажите email, пароль и имя. Попробуйте еще раз.`,
|
||||
},
|
||||
[EAdminAuthErrorCodes.INVALID_ADMIN_EMAIL]: {
|
||||
title: `Invalid admin email`,
|
||||
message: () => `Invalid admin email. Please try again.`,
|
||||
title: `Некорректный email администратора`,
|
||||
message: () => `Некорректный email администратора. Попробуйте еще раз.`,
|
||||
},
|
||||
[EAdminAuthErrorCodes.INVALID_ADMIN_PASSWORD]: {
|
||||
title: `Invalid admin password`,
|
||||
message: () => `Invalid admin password. Please try again.`,
|
||||
title: `Некорректный пароль администратора`,
|
||||
message: () => `Некорректный пароль администратора. Попробуйте еще раз.`,
|
||||
},
|
||||
[EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: {
|
||||
title: `Email and password required`,
|
||||
message: () => `Email and password required. Please try again.`,
|
||||
title: `Нужны email и пароль`,
|
||||
message: () => `Укажите email и пароль. Попробуйте еще раз.`,
|
||||
},
|
||||
[EAdminAuthErrorCodes.ADMIN_AUTHENTICATION_FAILED]: {
|
||||
title: `Authentication failed`,
|
||||
message: () => `Authentication failed. Please try again.`,
|
||||
title: `Ошибка входа`,
|
||||
message: () => `Не удалось войти. Проверьте данные и попробуйте еще раз.`,
|
||||
},
|
||||
[EAdminAuthErrorCodes.ADMIN_USER_ALREADY_EXIST]: {
|
||||
title: `Admin user already exists`,
|
||||
title: `Администратор уже существует`,
|
||||
message: () => (
|
||||
<div>
|
||||
Admin user already exists.
|
||||
Администратор уже существует.
|
||||
<Link className="font-medium underline underline-offset-4 transition-all hover:font-bold" href={`/admin`}>
|
||||
Sign In
|
||||
Войти
|
||||
</Link>
|
||||
now.
|
||||
сейчас.
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[EAdminAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: {
|
||||
title: `Admin user does not exist`,
|
||||
title: `Администратор не найден`,
|
||||
message: () => (
|
||||
<div>
|
||||
Admin user does not exist.
|
||||
Администратор не найден.
|
||||
<Link className="font-medium underline underline-offset-4 transition-all hover:font-bold" href={`/admin`}>
|
||||
Sign In
|
||||
Войти
|
||||
</Link>
|
||||
now.
|
||||
сейчас.
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[EAdminAuthErrorCodes.ADMIN_USER_DEACTIVATED]: {
|
||||
title: `User account deactivated`,
|
||||
message: () => `User account deactivated. Please contact ${SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`,
|
||||
title: `Аккаунт деактивирован`,
|
||||
message: () => `Аккаунт деактивирован. Свяжитесь с ${SUPPORT_EMAIL ? SUPPORT_EMAIL : "администратором"}.`,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -92,8 +92,8 @@ export const authErrorHandler = (errorCode: EAdminAuthErrorCodes, email?: string
|
|||
return {
|
||||
type: EErrorAlertType.BANNER_ALERT,
|
||||
code: errorCode,
|
||||
title: errorCodeMessages[errorCode]?.title || "Error",
|
||||
message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.",
|
||||
title: errorCodeMessages[errorCode]?.title || "Ошибка",
|
||||
message: errorCodeMessages[errorCode]?.message(email) || "Что-то пошло не так. Попробуйте еще раз.",
|
||||
};
|
||||
|
||||
return undefined;
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ function RootLayout() {
|
|||
}, [replace, isUserLoggedIn]);
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-screen w-screen flex-col items-center overflow-hidden overflow-y-auto bg-surface-1 px-8 pt-6 pb-10">
|
||||
<div className="nodedc-auth-shell relative z-10 flex h-screen w-screen flex-col items-center overflow-hidden overflow-y-auto bg-surface-1 px-8 pt-6 pb-10">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -45,6 +45,6 @@ function HomePage() {
|
|||
export default observer(HomePage);
|
||||
|
||||
export const meta: Route.MetaFunction = () => [
|
||||
{ title: "Admin – Instance Setup & Sign-In" },
|
||||
{ name: "description", content: "Configure your Plane instance or sign in to the admin portal." },
|
||||
{ title: "NODE.DC - вход в админ-панель" },
|
||||
{ name: "description", content: "Настройка инстанса NODE.DC и вход в глобальную админ-панель." },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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="relative flex w-full max-w-[22.5rem] flex-col gap-6">
|
||||
<div className="nodedc-auth-card relative flex w-full max-w-[22.5rem] flex-col gap-6">
|
||||
<FormHeader
|
||||
heading="Manage your Plane instance"
|
||||
subHeading="Configure instance-wide settings to secure your instance"
|
||||
heading="Управление инстансом NODE.DC"
|
||||
subHeading="Войдите, чтобы менять глобальные настройки системы"
|
||||
/>
|
||||
<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">
|
||||
Email <span className="text-danger-primary">*</span>
|
||||
Электронная почта <span className="text-danger-primary">*</span>
|
||||
</label>
|
||||
<Input
|
||||
className="w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
|
||||
className="nodedc-settings-input w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
|
||||
id="email"
|
||||
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">
|
||||
Password <span className="text-danger-primary">*</span>
|
||||
Пароль <span className="text-danger-primary">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
|
||||
className="nodedc-settings-input w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
inputSize="md"
|
||||
placeholder="Enter your password"
|
||||
placeholder="Введите пароль"
|
||||
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" /> : "Sign in"}
|
||||
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Войти"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -17,19 +17,16 @@ 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 - Page not found" className="h-full w-full object-contain" />
|
||||
<img src={Image404} alt="404 - страница не найдена" className="h-full w-full object-contain" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
<h3 className="text-16 font-semibold">Страница не найдена</h3>
|
||||
<p className="text-13 text-secondary">Похоже, раздел был удален, переименован или временно недоступен.</p>
|
||||
</div>
|
||||
<Link to="/general/">
|
||||
<span className="flex justify-center py-4">
|
||||
<Button variant="secondary" size="lg">
|
||||
Go to general settings
|
||||
Перейти в основные настройки
|
||||
</Button>
|
||||
</span>
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -21,9 +21,8 @@ import interVariableWoff2 from "@fontsource-variable/inter/files/inter-latin-wgh
|
|||
import "@fontsource/material-symbols-rounded";
|
||||
import "@fontsource/ibm-plex-mono";
|
||||
|
||||
const APP_TITLE = "Plane | 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.";
|
||||
const APP_TITLE = "NODE.DC | Глобальное администрирование";
|
||||
const APP_DESCRIPTION = "Панель глобального администрирования инстанса NODE.DC.";
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
{ rel: "apple-touch-icon", sizes: "180x180", href: appleTouchIcon },
|
||||
|
|
@ -43,7 +42,7 @@ export const links: LinksFunction = () => [
|
|||
|
||||
export function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<html lang="ru" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
|
@ -66,15 +65,14 @@ export const meta: Route.MetaFunction = () => [
|
|||
{ property: "og:url", content: "https://plane.so/" },
|
||||
{
|
||||
name: "keywords",
|
||||
content:
|
||||
"software development, customer feedback, software, accelerate, code management, release management, project management, work items tracking, agile, scrum, kanban, collaboration",
|
||||
content: "NODE.DC, администрирование, рабочие пространства, проекты, пользователи, настройки инстанса",
|
||||
},
|
||||
{ name: "twitter:site", content: "@planepowers" },
|
||||
{ name: "twitter:site", content: "@nodedc" },
|
||||
];
|
||||
|
||||
export default function Root() {
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas">
|
||||
<div className="nodedc-admin-root min-h-screen bg-canvas">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -91,7 +89,7 @@ export function HydrateFallback() {
|
|||
export function ErrorBoundary({ error: _error }: Route.ErrorBoundaryProps) {
|
||||
return (
|
||||
<div>
|
||||
<p>Something went wrong.</p>
|
||||
<p>Что-то пошло не так.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export function AuthenticationMethodCard(props: Props) {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex w-full items-center gap-14 rounded-lg bg-layer-2", {
|
||||
className={cn("nodedc-settings-card flex w-full items-center gap-14 rounded-lg bg-layer-2", {
|
||||
"border border-subtle px-4 py-3": withBorder,
|
||||
})}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -11,26 +11,35 @@ type Props = {
|
|||
label?: string;
|
||||
href?: string;
|
||||
icon?: React.ReactNode | undefined;
|
||||
isCurrent?: boolean;
|
||||
};
|
||||
|
||||
export function BreadcrumbLink(props: Props) {
|
||||
const { href, label, icon } = props;
|
||||
const { href, label, icon, isCurrent = false } = props;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{icon && <div className="flex h-5 w-5 items-center justify-center overflow-hidden !text-16">{icon}</div>}
|
||||
<div className="relative line-clamp-1 block max-w-[150px] truncate overflow-hidden">{label}</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip tooltipContent={label} position="bottom">
|
||||
<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 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>
|
||||
</Tooltip>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export function CodeBlock({ children, className, darkerShade }: TProps) {
|
|||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-md border border-subtle bg-surface-2 px-0.5 text-11 font-semibold text-tertiary",
|
||||
"nodedc-code-chip rounded-md border border-subtle bg-surface-2 px-0.5 text-11 font-semibold text-tertiary",
|
||||
{
|
||||
"border-subtle bg-layer-1 text-secondary": darkerShade,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -45,27 +45,25 @@ 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="relative transform overflow-hidden rounded-lg bg-surface-1 text-left shadow-raised-200 transition-all sm:my-8 sm:w-[30rem]">
|
||||
<Dialog.Panel className="nodedc-glass-modal relative transform overflow-hidden rounded-[1.75rem] bg-surface-1 text-left shadow-raised-200 transition-all sm:my-8 sm:w-[30rem]">
|
||||
<div className="px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="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">
|
||||
Changes you made will be lost if you go back. Do you wish to go back?
|
||||
</p>
|
||||
<p className="text-13 text-placeholder">Если уйти назад, текущие правки будут потеряны.</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>
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export function ControllerInput(props: Props) {
|
|||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
rules={{ required: required ? `${label} is required.` : false }}
|
||||
rules={{ required: required ? `Поле "${label}" обязательно.` : 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("w-full rounded-md font-medium", {
|
||||
className={cn("nodedc-settings-input w-full rounded-md font-medium", {
|
||||
"pr-10": type === "password",
|
||||
})}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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">Refresh user attributes from {label} during sign in</h4>
|
||||
<h4 className="text-sm text-custom-text-300">Обновлять атрибуты пользователя из {label} при входе</h4>
|
||||
<div className="relative">
|
||||
<Controller
|
||||
control={control}
|
||||
|
|
|
|||
|
|
@ -32,18 +32,18 @@ export function CopyField(props: Props) {
|
|||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
className="flex items-center justify-between py-2"
|
||||
className="nodedc-settings-secondary-button flex w-full items-center justify-between gap-3 py-2 text-left"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(url);
|
||||
setToast({
|
||||
type: TOAST_TYPE.INFO,
|
||||
title: "Copied to clipboard",
|
||||
message: `The ${label} has been successfully copied to your clipboard`,
|
||||
title: "Скопировано",
|
||||
message: `${label} скопировано в буфер обмена`,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<p className="text-13 font-medium">{url}</p>
|
||||
<CopyIcon width={18} height={18} color="#B9B9B9" />
|
||||
<p className="min-w-0 truncate text-13 font-medium">{url}</p>
|
||||
<CopyIcon width={18} height={18} color="#B9B9B9" className="shrink-0" />
|
||||
</Button>
|
||||
<div className="text-11 text-tertiary">{description}</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,15 +5,15 @@
|
|||
*/
|
||||
|
||||
export const CORE_HEADER_SEGMENT_LABELS: Record<string, string> = {
|
||||
general: "General",
|
||||
ai: "Artificial Intelligence",
|
||||
email: "Email",
|
||||
authentication: "Authentication",
|
||||
image: "Image",
|
||||
general: "Основное",
|
||||
ai: "Искусственный интеллект",
|
||||
email: "Почта",
|
||||
authentication: "Аутентификация",
|
||||
image: "Изображения",
|
||||
google: "Google",
|
||||
github: "GitHub",
|
||||
gitlab: "GitLab",
|
||||
gitea: "Gitea",
|
||||
workspace: "Workspace",
|
||||
create: "Create",
|
||||
workspace: "Воркспейсы",
|
||||
create: "Создание",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,11 +4,10 @@
|
|||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Fragment } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Menu, Settings } from "lucide-react";
|
||||
// icons
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
import { ChevronRight, Menu, Settings } from "lucide-react";
|
||||
// components
|
||||
import { BreadcrumbLink } from "../breadcrumb-link";
|
||||
// hooks
|
||||
|
|
@ -22,6 +21,7 @@ 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,32 +56,28 @@ export const AdminHeader = observer(function AdminHeader() {
|
|||
const breadcrumbItems = generateBreadcrumbItems(pathName || "");
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-header w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-subtle bg-surface-1 p-4">
|
||||
<div className="nodedc-admin-header nodedc-glass-modal relative z-10 flex h-header w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-subtle bg-surface-1 p-4">
|
||||
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||
<HamburgerToggle />
|
||||
{breadcrumbItems.length >= 0 && (
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,20 +24,19 @@ export const NewUserPopup = observer(function NewUserPopup() {
|
|||
|
||||
if (!isNewUserPopup) return <></>;
|
||||
return (
|
||||
<div className="shadow-md absolute right-8 bottom-8 w-96 rounded-lg border border-subtle bg-surface-1 p-6">
|
||||
<div className="nodedc-glass-modal shadow-md absolute right-8 bottom-8 w-96 rounded-[1.75rem] border border-subtle bg-surface-1 p-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="grow">
|
||||
<div className="text-14 font-semibold">Create workspace</div>
|
||||
<div className="text-14 font-semibold">Создать воркспейс</div>
|
||||
<div className="py-2 text-13 font-medium text-tertiary">
|
||||
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>
|
||||
|
|
@ -46,7 +45,7 @@ export const NewUserPopup = observer(function NewUserPopup() {
|
|||
src={resolveGeneralTheme(resolvedTheme) === "dark" ? TakeoffIconDark : TakeoffIconLight}
|
||||
height={80}
|
||||
width={80}
|
||||
alt="Plane icon"
|
||||
alt="NODE.DC"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ type TPageHeader = {
|
|||
};
|
||||
|
||||
export function PageHeader(props: TPageHeader) {
|
||||
const { title = "God Mode - Plane", description = "Plane god mode" } = props;
|
||||
const { title = "NODE.DC - глобальное администрирование", description = "Глобальное администрирование NODE.DC" } =
|
||||
props;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -24,16 +24,16 @@ export const PageWrapper = (props: TPageWrapperProps) => {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={cn("mx-auto h-full w-full space-y-6 py-4", {
|
||||
className={cn("nodedc-page mx-auto h-full w-full space-y-6 py-4", {
|
||||
"max-w-[1000px] md:px-4 2xl:max-w-[1200px]": size === "md",
|
||||
"px-4 lg:px-12": size === "lg",
|
||||
})}
|
||||
>
|
||||
{customHeader ? (
|
||||
<div className="mx-4 shrink-0 space-y-1 border-b border-subtle py-4">{customHeader}</div>
|
||||
<div className="nodedc-page-header mx-4 shrink-0 space-y-1 border-b border-subtle py-4">{customHeader}</div>
|
||||
) : (
|
||||
header && (
|
||||
<div className="mx-4 flex shrink-0 items-center justify-between gap-4 space-y-1 border-b border-subtle py-4">
|
||||
<div className="nodedc-page-header mx-4 flex shrink-0 items-center justify-between gap-4 space-y-1 border-b border-subtle py-4">
|
||||
<div className={header.actions ? "flex flex-col gap-1" : "space-y-1"}>
|
||||
<div className="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="vertical-scrollbar scrollbar-sm flex-grow overflow-hidden overflow-y-scroll px-4 pb-4">
|
||||
<div className="nodedc-page-body vertical-scrollbar scrollbar-sm flex-grow overflow-hidden overflow-y-scroll px-4 pb-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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="Instance failure illustration" />
|
||||
<h3 className="text-center text-20 font-medium text-on-color">Unable to fetch instance details.</h3>
|
||||
<img src={instanceImage} alt="Ошибка загрузки инстанса" />
|
||||
<h3 className="text-center text-20 font-medium text-on-color">Не удалось загрузить данные инстанса.</h3>
|
||||
<p className="text-center text-14 font-medium">
|
||||
We were unable to fetch the details of the instance. Fret not, it might just be a connectivity issue.
|
||||
Проверьте соединение с API и попробуйте обновить страницу.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button size="lg" onClick={handleRetry}>
|
||||
Retry
|
||||
Повторить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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">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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Link href={"/setup/?auth_enabled=0"}>
|
||||
<Button size="xl" className="w-full">
|
||||
Get started
|
||||
Начать
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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="relative flex w-full max-w-[22.5rem] flex-col gap-6">
|
||||
<div className="nodedc-auth-card relative flex w-full max-w-[22.5rem] flex-col gap-6">
|
||||
<FormHeader
|
||||
heading="Setup your Plane Instance"
|
||||
subHeading="Post setup you will be able to manage this Plane instance."
|
||||
heading="Настройка инстанса NODE.DC"
|
||||
subHeading="После настройки вы получите доступ к глобальному администрированию."
|
||||
/>
|
||||
{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">
|
||||
First name <span className="text-danger-primary">*</span>
|
||||
Имя <span className="text-danger-primary">*</span>
|
||||
</label>
|
||||
<Input
|
||||
className="w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
|
||||
className="nodedc-settings-input w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
type="text"
|
||||
inputSize="md"
|
||||
placeholder="Wilber"
|
||||
placeholder="Иван"
|
||||
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">
|
||||
Last name <span className="text-danger-primary">*</span>
|
||||
Фамилия <span className="text-danger-primary">*</span>
|
||||
</label>
|
||||
<Input
|
||||
className="w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
|
||||
className="nodedc-settings-input w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
type="text"
|
||||
inputSize="md"
|
||||
placeholder="Wright"
|
||||
placeholder="Иванов"
|
||||
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">
|
||||
Email <span className="text-danger-primary">*</span>
|
||||
Электронная почта <span className="text-danger-primary">*</span>
|
||||
</label>
|
||||
<Input
|
||||
className="w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
|
||||
className="nodedc-settings-input w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
|
||||
id="email"
|
||||
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">
|
||||
Company name <span className="text-danger-primary">*</span>
|
||||
Название компании <span className="text-danger-primary">*</span>
|
||||
</label>
|
||||
<Input
|
||||
className="w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
|
||||
className="nodedc-settings-input w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
type="text"
|
||||
inputSize="md"
|
||||
placeholder="Company name"
|
||||
placeholder="Название компании"
|
||||
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">
|
||||
Set a password <span className="text-danger-primary">*</span>
|
||||
Задайте пароль <span className="text-danger-primary">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
|
||||
className="nodedc-settings-input w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword.password ? "text" : "password"}
|
||||
inputSize="md"
|
||||
placeholder="New password"
|
||||
placeholder="Новый пароль"
|
||||
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">
|
||||
Confirm password <span className="text-danger-primary">*</span>
|
||||
Подтвердите пароль <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="Confirm password"
|
||||
className="w-full border border-subtle !bg-surface-1 pr-12 placeholder:text-placeholder"
|
||||
placeholder="Повторите пароль"
|
||||
className="nodedc-settings-input w-full border border-subtle !bg-surface-1 pr-12 placeholder:text-placeholder"
|
||||
onFocus={() => setIsRetryPasswordInputFocused(true)}
|
||||
onBlur={() => setIsRetryPasswordInputFocused(false)}
|
||||
autoComplete="new-password"
|
||||
|
|
@ -336,9 +336,7 @@ export function InstanceSetupForm() {
|
|||
</div>
|
||||
{!!formData.confirm_password &&
|
||||
formData.password !== formData.confirm_password &&
|
||||
renderPasswordMatchError && (
|
||||
<span className="text-13 text-danger-primary">Passwords don{"'"}t match</span>
|
||||
)}
|
||||
renderPasswordMatchError && <span className="text-13 text-danger-primary">Пароли не совпадают</span>}
|
||||
</div>
|
||||
|
||||
<div className="relative flex gap-2">
|
||||
|
|
@ -352,7 +350,7 @@ export function InstanceSetupForm() {
|
|||
/>
|
||||
</div>
|
||||
<label className="cursor-pointer text-13 font-medium text-tertiary" htmlFor="is_telemetry_enabled">
|
||||
Allow Plane to anonymously collect usage events.{" "}
|
||||
Разрешить NODE.DC анонимно собирать события использования.{" "}
|
||||
<a
|
||||
tabIndex={-1}
|
||||
href="https://developers.plane.so/self-hosting/telemetry"
|
||||
|
|
@ -360,14 +358,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" /> : "Continue"}
|
||||
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Продолжить"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,536 @@
|
|||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Fragment, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import useSWR from "swr";
|
||||
import { Ban, CheckCircle2, Loader, ShieldCheck, Trash2, UsersRound, X } from "lucide-react";
|
||||
// plane imports
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
|
||||
import { InstanceWorkspaceService } from "@plane/services";
|
||||
import type { TInstanceWorkspaceFeature, TInstanceWorkspaceMember } from "@plane/types";
|
||||
import { CustomSelect, ToggleSwitch } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
|
||||
type TWorkspaceAdminModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
workspaceId: string | null;
|
||||
};
|
||||
|
||||
const instanceWorkspaceService = new InstanceWorkspaceService();
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ label: "Гость", value: 5 },
|
||||
{ label: "Участник", value: 15 },
|
||||
{ label: "Администратор", value: 20 },
|
||||
];
|
||||
|
||||
const ROLE_LABELS: Record<number, string> = {
|
||||
5: "Гость",
|
||||
15: "Участник",
|
||||
20: "Администратор",
|
||||
};
|
||||
|
||||
const BAN_DURATION_OPTIONS = [
|
||||
{ label: "На 24 часа", value: "1d", hours: 24 },
|
||||
{ label: "На 7 дней", value: "7d", hours: 24 * 7 },
|
||||
{ label: "На 30 дней", value: "30d", hours: 24 * 30 },
|
||||
{ label: "До ручного разбана", value: "manual", hours: null },
|
||||
];
|
||||
|
||||
const ACCESS_MODE_LABELS: Record<TInstanceWorkspaceFeature["access_mode"], string> = {
|
||||
all_workspace_members: "Весь воркспейс",
|
||||
admins_only: "Только админы",
|
||||
selected_projects: "По контурам",
|
||||
selected_members: "По людям",
|
||||
};
|
||||
|
||||
function getMemberName(member: TInstanceWorkspaceMember) {
|
||||
return member.member.display_name || member.member.email || "Пользователь";
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
if (error && typeof error === "object" && "error" in error && typeof error.error === "string") return error.error;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getBanUntilIso(duration: string) {
|
||||
const option = BAN_DURATION_OPTIONS.find((item) => item.value === duration);
|
||||
if (!option?.hours) return null;
|
||||
|
||||
return new Date(Date.now() + option.hours * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
function formatBanUntil(bannedUntil?: string | null) {
|
||||
if (!bannedUntil) return "до ручного разбана";
|
||||
|
||||
return `до ${new Intl.DateTimeFormat("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(bannedUntil))}`;
|
||||
}
|
||||
|
||||
function RoleSelect(props: { disabled: boolean; onChange: (role: number) => void; value: number }) {
|
||||
const { disabled, onChange, value } = props;
|
||||
|
||||
return (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={<span className="truncate">{ROLE_LABELS[value] ?? "Роль"}</span>}
|
||||
onChange={(role: number) => onChange(Number(role))}
|
||||
buttonClassName="nodedc-settings-select h-10 min-h-10 w-full justify-between px-3 text-12"
|
||||
className="w-full"
|
||||
disabled={disabled}
|
||||
input
|
||||
maxHeight="sm"
|
||||
optionsClassName="z-[80] min-w-[12rem]"
|
||||
placement="bottom-start"
|
||||
>
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<CustomSelect.Option key={role.value} value={role.value} className="w-full">
|
||||
<span className="text-12 font-medium">{role.label}</span>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
);
|
||||
}
|
||||
|
||||
type TPendingMemberAction = {
|
||||
type: "ban" | "remove" | "unban";
|
||||
workspaceMember: TInstanceWorkspaceMember;
|
||||
};
|
||||
|
||||
function MemberActionConfirmModal(props: {
|
||||
action: TPendingMemberAction | null;
|
||||
banDuration: string;
|
||||
isLoading: boolean;
|
||||
onBanDurationChange: (duration: string) => void;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const { action, banDuration, isLoading, onBanDurationChange, onClose, onConfirm } = props;
|
||||
const memberName = action ? getMemberName(action.workspaceMember) : "";
|
||||
const title =
|
||||
action?.type === "ban"
|
||||
? "Заблокировать участника"
|
||||
: action?.type === "unban"
|
||||
? "Разблокировать участника"
|
||||
: "Удалить участника";
|
||||
const description =
|
||||
action?.type === "ban"
|
||||
? `${memberName} не сможет открыть этот воркспейс и принять новые приглашения до снятия блокировки.`
|
||||
: action?.type === "unban"
|
||||
? `${memberName} снова сможет войти в воркспейс. Проектные доступы после блокировки выдаются отдельно.`
|
||||
: `${memberName} будет удален из воркспейса и всех его проектов. Это крайний вариант удаления доступа.`;
|
||||
const confirmLabel = action?.type === "ban" ? "Заблокировать" : action?.type === "unban" ? "Разблокировать" : "Удалить";
|
||||
|
||||
return (
|
||||
<Transition.Root show={Boolean(action)} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-50" onClose={isLoading ? () => undefined : onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-150"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/45 backdrop-blur-xl" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="translate-y-2 opacity-0 scale-95"
|
||||
enterTo="translate-y-0 opacity-100 scale-100"
|
||||
leave="ease-in duration-150"
|
||||
leaveFrom="translate-y-0 opacity-100 scale-100"
|
||||
leaveTo="translate-y-2 opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="nodedc-glass-modal nodedc-technical-confirm-modal w-full max-w-[33rem] rounded-[1.75rem] p-6 text-left">
|
||||
<Dialog.Title as="h3" className="text-17 font-semibold text-primary">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
<p className="mt-2 text-13 leading-5 text-secondary">{description}</p>
|
||||
|
||||
{action?.type === "ban" && (
|
||||
<div className="mt-5">
|
||||
<div className="mb-2 text-12 font-medium text-tertiary">Срок блокировки</div>
|
||||
<CustomSelect
|
||||
value={banDuration}
|
||||
label={BAN_DURATION_OPTIONS.find((option) => option.value === banDuration)?.label}
|
||||
onChange={(duration: string) => onBanDurationChange(duration)}
|
||||
buttonClassName="nodedc-settings-select h-11 min-h-11 w-full justify-between px-4 text-13"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
input
|
||||
maxHeight="sm"
|
||||
optionsClassName="z-[90]"
|
||||
>
|
||||
{BAN_DURATION_OPTIONS.map((option) => (
|
||||
<CustomSelect.Option key={option.value} value={option.value} className="w-full">
|
||||
{option.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2.5">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="nodedc-settings-save-button min-w-[8.5rem] justify-center"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={onConfirm}
|
||||
loading={isLoading}
|
||||
className="nodedc-settings-secondary-button min-w-[8.5rem] justify-center"
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceModalShell(props: TWorkspaceAdminModalProps & { children: ReactNode; title: string; icon: ReactNode }) {
|
||||
const { children, icon, isOpen, onClose, title, workspaceId } = props;
|
||||
const { getWorkspaceById } = useWorkspace();
|
||||
const workspace = workspaceId ? getWorkspaceById(workspaceId) : undefined;
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-40" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-150"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="translate-y-2 opacity-0 scale-95"
|
||||
enterTo="translate-y-0 opacity-100 scale-100"
|
||||
leave="ease-in duration-150"
|
||||
leaveFrom="translate-y-0 opacity-100 scale-100"
|
||||
leaveTo="translate-y-2 opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="nodedc-glass-modal relative w-full max-w-[80rem] overflow-hidden rounded-[1.75rem] bg-surface-1 text-left shadow-raised-200 transition-all">
|
||||
<div className="flex items-start justify-between gap-4 p-5 pr-16">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-layer-2 text-accent-primary">
|
||||
{icon}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<Dialog.Title as="h3" className="truncate text-18 font-semibold text-primary">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
<div className="mt-1 truncate text-13 text-secondary">
|
||||
{workspace ? `${workspace.name} / [${workspace.slug}]` : "Воркспейс"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute top-2 right-2 flex h-10 min-h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/6 p-0 text-primary transition hover:bg-white/10 hover:text-primary focus-visible:outline-none"
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<X className="h-5 w-5 stroke-[2.2]" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[72vh] overflow-y-auto p-5">{children}</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceMembersModal(props: TWorkspaceAdminModalProps) {
|
||||
const { isOpen, workspaceId } = props;
|
||||
const [banDuration, setBanDuration] = useState("7d");
|
||||
const [mutatingMemberId, setMutatingMemberId] = useState<string | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<TPendingMemberAction | null>(null);
|
||||
const { data, isLoading, mutate } = useSWR(
|
||||
isOpen && workspaceId ? ["INSTANCE_WORKSPACE_MEMBERS", workspaceId] : null,
|
||||
() => instanceWorkspaceService.listMembers(workspaceId as string)
|
||||
);
|
||||
|
||||
const handleRoleChange = async (workspaceMemberId: string, role: number) => {
|
||||
if (!workspaceId) return;
|
||||
setMutatingMemberId(workspaceMemberId);
|
||||
try {
|
||||
await instanceWorkspaceService.updateMemberRole(workspaceId, workspaceMemberId, role);
|
||||
await mutate();
|
||||
setToast({ type: TOAST_TYPE.SUCCESS, title: "Роль участника обновлена" });
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Не удалось обновить роль",
|
||||
message: getErrorMessage(error, "Проверьте ограничения по администраторам воркспейса и проектов."),
|
||||
});
|
||||
} finally {
|
||||
setMutatingMemberId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMemberBan = async (workspaceMember: TInstanceWorkspaceMember, isBanned: boolean) => {
|
||||
if (!workspaceId) return;
|
||||
|
||||
setMutatingMemberId(workspaceMember.id);
|
||||
try {
|
||||
await instanceWorkspaceService.updateMemberBan(workspaceId, workspaceMember.id, {
|
||||
is_banned: isBanned,
|
||||
banned_until: isBanned ? getBanUntilIso(banDuration) : null,
|
||||
});
|
||||
await mutate();
|
||||
setToast({ type: TOAST_TYPE.SUCCESS, title: isBanned ? "Участник заблокирован" : "Участник разблокирован" });
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: isBanned ? "Не удалось заблокировать участника" : "Не удалось разблокировать участника",
|
||||
message: getErrorMessage(error, "Пользователь может быть единственным администратором."),
|
||||
});
|
||||
} finally {
|
||||
setMutatingMemberId(null);
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (workspaceMember: TInstanceWorkspaceMember) => {
|
||||
if (!workspaceId) return;
|
||||
|
||||
setMutatingMemberId(workspaceMember.id);
|
||||
try {
|
||||
await instanceWorkspaceService.removeMember(workspaceId, workspaceMember.id);
|
||||
await mutate();
|
||||
setToast({ type: TOAST_TYPE.SUCCESS, title: "Участник удален из воркспейса" });
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Не удалось удалить участника",
|
||||
message: getErrorMessage(error, "Пользователь может быть единственным администратором."),
|
||||
});
|
||||
} finally {
|
||||
setMutatingMemberId(null);
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmAction = () => {
|
||||
if (!pendingAction) return;
|
||||
|
||||
if (pendingAction.type === "ban") return handleMemberBan(pendingAction.workspaceMember, true);
|
||||
if (pendingAction.type === "unban") return handleMemberBan(pendingAction.workspaceMember, false);
|
||||
return handleRemove(pendingAction.workspaceMember);
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkspaceModalShell {...props} title="Участники воркспейса" icon={<UsersRound className="h-5 w-5" />}>
|
||||
<MemberActionConfirmModal
|
||||
action={pendingAction}
|
||||
banDuration={banDuration}
|
||||
isLoading={Boolean(mutatingMemberId)}
|
||||
onBanDurationChange={setBanDuration}
|
||||
onClose={() => setPendingAction(null)}
|
||||
onConfirm={handleConfirmAction}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-40 items-center justify-center text-secondary">
|
||||
<Loader className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-[1.35rem] bg-layer-1">
|
||||
<div className="grid grid-cols-[minmax(14rem,1.45fr)_13rem_6rem_8rem_20rem] gap-3 border-b border-subtle px-4 py-3 text-11 font-semibold uppercase text-tertiary">
|
||||
<div>Пользователь</div>
|
||||
<div>Роль</div>
|
||||
<div>Проекты</div>
|
||||
<div>Админ проектов</div>
|
||||
<div className="text-right">Доступ</div>
|
||||
</div>
|
||||
<div className="divide-y divide-subtle">
|
||||
{(data ?? []).map((workspaceMember) => (
|
||||
<div
|
||||
key={workspaceMember.id}
|
||||
className="grid grid-cols-[minmax(14rem,1.45fr)_13rem_6rem_8rem_20rem] items-center gap-3 px-4 py-3 text-13"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="truncate font-medium text-primary">{getMemberName(workspaceMember)}</div>
|
||||
{workspaceMember.is_banned && (
|
||||
<span className="shrink-0 rounded-full bg-red-500/12 px-2 py-0.5 text-10 font-semibold uppercase text-red-200">
|
||||
Блокировка {formatBanUntil(workspaceMember.banned_until)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-12 text-tertiary">{workspaceMember.member.email}</div>
|
||||
</div>
|
||||
<RoleSelect
|
||||
value={Number(workspaceMember.role)}
|
||||
disabled={mutatingMemberId === workspaceMember.id}
|
||||
onChange={(role) => handleRoleChange(workspaceMember.id, role)}
|
||||
/>
|
||||
<div className="text-secondary">{workspaceMember.active_project_count}</div>
|
||||
<div className="text-secondary">{workspaceMember.admin_project_count}</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingAction({ type: workspaceMember.is_banned ? "unban" : "ban", workspaceMember })}
|
||||
disabled={mutatingMemberId === workspaceMember.id}
|
||||
className="nodedc-settings-secondary-button flex h-9 min-h-9 min-w-[9.5rem] items-center justify-center gap-2 whitespace-nowrap px-3 text-12"
|
||||
aria-label={`${workspaceMember.is_banned ? "Разблокировать" : "Заблокировать"}: ${getMemberName(workspaceMember)}`}
|
||||
>
|
||||
{mutatingMemberId === workspaceMember.id ? (
|
||||
<Loader className="h-3.5 w-3.5 animate-spin" />
|
||||
) : workspaceMember.is_banned ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Ban className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{workspaceMember.is_banned ? "Разблокировать" : "Заблокировать"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingAction({ type: "remove", workspaceMember })}
|
||||
disabled={mutatingMemberId === workspaceMember.id}
|
||||
className="nodedc-settings-secondary-button flex h-9 min-h-9 min-w-[7.5rem] items-center justify-center gap-2 whitespace-nowrap px-3 text-12"
|
||||
aria-label={`Удалить участника: ${getMemberName(workspaceMember)}`}
|
||||
>
|
||||
{mutatingMemberId === workspaceMember.id ? (
|
||||
<Loader className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{(data ?? []).length === 0 && <div className="px-4 py-8 text-center text-13 text-tertiary">Активных участников нет</div>}
|
||||
</div>
|
||||
)}
|
||||
</WorkspaceModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceFeaturesModal(props: TWorkspaceAdminModalProps) {
|
||||
const { isOpen, workspaceId } = props;
|
||||
const [mutatingFeatureKey, setMutatingFeatureKey] = useState<string | null>(null);
|
||||
const { data, isLoading, mutate } = useSWR(
|
||||
isOpen && workspaceId ? ["INSTANCE_WORKSPACE_FEATURES", workspaceId] : null,
|
||||
() => instanceWorkspaceService.retrieveFeatures(workspaceId as string)
|
||||
);
|
||||
|
||||
const handleToggle = async (feature: TInstanceWorkspaceFeature) => {
|
||||
if (!workspaceId) return;
|
||||
setMutatingFeatureKey(feature.key);
|
||||
try {
|
||||
await instanceWorkspaceService.updateFeature(workspaceId, feature.key, !feature.is_enabled);
|
||||
await mutate();
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: feature.is_enabled ? "Фича отключена для воркспейса" : "Фича выдана воркспейсу",
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Не удалось обновить доступ",
|
||||
message: getErrorMessage(error, "Попробуйте еще раз."),
|
||||
});
|
||||
} finally {
|
||||
setMutatingFeatureKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkspaceModalShell {...props} title="Функции воркспейса" icon={<ShieldCheck className="h-5 w-5" />}>
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-40 items-center justify-center text-secondary">
|
||||
<Loader className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{(data?.features ?? []).map((feature) => (
|
||||
<div key={feature.key} className="nodedc-settings-card flex items-center justify-between gap-5 p-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-15 font-semibold text-primary">{feature.title}</div>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-11 font-medium",
|
||||
feature.is_enabled ? "bg-accent-primary text-on-color" : "bg-layer-2 text-tertiary"
|
||||
)}
|
||||
>
|
||||
{feature.is_enabled ? "Доступ выдан" : "Не выдано"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-13 text-secondary">{feature.description}</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-11 text-tertiary">
|
||||
<span className="rounded-full bg-layer-2 px-2 py-1">
|
||||
Workspace: {feature.workspace_setting_enabled ? "включено" : "выключено"}
|
||||
</span>
|
||||
<span className="rounded-full bg-layer-2 px-2 py-1">Доступ: {ACCESS_MODE_LABELS[feature.access_mode]}</span>
|
||||
<span className="rounded-full bg-layer-2 px-2 py-1">
|
||||
OpenAI key: {feature.has_workspace_key ? "есть" : "нет"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<ToggleSwitch
|
||||
value={feature.is_enabled}
|
||||
onChange={() => handleToggle(feature)}
|
||||
size="sm"
|
||||
disabled={mutatingFeatureKey === feature.key}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(data?.features ?? []).length === 0 && <div className="px-4 py-8 text-center text-13 text-tertiary">Функции не найдены</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-5 flex justify-end">
|
||||
<Button variant="secondary" size="lg" onClick={props.onClose} className="nodedc-settings-secondary-button">
|
||||
Закрыть
|
||||
</Button>
|
||||
</div>
|
||||
</WorkspaceModalShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,20 +5,26 @@
|
|||
*/
|
||||
|
||||
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({ workspaceId }: TWorkspaceListItemProps) {
|
||||
export const WorkspaceListItem = observer(function WorkspaceListItem({
|
||||
onFeaturesClick,
|
||||
onMembersClick,
|
||||
workspaceId,
|
||||
}: TWorkspaceListItemProps) {
|
||||
// store hooks
|
||||
const { getWorkspaceById } = useWorkspace();
|
||||
// derived values
|
||||
|
|
@ -26,14 +32,11 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
|
|||
|
||||
if (!workspace) return null;
|
||||
return (
|
||||
<a
|
||||
<div
|
||||
key={workspaceId}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex min-w-0 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"
|
||||
|
|
@ -43,29 +46,29 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
|
|||
<img
|
||||
src={getFileURL(workspace.logo_url)}
|
||||
className="absolute top-0 left-0 h-full w-full rounded-sm object-cover"
|
||||
alt="Workspace Logo"
|
||||
alt="Логотип воркспейса"
|
||||
/>
|
||||
) : (
|
||||
(workspace?.name?.[0] ?? "...")
|
||||
)}
|
||||
</span>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="flex min-w-0 flex-col items-start gap-1">
|
||||
<div className="flex w-full flex-wrap items-center gap-2.5">
|
||||
<h3 className={`text-14 font-medium capitalize`}>{workspace.name}</h3>/
|
||||
<Tooltip tooltipContent="The unique URL of your workspace">
|
||||
<h3 className={`truncate text-14 font-medium capitalize`}>{workspace.name}</h3>/
|
||||
<Tooltip tooltipContent="Уникальный URL воркспейса">
|
||||
<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">Owned by:</h3>
|
||||
<h3 className="font-medium text-secondary">Владелец:</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">Total projects:</h3>
|
||||
<h3 className="font-medium text-secondary">Проектов:</h3>
|
||||
<h4 className="text-tertiary">{workspace.total_projects}</h4>
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -73,7 +76,7 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
|
|||
<>
|
||||
•
|
||||
<span className="flex items-center gap-1">
|
||||
<h3 className="font-medium text-secondary">Total members:</h3>
|
||||
<h3 className="font-medium text-secondary">Участников:</h3>
|
||||
<h4 className="text-tertiary">{workspace.total_members}</h4>
|
||||
</span>
|
||||
</>
|
||||
|
|
@ -81,9 +84,39 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<NewTabIcon width={14} height={16} className="text-placeholder group-hover:text-secondary" />
|
||||
<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>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,17 +35,16 @@ export const getCoreAuthenticationModesMap: (
|
|||
}) => ({
|
||||
"unique-codes": {
|
||||
key: "unique-codes",
|
||||
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.",
|
||||
name: "Одноразовые коды",
|
||||
description: "Вход и регистрация по кодам из email. Для этого способа нужен настроенный SMTP.",
|
||||
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: "Passwords",
|
||||
description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.",
|
||||
name: "Пароли",
|
||||
description: "Пользователи создают аккаунты с паролем и входят по email.",
|
||||
icon: <KeyRound className="h-6 w-6 p-0.5 text-tertiary" />,
|
||||
config: <PasswordLoginConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
enabledConfigKey: "ENABLE_EMAIL_PASSWORD",
|
||||
|
|
@ -53,7 +52,7 @@ export const getCoreAuthenticationModesMap: (
|
|||
google: {
|
||||
key: "google",
|
||||
name: "Google",
|
||||
description: "Allow members to log in or sign up for Plane with their Google accounts.",
|
||||
description: "Вход и регистрация через аккаунты Google.",
|
||||
icon: <img src={googleLogo} height={20} width={20} alt="Google Logo" />,
|
||||
config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
enabledConfigKey: "IS_GOOGLE_ENABLED",
|
||||
|
|
@ -61,7 +60,7 @@ export const getCoreAuthenticationModesMap: (
|
|||
github: {
|
||||
key: "github",
|
||||
name: "GitHub",
|
||||
description: "Allow members to log in or sign up for Plane with their GitHub accounts.",
|
||||
description: "Вход и регистрация через аккаунты GitHub.",
|
||||
icon: (
|
||||
<img
|
||||
src={resolvedTheme === "dark" ? githubDarkModeImage : githubLightModeImage}
|
||||
|
|
@ -76,7 +75,7 @@ export const getCoreAuthenticationModesMap: (
|
|||
gitlab: {
|
||||
key: "gitlab",
|
||||
name: "GitLab",
|
||||
description: "Allow members to log in or sign up to plane with their GitLab accounts.",
|
||||
description: "Вход и регистрация через аккаунты GitLab.",
|
||||
icon: <img src={gitlabLogo} height={20} width={20} alt="GitLab Logo" />,
|
||||
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
enabledConfigKey: "IS_GITLAB_ENABLED",
|
||||
|
|
@ -84,7 +83,7 @@ export const getCoreAuthenticationModesMap: (
|
|||
gitea: {
|
||||
key: "gitea",
|
||||
name: "Gitea",
|
||||
description: "Allow members to log in or sign up to plane with their Gitea accounts.",
|
||||
description: "Вход и регистрация через аккаунты Gitea.",
|
||||
icon: <img src={giteaLogo} height={20} width={20} alt="Gitea Logo" />,
|
||||
config: <GiteaConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
enabledConfigKey: "IS_GITEA_ENABLED",
|
||||
|
|
|
|||
|
|
@ -15,38 +15,38 @@ export type TCoreSidebarMenuKey = "general" | "email" | "workspace" | "authentic
|
|||
export const coreSidebarMenuLinks: Record<TCoreSidebarMenuKey, TSidebarMenuItem> = {
|
||||
general: {
|
||||
Icon: Cog,
|
||||
name: "General",
|
||||
description: "Identify your instances and get key details.",
|
||||
name: "Основное",
|
||||
description: "Имя инстанса, ID и телеметрия.",
|
||||
href: `/general/`,
|
||||
},
|
||||
email: {
|
||||
Icon: Mail,
|
||||
name: "Email",
|
||||
description: "Configure your SMTP controls.",
|
||||
name: "Почта",
|
||||
description: "SMTP и тестовая отправка.",
|
||||
href: `/email/`,
|
||||
},
|
||||
workspace: {
|
||||
Icon: WorkspaceIcon,
|
||||
name: "Workspaces",
|
||||
description: "Manage all workspaces on this instance.",
|
||||
name: "Воркспейсы",
|
||||
description: "Все рабочие пространства инстанса.",
|
||||
href: `/workspace/`,
|
||||
},
|
||||
authentication: {
|
||||
Icon: LockIcon,
|
||||
name: "Authentication",
|
||||
description: "Configure authentication modes.",
|
||||
name: "Аутентификация",
|
||||
description: "Вход, регистрация и OAuth.",
|
||||
href: `/authentication/`,
|
||||
},
|
||||
ai: {
|
||||
Icon: BrainCog,
|
||||
name: "Artificial intelligence",
|
||||
description: "Configure your OpenAI creds.",
|
||||
name: "ИИ",
|
||||
description: "OpenAI модель и ключ API.",
|
||||
href: `/ai/`,
|
||||
},
|
||||
image: {
|
||||
Icon: Image,
|
||||
name: "Images in Plane",
|
||||
description: "Allow third-party image libraries.",
|
||||
name: "Изображения",
|
||||
description: "Внешние библиотеки изображений.",
|
||||
href: `/image/`,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ http {
|
|||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /god-mode/index.html;
|
||||
try_files $uri $uri/ /nodedcsudo/index.html;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ const DEFAULT_SWR_CONFIG = {
|
|||
|
||||
export function CoreProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem>
|
||||
<ThemeProvider themes={["light", "dark"]} defaultTheme="dark" enableSystem>
|
||||
<AppProgressBar />
|
||||
<ToastWithTheme />
|
||||
<SWRConfig value={DEFAULT_SWR_CONFIG}>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,27 @@
|
|||
@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 */
|
||||
}
|
||||
|
|
@ -40,3 +62,510 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ from rest_framework import status
|
|||
|
||||
from enum import Enum
|
||||
|
||||
from plane.utils.workspace_bans import release_expired_workspace_bans
|
||||
|
||||
|
||||
class ROLE(Enum):
|
||||
ADMIN = 20
|
||||
|
|
@ -20,6 +22,9 @@ 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
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ 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):
|
||||
|
|
@ -15,6 +21,8 @@ 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(
|
||||
|
|
@ -58,6 +66,8 @@ 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(
|
||||
|
|
@ -87,6 +97,8 @@ 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:
|
||||
|
|
@ -121,6 +133,8 @@ 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,
|
||||
|
|
@ -135,6 +149,8 @@ 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,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ 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
|
||||
|
|
@ -15,6 +16,11 @@ 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):
|
||||
|
|
@ -25,6 +31,8 @@ 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
|
||||
|
|
@ -53,6 +61,8 @@ 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()
|
||||
|
|
@ -63,6 +73,8 @@ 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,
|
||||
|
|
@ -76,6 +88,8 @@ 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(
|
||||
|
|
@ -95,6 +109,8 @@ 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()
|
||||
|
|
@ -105,6 +121,8 @@ 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()
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
from rest_framework import serializers
|
||||
|
||||
from plane.db.models import Project, WorkspaceAICredential, WorkspaceAISettings
|
||||
from plane.db.models import Project, WorkspaceAICredential, WorkspaceAISettings, WorkspaceMember
|
||||
from plane.license.utils.encryption import encrypt_data
|
||||
|
||||
from .base import BaseSerializer
|
||||
|
|
@ -12,6 +12,8 @@ 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)
|
||||
|
||||
|
|
@ -26,9 +28,16 @@ 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",
|
||||
|
|
@ -45,6 +54,12 @@ 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
|
||||
|
|
@ -54,6 +69,32 @@ 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.")
|
||||
|
|
@ -69,9 +110,36 @@ 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
|
||||
|
|
@ -81,6 +149,12 @@ 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(
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ from django.urls import path
|
|||
|
||||
from plane.app.views import (
|
||||
VoiceTaskCommitEndpoint,
|
||||
VoiceTaskMonitorEndpoint,
|
||||
VoiceTaskParseEndpoint,
|
||||
VoiceTaskPreflightEndpoint,
|
||||
VoiceTaskSessionEndpoint,
|
||||
WorkspaceAISettingsEndpoint,
|
||||
WorkspaceAISettingsTestConnectionEndpoint,
|
||||
)
|
||||
|
|
@ -24,6 +26,11 @@ 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(),
|
||||
|
|
@ -34,6 +41,11 @@ 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(),
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ from plane.app.views import (
|
|||
UserRecentVisitViewSet,
|
||||
WorkspaceHomePreferenceViewSet,
|
||||
WorkspaceStickyViewSet,
|
||||
WorkspaceStorageMaintenanceEndpoint,
|
||||
WorkspaceStorageProjectQuotaEndpoint,
|
||||
WorkspaceStorageSummaryEndpoint,
|
||||
WorkspaceUserPreferenceViewSet,
|
||||
)
|
||||
|
|
@ -263,6 +265,16 @@ 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/",
|
||||
|
|
|
|||
|
|
@ -83,7 +83,11 @@ 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 WorkspaceStorageSummaryEndpoint
|
||||
from .workspace.storage import (
|
||||
WorkspaceStorageMaintenanceEndpoint,
|
||||
WorkspaceStorageProjectQuotaEndpoint,
|
||||
WorkspaceStorageSummaryEndpoint,
|
||||
)
|
||||
|
||||
from .state.base import StateViewSet, IntakeStateEndpoint
|
||||
from .view.base import (
|
||||
|
|
@ -247,8 +251,10 @@ from .webhook.base import (
|
|||
|
||||
from .voice_tasker import (
|
||||
VoiceTaskCommitEndpoint,
|
||||
VoiceTaskMonitorEndpoint,
|
||||
VoiceTaskParseEndpoint,
|
||||
VoiceTaskPreflightEndpoint,
|
||||
VoiceTaskSessionEndpoint,
|
||||
WorkspaceAISettingsEndpoint,
|
||||
WorkspaceAISettingsTestConnectionEndpoint,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 resolve_workspace_upload_size_limit
|
||||
from plane.utils.upload_limits import get_project_storage_quota_response, resolve_workspace_upload_size_limit
|
||||
from plane.utils.file_dedup import (
|
||||
UploadedObjectMissing,
|
||||
attach_existing_blob_to_file_asset,
|
||||
|
|
@ -361,6 +361,14 @@ 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}"
|
||||
|
|
@ -564,6 +572,10 @@ 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}"
|
||||
|
|
@ -576,7 +588,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),
|
||||
)
|
||||
|
||||
|
|
@ -767,10 +779,6 @@ 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()
|
||||
|
|
@ -778,6 +786,14 @@ 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={
|
||||
|
|
|
|||
|
|
@ -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, Workspace
|
||||
from plane.db.models import FileAsset, Project, 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 resolve_workspace_upload_size_limit
|
||||
from plane.utils.upload_limits import get_project_storage_quota_response, resolve_workspace_upload_size_limit
|
||||
from plane.utils.attachment_preview import attachment_object_exists, get_attachment_preview_response
|
||||
from plane.utils.file_dedup import finalize_uploaded_file_asset, release_file_asset_blob, UploadedObjectMissing
|
||||
|
||||
|
|
@ -118,12 +118,16 @@ 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(
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ 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):
|
||||
|
|
@ -195,7 +196,19 @@ class ProjectJoinEndpoint(BaseAPIView):
|
|||
)
|
||||
|
||||
if project_invite.responded_at is None:
|
||||
project_invite.accepted = request.data.get("accepted", False)
|
||||
accepted = request.data.get("accepted", False)
|
||||
if accepted:
|
||||
user = User.objects.filter(email=email).first()
|
||||
workspace_member = WorkspaceMember.objects.filter(workspace__slug=slug, member=user).first()
|
||||
if is_workspace_member_currently_banned(workspace_member):
|
||||
return Response(
|
||||
{"error": "You are banned from this workspace"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
if workspace_member is not None and workspace_member.is_banned:
|
||||
release_workspace_member_ban(workspace_member)
|
||||
|
||||
project_invite.accepted = accepted
|
||||
project_invite.responded_at = timezone.now()
|
||||
project_invite.save()
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -50,6 +50,7 @@ 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):
|
||||
|
|
@ -207,6 +208,8 @@ 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)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from plane.db.models import User, Workspace, WorkspaceMember, WorkspaceMemberInv
|
|||
from plane.utils.cache import invalidate_cache, invalidate_cache_directly
|
||||
from plane.utils.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
|
||||
|
||||
|
||||
|
|
@ -85,6 +86,17 @@ 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:
|
||||
|
|
@ -174,7 +186,23 @@ class WorkspaceJoinEndpoint(BaseAPIView):
|
|||
|
||||
# If already responded then return error
|
||||
if workspace_invite.responded_at is None:
|
||||
workspace_invite.accepted = request.data.get("accepted", False)
|
||||
accepted = request.data.get("accepted", False)
|
||||
if accepted:
|
||||
user = User.objects.filter(email=workspace_invite.email).first()
|
||||
workspace_member = (
|
||||
WorkspaceMember.objects.filter(workspace=workspace_invite.workspace, member=user).first()
|
||||
if user is not None
|
||||
else None
|
||||
)
|
||||
if is_workspace_member_currently_banned(workspace_member):
|
||||
return Response(
|
||||
{"error": "You are banned from this workspace"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
if workspace_member is not None and workspace_member.is_banned:
|
||||
release_workspace_member_ban(workspace_member)
|
||||
|
||||
workspace_invite.accepted = accepted
|
||||
workspace_invite.responded_at = timezone.now()
|
||||
workspace_invite.save()
|
||||
|
||||
|
|
@ -260,6 +288,17 @@ 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,
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
# 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
|
||||
|
|
@ -12,6 +14,8 @@ 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):
|
||||
|
|
@ -33,24 +37,94 @@ 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)
|
||||
stale_cutoff = timezone.now() - timedelta(days=1)
|
||||
cutoffs = _storage_cutoffs()
|
||||
|
||||
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 = _sum_blob_size(active_blob_ids)
|
||||
workspace_physical_size = _blob_size_by_ids(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=stale_cutoff)
|
||||
stale_unuploaded = failed_uploads.filter(created_at__lt=cutoffs["stale_upload_cutoff"])
|
||||
soft_deleted_assets = FileAsset.all_objects.filter(workspace=workspace, deleted_at__isnull=False)
|
||||
orphaned_blobs = StoredBlob.objects.filter(workspace=workspace, status=StoredBlob.Status.ORPHANED)
|
||||
missing_blobs = StoredBlob.objects.filter(workspace=workspace, status=StoredBlob.Status.MISSING)
|
||||
|
|
@ -64,8 +138,9 @@ class WorkspaceStorageSummaryEndpoint(BaseAPIView):
|
|||
project_assets.exclude(blob__isnull=True).values_list("blob_id", flat=True).distinct()
|
||||
)
|
||||
project_logical_size = _sum_asset_size(project_assets)
|
||||
project_physical_size = _sum_blob_size(project_blob_ids)
|
||||
project_physical_size = _blob_size_by_ids(project_blob_ids)
|
||||
project_failed_uploads = failed_uploads.filter(project=project)
|
||||
project_failed_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)
|
||||
|
||||
|
|
@ -81,9 +156,15 @@ 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),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -114,8 +195,160 @@ 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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = "/god-mode/"
|
||||
admin_base_path = "/nodedcsudo/"
|
||||
if not admin_base_path.startswith("/"):
|
||||
admin_base_path = "/" + admin_base_path
|
||||
if not admin_base_path.endswith("/"):
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@
|
|||
# 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,42 +15,9 @@ from django.db.models import Q
|
|||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import FileAsset, StoredBlob
|
||||
from plane.db.models import FileAsset
|
||||
from plane.settings.storage import S3Storage
|
||||
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)
|
||||
from plane.utils.file_dedup import hard_delete_file_asset, release_file_asset_blob
|
||||
|
||||
|
||||
@shared_task
|
||||
|
|
@ -74,4 +41,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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=60)
|
||||
def process_voice_task_session(self, voice_session_id):
|
||||
from plane.app.views.voice_tasker import fail_voice_task_session, process_voice_task_session_pipeline
|
||||
|
||||
result = process_voice_task_session_pipeline(voice_session_id) or {}
|
||||
if not result.get("retry"):
|
||||
return result
|
||||
|
||||
if self.request.retries >= self.max_retries:
|
||||
fail_voice_task_session(
|
||||
voice_session_id,
|
||||
"voice_task_queue_timeout",
|
||||
"Voice Tasker session stayed in retry queue for too long.",
|
||||
)
|
||||
return {"ok": False, "code": "voice_task_queue_timeout"}
|
||||
|
||||
countdown = int(result.get("countdown") or 15)
|
||||
raise self.retry(countdown=countdown, exc=Exception(result.get("code") or "voice_task_retry"))
|
||||
|
||||
|
||||
@shared_task
|
||||
def cleanup_voice_task_sessions():
|
||||
from plane.app.views.voice_tasker import cleanup_voice_task_sessions as cleanup_voice_task_sessions_impl
|
||||
|
||||
return cleanup_voice_task_sessions_impl()
|
||||
|
|
@ -73,6 +73,10 @@ 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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
# Generated by Codex on 2026-04-28
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("db", "0128_stored_blob_dedup"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="workspaceaisettings",
|
||||
name="access_mode",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("all_workspace_members", "All workspace members"),
|
||||
("admins_only", "Admins only"),
|
||||
("selected_projects", "Selected projects"),
|
||||
("selected_members", "Selected members"),
|
||||
],
|
||||
default="all_workspace_members",
|
||||
max_length=40,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="workspaceaisettings",
|
||||
name="enabled_members",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="workspace_ai_feature_settings",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="workspaceaisettings",
|
||||
name="enabled_projects",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="workspace_ai_feature_settings",
|
||||
to="db.project",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# Generated by Plane on 2026-04-28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("db", "0129_workspace_ai_access_scope"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="project",
|
||||
name="storage_quota_enabled",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="project",
|
||||
name="storage_quota",
|
||||
field=models.PositiveBigIntegerField(default=0),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# Generated by Codex on 2026-04-28
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("db", "0130_project_storage_quotas"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="workspaceaicredential",
|
||||
name="deleted_at",
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name="Deleted At"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="workspaceaisettings",
|
||||
name="deleted_at",
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name="Deleted At"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="workspaceaisettings",
|
||||
name="per_user_daily_limit",
|
||||
field=models.PositiveIntegerField(default=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="workspaceaisettings",
|
||||
name="workspace_daily_limit",
|
||||
field=models.PositiveIntegerField(default=1000),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="workspaceaisettings",
|
||||
name="project_daily_limit",
|
||||
field=models.PositiveIntegerField(default=300),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="voicetasksession",
|
||||
name="project",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="voice_task_sessions",
|
||||
to="db.project",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="voicetasksession",
|
||||
index=models.Index(
|
||||
fields=["workspace", "project", "-created_at"],
|
||||
name="voice_task_session_project_idx",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
# Generated for NODEDC Voice Tasker queue lifecycle.
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("db", "0131_voice_tasker_daily_project_limits"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="workspaceaisettings",
|
||||
name="workspace_concurrency_limit",
|
||||
field=models.PositiveIntegerField(default=3),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="voicetasksession",
|
||||
name="audio_file",
|
||||
field=models.FileField(blank=True, null=True, upload_to="voice-task-sessions/%Y/%m/%d/"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="voicetasksession",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("queued", "Queued"),
|
||||
("processing", "Processing"),
|
||||
("uploaded", "Uploaded"),
|
||||
("transcribing", "Transcribing"),
|
||||
("transcribed", "Transcribed"),
|
||||
("parsing", "Parsing"),
|
||||
("parsed", "Parsed"),
|
||||
("failed", "Failed"),
|
||||
],
|
||||
default="uploaded",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
# Generated by Codex on 2026-04-28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0132_voice_tasker_queue_concurrency"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="voicetasksession",
|
||||
name="completed_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="voicetasksession",
|
||||
name="failed_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="voicetasksession",
|
||||
name="parser_completion_tokens",
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="voicetasksession",
|
||||
name="parser_prompt_tokens",
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="voicetasksession",
|
||||
name="parser_total_tokens",
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="voicetasksession",
|
||||
name="parsing_duration_ms",
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="voicetasksession",
|
||||
name="processing_duration_ms",
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="voicetasksession",
|
||||
name="processing_started_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="voicetasksession",
|
||||
name="queued_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="voicetasksession",
|
||||
name="transcribed_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="voicetasksession",
|
||||
name="transcription_duration_ms",
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Codex on 2026-04-28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0133_voice_tasker_observability"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="workspaceaisettings",
|
||||
name="sensitive_data_retention_days",
|
||||
field=models.PositiveIntegerField(default=30),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="voicetasksession",
|
||||
name="sensitive_data_redacted_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
# Generated by Codex on 2026-04-28
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("db", "0134_voice_tasker_retention"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="WorkspaceFeatureEntitlement",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Last Modified At"),
|
||||
),
|
||||
("deleted_at", models.DateTimeField(blank=True, editable=False, null=True)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"feature_key",
|
||||
models.CharField(
|
||||
choices=[("voice_tasker", "AI / Voice Tasker")],
|
||||
max_length=80,
|
||||
),
|
||||
),
|
||||
("is_enabled", models.BooleanField(default=False)),
|
||||
("metadata", models.JSONField(blank=True, default=dict)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="feature_entitlements",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Workspace Feature Entitlement",
|
||||
"verbose_name_plural": "Workspace Feature Entitlements",
|
||||
"db_table": "workspace_feature_entitlements",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="workspacefeatureentitlement",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("deleted_at__isnull", True)),
|
||||
fields=("workspace", "feature_key"),
|
||||
name="workspace_feature_entitlement_unique_active_feature",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# Generated by Codex on 2026-04-29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("db", "0135_workspace_feature_entitlements"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="workspacemember",
|
||||
name="is_banned",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="workspacemember",
|
||||
name="banned_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="workspacemember",
|
||||
name="banned_until",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="workspacemember",
|
||||
name="ban_project_member_ids",
|
||||
field=models.JSONField(default=list),
|
||||
),
|
||||
]
|
||||
|
|
@ -69,6 +69,7 @@ from .voice_tasker import VoiceTaskSession, WorkspaceAICredential, WorkspaceAISe
|
|||
from .workspace import (
|
||||
Workspace,
|
||||
WorkspaceBaseModel,
|
||||
WorkspaceFeatureEntitlement,
|
||||
WorkspaceMember,
|
||||
WorkspaceMemberInvite,
|
||||
WorkspaceTheme,
|
||||
|
|
|
|||
|
|
@ -98,6 +98,8 @@ 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",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ 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",
|
||||
|
|
@ -37,9 +39,24 @@ 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"
|
||||
|
|
@ -77,6 +94,8 @@ 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"
|
||||
|
|
@ -94,7 +113,15 @@ 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)
|
||||
|
|
@ -102,6 +129,18 @@ 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,
|
||||
|
|
@ -126,6 +165,7 @@ 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"),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -210,6 +210,10 @@ 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)
|
||||
|
|
@ -260,6 +264,36 @@ 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)
|
||||
|
|
|
|||
|
|
@ -25,4 +25,7 @@ from .admin import (
|
|||
from .workspace import (
|
||||
InstanceWorkSpaceAvailabilityCheckEndpoint,
|
||||
InstanceWorkSpaceEndpoint,
|
||||
InstanceWorkSpaceFeatureEndpoint,
|
||||
InstanceWorkSpaceMemberBanEndpoint,
|
||||
InstanceWorkSpaceMemberEndpoint,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,14 +6,115 @@
|
|||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import OuterRef, Func, F
|
||||
from django.db.models import Count, OuterRef, Func, F, Q
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
||||
# Module imports
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.license.api.permissions import InstanceAdminPermission
|
||||
from plane.db.models import Workspace, WorkspaceMember, Project
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
Workspace,
|
||||
WorkspaceAICredential,
|
||||
WorkspaceAISettings,
|
||||
WorkspaceFeatureEntitlement,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.license.api.serializers import WorkspaceSerializer
|
||||
from plane.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):
|
||||
|
|
@ -108,3 +209,202 @@ 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)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ from plane.license.api.views import (
|
|||
InstanceAdminUserSessionEndpoint,
|
||||
InstanceWorkSpaceAvailabilityCheckEndpoint,
|
||||
InstanceWorkSpaceEndpoint,
|
||||
InstanceWorkSpaceFeatureEndpoint,
|
||||
InstanceWorkSpaceMemberBanEndpoint,
|
||||
InstanceWorkSpaceMemberEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
|
|
@ -71,4 +74,24 @@ 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
Loading…
Reference in New Issue