Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e689f00fb2 | |||
| ef99f8bbca | |||
| 2804987fe8 | |||
| 09f4e3d4ae | |||
| 7c8cbc4ead | |||
| ce6299937f | |||
| d6fce114d6 | |||
| e6d584fde7 | |||
| 0240e46f04 | |||
| e36f4f47f1 | |||
| ba4e711f9b | |||
| 0f4309659a | |||
| 5cfb9538df | |||
| 6a26ce3a2b | |||
| ec9c868f85 | |||
| 03c581433a | |||
| 2e15e4f786 | |||
| 9611cd1e73 | |||
| 8307badae5 | |||
| 83fdebf64d | |||
| 90866fb925 | |||
| f3031a44f7 | |||
| 83679806fd | |||
| d462546055 | |||
| f510020daa | |||
| 1eaa48c95c | |||
| d6503232de | |||
| 88a41479f4 | |||
| b73cf4f0e2 | |||
| 2e6225a883 | |||
| f34ca18a34 | |||
| 69323f4164 | |||
| 06300d1a8e | |||
| 6e32cb51b6 | |||
| bd0361de92 | |||
| ebab5e209f | |||
| c65e2c4aab | |||
| c04ae51d20 | |||
| 19a0ef490b | |||
| 4e357c4ad0 | |||
| 80670b2b3f | |||
| 64f90b828b | |||
| 95c742244e | |||
| 34c0e81d8e | |||
| 2a2c4921a7 | |||
| f4c2d519fc | |||
| cbfdcd5638 | |||
| a04d3b5c29 | |||
| 30da349475 | |||
| 0b78e03055 | |||
| f8a9a1576d | |||
| 6ba15545d9 | |||
| da22c2f670 | |||
| d47755b4c6 | |||
| b51da2f997 | |||
| 1447be12e6 | |||
| 433b5a4fe1 | |||
| 4ae0763d0f | |||
| a407105bb8 | |||
| 5d7bf554dd | |||
| 5ec09a7fe1 | |||
| 3f0721c59a |
@@ -0,0 +1,48 @@
|
||||
---
|
||||
description: Guidelines for bash commands and tooling in the monorepo
|
||||
applyTo: "**/*.sh"
|
||||
---
|
||||
|
||||
# Bash & Tooling Instructions
|
||||
|
||||
This document outlines the standard tools and commands used in this monorepo.
|
||||
|
||||
## Package Manager
|
||||
|
||||
We use **pnpm** for package management.
|
||||
- **Do not use `npm` or `yarn`.**
|
||||
- Lockfile: `pnpm-lock.yaml`
|
||||
- Workspace configuration: `pnpm-workspace.yaml`
|
||||
|
||||
### Common Commands
|
||||
- Install dependencies: `pnpm install`
|
||||
- Run a script in a specific package: `pnpm --filter <package_name> run <script>`
|
||||
- Run a script in all packages: `pnpm -r run <script>`
|
||||
|
||||
## Monorepo Tooling
|
||||
|
||||
We use **Turbo** for build system orchestration.
|
||||
- Configuration: `turbo.json`
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `apps/`: Contains application services (admin, api, live, proxy, space, web).
|
||||
- `packages/`: Contains shared packages and libraries.
|
||||
- `deployments/`: Deployment configurations.
|
||||
|
||||
## Running Tests
|
||||
|
||||
- To run tests in a specific package (e.g., codemods):
|
||||
```bash
|
||||
cd packages/codemods
|
||||
pnpm run test
|
||||
```
|
||||
- Or from root:
|
||||
```bash
|
||||
pnpm --filter @plane/codemods run test
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
- Local development uses `docker-compose-local.yml`.
|
||||
- Production/Staging uses `docker-compose.yml`.
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
description: Guidelines for using modern TypeScript features (v5.0-v5.8)
|
||||
applyTo: "**/*.{ts,tsx,mts,cts}"
|
||||
---
|
||||
|
||||
# TypeScript Coding Guidelines & Modern Features (v5.0 - v5.8)
|
||||
|
||||
When writing TypeScript code, prioritize using modern features and best practices introduced in recent versions (up to 5.8).
|
||||
|
||||
## Global Themes Across 5.x
|
||||
|
||||
1. **Standard decorators are here; legacy decorators are legacy.**
|
||||
New TC39-compliant decorators landed in 5.0 and were extended in 5.2 (metadata). Old `experimentalDecorators`-style behavior is still supported but should be treated as legacy.
|
||||
|
||||
2. **Type system is more precise and less noisy.**
|
||||
Major work went into narrowing, control flow analysis, error messages, and new helpers like `NoInfer`, inferred predicates, and better `undefined`/`never`/uninitialized checks.
|
||||
|
||||
3. **Module / runtime interop has been modernized.**
|
||||
Options like `--moduleResolution bundler`, `--module nodenext`/`node18`, `--rewriteRelativeImportExtensions`, `--erasableSyntaxOnly`, and `--verbatimModuleSyntax` are about playing nicely with ESM, Node 18+/22+, direct TypeScript execution, and bundlers.
|
||||
|
||||
4. **The standard library keeps tracking modern JS.**
|
||||
Support for new ES features (iterator helpers, `Object.groupBy`/`Map.groupBy`, new Set/ES2024 APIs) shows up as type declarations and sometimes extra checks (regex syntax checking, etc.).
|
||||
|
||||
When generating or refactoring code, prefer these newer idioms, and avoid patterns that conflict with updated checks.
|
||||
|
||||
## Modern Features to Utilize
|
||||
|
||||
### Type System & Inference
|
||||
- **`const` Type Parameters (5.0)**: Use `const` type parameters for more precise literal inference.
|
||||
```typescript
|
||||
declare function names<const T extends string[]>(...names: T): void;
|
||||
```
|
||||
- **`@satisfies` Operator (5.0)**: Use `satisfies` to validate types without widening them.
|
||||
- **Inferred Type Predicates (5.5)**: Allow TypeScript to infer type predicates for functions that filter arrays or check types, reducing the need for explicit `is` return types.
|
||||
- **`NoInfer` Utility (5.4)**: Use `NoInfer<T>` to block inference for specific type arguments when you want them to be determined by other arguments.
|
||||
- **Narrowing**:
|
||||
- **Switch(true) (5.3)**: Utilize narrowing in `switch(true)` blocks.
|
||||
- **Boolean Comparisons (5.3)**: Rely on narrowing from direct boolean comparisons.
|
||||
- **Closures (5.4)**: Trust preserved narrowing in closures when variables aren't modified after the check.
|
||||
- **Constant Indexed Access (5.5)**: Use constant indices to narrow object/array properties.
|
||||
|
||||
### Syntax & Control Flow
|
||||
- **Decorators (5.0)**: Use standard ECMAScript decorators (Stage 3).
|
||||
- **`using` Declarations (5.2)**: Use `using` for explicit resource management (Disposable pattern) instead of manual cleanup.
|
||||
```typescript
|
||||
using resource = new Resource();
|
||||
```
|
||||
- **Import Attributes (5.3/5.8)**: Use `with { type: "json" }` for import attributes. Avoid the deprecated `assert` syntax.
|
||||
- **`switch` Exhaustiveness**: Rely on TypeScript's exhaustiveness checking in switch statements.
|
||||
|
||||
### Modules & Imports
|
||||
- **`verbatimModuleSyntax` (5.0)**: Respect this flag by using `import type` explicitly when importing types to ensure they are erased during compilation.
|
||||
- **Type-Only Imports with Extensions (5.2)**: You can use `.ts`, `.mts`, `.cts` extensions in `import type` statements.
|
||||
- **`resolution-mode` (5.3)**: Use `import type { Type } from "mod" with { "resolution-mode": "import" }` if needed for specific module resolution contexts.
|
||||
- **JSDoc `@import` (5.5)**: Use `@import` tags in JSDoc for cleaner type imports in JS files if working in a mixed codebase.
|
||||
|
||||
### Standard Library & Built-ins
|
||||
- **Iterator Helpers (5.6)**: Use new iterator methods (map, filter, etc.) if targeting modern environments.
|
||||
- **Set Methods (5.5)**: Utilize new `Set` methods like `union`, `intersection`, etc., when available.
|
||||
- **`Object.groupBy` / `Map.groupBy` (5.4)**: Use these standard methods for grouping instead of external libraries like Lodash when appropriate.
|
||||
- **`Promise.withResolvers` (5.7)**: Use `Promise.withResolvers()` for creating promises with exposed resolve/reject functions.
|
||||
|
||||
### Configuration & Tooling
|
||||
- **`--moduleResolution bundler` (5.0)**: Assume this resolution strategy for modern web projects (Vite, Next.js, etc.).
|
||||
- **`--erasableSyntaxOnly` (5.8)**: Be aware of this flag; avoid TypeScript-specific syntax that cannot be simply erased (like `enum`s or `namespaces`) if the project aims for maximum compatibility with tools like Node.js's `--strip-types`. Prefer `const` objects or unions over `enum`s if requested.
|
||||
|
||||
## Specific Coding Patterns
|
||||
|
||||
### Arrays & Collections
|
||||
- Use **Copying Array Methods (5.2)** (`toSorted`, `toSpliced`, `with`) for immutable array operations.
|
||||
- **TypedArrays (5.7)**: Be aware that TypedArrays are now generic over `ArrayBufferLike`.
|
||||
|
||||
### Classes
|
||||
- **Parameter Decorators (5.0/5.2)**: Use modern standard decorators.
|
||||
- **`super` Property Access (5.3)**: Avoid accessing instance fields via `super`.
|
||||
|
||||
### Error Handling
|
||||
- **Checks for Never-Initialized Variables (5.7)**: Ensure variables are initialized before use to avoid new errors.
|
||||
|
||||
## Deprecations to Avoid
|
||||
- Avoid `import ... assert` (use `with`).
|
||||
- Avoid implicit `any` returns in `undefined`-returning functions (though 5.1 makes this easier, explicit is better).
|
||||
- Avoid `enum`s if the project prefers erasable syntax (5.8).
|
||||
|
||||
## Version-Specific Highlights
|
||||
|
||||
### TypeScript 5.0
|
||||
- **Decorators**: Use standard decorators unless `experimentalDecorators` is explicitly enabled.
|
||||
- **`const` Type Parameters**: Use for literal inference.
|
||||
- **Enums**: All enums are union enums.
|
||||
- **Modules**: `--moduleResolution bundler` and `--verbatimModuleSyntax` are key for modern bundlers.
|
||||
|
||||
### TypeScript 5.1
|
||||
- **Returns**: `undefined`-returning functions don't need explicit returns.
|
||||
- **Getters/Setters**: Can have unrelated types with explicit annotations.
|
||||
|
||||
### TypeScript 5.2
|
||||
- **Resource Management**: `using` declarations for `Symbol.dispose`.
|
||||
- **Decorator Metadata**: Use `context.metadata` for design-time metadata.
|
||||
|
||||
### TypeScript 5.3
|
||||
- **Import Attributes**: Use `with { type: "json" }`.
|
||||
- **Switch(true)**: Narrowing works in `switch(true)`.
|
||||
|
||||
### TypeScript 5.4
|
||||
- **Closures**: Narrowing preserved in closures if last assignment is before creation.
|
||||
- **`NoInfer`**: Block inference for specific arguments.
|
||||
- **Grouping**: `Object.groupBy` / `Map.groupBy`.
|
||||
|
||||
### TypeScript 5.5
|
||||
- **Inferred Predicates**: Functions checking types often don't need explicit `is` return types.
|
||||
- **Constant Index Access**: Better narrowing for constant keys.
|
||||
- **Regex**: Syntax checking for regex literals.
|
||||
|
||||
### TypeScript 5.6
|
||||
- **Truthiness Checks**: Errors on always-truthy/falsy conditions (e.g., `if (/regex/)`).
|
||||
- **Iterator Helpers**: `.map`, `.filter` on iterators.
|
||||
|
||||
### TypeScript 5.7
|
||||
- **Uninitialized Variables**: Stricter checks for never-initialized variables.
|
||||
- **Relative Imports**: `--rewriteRelativeImportExtensions` for `.ts` imports in output.
|
||||
- **ES2024**: Support for `Promise.withResolvers`, `Atomics.waitAsync`.
|
||||
|
||||
### TypeScript 5.8
|
||||
- **Return Checks**: Granular checks for conditional returns.
|
||||
- **Node Modules**: `--module node18` stable; `require()` of ESM allowed in `nodenext`.
|
||||
- **Erasable Syntax**: `--erasableSyntaxOnly` forbids enums, namespaces, etc.
|
||||
|
||||
When generating code, always prefer the most modern, standard, and type-safe approach available in TypeScript 5.8.
|
||||
@@ -49,5 +49,8 @@ jobs:
|
||||
- name: Check Affected format
|
||||
run: pnpm turbo run check:format --affected
|
||||
|
||||
- name: Check Affected types
|
||||
run: pnpm turbo run check:types --affected
|
||||
|
||||
- name: Build Affected
|
||||
run: pnpm turbo run build --affected
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"plugins": ["@prettier/plugin-oxc"]
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
NEXT_PUBLIC_API_BASE_URL="http://localhost:8000"
|
||||
VITE_API_BASE_URL="http://localhost:8000"
|
||||
|
||||
NEXT_PUBLIC_WEB_BASE_URL="http://localhost:3000"
|
||||
VITE_WEB_BASE_URL="http://localhost:3000"
|
||||
|
||||
NEXT_PUBLIC_ADMIN_BASE_URL="http://localhost:3001"
|
||||
NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
VITE_ADMIN_BASE_URL="http://localhost:3001"
|
||||
VITE_ADMIN_BASE_PATH="/god-mode"
|
||||
|
||||
NEXT_PUBLIC_SPACE_BASE_URL="http://localhost:3002"
|
||||
NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
|
||||
VITE_SPACE_BASE_URL="http://localhost:3002"
|
||||
VITE_SPACE_BASE_PATH="/spaces"
|
||||
|
||||
NEXT_PUBLIC_LIVE_BASE_URL="http://localhost:3100"
|
||||
NEXT_PUBLIC_LIVE_BASE_PATH="/live"
|
||||
VITE_LIVE_BASE_URL="http://localhost:3100"
|
||||
VITE_LIVE_BASE_PATH="/live"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
.next/*
|
||||
.react-router/*
|
||||
.vite/*
|
||||
out/*
|
||||
public/*
|
||||
dist/*
|
||||
|
||||
@@ -3,7 +3,6 @@ module.exports = {
|
||||
extends: ["@plane/eslint-config/next.js"],
|
||||
ignorePatterns: ["build/**", "dist/**", ".vite/**"],
|
||||
rules: {
|
||||
"no-duplicate-imports": "off",
|
||||
"import/no-duplicates": ["error", { "prefer-inline": false }],
|
||||
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
|
||||
"@typescript-eslint/no-import-type-side-effects": "error",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
.next
|
||||
.react-router
|
||||
.vite
|
||||
.vercel
|
||||
.tubro
|
||||
out/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
"trailingComma": "es5",
|
||||
"plugins": ["@prettier/plugin-oxc"]
|
||||
}
|
||||
|
||||
+25
-25
@@ -28,35 +28,35 @@ FROM base AS installer
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Public envs required at build time (pick up via process.env)
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
ARG NEXT_PUBLIC_API_BASE_PATH="/api"
|
||||
ENV NEXT_PUBLIC_API_BASE_PATH=$NEXT_PUBLIC_API_BASE_PATH
|
||||
ARG VITE_API_BASE_URL=""
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
ARG VITE_API_BASE_PATH="/api"
|
||||
ENV VITE_API_BASE_PATH=$VITE_API_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_ADMIN_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
|
||||
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
|
||||
ARG VITE_ADMIN_BASE_URL=""
|
||||
ENV VITE_ADMIN_BASE_URL=$VITE_ADMIN_BASE_URL
|
||||
ARG VITE_ADMIN_BASE_PATH="/god-mode"
|
||||
ENV VITE_ADMIN_BASE_PATH=$VITE_ADMIN_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
|
||||
ARG VITE_SPACE_BASE_URL=""
|
||||
ENV VITE_SPACE_BASE_URL=$VITE_SPACE_BASE_URL
|
||||
ARG VITE_SPACE_BASE_PATH="/spaces"
|
||||
ENV VITE_SPACE_BASE_PATH=$VITE_SPACE_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_LIVE_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_LIVE_BASE_URL=$NEXT_PUBLIC_LIVE_BASE_URL
|
||||
ARG NEXT_PUBLIC_LIVE_BASE_PATH="/live"
|
||||
ENV NEXT_PUBLIC_LIVE_BASE_PATH=$NEXT_PUBLIC_LIVE_BASE_PATH
|
||||
ARG VITE_LIVE_BASE_URL=""
|
||||
ENV VITE_LIVE_BASE_URL=$VITE_LIVE_BASE_URL
|
||||
ARG VITE_LIVE_BASE_PATH="/live"
|
||||
ENV VITE_LIVE_BASE_PATH=$VITE_LIVE_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_WEB_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
|
||||
ARG NEXT_PUBLIC_WEB_BASE_PATH=""
|
||||
ENV NEXT_PUBLIC_WEB_BASE_PATH=$NEXT_PUBLIC_WEB_BASE_PATH
|
||||
ARG VITE_WEB_BASE_URL=""
|
||||
ENV VITE_WEB_BASE_URL=$VITE_WEB_BASE_URL
|
||||
ARG VITE_WEB_BASE_PATH=""
|
||||
ENV VITE_WEB_BASE_PATH=$VITE_WEB_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_WEBSITE_URL="https://plane.so"
|
||||
ENV NEXT_PUBLIC_WEBSITE_URL=$NEXT_PUBLIC_WEBSITE_URL
|
||||
ARG NEXT_PUBLIC_SUPPORT_EMAIL="support@plane.so"
|
||||
ENV NEXT_PUBLIC_SUPPORT_EMAIL=$NEXT_PUBLIC_SUPPORT_EMAIL
|
||||
ARG VITE_WEBSITE_URL="https://plane.so"
|
||||
ENV VITE_WEBSITE_URL=$VITE_WEBSITE_URL
|
||||
ARG VITE_SUPPORT_EMAIL="support@plane.so"
|
||||
ENV VITE_SUPPORT_EMAIL=$VITE_SUPPORT_EMAIL
|
||||
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=builder /app/out/json/ .
|
||||
@@ -66,7 +66,7 @@ COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store --prod=false
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store --prod=false
|
||||
|
||||
# Build only the admin package
|
||||
RUN pnpm turbo run build --filter=admin
|
||||
|
||||
@@ -8,7 +8,7 @@ COPY . .
|
||||
RUN corepack enable pnpm && pnpm add -g turbo
|
||||
RUN pnpm install
|
||||
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
ENV VITE_ADMIN_BASE_PATH="/god-mode"
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Lightbulb } from "lucide-react";
|
||||
import { Button } from "@plane/propel/button";
|
||||
@@ -17,7 +15,7 @@ type IInstanceAIForm = {
|
||||
|
||||
type AIFormValues = Record<TInstanceAIConfigurationKeys, string>;
|
||||
|
||||
export const InstanceAIForm: React.FC<IInstanceAIForm> = (props) => {
|
||||
export function InstanceAIForm(props: IInstanceAIForm) {
|
||||
const { config } = props;
|
||||
// store
|
||||
const { updateInstanceConfigurations } = useInstance();
|
||||
@@ -133,4 +131,4 @@ export const InstanceAIForm: React.FC<IInstanceAIForm> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { Loader } from "@plane/ui";
|
||||
@@ -9,7 +7,7 @@ import { useInstance } from "@/hooks/store";
|
||||
import type { Route } from "./+types/page";
|
||||
import { InstanceAIForm } from "./form";
|
||||
|
||||
const InstanceAIPage = observer<React.FC<Route.ComponentProps>>(() => {
|
||||
const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentProps) {
|
||||
// store
|
||||
const { fetchInstanceConfigurations, formattedConfig } = useInstance();
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import Link from "next/link";
|
||||
@@ -27,7 +24,7 @@ type Props = {
|
||||
|
||||
type GiteaConfigFormValues = Record<TInstanceGiteaAuthenticationConfigurationKeys, string>;
|
||||
|
||||
export const InstanceGiteaConfigForm: FC<Props> = (props) => {
|
||||
export function InstanceGiteaConfigForm(props: Props) {
|
||||
const { config } = props;
|
||||
// states
|
||||
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
|
||||
@@ -207,4 +204,4 @@ export const InstanceGiteaConfigForm: FC<Props> = (props) => {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import useSWR from "swr";
|
||||
// plane internal packages
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
@@ -16,7 +13,7 @@ import { useInstance } from "@/hooks/store";
|
||||
import type { Route } from "./+types/page";
|
||||
import { InstanceGiteaConfigForm } from "./form";
|
||||
|
||||
const InstanceGiteaAuthenticationPage = observer(() => {
|
||||
const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthenticationPage() {
|
||||
// store
|
||||
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
|
||||
// state
|
||||
@@ -65,7 +62,7 @@ const InstanceGiteaAuthenticationPage = observer(() => {
|
||||
<AuthenticationMethodCard
|
||||
name="Gitea"
|
||||
description="Allow members to login or sign up to plane with their Gitea accounts."
|
||||
icon={<Image src={giteaLogo} height={24} width={24} alt="Gitea Logo" />}
|
||||
icon={<img src={giteaLogo} height={24} width={24} alt="Gitea Logo" />}
|
||||
config={
|
||||
<ToggleSwitch
|
||||
value={isGiteaEnabled}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import Link from "next/link";
|
||||
@@ -28,7 +26,7 @@ type Props = {
|
||||
|
||||
type GithubConfigFormValues = Record<TInstanceGithubAuthenticationConfigurationKeys, string>;
|
||||
|
||||
export const InstanceGithubConfigForm: React.FC<Props> = (props) => {
|
||||
export function InstanceGithubConfigForm(props: Props) {
|
||||
const { config } = props;
|
||||
// states
|
||||
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
|
||||
@@ -245,4 +243,4 @@ export const InstanceGithubConfigForm: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import useSWR from "swr";
|
||||
// plane internal packages
|
||||
@@ -20,7 +17,9 @@ import { useInstance } from "@/hooks/store";
|
||||
import type { Route } from "./+types/page";
|
||||
import { InstanceGithubConfigForm } from "./form";
|
||||
|
||||
const InstanceGithubAuthenticationPage = observer<React.FC<Route.ComponentProps>>(() => {
|
||||
const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthenticationPage(
|
||||
_props: Route.ComponentProps
|
||||
) {
|
||||
// store
|
||||
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
|
||||
// state
|
||||
@@ -73,7 +72,7 @@ const InstanceGithubAuthenticationPage = observer<React.FC<Route.ComponentProps>
|
||||
name="GitHub"
|
||||
description="Allow members to login or sign up to plane with their GitHub accounts."
|
||||
icon={
|
||||
<Image
|
||||
<img
|
||||
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
|
||||
height={24}
|
||||
width={24}
|
||||
|
||||
@@ -24,7 +24,7 @@ type Props = {
|
||||
|
||||
type GitlabConfigFormValues = Record<TInstanceGitlabAuthenticationConfigurationKeys, string>;
|
||||
|
||||
export const InstanceGitlabConfigForm: React.FC<Props> = (props) => {
|
||||
export function InstanceGitlabConfigForm(props: Props) {
|
||||
const { config } = props;
|
||||
// states
|
||||
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
|
||||
@@ -208,4 +208,4 @@ export const InstanceGitlabConfigForm: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import useSWR from "swr";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
@@ -16,7 +13,9 @@ import { useInstance } from "@/hooks/store";
|
||||
import type { Route } from "./+types/page";
|
||||
import { InstanceGitlabConfigForm } from "./form";
|
||||
|
||||
const InstanceGitlabAuthenticationPage = observer<React.FC<Route.ComponentProps>>(() => {
|
||||
const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthenticationPage(
|
||||
_props: Route.ComponentProps
|
||||
) {
|
||||
// store
|
||||
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
|
||||
// state
|
||||
@@ -63,7 +62,7 @@ const InstanceGitlabAuthenticationPage = observer<React.FC<Route.ComponentProps>
|
||||
<AuthenticationMethodCard
|
||||
name="GitLab"
|
||||
description="Allow members to login or sign up to plane with their GitLab accounts."
|
||||
icon={<Image src={GitlabLogo} height={24} width={24} alt="GitLab Logo" />}
|
||||
icon={<img src={GitlabLogo} height={24} width={24} alt="GitLab Logo" />}
|
||||
config={
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableGitlabConfig))}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import Link from "next/link";
|
||||
@@ -27,7 +25,7 @@ type Props = {
|
||||
|
||||
type GoogleConfigFormValues = Record<TInstanceGoogleAuthenticationConfigurationKeys, string>;
|
||||
|
||||
export const InstanceGoogleConfigForm: React.FC<Props> = (props) => {
|
||||
export function InstanceGoogleConfigForm(props: Props) {
|
||||
const { config } = props;
|
||||
// states
|
||||
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
|
||||
@@ -232,4 +230,4 @@ export const InstanceGoogleConfigForm: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import useSWR from "swr";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
@@ -16,7 +13,9 @@ import { useInstance } from "@/hooks/store";
|
||||
import type { Route } from "./+types/page";
|
||||
import { InstanceGoogleConfigForm } from "./form";
|
||||
|
||||
const InstanceGoogleAuthenticationPage = observer<React.FC<Route.ComponentProps>>(() => {
|
||||
const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthenticationPage(
|
||||
_props: Route.ComponentProps
|
||||
) {
|
||||
// store
|
||||
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
|
||||
// state
|
||||
@@ -64,7 +63,7 @@ const InstanceGoogleAuthenticationPage = observer<React.FC<Route.ComponentProps>
|
||||
name="Google"
|
||||
description="Allow members to login or sign up to plane with their Google
|
||||
accounts."
|
||||
icon={<Image src={GoogleLogo} height={24} width={24} alt="Google Logo" />}
|
||||
icon={<img src={GoogleLogo} height={24} width={24} alt="Google Logo" />}
|
||||
config={
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableGoogleConfig))}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
@@ -14,7 +12,7 @@ import { useInstance } from "@/hooks/store";
|
||||
import { AuthenticationModes } from "@/plane-admin/components/authentication";
|
||||
import type { Route } from "./+types/page";
|
||||
|
||||
const InstanceAuthenticationPage = observer<React.FC<Route.ComponentProps>>(() => {
|
||||
const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(_props: Route.ComponentProps) {
|
||||
// store
|
||||
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
// types
|
||||
@@ -30,7 +28,7 @@ const EMAIL_SECURITY_OPTIONS: { [key in TEmailSecurityKeys]: string } = {
|
||||
NONE: "No email security",
|
||||
};
|
||||
|
||||
export const InstanceEmailForm: React.FC<IInstanceEmailForm> = (props) => {
|
||||
export function InstanceEmailForm(props: IInstanceEmailForm) {
|
||||
const { config } = props;
|
||||
// states
|
||||
const [isSendTestEmailModalOpen, setIsSendTestEmailModalOpen] = useState(false);
|
||||
@@ -224,4 +222,4 @@ export const InstanceEmailForm: React.FC<IInstanceEmailForm> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
@@ -11,7 +9,7 @@ import { useInstance } from "@/hooks/store";
|
||||
import type { Route } from "./+types/page";
|
||||
import { InstanceEmailForm } from "./email-config-form";
|
||||
|
||||
const InstanceEmailPage = observer<React.FC<Route.ComponentProps>>(() => {
|
||||
const InstanceEmailPage = observer(function InstanceEmailPage(_props: Route.ComponentProps) {
|
||||
// store
|
||||
const { fetchInstanceConfigurations, formattedConfig, disableEmail } = useInstance();
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ enum ESendEmailSteps {
|
||||
|
||||
const instanceService = new InstanceService();
|
||||
|
||||
export const SendTestEmailModal: React.FC<Props> = (props) => {
|
||||
export function SendTestEmailModal(props: Props) {
|
||||
const { isOpen, handleClose } = props;
|
||||
|
||||
// state
|
||||
@@ -133,4 +133,4 @@ export const SendTestEmailModal: React.FC<Props> = (props) => {
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
"use client";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Telescope } from "lucide-react";
|
||||
@@ -19,7 +18,7 @@ export interface IGeneralConfigurationForm {
|
||||
instanceAdmins: IInstanceAdmin[];
|
||||
}
|
||||
|
||||
export const GeneralConfigurationForm: React.FC<IGeneralConfigurationForm> = observer((props) => {
|
||||
export const GeneralConfigurationForm = observer(function GeneralConfigurationForm(props: IGeneralConfigurationForm) {
|
||||
const { instance, instanceAdmins } = props;
|
||||
// hooks
|
||||
const { instanceConfigurations, updateInstanceInfo, updateInstanceConfigurations } = useInstance();
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
@@ -13,7 +11,7 @@ type TIntercomConfig = {
|
||||
isTelemetryEnabled: boolean;
|
||||
};
|
||||
|
||||
export const IntercomConfig: React.FC<TIntercomConfig> = observer((props) => {
|
||||
export const IntercomConfig = observer(function IntercomConfig(props: TIntercomConfig) {
|
||||
const { isTelemetryEnabled } = props;
|
||||
// hooks
|
||||
const { instanceConfigurations, updateInstanceConfigurations, fetchInstanceConfigurations } = useInstance();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
"use client";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Menu, Settings } from "lucide-react";
|
||||
@@ -10,7 +8,7 @@ import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
// hooks
|
||||
import { useTheme } from "@/hooks/store";
|
||||
|
||||
export const HamburgerToggle = observer(() => {
|
||||
export const HamburgerToggle = observer(function HamburgerToggle() {
|
||||
const { isSidebarCollapsed, toggleSidebar } = useTheme();
|
||||
return (
|
||||
<div
|
||||
@@ -22,7 +20,7 @@ export const HamburgerToggle = observer(() => {
|
||||
);
|
||||
});
|
||||
|
||||
export const AdminHeader = observer(() => {
|
||||
export const AdminHeader = observer(function AdminHeader() {
|
||||
const pathName = usePathname();
|
||||
|
||||
const getHeaderTitle = (pathName: string) => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
"use client";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
@@ -14,7 +13,7 @@ type IInstanceImageConfigForm = {
|
||||
|
||||
type ImageConfigFormValues = Record<TInstanceImageConfigurationKeys, string>;
|
||||
|
||||
export const InstanceImageConfigForm: React.FC<IInstanceImageConfigForm> = (props) => {
|
||||
export function InstanceImageConfigForm(props: IInstanceImageConfigForm) {
|
||||
const { config } = props;
|
||||
// store hooks
|
||||
const { updateInstanceConfigurations } = useInstance();
|
||||
@@ -77,4 +76,4 @@ export const InstanceImageConfigForm: React.FC<IInstanceImageConfigForm> = (prop
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { Loader } from "@plane/ui";
|
||||
@@ -9,7 +7,7 @@ import { useInstance } from "@/hooks/store";
|
||||
import type { Route } from "./+types/page";
|
||||
import { InstanceImageConfigForm } from "./form";
|
||||
|
||||
const InstanceImagePage = observer<React.FC<Route.ComponentProps>>(() => {
|
||||
const InstanceImagePage = observer(function InstanceImagePage(_props: Route.ComponentProps) {
|
||||
// store
|
||||
const { formattedConfig, fetchInstanceConfigurations } = useInstance();
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -14,7 +12,7 @@ import type { Route } from "./+types/layout";
|
||||
import { AdminHeader } from "./header";
|
||||
import { AdminSidebar } from "./sidebar";
|
||||
|
||||
const AdminLayout: React.FC<Route.ComponentProps> = () => {
|
||||
function AdminLayout(_props: Route.ComponentProps) {
|
||||
// router
|
||||
const { replace } = useRouter();
|
||||
// store hooks
|
||||
@@ -48,6 +46,6 @@ const AdminLayout: React.FC<Route.ComponentProps> = () => {
|
||||
}
|
||||
|
||||
return <></>;
|
||||
};
|
||||
}
|
||||
|
||||
export default observer(AdminLayout);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme as useNextTheme } from "next-themes";
|
||||
@@ -16,7 +14,7 @@ import { useTheme, useUser } from "@/hooks/store";
|
||||
// service initialization
|
||||
const authService = new AuthService();
|
||||
|
||||
export const AdminSidebarDropdown = observer(() => {
|
||||
export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
|
||||
// store hooks
|
||||
const { isSidebarCollapsed } = useTheme();
|
||||
const { currentUser, signOut } = useUser();
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
@@ -34,7 +32,7 @@ const helpOptions = [
|
||||
},
|
||||
];
|
||||
|
||||
export const AdminSidebarHelpSection: React.FC = observer(() => {
|
||||
export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection() {
|
||||
// states
|
||||
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
|
||||
// store
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
@@ -50,7 +48,7 @@ const INSTANCE_ADMIN_LINKS = [
|
||||
},
|
||||
];
|
||||
|
||||
export const AdminSidebarMenu = observer(() => {
|
||||
export const AdminSidebarMenu = observer(function AdminSidebarMenu() {
|
||||
// store hooks
|
||||
const { isSidebarCollapsed, toggleSidebar } = useTheme();
|
||||
// router
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane helpers
|
||||
@@ -11,7 +9,7 @@ import { AdminSidebarDropdown } from "./sidebar-dropdown";
|
||||
import { AdminSidebarHelpSection } from "./sidebar-help-section";
|
||||
import { AdminSidebarMenu } from "./sidebar-menu";
|
||||
|
||||
export const AdminSidebar = observer(() => {
|
||||
export const AdminSidebar = observer(function AdminSidebar() {
|
||||
// store
|
||||
const { isSidebarCollapsed, toggleSidebar } = useTheme();
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useWorkspace } from "@/hooks/store";
|
||||
|
||||
const instanceWorkspaceService = new InstanceWorkspaceService();
|
||||
|
||||
export const WorkspaceCreateForm = () => {
|
||||
export function WorkspaceCreateForm() {
|
||||
// router
|
||||
const router = useRouter();
|
||||
// states
|
||||
@@ -209,4 +209,4 @@ export const WorkspaceCreateForm = () => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import type { Route } from "./+types/page";
|
||||
import { WorkspaceCreateForm } from "./form";
|
||||
|
||||
const WorkspaceCreatePage = observer<React.FC<Route.ComponentProps>>(() => (
|
||||
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
|
||||
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
|
||||
<div className="text-xl font-medium text-custom-text-100">Create a new workspace on this instance.</div>
|
||||
<div className="text-sm font-normal text-custom-text-300">
|
||||
You will need to invite users from Workspace Settings after you create this workspace.
|
||||
const WorkspaceCreatePage = observer(function WorkspaceCreatePage(_props: Route.ComponentProps) {
|
||||
return (
|
||||
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
|
||||
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
|
||||
<div className="text-xl font-medium text-custom-text-100">Create a new workspace on this instance.</div>
|
||||
<div className="text-sm font-normal text-custom-text-300">
|
||||
You will need to invite users from Workspace Settings after you create this workspace.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
|
||||
<WorkspaceCreateForm />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
|
||||
<WorkspaceCreateForm />
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
|
||||
export const meta: Route.MetaFunction = () => [{ title: "Create Workspace - God Mode" }];
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
@@ -18,7 +16,7 @@ import { WorkspaceListItem } from "@/components/workspace/list-item";
|
||||
import { useInstance, useWorkspace } from "@/hooks/store";
|
||||
import type { Route } from "./+types/page";
|
||||
|
||||
const WorkspaceManagementPage = observer<React.FC<Route.ComponentProps>>(() => {
|
||||
const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props: Route.ComponentProps) {
|
||||
// states
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
// store
|
||||
|
||||
@@ -9,7 +9,7 @@ type TAuthBanner = {
|
||||
handleBannerData?: (bannerData: TAdminAuthErrorInfo | undefined) => void;
|
||||
};
|
||||
|
||||
export const AuthBanner: React.FC<TAuthBanner> = (props) => {
|
||||
export function AuthBanner(props: TAuthBanner) {
|
||||
const { bannerData, handleBannerData } = props;
|
||||
|
||||
if (!bannerData) return <></>;
|
||||
@@ -27,4 +27,4 @@ export const AuthBanner: React.FC<TAuthBanner> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { PlaneLockup } from "@plane/propel/icons";
|
||||
|
||||
export const AuthHeader = () => (
|
||||
<div className="flex items-center justify-between gap-6 w-full flex-shrink-0 sticky top-0">
|
||||
<Link href="/">
|
||||
<PlaneLockup height={20} width={95} className="text-custom-text-100" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
export function AuthHeader() {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6 w-full flex-shrink-0 sticky top-0">
|
||||
<Link href="/">
|
||||
<PlaneLockup height={20} width={95} className="text-custom-text-100" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { KeyRound, Mails } from "lucide-react";
|
||||
// plane packages
|
||||
@@ -135,7 +134,7 @@ export const getBaseAuthenticationModes: (props: TGetBaseAuthenticationModeProps
|
||||
key: "google",
|
||||
name: "Google",
|
||||
description: "Allow members to log in or sign up for Plane with their Google accounts.",
|
||||
icon: <Image src={GoogleLogo} height={20} width={20} alt="Google Logo" />,
|
||||
icon: <img src={GoogleLogo} height={20} width={20} alt="Google Logo" />,
|
||||
config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
@@ -143,7 +142,7 @@ export const getBaseAuthenticationModes: (props: TGetBaseAuthenticationModeProps
|
||||
name: "GitHub",
|
||||
description: "Allow members to log in or sign up for Plane with their GitHub accounts.",
|
||||
icon: (
|
||||
<Image
|
||||
<img
|
||||
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
|
||||
height={20}
|
||||
width={20}
|
||||
@@ -156,7 +155,7 @@ export const getBaseAuthenticationModes: (props: TGetBaseAuthenticationModeProps
|
||||
key: "gitlab",
|
||||
name: "GitLab",
|
||||
description: "Allow members to log in or sign up to plane with their GitLab accounts.",
|
||||
icon: <Image src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
|
||||
icon: <img src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
|
||||
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
@@ -11,7 +9,7 @@ import { useInstance } from "@/hooks/store";
|
||||
import type { Route } from "./+types/page";
|
||||
import { InstanceSignInForm } from "./sign-in-form";
|
||||
|
||||
const HomePage = () => {
|
||||
function HomePage() {
|
||||
// store hooks
|
||||
const { instance, error } = useInstance();
|
||||
|
||||
@@ -36,7 +34,7 @@ const HomePage = () => {
|
||||
|
||||
// if instance is fetched and setup is done, show sign in form
|
||||
return <InstanceSignInForm />;
|
||||
};
|
||||
}
|
||||
|
||||
export default observer(HomePage);
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
@@ -45,7 +43,7 @@ const defaultFromData: TFormData = {
|
||||
password: "",
|
||||
};
|
||||
|
||||
export const InstanceSignInForm: React.FC = () => {
|
||||
export function InstanceSignInForm() {
|
||||
// search params
|
||||
const searchParams = useSearchParams();
|
||||
const emailParam = searchParams.get("email") || undefined;
|
||||
@@ -192,4 +190,4 @@ export const InstanceSignInForm: React.FC = () => {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import useSWR from "swr";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
export const InstanceProvider = observer<React.FC<React.PropsWithChildren>>((props) => {
|
||||
export const InstanceProvider = observer(function InstanceProvider(props: React.PropsWithChildren) {
|
||||
const { children } = props;
|
||||
// store hooks
|
||||
const { fetchInstanceInfo } = useInstance();
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { createContext } from "react";
|
||||
// plane admin store
|
||||
import { RootStore } from "@/plane-admin/store/root.store";
|
||||
@@ -28,7 +26,7 @@ export type StoreProviderProps = {
|
||||
initialState?: any;
|
||||
};
|
||||
|
||||
export const StoreProvider = ({ children, initialState = {} }: StoreProviderProps) => {
|
||||
export function StoreProvider({ children, initialState = {} }: StoreProviderProps) {
|
||||
const store = initializeStore(initialState);
|
||||
return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toast } from "@plane/propel/toast";
|
||||
import { resolveGeneralTheme } from "@plane/utils";
|
||||
|
||||
export const ToastWithTheme = () => {
|
||||
export function ToastWithTheme() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
return <Toast theme={resolveGeneralTheme(resolvedTheme)} />;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import { useInstance, useTheme, useUser } from "@/hooks/store";
|
||||
|
||||
export const UserProvider = observer<React.FC<React.PropsWithChildren>>(({ children }) => {
|
||||
export const UserProvider = observer(function UserProvider({ children }: React.PropsWithChildren) {
|
||||
// hooks
|
||||
const { isSidebarCollapsed, toggleSidebar } = useTheme();
|
||||
const { currentUser, fetchCurrentUser } = useUser();
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
// Minimal shim so code using next/image compiles under React Router + Vite
|
||||
@@ -9,6 +7,8 @@ type NextImageProps = React.ImgHTMLAttributes<HTMLImageElement> & {
|
||||
src: string;
|
||||
};
|
||||
|
||||
const Image: React.FC<NextImageProps> = ({ src, alt = "", ...rest }) => <img src={src} alt={alt} {...rest} />;
|
||||
function Image({ src, alt = "", ...rest }: NextImageProps) {
|
||||
return <img src={src} alt={alt} {...rest} />;
|
||||
}
|
||||
|
||||
export default Image;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Link as RRLink } from "react-router";
|
||||
import { ensureTrailingSlash } from "./helper";
|
||||
@@ -12,13 +10,8 @@ type NextLinkProps = React.ComponentProps<"a"> & {
|
||||
shallow?: boolean; // next.js prop, ignored
|
||||
};
|
||||
|
||||
const Link: React.FC<NextLinkProps> = ({
|
||||
href,
|
||||
replace,
|
||||
prefetch: _prefetch,
|
||||
scroll: _scroll,
|
||||
shallow: _shallow,
|
||||
...rest
|
||||
}) => <RRLink to={ensureTrailingSlash(href)} replace={replace} {...rest} />;
|
||||
function Link({ href, replace, prefetch: _prefetch, scroll: _scroll, shallow: _shallow, ...rest }: NextLinkProps) {
|
||||
return <RRLink to={ensureTrailingSlash(href)} replace={replace} {...rest} />;
|
||||
}
|
||||
|
||||
export default Link;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams as useSearchParamsRR } from "react-router";
|
||||
import { ensureTrailingSlash } from "./helper";
|
||||
|
||||
@@ -5,30 +5,32 @@ import { Button } from "@plane/propel/button";
|
||||
// images
|
||||
import Image404 from "@/app/assets/images/404.svg?url";
|
||||
|
||||
const PageNotFound = () => (
|
||||
<div className={`h-screen w-full overflow-hidden bg-custom-background-100`}>
|
||||
<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" />
|
||||
function PageNotFound() {
|
||||
return (
|
||||
<div className={`h-screen w-full overflow-hidden bg-custom-background-100`}>
|
||||
<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" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">Oops! Something went wrong.</h3>
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Sorry, the page you are looking for cannot be found. It may have been removed, had its name changed, or is
|
||||
temporarily unavailable.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/general/">
|
||||
<span className="flex justify-center py-4">
|
||||
<Button variant="neutral-primary" size="md">
|
||||
Go to general settings
|
||||
</Button>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">Oops! Something went wrong.</h3>
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Sorry, the page you are looking for cannot be found. It may have been removed, had its name changed, or is
|
||||
temporarily unavailable.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/general/">
|
||||
<span className="flex justify-center py-4">
|
||||
<Button variant="neutral-primary" size="md">
|
||||
Go to general settings
|
||||
</Button>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
export default PageNotFound;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/* eslint-disable import/order */
|
||||
import * as Sentry from "@sentry/react-router";
|
||||
import { startTransition, StrictMode } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
import { HydratedRouter } from "react-router/dom";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.VITE_SENTRY_DSN,
|
||||
environment: process.env.VITE_SENTRY_ENVIRONMENT,
|
||||
sendDefaultPii: process.env.VITE_SENTRY_SEND_DEFAULT_PII ? process.env.VITE_SENTRY_SEND_DEFAULT_PII === "1" : false,
|
||||
release: process.env.VITE_APP_VERSION,
|
||||
tracesSampleRate: process.env.VITE_SENTRY_TRACES_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_TRACES_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
profilesSampleRate: process.env.VITE_SENTRY_PROFILES_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_PROFILES_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
replaysSessionSampleRate: process.env.VITE_SENTRY_REPLAYS_SESSION_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_REPLAYS_SESSION_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
replaysOnErrorSampleRate: process.env.VITE_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE)
|
||||
: 1.0,
|
||||
integrations: [],
|
||||
});
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<HydratedRouter />
|
||||
</StrictMode>
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { SWRConfig } from "swr";
|
||||
import { AppProgressBar } from "@/lib/b-progress";
|
||||
|
||||
+12
-2
@@ -1,10 +1,12 @@
|
||||
import type { ReactNode } from "react";
|
||||
import * as Sentry from "@sentry/react-router";
|
||||
import { Links, Meta, Outlet, Scripts } from "react-router";
|
||||
import type { LinksFunction } from "react-router";
|
||||
import appleTouchIcon from "@/app/assets/favicon/apple-touch-icon.png?url";
|
||||
import favicon16 from "@/app/assets/favicon/favicon-16x16.png?url";
|
||||
import favicon32 from "@/app/assets/favicon/favicon-32x32.png?url";
|
||||
import faviconIco from "@/app/assets/favicon/favicon.ico?url";
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import globalStyles from "@/styles/globals.css?url";
|
||||
import type { Route } from "./+types/root";
|
||||
import { AppProviders } from "./providers";
|
||||
@@ -58,10 +60,18 @@ export default function Root() {
|
||||
}
|
||||
|
||||
export function HydrateFallback() {
|
||||
return null;
|
||||
return (
|
||||
<div className="relative flex h-screen w-full items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
if (error) {
|
||||
Sentry.captureException(error);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Something went wrong.</p>
|
||||
|
||||
Vendored
-5
@@ -1,5 +0,0 @@
|
||||
declare module "next/image" {
|
||||
type Props = React.ComponentProps<"img"> & { src: string };
|
||||
const Image: React.FC<Props>;
|
||||
export default Image;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import { KeyRound, Mails } from "lucide-react";
|
||||
// types
|
||||
@@ -58,7 +57,7 @@ export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) =>
|
||||
key: "google",
|
||||
name: "Google",
|
||||
description: "Allow members to log in or sign up for Plane with their Google accounts.",
|
||||
icon: <Image src={GoogleLogo} height={20} width={20} alt="Google Logo" />,
|
||||
icon: <img src={GoogleLogo} height={20} width={20} alt="Google Logo" />,
|
||||
config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
@@ -66,7 +65,7 @@ export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) =>
|
||||
name: "GitHub",
|
||||
description: "Allow members to log in or sign up for Plane with their GitHub accounts.",
|
||||
icon: (
|
||||
<Image
|
||||
<img
|
||||
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
|
||||
height={20}
|
||||
width={20}
|
||||
@@ -79,21 +78,21 @@ export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) =>
|
||||
key: "gitlab",
|
||||
name: "GitLab",
|
||||
description: "Allow members to log in or sign up to plane with their GitLab accounts.",
|
||||
icon: <Image src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
|
||||
icon: <img src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
|
||||
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "gitea",
|
||||
name: "Gitea",
|
||||
description: "Allow members to log in or sign up to plane with their Gitea accounts.",
|
||||
icon: <Image src={giteaLogo} height={20} width={20} alt="Gitea Logo" />,
|
||||
icon: <img src={giteaLogo} height={20} width={20} alt="Gitea Logo" />,
|
||||
config: <GiteaConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "oidc",
|
||||
name: "OIDC",
|
||||
description: "Authenticate your users via the OpenID Connect protocol.",
|
||||
icon: <Image src={OIDCLogo} height={22} width={22} alt="OIDC Logo" />,
|
||||
icon: <img src={OIDCLogo} height={22} width={22} alt="OIDC Logo" />,
|
||||
config: <UpgradeButton />,
|
||||
unavailable: true,
|
||||
},
|
||||
@@ -101,13 +100,13 @@ export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) =>
|
||||
key: "saml",
|
||||
name: "SAML",
|
||||
description: "Authenticate your users via the Security Assertion Markup Language protocol.",
|
||||
icon: <Image src={SAMLLogo} height={22} width={22} alt="SAML Logo" className="pl-0.5" />,
|
||||
icon: <img src={SAMLLogo} height={22} width={22} alt="SAML Logo" className="pl-0.5" />,
|
||||
config: <UpgradeButton />,
|
||||
unavailable: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const AuthenticationModes = observer<React.FC<TAuthenticationModeProps>>((props) => {
|
||||
export const AuthenticationModes = observer(function AuthenticationModes(props: TAuthenticationModeProps) {
|
||||
const { disabled, updateConfig } = props;
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
// icons
|
||||
import { SquareArrowOutUpRight } from "lucide-react";
|
||||
@@ -7,9 +5,15 @@ import { SquareArrowOutUpRight } from "lucide-react";
|
||||
import { getButtonStyling } from "@plane/propel/button";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
export const UpgradeButton: React.FC = () => (
|
||||
<a href="https://plane.so/pricing?mode=self-hosted" target="_blank" className={cn(getButtonStyling("primary", "sm"))}>
|
||||
Upgrade
|
||||
<SquareArrowOutUpRight className="h-3.5 w-3.5 p-0.5" />
|
||||
</a>
|
||||
);
|
||||
export function UpgradeButton() {
|
||||
return (
|
||||
<a
|
||||
href="https://plane.so/pricing?mode=self-hosted"
|
||||
target="_blank"
|
||||
className={cn(getButtonStyling("primary", "sm"))}
|
||||
>
|
||||
Upgrade
|
||||
<SquareArrowOutUpRight className="h-3.5 w-3.5 p-0.5" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
@@ -13,7 +11,7 @@ type Props = {
|
||||
unavailable?: boolean;
|
||||
};
|
||||
|
||||
export const AuthenticationMethodCard: React.FC<Props> = (props) => {
|
||||
export function AuthenticationMethodCard(props: Props) {
|
||||
const { name, description, icon, config, disabled = false, withBorder = true, unavailable = false } = props;
|
||||
|
||||
return (
|
||||
@@ -52,4 +50,4 @@ export const AuthenticationMethodCard: React.FC<Props> = (props) => {
|
||||
<div className={`shrink-0 ${disabled && "opacity-70"}`}>{config}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
@@ -14,7 +12,7 @@ type Props = {
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
export const EmailCodesConfiguration: React.FC<Props> = observer((props) => {
|
||||
export const EmailCodesConfiguration = observer(function EmailCodesConfiguration(props: Props) {
|
||||
const { disabled, updateConfig } = props;
|
||||
// store
|
||||
const { formattedConfig } = useInstance();
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
@@ -17,7 +15,7 @@ type Props = {
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
export const GiteaConfiguration: React.FC<Props> = observer((props) => {
|
||||
export const GiteaConfiguration = observer(function GiteaConfiguration(props: Props) {
|
||||
const { disabled, updateConfig } = props;
|
||||
// store
|
||||
const { formattedConfig } = useInstance();
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
@@ -18,7 +16,7 @@ type Props = {
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
export const GithubConfiguration: React.FC<Props> = observer((props) => {
|
||||
export const GithubConfiguration = observer(function GithubConfiguration(props: Props) {
|
||||
const { disabled, updateConfig } = props;
|
||||
// store
|
||||
const { formattedConfig } = useInstance();
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
// icons
|
||||
@@ -17,7 +15,7 @@ type Props = {
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
export const GitlabConfiguration: React.FC<Props> = observer((props) => {
|
||||
export const GitlabConfiguration = observer(function GitlabConfiguration(props: Props) {
|
||||
const { disabled, updateConfig } = props;
|
||||
// store
|
||||
const { formattedConfig } = useInstance();
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
// icons
|
||||
@@ -17,7 +15,7 @@ type Props = {
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
export const GoogleConfiguration: React.FC<Props> = observer((props) => {
|
||||
export const GoogleConfiguration = observer(function GoogleConfiguration(props: Props) {
|
||||
const { disabled, updateConfig } = props;
|
||||
// store
|
||||
const { formattedConfig } = useInstance();
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
@@ -14,7 +12,7 @@ type Props = {
|
||||
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
|
||||
};
|
||||
|
||||
export const PasswordLoginConfiguration: React.FC<Props> = observer((props) => {
|
||||
export const PasswordLoginConfiguration = observer(function PasswordLoginConfiguration(props: Props) {
|
||||
const { disabled, updateConfig } = props;
|
||||
// store
|
||||
const { formattedConfig } = useInstance();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { FC } from "react";
|
||||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
|
||||
type TBanner = {
|
||||
@@ -6,7 +5,7 @@ type TBanner = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const Banner: FC<TBanner> = (props) => {
|
||||
export function Banner(props: TBanner) {
|
||||
const { type, message } = props;
|
||||
|
||||
return (
|
||||
@@ -29,4 +28,4 @@ export const Banner: FC<TBanner> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
|
||||
@@ -9,7 +7,7 @@ type Props = {
|
||||
icon?: React.ReactNode | undefined;
|
||||
};
|
||||
|
||||
export const BreadcrumbLink: React.FC<Props> = (props) => {
|
||||
export function BreadcrumbLink(props: Props) {
|
||||
const { href, label, icon } = props;
|
||||
return (
|
||||
<Tooltip tooltipContent={label} position="bottom">
|
||||
@@ -35,4 +33,4 @@ export const BreadcrumbLink: React.FC<Props> = (props) => {
|
||||
</li>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,16 +6,18 @@ type TProps = {
|
||||
darkerShade?: boolean;
|
||||
};
|
||||
|
||||
export const CodeBlock = ({ children, className, darkerShade }: TProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"px-0.5 text-xs text-custom-text-300 bg-custom-background-90 font-semibold rounded-md border border-custom-border-100",
|
||||
{
|
||||
"text-custom-text-200 bg-custom-background-80 border-custom-border-200": darkerShade,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
export function CodeBlock({ children, className, darkerShade }: TProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"px-0.5 text-xs text-custom-text-300 bg-custom-background-90 font-semibold rounded-md border border-custom-border-100",
|
||||
{
|
||||
"text-custom-text-200 bg-custom-background-80 border-custom-border-200": darkerShade,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
// headless ui
|
||||
@@ -13,7 +11,7 @@ type Props = {
|
||||
onDiscardHref: string;
|
||||
};
|
||||
|
||||
export const ConfirmDiscardModal: React.FC<Props> = (props) => {
|
||||
export function ConfirmDiscardModal(props: Props) {
|
||||
const { isOpen, handleClose, onDiscardHref } = props;
|
||||
|
||||
return (
|
||||
@@ -71,4 +69,4 @@ export const ConfirmDiscardModal: React.FC<Props> = (props) => {
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import type { Control } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
@@ -30,7 +28,7 @@ export type TControllerInputFormField = {
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
export const ControllerInput: React.FC<Props> = (props) => {
|
||||
export function ControllerInput(props: Props) {
|
||||
const { name, control, type, label, description, placeholder, error, required } = props;
|
||||
// states
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
@@ -81,4 +79,4 @@ export const ControllerInput: React.FC<Props> = (props) => {
|
||||
{description && <p className="pt-0.5 text-xs text-custom-text-300">{description}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
// ui
|
||||
import { Copy } from "lucide-react";
|
||||
@@ -19,7 +17,7 @@ export type TCopyField = {
|
||||
description: string | React.ReactNode;
|
||||
};
|
||||
|
||||
export const CopyField: React.FC<Props> = (props) => {
|
||||
export function CopyField(props: Props) {
|
||||
const { label, url, description } = props;
|
||||
|
||||
return (
|
||||
@@ -43,4 +41,4 @@ export const CopyField: React.FC<Props> = (props) => {
|
||||
<div className="text-xs text-custom-text-300">{description}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import { Button } from "@plane/propel/button";
|
||||
|
||||
type Props = {
|
||||
@@ -17,32 +14,27 @@ type Props = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const EmptyState: React.FC<Props> = ({
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
disabled = false,
|
||||
}) => (
|
||||
<div className={`flex h-full w-full items-center justify-center`}>
|
||||
<div className="flex w-full flex-col items-center text-center">
|
||||
{image && <Image src={image} className="w-52 sm:w-60" alt={primaryButton?.text || "button image"} />}
|
||||
<h6 className="mb-3 mt-6 text-xl font-semibold sm:mt-8">{title}</h6>
|
||||
{description && <p className="mb-7 px-5 text-custom-text-300 sm:mb-8">{description}</p>}
|
||||
<div className="flex items-center gap-4">
|
||||
{primaryButton && (
|
||||
<Button
|
||||
variant="primary"
|
||||
prependIcon={primaryButton.icon}
|
||||
onClick={primaryButton.onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{primaryButton.text}
|
||||
</Button>
|
||||
)}
|
||||
{secondaryButton}
|
||||
export function EmptyState({ title, description, image, primaryButton, secondaryButton, disabled = false }: Props) {
|
||||
return (
|
||||
<div className={`flex h-full w-full items-center justify-center`}>
|
||||
<div className="flex w-full flex-col items-center text-center">
|
||||
{image && <img src={image} className="w-52 sm:w-60" alt={primaryButton?.text || "button image"} />}
|
||||
<h6 className="mb-3 mt-6 text-xl font-semibold sm:mt-8">{title}</h6>
|
||||
{description && <p className="mb-7 px-5 text-custom-text-300 sm:mb-8">{description}</p>}
|
||||
<div className="flex items-center gap-4">
|
||||
{primaryButton && (
|
||||
<Button
|
||||
variant="primary"
|
||||
prependIcon={primaryButton.icon}
|
||||
onClick={primaryButton.onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{primaryButton.text}
|
||||
</Button>
|
||||
)}
|
||||
{secondaryButton}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import LogoSpinnerDark from "@/app/assets/images/logo-spinner-dark.gif?url";
|
||||
import LogoSpinnerLight from "@/app/assets/images/logo-spinner-light.gif?url";
|
||||
|
||||
export const LogoSpinner = () => {
|
||||
export function LogoSpinner() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerLight : LogoSpinnerDark;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Image src={logoSrc} alt="logo" className="h-6 w-auto sm:h-11" />
|
||||
<img src={logoSrc} alt="logo" className="h-6 w-auto sm:h-11" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"use client";
|
||||
|
||||
type TPageHeader = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export const PageHeader: React.FC<TPageHeader> = (props) => {
|
||||
export function PageHeader(props: TPageHeader) {
|
||||
const { title = "God Mode - Plane", description = "Plane god mode" } = props;
|
||||
|
||||
return (
|
||||
@@ -14,4 +12,4 @@ export const PageHeader: React.FC<TPageHeader> = (props) => {
|
||||
<meta name="description" content={description} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"use client";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Button } from "@plane/propel/button";
|
||||
// assets
|
||||
@@ -8,7 +6,7 @@ import { AuthHeader } from "@/app/(all)/(home)/auth-header";
|
||||
import InstanceFailureDarkImage from "@/app/assets/instance/instance-failure-dark.svg?url";
|
||||
import InstanceFailureImage from "@/app/assets/instance/instance-failure.svg?url";
|
||||
|
||||
export const InstanceFailureView: React.FC = observer(() => {
|
||||
export const InstanceFailureView = observer(function InstanceFailureView() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage;
|
||||
@@ -23,7 +21,7 @@ export const InstanceFailureView: React.FC = observer(() => {
|
||||
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
|
||||
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">
|
||||
<div className="relative flex flex-col justify-center items-center space-y-4">
|
||||
<Image src={instanceImage} alt="Plane Logo" />
|
||||
<img src={instanceImage} alt="Instance failure illustration" />
|
||||
<h3 className="font-medium text-2xl text-white text-center">Unable to fetch instance details.</h3>
|
||||
<p className="font-medium text-base text-center">
|
||||
We were unable to fetch the details of the instance. Fret not, it might just be a connectivity issue.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
export const FormHeader = ({ heading, subHeading }: { heading: string; subHeading: string }) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-2xl font-semibold text-custom-text-100 leading-7">{heading}</span>
|
||||
<span className="text-lg font-semibold text-custom-text-400 leading-7">{subHeading}</span>
|
||||
</div>
|
||||
);
|
||||
export function FormHeader({ heading, subHeading }: { heading: string; subHeading: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-2xl font-semibold text-custom-text-100 leading-7">{heading}</span>
|
||||
<span className="text-lg font-semibold text-custom-text-400 leading-7">{subHeading}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@plane/propel/button";
|
||||
// assets
|
||||
import PlaneTakeOffImage from "@/app/assets/images/plane-takeoff.png?url";
|
||||
|
||||
export const InstanceNotReady: React.FC = () => (
|
||||
<div className="h-full w-full relative container px-5 mx-auto flex justify-center items-center">
|
||||
<div className="w-auto max-w-2xl relative space-y-8 py-10">
|
||||
<div className="relative flex flex-col justify-center items-center space-y-4">
|
||||
<h1 className="text-3xl font-bold pb-3">Welcome aboard Plane!</h1>
|
||||
<Image src={PlaneTakeOffImage} alt="Plane Logo" />
|
||||
<p className="font-medium text-base text-custom-text-400">
|
||||
Get started by setting up your instance and workspace
|
||||
</p>
|
||||
</div>
|
||||
export function InstanceNotReady() {
|
||||
return (
|
||||
<div className="h-full w-full relative container px-5 mx-auto flex justify-center items-center">
|
||||
<div className="w-auto max-w-2xl relative space-y-8 py-10">
|
||||
<div className="relative flex flex-col justify-center items-center space-y-4">
|
||||
<h1 className="text-3xl font-bold pb-3">Welcome aboard Plane!</h1>
|
||||
<img src={PlaneTakeOffImage} alt="Plane Logo" />
|
||||
<p className="font-medium text-base text-custom-text-400">
|
||||
Get started by setting up your instance and workspace
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Link href={"/setup/?auth_enabled=0"}>
|
||||
<Button size="lg" className="w-full">
|
||||
Get started
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<Link href={"/setup/?auth_enabled=0"}>
|
||||
<Button size="lg" className="w-full">
|
||||
Get started
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// assets
|
||||
import LogoSpinnerDark from "@/app/assets/images/logo-spinner-dark.gif?url";
|
||||
import LogoSpinnerLight from "@/app/assets/images/logo-spinner-light.gif?url";
|
||||
|
||||
export const InstanceLoading = () => {
|
||||
export function InstanceLoading() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerLight : LogoSpinnerDark;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Image src={logoSrc} alt="logo" className="h-6 w-auto sm:h-11" />
|
||||
<img src={logoSrc} alt="logo" className="h-6 w-auto sm:h-11" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
// icons
|
||||
@@ -53,8 +51,7 @@ const defaultFromData: TFormData = {
|
||||
is_telemetry_enabled: true,
|
||||
};
|
||||
|
||||
export const InstanceSetupForm: React.FC = (props) => {
|
||||
const {} = props;
|
||||
export function InstanceSetupForm() {
|
||||
// search params
|
||||
const searchParams = useSearchParams();
|
||||
const firstNameParam = searchParams.get("first_name") || undefined;
|
||||
@@ -351,4 +348,4 @@ export const InstanceSetupForm: React.FC = (props) => {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTheme as useNextTheme } from "next-themes";
|
||||
// ui
|
||||
@@ -13,7 +10,7 @@ import TakeoffIconLight from "@/app/assets/logos/takeoff-icon-light.svg?url";
|
||||
import { useTheme } from "@/hooks/store";
|
||||
// icons
|
||||
|
||||
export const NewUserPopup = observer(() => {
|
||||
export const NewUserPopup = observer(function NewUserPopup() {
|
||||
// hooks
|
||||
const { isNewUserPopup, toggleNewUserPopup } = useTheme();
|
||||
// theme
|
||||
@@ -39,7 +36,7 @@ export const NewUserPopup = observer(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center justify-center">
|
||||
<Image
|
||||
<img
|
||||
src={resolveGeneralTheme(resolvedTheme) === "dark" ? TakeoffIconDark : TakeoffIconLight}
|
||||
height={80}
|
||||
width={80}
|
||||
|
||||
@@ -11,7 +11,7 @@ type TWorkspaceListItemProps = {
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
export const WorkspaceListItem = observer(({ workspaceId }: TWorkspaceListItemProps) => {
|
||||
export const WorkspaceListItem = observer(function WorkspaceListItem({ workspaceId }: TWorkspaceListItemProps) {
|
||||
// store hooks
|
||||
const { getWorkspaceById } = useWorkspace();
|
||||
// derived values
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { BProgress } from "@bprogress/core";
|
||||
import { useNavigation } from "react-router";
|
||||
@@ -38,7 +36,6 @@ const PROGRESS_CONFIG: Readonly<ProgressConfig> = {
|
||||
easing: "ease",
|
||||
trickle: true,
|
||||
delay: 0,
|
||||
isDisabled: import.meta.env.PROD, // Disable progress bar in production builds
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { next } from '@vercel/edge';
|
||||
|
||||
export default function middleware() {
|
||||
return next({
|
||||
headers: {
|
||||
'Referrer-Policy': 'origin-when-cross-origin',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-DNS-Prefetch-Control': 'on',
|
||||
'Strict-Transport-Security':
|
||||
'max-age=31536000; includeSubDomains; preload',
|
||||
},
|
||||
});
|
||||
}
|
||||
+10
-18
@@ -6,9 +6,9 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_ENV=development PORT=3001 node server.mjs",
|
||||
"dev": "react-router dev --port 3001",
|
||||
"build": "react-router build",
|
||||
"preview": "react-router build && cross-env NODE_ENV=production PORT=3001 node server.mjs",
|
||||
"preview": "react-router build && serve -s build/client -l 3001",
|
||||
"start": "serve -s build/client -l 3001",
|
||||
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist && rm -rf build",
|
||||
"check:lint": "eslint . --max-warnings 19",
|
||||
@@ -27,29 +27,22 @@
|
||||
"@plane/types": "workspace:*",
|
||||
"@plane/ui": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
"@react-router/express": "^7.9.3",
|
||||
"@react-router/node": "^7.9.3",
|
||||
"@react-router/node": "catalog:",
|
||||
"@sentry/react-router": "catalog:",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tanstack/virtual-core": "^3.13.12",
|
||||
"@vercel/edge": "1.2.2",
|
||||
"axios": "catalog:",
|
||||
"compression": "^1.8.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^5.1.0",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"isbot": "^5.1.31",
|
||||
"lodash-es": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"mobx": "catalog:",
|
||||
"mobx-react": "catalog:",
|
||||
"morgan": "^1.10.1",
|
||||
"next-themes": "^0.2.1",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-hook-form": "7.51.5",
|
||||
"react-router": "^7.9.1",
|
||||
"react-router-dom": "^7.9.1",
|
||||
"react-router": "catalog:",
|
||||
"react-router-dom": "catalog:",
|
||||
"serve": "14.2.5",
|
||||
"swr": "catalog:",
|
||||
"uuid": "catalog:"
|
||||
@@ -58,16 +51,15 @@
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@react-router/dev": "^7.9.1",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "4.17.23",
|
||||
"@prettier/plugin-oxc": "0.0.4",
|
||||
"@react-router/dev": "catalog:",
|
||||
"@types/lodash-es": "catalog:",
|
||||
"@types/morgan": "^1.9.10",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"dotenv": "^16.4.5",
|
||||
"typescript": "catalog:",
|
||||
"vite": "7.1.7",
|
||||
"vite": "catalog:",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { Config } from "@react-router/dev/config";
|
||||
import { joinUrlPath } from "@plane/utils";
|
||||
|
||||
const basePath = joinUrlPath(process.env.VITE_ADMIN_BASE_PATH ?? "", "/") ?? "/";
|
||||
|
||||
export default {
|
||||
appDirectory: "app",
|
||||
basename: process.env.NEXT_PUBLIC_ADMIN_BASE_PATH,
|
||||
basename: basePath,
|
||||
// Admin runs as a client-side app; build a static client bundle only
|
||||
ssr: false,
|
||||
} satisfies Config;
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import compression from "compression";
|
||||
import dotenv from "dotenv";
|
||||
import express from "express";
|
||||
import morgan from "morgan";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
dotenv.config({ path: path.resolve(__dirname, ".env") });
|
||||
|
||||
const BUILD_PATH = "./build/server/index.js";
|
||||
const DEVELOPMENT = process.env.NODE_ENV !== "production";
|
||||
|
||||
// Derive the port from NEXT_PUBLIC_ADMIN_BASE_URL when available, otherwise
|
||||
// default to http://localhost:3001 and fall back to PORT env if explicitly set.
|
||||
const DEFAULT_BASE_URL = "http://localhost:3001";
|
||||
const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || DEFAULT_BASE_URL;
|
||||
let parsedBaseUrl;
|
||||
try {
|
||||
parsedBaseUrl = new URL(ADMIN_BASE_URL);
|
||||
} catch {
|
||||
parsedBaseUrl = new URL(DEFAULT_BASE_URL);
|
||||
}
|
||||
|
||||
const PORT = Number.parseInt(parsedBaseUrl.port, 10);
|
||||
|
||||
async function start() {
|
||||
const app = express();
|
||||
|
||||
app.use(compression());
|
||||
app.disable("x-powered-by");
|
||||
|
||||
if (DEVELOPMENT) {
|
||||
console.log("Starting development server");
|
||||
|
||||
const vite = await import("vite").then((vite) =>
|
||||
vite.createServer({
|
||||
server: { middlewareMode: true },
|
||||
appType: "custom",
|
||||
})
|
||||
);
|
||||
|
||||
app.use(vite.middlewares);
|
||||
|
||||
app.use(async (req, res, next) => {
|
||||
try {
|
||||
const source = await vite.ssrLoadModule("./server/app.ts");
|
||||
return source.app(req, res, next);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
vite.ssrFixStacktrace(error);
|
||||
}
|
||||
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log("Starting production server");
|
||||
|
||||
app.use("/assets", express.static("build/client/assets", { immutable: true, maxAge: "1y" }));
|
||||
app.use(morgan("tiny"));
|
||||
app.use(express.static("build/client", { maxAge: "1h" }));
|
||||
app.use(await import(BUILD_PATH).then((mod) => mod.app));
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
const origin = `${parsedBaseUrl.protocol}//${parsedBaseUrl.hostname}:${PORT}`;
|
||||
console.log(`Server is running on ${origin}`);
|
||||
});
|
||||
}
|
||||
|
||||
start().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import "react-router";
|
||||
import { createRequestHandler } from "@react-router/express";
|
||||
import express from "express";
|
||||
import type { Express } from "express";
|
||||
import { createProxyMiddleware } from "http-proxy-middleware";
|
||||
|
||||
const NEXT_PUBLIC_API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL
|
||||
? process.env.NEXT_PUBLIC_API_BASE_URL.replace(/\/$/, "")
|
||||
: "http://127.0.0.1:8000";
|
||||
const NEXT_PUBLIC_API_BASE_PATH = process.env.NEXT_PUBLIC_API_BASE_PATH
|
||||
? process.env.NEXT_PUBLIC_API_BASE_PATH.replace(/\/+$/, "")
|
||||
: "/api";
|
||||
const NORMALIZED_API_BASE_PATH = NEXT_PUBLIC_API_BASE_PATH.startsWith("/")
|
||||
? NEXT_PUBLIC_API_BASE_PATH
|
||||
: `/${NEXT_PUBLIC_API_BASE_PATH}`;
|
||||
const NEXT_PUBLIC_ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH
|
||||
? process.env.NEXT_PUBLIC_ADMIN_BASE_PATH.replace(/\/$/, "")
|
||||
: "/";
|
||||
|
||||
export const app: Express = express();
|
||||
|
||||
// Ensure proxy-aware hostname/URL handling (e.g., X-Forwarded-Host/Proto)
|
||||
// so generated URLs/redirects reflect the public host when behind Nginx.
|
||||
// See related fix in Remix Express adapter.
|
||||
app.set("trust proxy", true);
|
||||
|
||||
app.use(
|
||||
"/api",
|
||||
createProxyMiddleware({
|
||||
target: NEXT_PUBLIC_API_BASE_URL,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
pathRewrite: (path: string) =>
|
||||
NORMALIZED_API_BASE_PATH === "/api" ? path : path.replace(/^\/api/, NORMALIZED_API_BASE_PATH),
|
||||
})
|
||||
);
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(
|
||||
createRequestHandler({
|
||||
build: () => import("virtual:react-router/server-build"),
|
||||
})
|
||||
);
|
||||
|
||||
app.use(NEXT_PUBLIC_ADMIN_BASE_PATH, router);
|
||||
+31
-49
@@ -1,59 +1,41 @@
|
||||
import path from "node:path";
|
||||
import { reactRouter } from "@react-router/dev/vite";
|
||||
import dotenv from "dotenv";
|
||||
import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import { joinUrlPath } from "@plane/utils";
|
||||
|
||||
const PUBLIC_ENV_KEYS = [
|
||||
"NEXT_PUBLIC_API_BASE_URL",
|
||||
"NEXT_PUBLIC_API_BASE_PATH",
|
||||
"NEXT_PUBLIC_ADMIN_BASE_URL",
|
||||
"NEXT_PUBLIC_ADMIN_BASE_PATH",
|
||||
"NEXT_PUBLIC_SPACE_BASE_URL",
|
||||
"NEXT_PUBLIC_SPACE_BASE_PATH",
|
||||
"NEXT_PUBLIC_LIVE_BASE_URL",
|
||||
"NEXT_PUBLIC_LIVE_BASE_PATH",
|
||||
"NEXT_PUBLIC_WEB_BASE_URL",
|
||||
"NEXT_PUBLIC_WEB_BASE_PATH",
|
||||
"NEXT_PUBLIC_WEBSITE_URL",
|
||||
"NEXT_PUBLIC_SUPPORT_EMAIL",
|
||||
];
|
||||
dotenv.config({ path: path.resolve(__dirname, ".env") });
|
||||
|
||||
const publicEnv = PUBLIC_ENV_KEYS.reduce<Record<string, string>>((acc, key) => {
|
||||
acc[key] = process.env[key] ?? "";
|
||||
return acc;
|
||||
}, {});
|
||||
// Expose only vars starting with VITE_
|
||||
const viteEnv = Object.keys(process.env)
|
||||
.filter((k) => k.startsWith("VITE_"))
|
||||
.reduce<Record<string, string>>((a, k) => {
|
||||
a[k] = process.env[k] ?? "";
|
||||
return a;
|
||||
}, {});
|
||||
|
||||
export default defineConfig(({ isSsrBuild }) => {
|
||||
// Only produce an SSR bundle when explicitly enabled.
|
||||
// For static deployments (default), we skip the server build entirely.
|
||||
const enableSsrBuild = process.env.ADMIN_ENABLE_SSR_BUILD === "true";
|
||||
const basePath = joinUrlPath(process.env.NEXT_PUBLIC_ADMIN_BASE_PATH ?? "", "/") ?? "/";
|
||||
const basePath = joinUrlPath(process.env.VITE_ADMIN_BASE_PATH ?? "", "/") ?? "/";
|
||||
|
||||
return {
|
||||
base: basePath,
|
||||
define: {
|
||||
"process.env": JSON.stringify(publicEnv),
|
||||
export default defineConfig(() => ({
|
||||
base: basePath,
|
||||
define: {
|
||||
"process.env": JSON.stringify(viteEnv),
|
||||
},
|
||||
build: {
|
||||
assetsInlineLimit: 0,
|
||||
},
|
||||
plugins: [reactRouter(), tsconfigPaths({ projects: [path.resolve(__dirname, "tsconfig.json")] })],
|
||||
resolve: {
|
||||
alias: {
|
||||
// Next.js compatibility shims used within admin
|
||||
"next/link": path.resolve(__dirname, "app/compat/next/link.tsx"),
|
||||
"next/navigation": path.resolve(__dirname, "app/compat/next/navigation.ts"),
|
||||
},
|
||||
build: {
|
||||
assetsInlineLimit: 0,
|
||||
rollupOptions:
|
||||
isSsrBuild && enableSsrBuild
|
||||
? {
|
||||
input: path.resolve(__dirname, "server/app.ts"),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
plugins: [reactRouter(), tsconfigPaths({ projects: [path.resolve(__dirname, "tsconfig.json")] })],
|
||||
resolve: {
|
||||
alias: {
|
||||
// Next.js compatibility shims used within admin
|
||||
"next/image": path.resolve(__dirname, "app/compat/next/image.tsx"),
|
||||
"next/link": path.resolve(__dirname, "app/compat/next/link.tsx"),
|
||||
"next/navigation": path.resolve(__dirname, "app/compat/next/navigation.ts"),
|
||||
},
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
// No SSR-specific overrides needed; alias resolves to ESM build
|
||||
};
|
||||
});
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
},
|
||||
// No SSR-specific overrides needed; alias resolves to ESM build
|
||||
}));
|
||||
|
||||
@@ -364,6 +364,19 @@ class LabelSerializer(BaseSerializer):
|
||||
]
|
||||
read_only_fields = ["workspace", "project"]
|
||||
|
||||
def validate_name(self, value):
|
||||
project_id = self.context.get("project_id")
|
||||
|
||||
label = Label.objects.filter(project_id=project_id, name__iexact=value)
|
||||
|
||||
if self.instance:
|
||||
label = label.exclude(id=self.instance.pk)
|
||||
|
||||
if label.exists():
|
||||
raise serializers.ValidationError(detail="LABEL_NAME_ALREADY_EXISTS")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class LabelLiteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
|
||||
@@ -13,6 +13,7 @@ from plane.app.views import (
|
||||
ProjectAssetEndpoint,
|
||||
ProjectBulkAssetEndpoint,
|
||||
AssetCheckEndpoint,
|
||||
DuplicateAssetEndpoint,
|
||||
WorkspaceAssetDownloadEndpoint,
|
||||
ProjectAssetDownloadEndpoint,
|
||||
)
|
||||
@@ -91,6 +92,11 @@ urlpatterns = [
|
||||
AssetCheckEndpoint.as_view(),
|
||||
name="asset-check",
|
||||
),
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/duplicate-assets/<uuid:asset_id>/",
|
||||
DuplicateAssetEndpoint.as_view(),
|
||||
name="duplicate-assets",
|
||||
),
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/download/<uuid:asset_id>/",
|
||||
WorkspaceAssetDownloadEndpoint.as_view(),
|
||||
|
||||
@@ -30,6 +30,16 @@ urlpatterns = [
|
||||
UserEndpoint.as_view({"get": "retrieve_user_settings"}),
|
||||
name="users",
|
||||
),
|
||||
path(
|
||||
"users/me/email/generate-code/",
|
||||
UserEndpoint.as_view({"post": "generate_email_verification_code"}),
|
||||
name="user-email-verify-code",
|
||||
),
|
||||
path(
|
||||
"users/me/email/",
|
||||
UserEndpoint.as_view({"patch": "update_email"}),
|
||||
name="user-email-update",
|
||||
),
|
||||
# Profile
|
||||
path("users/me/profile/", ProfileEndpoint.as_view(), name="accounts"),
|
||||
# End profile
|
||||
|
||||
@@ -107,6 +107,7 @@ from .asset.v2 import (
|
||||
ProjectAssetEndpoint,
|
||||
ProjectBulkAssetEndpoint,
|
||||
AssetCheckEndpoint,
|
||||
DuplicateAssetEndpoint,
|
||||
WorkspaceAssetDownloadEndpoint,
|
||||
ProjectAssetDownloadEndpoint,
|
||||
)
|
||||
|
||||
@@ -19,6 +19,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.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from plane.throttles.asset import AssetRateThrottle
|
||||
|
||||
|
||||
class UserAssetsV2Endpoint(BaseAPIView):
|
||||
@@ -44,7 +45,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
||||
# Save the new avatar
|
||||
user.avatar_asset_id = asset_id
|
||||
user.save()
|
||||
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
|
||||
invalidate_cache_directly(
|
||||
path="/api/users/me/", url_params=False, user=True, request=request
|
||||
)
|
||||
invalidate_cache_directly(
|
||||
path="/api/users/me/settings/",
|
||||
url_params=False,
|
||||
@@ -62,7 +65,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
||||
# Save the new cover image
|
||||
user.cover_image_asset_id = asset_id
|
||||
user.save()
|
||||
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
|
||||
invalidate_cache_directly(
|
||||
path="/api/users/me/", url_params=False, user=True, request=request
|
||||
)
|
||||
invalidate_cache_directly(
|
||||
path="/api/users/me/settings/",
|
||||
url_params=False,
|
||||
@@ -78,7 +83,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
||||
user = User.objects.get(id=asset.user_id)
|
||||
user.avatar_asset_id = None
|
||||
user.save()
|
||||
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
|
||||
invalidate_cache_directly(
|
||||
path="/api/users/me/", url_params=False, user=True, request=request
|
||||
)
|
||||
invalidate_cache_directly(
|
||||
path="/api/users/me/settings/",
|
||||
url_params=False,
|
||||
@@ -91,7 +98,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
||||
user = User.objects.get(id=asset.user_id)
|
||||
user.cover_image_asset_id = None
|
||||
user.save()
|
||||
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
|
||||
invalidate_cache_directly(
|
||||
path="/api/users/me/", url_params=False, user=True, request=request
|
||||
)
|
||||
invalidate_cache_directly(
|
||||
path="/api/users/me/settings/",
|
||||
url_params=False,
|
||||
@@ -151,7 +160,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request)
|
||||
# Generate a presigned URL to share an S3 object
|
||||
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
|
||||
presigned_url = storage.generate_presigned_post(
|
||||
object_name=asset_key, file_type=type, file_size=size_limit
|
||||
)
|
||||
# Return the presigned URL
|
||||
return Response(
|
||||
{
|
||||
@@ -188,7 +199,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
# get the entity and save the asset id for the request field
|
||||
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
|
||||
self.entity_asset_delete(
|
||||
entity_type=asset.entity_type, asset=asset, request=request
|
||||
)
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -252,14 +265,18 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
||||
workspace.logo = ""
|
||||
workspace.logo_asset_id = asset_id
|
||||
workspace.save()
|
||||
invalidate_cache_directly(path="/api/workspaces/", url_params=False, user=False, request=request)
|
||||
invalidate_cache_directly(
|
||||
path="/api/workspaces/", url_params=False, user=False, request=request
|
||||
)
|
||||
invalidate_cache_directly(
|
||||
path="/api/users/me/workspaces/",
|
||||
url_params=False,
|
||||
user=True,
|
||||
request=request,
|
||||
)
|
||||
invalidate_cache_directly(path="/api/instances/", url_params=False, user=False, request=request)
|
||||
invalidate_cache_directly(
|
||||
path="/api/instances/", url_params=False, user=False, request=request
|
||||
)
|
||||
return
|
||||
|
||||
# Project Cover
|
||||
@@ -286,14 +303,18 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
||||
return
|
||||
workspace.logo_asset_id = None
|
||||
workspace.save()
|
||||
invalidate_cache_directly(path="/api/workspaces/", url_params=False, user=False, request=request)
|
||||
invalidate_cache_directly(
|
||||
path="/api/workspaces/", url_params=False, user=False, request=request
|
||||
)
|
||||
invalidate_cache_directly(
|
||||
path="/api/users/me/workspaces/",
|
||||
url_params=False,
|
||||
user=True,
|
||||
request=request,
|
||||
)
|
||||
invalidate_cache_directly(path="/api/instances/", url_params=False, user=False, request=request)
|
||||
invalidate_cache_directly(
|
||||
path="/api/instances/", url_params=False, user=False, request=request
|
||||
)
|
||||
return
|
||||
# Project Cover
|
||||
elif entity_type == FileAsset.EntityTypeContext.PROJECT_COVER:
|
||||
@@ -354,13 +375,17 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
||||
workspace=workspace,
|
||||
created_by=request.user,
|
||||
entity_type=entity_type,
|
||||
**self.get_entity_id_field(entity_type=entity_type, entity_id=entity_identifier),
|
||||
**self.get_entity_id_field(
|
||||
entity_type=entity_type, entity_id=entity_identifier
|
||||
),
|
||||
)
|
||||
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request)
|
||||
# Generate a presigned URL to share an S3 object
|
||||
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
|
||||
presigned_url = storage.generate_presigned_post(
|
||||
object_name=asset_key, file_type=type, file_size=size_limit
|
||||
)
|
||||
# Return the presigned URL
|
||||
return Response(
|
||||
{
|
||||
@@ -397,7 +422,9 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
# get the entity and save the asset id for the request field
|
||||
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
|
||||
self.entity_asset_delete(
|
||||
entity_type=asset.entity_type, asset=asset, request=request
|
||||
)
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -560,7 +587,9 @@ class ProjectAssetEndpoint(BaseAPIView):
|
||||
# Get the presigned URL
|
||||
storage = S3Storage(request=request)
|
||||
# Generate a presigned URL to share an S3 object
|
||||
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
|
||||
presigned_url = storage.generate_presigned_post(
|
||||
object_name=asset_key, file_type=type, file_size=size_limit
|
||||
)
|
||||
# Return the presigned URL
|
||||
return Response(
|
||||
{
|
||||
@@ -590,7 +619,9 @@ class ProjectAssetEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def delete(self, request, slug, project_id, pk):
|
||||
# Get the asset
|
||||
asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id)
|
||||
asset = FileAsset.objects.get(
|
||||
id=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
# Check deleted assets
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
@@ -601,7 +632,9 @@ class ProjectAssetEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, pk):
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||
asset = FileAsset.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
|
||||
# Check if the asset is uploaded
|
||||
if not asset.is_uploaded:
|
||||
@@ -634,7 +667,9 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
|
||||
|
||||
# Check if the asset ids are provided
|
||||
if not asset_ids:
|
||||
return Response({"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(
|
||||
{"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# get the asset id
|
||||
assets = FileAsset.objects.filter(id__in=asset_ids, workspace__slug=slug)
|
||||
@@ -688,10 +723,110 @@ class AssetCheckEndpoint(BaseAPIView):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def get(self, request, slug, asset_id):
|
||||
asset = FileAsset.all_objects.filter(id=asset_id, workspace__slug=slug, deleted_at__isnull=True).exists()
|
||||
asset = FileAsset.all_objects.filter(
|
||||
id=asset_id, workspace__slug=slug, deleted_at__isnull=True
|
||||
).exists()
|
||||
return Response({"exists": asset}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class DuplicateAssetEndpoint(BaseAPIView):
|
||||
|
||||
throttle_classes = [AssetRateThrottle]
|
||||
|
||||
def get_entity_id_field(self, entity_type, entity_id):
|
||||
# Workspace Logo
|
||||
if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO:
|
||||
return {"workspace_id": entity_id}
|
||||
|
||||
# Project Cover
|
||||
if entity_type == FileAsset.EntityTypeContext.PROJECT_COVER:
|
||||
return {"project_id": entity_id}
|
||||
|
||||
# User Avatar and Cover
|
||||
if entity_type in [
|
||||
FileAsset.EntityTypeContext.USER_AVATAR,
|
||||
FileAsset.EntityTypeContext.USER_COVER,
|
||||
]:
|
||||
return {"user_id": entity_id}
|
||||
|
||||
# Issue Attachment and Description
|
||||
if entity_type in [
|
||||
FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
FileAsset.EntityTypeContext.ISSUE_DESCRIPTION,
|
||||
]:
|
||||
return {"issue_id": entity_id}
|
||||
|
||||
# Page Description
|
||||
if entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION:
|
||||
return {"page_id": entity_id}
|
||||
|
||||
# Comment Description
|
||||
if entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION:
|
||||
return {"comment_id": entity_id}
|
||||
|
||||
return {}
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def post(self, request, slug, asset_id):
|
||||
project_id = request.data.get("project_id", None)
|
||||
entity_id = request.data.get("entity_id", None)
|
||||
entity_type = request.data.get("entity_type", None)
|
||||
|
||||
|
||||
if (
|
||||
not entity_type
|
||||
or entity_type not in FileAsset.EntityTypeContext.values
|
||||
):
|
||||
return Response(
|
||||
{"error": "Invalid entity type or entity id"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
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(
|
||||
workspace=workspace, id=asset_id, is_uploaded=True
|
||||
).first()
|
||||
|
||||
if not original_asset:
|
||||
return Response(
|
||||
{"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
destination_key = (
|
||||
f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
|
||||
)
|
||||
duplicated_asset = FileAsset.objects.create(
|
||||
attributes={
|
||||
"name": original_asset.attributes.get("name"),
|
||||
"type": original_asset.attributes.get("type"),
|
||||
"size": original_asset.attributes.get("size"),
|
||||
},
|
||||
asset=destination_key,
|
||||
size=original_asset.size,
|
||||
workspace=workspace,
|
||||
created_by_id=request.user.id,
|
||||
entity_type=entity_type,
|
||||
project_id=project_id if project_id else None,
|
||||
storage_metadata=original_asset.storage_metadata,
|
||||
**self.get_entity_id_field(entity_type=entity_type, entity_id=entity_id),
|
||||
)
|
||||
storage.copy_object(original_asset.asset, destination_key)
|
||||
# Update the is_uploaded field for all newly created assets
|
||||
FileAsset.objects.filter(id=duplicated_asset.id).update(is_uploaded=True)
|
||||
|
||||
return Response(
|
||||
{"asset_id": str(duplicated_asset.id)}, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceAssetDownloadEndpoint(BaseAPIView):
|
||||
"""Endpoint to generate a download link for an asset with content-disposition=attachment."""
|
||||
|
||||
|
||||
@@ -39,7 +39,9 @@ class LabelViewSet(BaseViewSet):
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
serializer = LabelSerializer(data=request.data)
|
||||
serializer = LabelSerializer(
|
||||
data=request.data, context={"project_id": project_id}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
@@ -64,8 +66,18 @@ class LabelViewSet(BaseViewSet):
|
||||
{"error": "Label with the same name already exists in the project"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# call the parent method to perform the update
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
serializer = LabelSerializer(
|
||||
instance=self.get_object(),
|
||||
data=request.data,
|
||||
context={"project_id": kwargs["project_id"]},
|
||||
partial=True,
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
@@ -77,6 +89,7 @@ class BulkCreateIssueLabelsEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def post(self, request, slug, project_id):
|
||||
label_data = request.data.get("label_data", [])
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
labels = Label.objects.bulk_create(
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
import secrets
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Case, Count, IntegerField, Q, When
|
||||
from django.contrib.auth import logout
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_control
|
||||
from django.views.decorators.vary import vary_on_cookie
|
||||
from django.core.validators import validate_email
|
||||
from django.core.cache import cache
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
@@ -36,9 +46,11 @@ from plane.utils.paginator import BasePaginator
|
||||
from plane.authentication.utils.host import user_ip
|
||||
from plane.bgtasks.user_deactivation_email_task import user_deactivation_email
|
||||
from plane.utils.host import base_host
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_control
|
||||
from django.views.decorators.vary import vary_on_cookie
|
||||
from plane.bgtasks.user_email_update_task import send_email_update_magic_code, send_email_update_confirmation
|
||||
from plane.authentication.rate_limit import EmailVerificationThrottle
|
||||
|
||||
|
||||
logger = logging.getLogger("plane")
|
||||
|
||||
|
||||
class UserEndpoint(BaseViewSet):
|
||||
@@ -49,6 +61,14 @@ class UserEndpoint(BaseViewSet):
|
||||
def get_object(self):
|
||||
return self.request.user
|
||||
|
||||
def get_throttles(self):
|
||||
"""
|
||||
Apply rate limiting to specific endpoints.
|
||||
"""
|
||||
if self.action == "generate_email_verification_code":
|
||||
return [EmailVerificationThrottle()]
|
||||
return super().get_throttles()
|
||||
|
||||
@method_decorator(cache_control(private=True, max_age=12))
|
||||
@method_decorator(vary_on_cookie)
|
||||
def retrieve(self, request):
|
||||
@@ -69,6 +89,169 @@ class UserEndpoint(BaseViewSet):
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
def _validate_new_email(self, user, new_email):
|
||||
"""
|
||||
Validate the new email address.
|
||||
|
||||
Args:
|
||||
user: The User instance
|
||||
new_email: The new email address to validate
|
||||
|
||||
Returns:
|
||||
Response object with error if validation fails, None if validation passes
|
||||
"""
|
||||
if not new_email:
|
||||
return Response(
|
||||
{"error": "Email is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Validate email format
|
||||
try:
|
||||
validate_email(new_email)
|
||||
except Exception:
|
||||
return Response(
|
||||
{"error": "Invalid email format"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if email is the same as current email
|
||||
if new_email == user.email:
|
||||
return Response(
|
||||
{"error": "New email must be different from current email"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if email already exists in the User model
|
||||
if User.objects.filter(email=new_email).exclude(id=user.id).exists():
|
||||
return Response(
|
||||
{"error": "An account with this email already exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def generate_email_verification_code(self, request):
|
||||
"""
|
||||
Generate and send a magic code to the new email address for verification.
|
||||
Rate limited to 3 requests per hour per user (enforced by EmailVerificationThrottle).
|
||||
Additional per-email cooldown of 60 seconds prevents rapid repeated requests.
|
||||
"""
|
||||
user = self.get_object()
|
||||
new_email = request.data.get("email", "").strip().lower()
|
||||
|
||||
# Validate the new email
|
||||
validation_error = self._validate_new_email(user, new_email)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
|
||||
try:
|
||||
# Generate magic code for email verification
|
||||
# Use a special key prefix to distinguish from regular magic signin
|
||||
# Include user ID to bind the code to the specific user
|
||||
cache_key = f"magic_email_update_{user.id}_{new_email}"
|
||||
## Generate a random token
|
||||
token = (
|
||||
"".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
|
||||
+ "-"
|
||||
+ "".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
|
||||
+ "-"
|
||||
+ "".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
|
||||
)
|
||||
# Store in cache with 10 minute expiration
|
||||
cache_data = json.dumps({"token": token})
|
||||
cache.set(cache_key, cache_data, timeout=600)
|
||||
|
||||
# Send magic code to the new email
|
||||
send_email_update_magic_code.delay(new_email, token)
|
||||
|
||||
return Response(
|
||||
{"message": "Verification code sent to email"},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to generate verification code: %s", str(e), exc_info=True)
|
||||
return Response(
|
||||
{"error": "Failed to generate verification code. Please try again."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def update_email(self, request):
|
||||
"""
|
||||
Verify the magic code and update the user's email address.
|
||||
This endpoint verifies the code and updates the existing user record
|
||||
without creating a new user, ensuring the user ID remains unchanged.
|
||||
"""
|
||||
user = self.get_object()
|
||||
new_email = request.data.get("email", "").strip().lower()
|
||||
code = request.data.get("code", "").strip()
|
||||
|
||||
# Validate the new email
|
||||
validation_error = self._validate_new_email(user, new_email)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
|
||||
if not code:
|
||||
return Response(
|
||||
{"error": "Verification code is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Verify the magic code
|
||||
try:
|
||||
cache_key = f"magic_email_update_{user.id}_{new_email}"
|
||||
cached_data = cache.get(cache_key)
|
||||
|
||||
if not cached_data:
|
||||
logger.warning("Cache key not found: %s. Code may have expired or was never generated.", cache_key)
|
||||
return Response(
|
||||
{"error": "Verification code has expired or is invalid"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
data = json.loads(cached_data)
|
||||
stored_token = data.get("token")
|
||||
|
||||
if str(stored_token) != str(code):
|
||||
return Response(
|
||||
{"error": "Invalid verification code"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"error": "Failed to verify code. Please try again."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Final check: ensure email is still available (might have been taken between code generation and update)
|
||||
if User.objects.filter(email=new_email).exclude(id=user.id).exists():
|
||||
return Response(
|
||||
{"error": "An account with this email already exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
old_email = user.email
|
||||
# Update the email - this updates the existing user record without creating a new user
|
||||
user.email = new_email
|
||||
# Reset email verification status when email is changed
|
||||
user.is_email_verified = False
|
||||
user.save()
|
||||
|
||||
# delete the cache
|
||||
cache.delete(cache_key)
|
||||
|
||||
# Logout the user
|
||||
logout(request)
|
||||
|
||||
# Send confirmation email to the new email address
|
||||
send_email_update_confirmation.delay(new_email)
|
||||
# send the email to the old email address
|
||||
send_email_update_confirmation.delay(old_email)
|
||||
|
||||
# Return updated user data
|
||||
serialized_data = UserMeSerializer(user).data
|
||||
return Response(serialized_data, status=status.HTTP_200_OK)
|
||||
|
||||
def deactivate(self, request):
|
||||
# Check all workspace user is active
|
||||
user = self.get_object()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Third party imports
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
@@ -22,3 +22,22 @@ class AuthenticationThrottle(AnonRateThrottle):
|
||||
)
|
||||
except AuthenticationException as e:
|
||||
return Response(e.get_error_dict(), status=status.HTTP_429_TOO_MANY_REQUESTS)
|
||||
|
||||
|
||||
class EmailVerificationThrottle(UserRateThrottle):
|
||||
"""
|
||||
Throttle for email verification code generation.
|
||||
Limits to 3 requests per hour per user to prevent abuse.
|
||||
"""
|
||||
|
||||
rate = "3/hour"
|
||||
scope = "email_verification"
|
||||
|
||||
def throttle_failure_view(self, request, *args, **kwargs):
|
||||
try:
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"],
|
||||
error_message="RATE_LIMIT_EXCEEDED",
|
||||
)
|
||||
except AuthenticationException as e:
|
||||
return Response(e.get_error_dict(), status=status.HTTP_429_TOO_MANY_REQUESTS)
|
||||
|
||||
@@ -12,6 +12,7 @@ from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers import IssueActivitySerializer
|
||||
from plane.bgtasks.notification_task import notifications
|
||||
from plane.db.models import (
|
||||
CommentReaction,
|
||||
Cycle,
|
||||
@@ -31,7 +32,7 @@ from plane.settings.redis import redis_instance
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.issue_relation_mapper import get_inverse_relation
|
||||
from plane.utils.uuid import is_valid_uuid
|
||||
from plane.bgtasks.notification_task import process_issue_notifications
|
||||
|
||||
|
||||
def extract_ids(data: dict | None, primary_key: str, fallback_key: str) -> set[str]:
|
||||
if not data:
|
||||
@@ -1579,18 +1580,18 @@ def issue_activity(
|
||||
issue_activities_created = IssueActivity.objects.bulk_create(issue_activities)
|
||||
|
||||
if notification:
|
||||
process_issue_notifications(
|
||||
notifications.delay(
|
||||
type=type,
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
actor_id=actor_id,
|
||||
activities_data=json.dumps(
|
||||
project_id=project_id,
|
||||
subscriber=subscriber,
|
||||
issue_activities_created=json.dumps(
|
||||
IssueActivitySerializer(issue_activities_created, many=True).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
requested_data=requested_data,
|
||||
current_instance=current_instance,
|
||||
subscriber=subscriber,
|
||||
notification_type=type,
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
@@ -1,59 +1,670 @@
|
||||
# Third Party imports
|
||||
from celery import shared_task
|
||||
# Python imports
|
||||
import json
|
||||
import uuid
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
IssueMention,
|
||||
IssueSubscriber,
|
||||
Project,
|
||||
User,
|
||||
IssueAssignee,
|
||||
Issue,
|
||||
State,
|
||||
EmailNotificationLog,
|
||||
Notification,
|
||||
IssueComment,
|
||||
IssueActivity,
|
||||
UserNotificationPreference,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.utils.notifications import IssueNotificationHandler, NotificationContext
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from django.db.models import Subquery
|
||||
|
||||
# Third Party imports
|
||||
from celery import shared_task
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
# =========== Issue Description Html Parsing and notification Functions ======================
|
||||
|
||||
|
||||
def update_mentions_for_issue(issue, project, new_mentions, removed_mention):
|
||||
aggregated_issue_mentions = []
|
||||
for mention_id in new_mentions:
|
||||
aggregated_issue_mentions.append(
|
||||
IssueMention(
|
||||
mention_id=mention_id,
|
||||
issue=issue,
|
||||
project=project,
|
||||
workspace_id=project.workspace_id,
|
||||
)
|
||||
)
|
||||
|
||||
IssueMention.objects.bulk_create(aggregated_issue_mentions, batch_size=100)
|
||||
IssueMention.objects.filter(issue=issue, mention__in=removed_mention).delete()
|
||||
|
||||
|
||||
def get_new_mentions(requested_instance, current_instance):
|
||||
# requested_data is the newer instance of the current issue
|
||||
# current_instance is the older instance of the current issue, saved in the database
|
||||
|
||||
# extract mentions from both the instance of data
|
||||
mentions_older = extract_mentions(current_instance)
|
||||
|
||||
mentions_newer = extract_mentions(requested_instance)
|
||||
|
||||
# Getting Set Difference from mentions_newer
|
||||
new_mentions = [mention for mention in mentions_newer if mention not in mentions_older]
|
||||
|
||||
return new_mentions
|
||||
|
||||
|
||||
# Get Removed Mention
|
||||
def get_removed_mentions(requested_instance, current_instance):
|
||||
# requested_data is the newer instance of the current issue
|
||||
# current_instance is the older instance of the current issue, saved in the database
|
||||
|
||||
# extract mentions from both the instance of data
|
||||
mentions_older = extract_mentions(current_instance)
|
||||
mentions_newer = extract_mentions(requested_instance)
|
||||
|
||||
# Getting Set Difference from mentions_newer
|
||||
removed_mentions = [mention for mention in mentions_older if mention not in mentions_newer]
|
||||
|
||||
return removed_mentions
|
||||
|
||||
|
||||
# Adds mentions as subscribers
|
||||
def extract_mentions_as_subscribers(project_id, issue_id, mentions):
|
||||
# mentions is an array of User IDs representing the FILTERED set of mentioned users
|
||||
|
||||
bulk_mention_subscribers = []
|
||||
|
||||
for mention_id in mentions:
|
||||
# If the particular mention has not already been subscribed to the issue, he must be sent the mentioned notification # noqa: E501
|
||||
if (
|
||||
not IssueSubscriber.objects.filter(
|
||||
issue_id=issue_id, subscriber_id=mention_id, project_id=project_id
|
||||
).exists()
|
||||
and not IssueAssignee.objects.filter(
|
||||
project_id=project_id, issue_id=issue_id, assignee_id=mention_id
|
||||
).exists()
|
||||
and not Issue.objects.filter(project_id=project_id, pk=issue_id, created_by_id=mention_id).exists()
|
||||
and ProjectMember.objects.filter(project_id=project_id, member_id=mention_id, is_active=True).exists()
|
||||
):
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
bulk_mention_subscribers.append(
|
||||
IssueSubscriber(
|
||||
workspace_id=project.workspace_id,
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
subscriber_id=mention_id,
|
||||
)
|
||||
)
|
||||
return bulk_mention_subscribers
|
||||
|
||||
|
||||
# Parse Issue Description & extracts mentions
|
||||
def extract_mentions(issue_instance):
|
||||
try:
|
||||
# issue_instance has to be a dictionary passed, containing the description_html and other set of activity data. # noqa: E501
|
||||
mentions = []
|
||||
# Convert string to dictionary
|
||||
data = json.loads(issue_instance)
|
||||
html = data.get("description_html")
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
mention_tags = soup.find_all("mention-component", attrs={"entity_name": "user_mention"})
|
||||
|
||||
mentions = [mention_tag["entity_identifier"] for mention_tag in mention_tags]
|
||||
|
||||
return list(set(mentions))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# =========== Comment Parsing and notification Functions ======================
|
||||
def extract_comment_mentions(comment_value):
|
||||
try:
|
||||
mentions = []
|
||||
soup = BeautifulSoup(comment_value, "html.parser")
|
||||
mentions_tags = soup.find_all("mention-component", attrs={"entity_name": "user_mention"})
|
||||
for mention_tag in mentions_tags:
|
||||
mentions.append(mention_tag["entity_identifier"])
|
||||
return list(set(mentions))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def get_new_comment_mentions(new_value, old_value):
|
||||
mentions_newer = extract_comment_mentions(new_value)
|
||||
if old_value is None:
|
||||
return mentions_newer
|
||||
|
||||
mentions_older = extract_comment_mentions(old_value)
|
||||
# Getting Set Difference from mentions_newer
|
||||
new_mentions = [mention for mention in mentions_newer if mention not in mentions_older]
|
||||
|
||||
return new_mentions
|
||||
|
||||
|
||||
def create_mention_notification(project, notification_comment, issue, actor_id, mention_id, issue_id, activity):
|
||||
return Notification(
|
||||
workspace=project.workspace,
|
||||
sender="in_app:issue_activities:mentioned",
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=mention_id,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
project=project,
|
||||
message=notification_comment,
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(activity.get("id")),
|
||||
"verb": str(activity.get("verb")),
|
||||
"field": str(activity.get("field")),
|
||||
"actor": str(activity.get("actor_id")),
|
||||
"new_value": str(activity.get("new_value")),
|
||||
"old_value": str(activity.get("old_value")),
|
||||
"old_identifier": (str(activity.get("old_identifier")) if activity.get("old_identifier") else None),
|
||||
"new_identifier": (str(activity.get("new_identifier")) if activity.get("new_identifier") else None),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def process_issue_notifications(
|
||||
def notifications(
|
||||
type,
|
||||
issue_id,
|
||||
project_id,
|
||||
actor_id,
|
||||
activities_data,
|
||||
requested_data=None,
|
||||
current_instance=None,
|
||||
subscriber=False,
|
||||
notification_type=""
|
||||
subscriber,
|
||||
issue_activities_created,
|
||||
requested_data,
|
||||
current_instance,
|
||||
):
|
||||
"""
|
||||
Process notifications for issue activities.
|
||||
"""
|
||||
try:
|
||||
# Let the handler normalize and parse activities
|
||||
activities = IssueNotificationHandler.parse_activities(activities_data)
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
workspace_id = project.workspace_id
|
||||
|
||||
# Create context
|
||||
context = NotificationContext(
|
||||
entity_id=issue_id,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
actor_id=actor_id,
|
||||
activities=activities,
|
||||
requested_data=requested_data,
|
||||
current_instance=current_instance,
|
||||
subscriber=subscriber,
|
||||
notification_type=notification_type
|
||||
issue_activities_created = (
|
||||
json.loads(issue_activities_created) if issue_activities_created is not None else None
|
||||
)
|
||||
|
||||
# Process notifications
|
||||
handler = IssueNotificationHandler(context)
|
||||
payload = handler.process()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"in_app_count": len(payload.in_app_notifications),
|
||||
"email_count": len(payload.email_logs),
|
||||
}
|
||||
if type not in [
|
||||
"cycle.activity.created",
|
||||
"cycle.activity.deleted",
|
||||
"module.activity.created",
|
||||
"module.activity.deleted",
|
||||
"issue_reaction.activity.created",
|
||||
"issue_reaction.activity.deleted",
|
||||
"comment_reaction.activity.created",
|
||||
"comment_reaction.activity.deleted",
|
||||
"issue_vote.activity.created",
|
||||
"issue_vote.activity.deleted",
|
||||
"issue_draft.activity.created",
|
||||
"issue_draft.activity.updated",
|
||||
"issue_draft.activity.deleted",
|
||||
]:
|
||||
# Create Notifications
|
||||
bulk_notifications = []
|
||||
bulk_email_logs = []
|
||||
|
||||
"""
|
||||
Mention Tasks
|
||||
1. Perform Diffing and Extract the mentions, that mention notification needs to be sent
|
||||
2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers
|
||||
"""
|
||||
|
||||
# get the list of active project members
|
||||
project_members = ProjectMember.objects.filter(project_id=project_id, is_active=True).values_list(
|
||||
"member_id", flat=True
|
||||
)
|
||||
|
||||
# Get new mentions from the newer instance
|
||||
new_mentions = get_new_mentions(requested_instance=requested_data, current_instance=current_instance)
|
||||
new_mentions = list(set(new_mentions) & {str(member) for member in project_members})
|
||||
removed_mention = get_removed_mentions(requested_instance=requested_data, current_instance=current_instance)
|
||||
|
||||
comment_mentions = []
|
||||
all_comment_mentions = []
|
||||
|
||||
# Get New Subscribers from the mentions of the newer instance
|
||||
requested_mentions = extract_mentions(issue_instance=requested_data)
|
||||
mention_subscribers = extract_mentions_as_subscribers(
|
||||
project_id=project_id, issue_id=issue_id, mentions=requested_mentions
|
||||
)
|
||||
|
||||
for issue_activity in issue_activities_created:
|
||||
issue_comment = issue_activity.get("issue_comment")
|
||||
issue_comment_new_value = issue_activity.get("new_value")
|
||||
issue_comment_old_value = issue_activity.get("old_value")
|
||||
if issue_comment is not None:
|
||||
# TODO: Maybe save the comment mentions, so that in future, we can filter out the issues based on comment mentions as well.
|
||||
|
||||
all_comment_mentions = all_comment_mentions + extract_comment_mentions(issue_comment_new_value)
|
||||
|
||||
new_comment_mentions = get_new_comment_mentions(
|
||||
old_value=issue_comment_old_value,
|
||||
new_value=issue_comment_new_value,
|
||||
)
|
||||
comment_mentions = comment_mentions + new_comment_mentions
|
||||
comment_mentions = [
|
||||
mention for mention in comment_mentions if UUID(mention) in set(project_members)
|
||||
]
|
||||
|
||||
comment_mention_subscribers = extract_mentions_as_subscribers(
|
||||
project_id=project_id, issue_id=issue_id, mentions=all_comment_mentions
|
||||
)
|
||||
"""
|
||||
We will not send subscription activity notification to the below mentioned user sets
|
||||
- Those who have been newly mentioned in the issue description, we will send mention notification to them.
|
||||
- When the activity is a comment_created and there exist a mention in the comment,
|
||||
then we have to send the "mention_in_comment" notification
|
||||
- When the activity is a comment_updated and there exist a mention change,
|
||||
then also we have to send the "mention_in_comment" notification
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------------------------------------
|
||||
issue_subscribers = list(
|
||||
IssueSubscriber.objects.filter(
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
subscriber__in=Subquery(project_members),
|
||||
)
|
||||
.exclude(subscriber_id__in=list(new_mentions + comment_mentions + [actor_id]))
|
||||
.values_list("subscriber", flat=True)
|
||||
)
|
||||
|
||||
issue = Issue.objects.filter(pk=issue_id).first()
|
||||
|
||||
if subscriber:
|
||||
# add the user to issue subscriber
|
||||
try:
|
||||
_ = IssueSubscriber.objects.get_or_create(
|
||||
project_id=project_id, issue_id=issue_id, subscriber_id=actor_id
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
issue_assignees = IssueAssignee.objects.filter(
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
assignee__in=Subquery(project_members),
|
||||
).values_list("assignee", flat=True)
|
||||
|
||||
issue_subscribers = list(set(issue_subscribers) - {uuid.UUID(actor_id)})
|
||||
|
||||
for subscriber in issue_subscribers:
|
||||
if issue.created_by_id and issue.created_by_id == subscriber:
|
||||
sender = "in_app:issue_activities:created"
|
||||
elif subscriber in issue_assignees and issue.created_by_id not in issue_assignees:
|
||||
sender = "in_app:issue_activities:assigned"
|
||||
else:
|
||||
sender = "in_app:issue_activities:subscribed"
|
||||
|
||||
preference = UserNotificationPreference.objects.get(user_id=subscriber)
|
||||
|
||||
for issue_activity in issue_activities_created:
|
||||
# If activity done in blocking then blocked by email should not go
|
||||
if issue_activity.get("issue_detail").get("id") != issue_id:
|
||||
continue
|
||||
|
||||
# Do not send notification for description update
|
||||
if issue_activity.get("field") == "description":
|
||||
continue
|
||||
|
||||
# Check if the value should be sent or not
|
||||
send_email = False
|
||||
if issue_activity.get("field") == "state" and preference.state_change:
|
||||
send_email = True
|
||||
elif (
|
||||
issue_activity.get("field") == "state"
|
||||
and preference.issue_completed
|
||||
and State.objects.filter(
|
||||
project_id=project_id,
|
||||
pk=issue_activity.get("new_identifier"),
|
||||
group="completed",
|
||||
).exists()
|
||||
):
|
||||
send_email = True
|
||||
elif issue_activity.get("field") == "comment" and preference.comment:
|
||||
send_email = True
|
||||
elif preference.property_change:
|
||||
send_email = True
|
||||
else:
|
||||
send_email = False
|
||||
|
||||
# If activity is of issue comment fetch the comment
|
||||
issue_comment = (
|
||||
IssueComment.objects.filter(
|
||||
id=issue_activity.get("issue_comment"),
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
).first()
|
||||
if issue_activity.get("issue_comment")
|
||||
else None
|
||||
)
|
||||
|
||||
# Create in app notification
|
||||
bulk_notifications.append(
|
||||
Notification(
|
||||
workspace=project.workspace,
|
||||
sender=sender,
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
project=project,
|
||||
title=issue_activity.get("comment"),
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(issue_activity.get("id")),
|
||||
"verb": str(issue_activity.get("verb")),
|
||||
"field": str(issue_activity.get("field")),
|
||||
"actor": str(issue_activity.get("actor_id")),
|
||||
"new_value": str(issue_activity.get("new_value")),
|
||||
"old_value": str(issue_activity.get("old_value")),
|
||||
"issue_comment": str(
|
||||
issue_comment.comment_stripped if issue_comment is not None else ""
|
||||
),
|
||||
"old_identifier": (
|
||||
str(issue_activity.get("old_identifier"))
|
||||
if issue_activity.get("old_identifier")
|
||||
else None
|
||||
),
|
||||
"new_identifier": (
|
||||
str(issue_activity.get("new_identifier"))
|
||||
if issue_activity.get("new_identifier")
|
||||
else None
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
# Create email notification
|
||||
if send_email:
|
||||
bulk_email_logs.append(
|
||||
EmailNotificationLog(
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"project_id": str(issue.project.id),
|
||||
"workspace_slug": str(issue.project.workspace.slug),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(issue_activity.get("id")),
|
||||
"verb": str(issue_activity.get("verb")),
|
||||
"field": str(issue_activity.get("field")),
|
||||
"actor": str(issue_activity.get("actor_id")),
|
||||
"new_value": str(issue_activity.get("new_value")),
|
||||
"old_value": str(issue_activity.get("old_value")),
|
||||
"issue_comment": str(
|
||||
issue_comment.comment_stripped if issue_comment is not None else ""
|
||||
),
|
||||
"old_identifier": (
|
||||
str(issue_activity.get("old_identifier"))
|
||||
if issue_activity.get("old_identifier")
|
||||
else None
|
||||
),
|
||||
"new_identifier": (
|
||||
str(issue_activity.get("new_identifier"))
|
||||
if issue_activity.get("new_identifier")
|
||||
else None
|
||||
),
|
||||
"activity_time": issue_activity.get("created_at"),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------------------------------------- #
|
||||
|
||||
# Add Mentioned as Issue Subscribers
|
||||
IssueSubscriber.objects.bulk_create(
|
||||
mention_subscribers + comment_mention_subscribers,
|
||||
batch_size=100,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
last_activity = IssueActivity.objects.filter(issue_id=issue_id).order_by("-created_at").first()
|
||||
|
||||
actor = User.objects.get(pk=actor_id)
|
||||
|
||||
for mention_id in comment_mentions:
|
||||
if mention_id != actor_id:
|
||||
preference = UserNotificationPreference.objects.get(user_id=mention_id)
|
||||
for issue_activity in issue_activities_created:
|
||||
notification = create_mention_notification(
|
||||
project=project,
|
||||
issue=issue,
|
||||
notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}", # noqa: E501
|
||||
actor_id=actor_id,
|
||||
mention_id=mention_id,
|
||||
issue_id=issue_id,
|
||||
activity=issue_activity,
|
||||
)
|
||||
|
||||
# check for email notifications
|
||||
if preference.mention:
|
||||
bulk_email_logs.append(
|
||||
EmailNotificationLog(
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=mention_id,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
"project_id": str(issue.project.id),
|
||||
"workspace_slug": str(issue.project.workspace.slug),
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(issue_activity.get("id")),
|
||||
"verb": str(issue_activity.get("verb")),
|
||||
"field": str("mention"),
|
||||
"actor": str(issue_activity.get("actor_id")),
|
||||
"new_value": str(issue_activity.get("new_value")),
|
||||
"old_value": str(issue_activity.get("old_value")),
|
||||
"old_identifier": (
|
||||
str(issue_activity.get("old_identifier"))
|
||||
if issue_activity.get("old_identifier")
|
||||
else None
|
||||
),
|
||||
"new_identifier": (
|
||||
str(issue_activity.get("new_identifier"))
|
||||
if issue_activity.get("new_identifier")
|
||||
else None
|
||||
),
|
||||
"activity_time": issue_activity.get("created_at"),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
bulk_notifications.append(notification)
|
||||
|
||||
for mention_id in new_mentions:
|
||||
if mention_id != actor_id:
|
||||
preference = UserNotificationPreference.objects.get(user_id=mention_id)
|
||||
if (
|
||||
last_activity is not None
|
||||
and last_activity.field == "description"
|
||||
and actor_id == str(last_activity.actor_id)
|
||||
):
|
||||
bulk_notifications.append(
|
||||
Notification(
|
||||
workspace=project.workspace,
|
||||
sender="in_app:issue_activities:mentioned",
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=mention_id,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
project=project,
|
||||
message=f"You have been mentioned in the issue {issue.name}",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
"project_id": str(issue.project.id),
|
||||
"workspace_slug": str(issue.project.workspace.slug),
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(last_activity.id),
|
||||
"verb": str(last_activity.verb),
|
||||
"field": str(last_activity.field),
|
||||
"actor": str(last_activity.actor_id),
|
||||
"new_value": str(last_activity.new_value),
|
||||
"old_value": str(last_activity.old_value),
|
||||
"old_identifier": (
|
||||
str(issue_activity.get("old_identifier"))
|
||||
if issue_activity.get("old_identifier")
|
||||
else None
|
||||
),
|
||||
"new_identifier": (
|
||||
str(issue_activity.get("new_identifier"))
|
||||
if issue_activity.get("new_identifier")
|
||||
else None
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
if preference.mention:
|
||||
bulk_email_logs.append(
|
||||
EmailNotificationLog(
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(last_activity.id),
|
||||
"verb": str(last_activity.verb),
|
||||
"field": "mention",
|
||||
"actor": str(last_activity.actor_id),
|
||||
"new_value": str(last_activity.new_value),
|
||||
"old_value": str(last_activity.old_value),
|
||||
"old_identifier": (
|
||||
str(issue_activity.get("old_identifier"))
|
||||
if issue_activity.get("old_identifier")
|
||||
else None
|
||||
),
|
||||
"new_identifier": (
|
||||
str(issue_activity.get("new_identifier"))
|
||||
if issue_activity.get("new_identifier")
|
||||
else None
|
||||
),
|
||||
"activity_time": str(last_activity.created_at),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
else:
|
||||
for issue_activity in issue_activities_created:
|
||||
notification = create_mention_notification(
|
||||
project=project,
|
||||
issue=issue,
|
||||
notification_comment=f"You have been mentioned in the issue {issue.name}",
|
||||
actor_id=actor_id,
|
||||
mention_id=mention_id,
|
||||
issue_id=issue_id,
|
||||
activity=issue_activity,
|
||||
)
|
||||
if preference.mention:
|
||||
bulk_email_logs.append(
|
||||
EmailNotificationLog(
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(issue_activity.get("id")),
|
||||
"verb": str(issue_activity.get("verb")),
|
||||
"field": str("mention"),
|
||||
"actor": str(issue_activity.get("actor_id")),
|
||||
"new_value": str(issue_activity.get("new_value")),
|
||||
"old_value": str(issue_activity.get("old_value")),
|
||||
"old_identifier": (
|
||||
str(issue_activity.get("old_identifier"))
|
||||
if issue_activity.get("old_identifier")
|
||||
else None
|
||||
),
|
||||
"new_identifier": (
|
||||
str(issue_activity.get("new_identifier"))
|
||||
if issue_activity.get("new_identifier")
|
||||
else None
|
||||
),
|
||||
"activity_time": issue_activity.get("created_at"),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
bulk_notifications.append(notification)
|
||||
|
||||
# save new mentions for the particular issue and remove the mentions that has been deleted from the description # noqa: E501
|
||||
update_mentions_for_issue(
|
||||
issue=issue,
|
||||
project=project,
|
||||
new_mentions=new_mentions,
|
||||
removed_mention=removed_mention,
|
||||
)
|
||||
# Bulk create notifications
|
||||
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
|
||||
EmailNotificationLog.objects.bulk_create(bulk_email_logs, batch_size=100, ignore_conflicts=True)
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
print(e)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# Python imports
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
# Module imports
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_email_update_magic_code(email, token):
|
||||
try:
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_USE_SSL,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
# Send the mail
|
||||
subject = "Verify your new email address"
|
||||
context = {"code": token, "email": email}
|
||||
|
||||
html_content = render_to_string("emails/auth/magic_signin.html", context)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
use_ssl=EMAIL_USE_SSL == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[email],
|
||||
connection=connection,
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane.worker").info("Email sent successfully.")
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_email_update_confirmation(email):
|
||||
"""
|
||||
Send a confirmation email to the user after their email address has been successfully updated.
|
||||
|
||||
Args:
|
||||
email: The new email address that was successfully updated
|
||||
"""
|
||||
try:
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_USE_SSL,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
# Send the confirmation email
|
||||
subject = "Plane email address successfully updated"
|
||||
context = {"email": email}
|
||||
|
||||
html_content = render_to_string("emails/user/email_updated.html", context)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
use_ssl=EMAIL_USE_SSL == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[email],
|
||||
connection=connection,
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane.worker").info(f"Email update confirmation sent successfully to {email}.")
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user