Compare commits

..

1 Commits

Author SHA1 Message Date
gakshita 6b4c530d69 fix: sidebar toggle button 2025-07-30 16:17:09 +05:30
1859 changed files with 20250 additions and 35387 deletions
-45
View File
@@ -16,48 +16,3 @@ out/
**/out/
dist/
**/dist/
# Logs
npm-debug.log*
pnpm-debug.log*
.pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS junk
.DS_Store
Thumbs.db
# Editor settings
.vscode
.idea
# Coverage and test output
coverage/
**/coverage/
*.lcov
.junit/
test-results/
# Caches and build artifacts
.cache/
**/.cache/
storybook-static/
*storybook.log
*.tsbuildinfo
# Local env and secrets
.env.local
.env.development.local
.env.test.local
.env.production.local
.secrets
tmp/
temp/
# Database/cache dumps
*.rdb
*.rdb.gz
# Misc
*.pem
*.key
+32 -51
View File
@@ -35,10 +35,6 @@ on:
- preview
- canary
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
TARGET_BRANCH: ${{ github.ref_name }}
ARM64_BUILD: ${{ github.event.inputs.arm64 }}
@@ -139,8 +135,7 @@ jobs:
branch_build_push_admin:
name: Build-Push Admin Docker Image
runs-on: ubuntu-22.04
needs:
- branch_build_setup
needs: [branch_build_setup]
steps:
- name: Admin Build and Push
uses: makeplane/actions/build-push@v1.0.0
@@ -153,9 +148,7 @@ jobs:
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_admin }}
build-context: .
dockerfile-path: ./Dockerfile.node
build-args: |
APP_SCOPE=admin
dockerfile-path: ./apps/admin/Dockerfile.admin
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
@@ -164,8 +157,7 @@ jobs:
branch_build_push_web:
name: Build-Push Web Docker Image
runs-on: ubuntu-22.04
needs:
- branch_build_setup
needs: [branch_build_setup]
steps:
- name: Web Build and Push
uses: makeplane/actions/build-push@v1.0.0
@@ -178,9 +170,7 @@ jobs:
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_web }}
build-context: .
dockerfile-path: ./Dockerfile.node
build-args: |
APP_SCOPE=web
dockerfile-path: ./apps/web/Dockerfile.web
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
@@ -189,8 +179,7 @@ jobs:
branch_build_push_space:
name: Build-Push Space Docker Image
runs-on: ubuntu-22.04
needs:
- branch_build_setup
needs: [branch_build_setup]
steps:
- name: Space Build and Push
uses: makeplane/actions/build-push@v1.0.0
@@ -203,9 +192,7 @@ jobs:
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_space }}
build-context: .
dockerfile-path: ./Dockerfile.node
build-args: |
APP_SCOPE=space
dockerfile-path: ./apps/space/Dockerfile.space
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
@@ -214,8 +201,7 @@ jobs:
branch_build_push_live:
name: Build-Push Live Collaboration Docker Image
runs-on: ubuntu-22.04
needs:
- branch_build_setup
needs: [branch_build_setup]
steps:
- name: Live Build and Push
uses: makeplane/actions/build-push@v1.0.0
@@ -237,8 +223,7 @@ jobs:
branch_build_push_api:
name: Build-Push API Server Docker Image
runs-on: ubuntu-22.04
needs:
- branch_build_setup
needs: [branch_build_setup]
steps:
- name: Backend Build and Push
uses: makeplane/actions/build-push@v1.0.0
@@ -250,8 +235,8 @@ jobs:
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_backend }}
build-context: .
dockerfile-path: ./Dockerfile.api
build-context: ./apps/api
dockerfile-path: ./apps/api/Dockerfile.api
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
@@ -260,8 +245,7 @@ jobs:
branch_build_push_proxy:
name: Build-Push Proxy Docker Image
runs-on: ubuntu-22.04
needs:
- branch_build_setup
needs: [branch_build_setup]
steps:
- name: Proxy Build and Push
uses: makeplane/actions/build-push@v1.0.0
@@ -284,14 +268,15 @@ jobs:
if: ${{ needs.branch_build_setup.outputs.aio_build == 'true' }}
name: Build-Push AIO Docker Image
runs-on: ubuntu-22.04
needs:
- branch_build_setup
- branch_build_push_admin
- branch_build_push_web
- branch_build_push_space
- branch_build_push_live
- branch_build_push_api
- branch_build_push_proxy
needs: [
branch_build_setup,
branch_build_push_admin,
branch_build_push_web,
branch_build_push_space,
branch_build_push_live,
branch_build_push_api,
branch_build_push_proxy
]
steps:
- name: Checkout Files
uses: actions/checkout@v4
@@ -300,7 +285,7 @@ jobs:
id: prepare_aio_assets
run: |
cd deployments/aio/community
if [ "${{ needs.branch_build_setup.outputs.build_type }}" == "Release" ]; then
aio_version=${{ needs.branch_build_setup.outputs.release_version }}
else
@@ -339,14 +324,7 @@ jobs:
upload_build_assets:
name: Upload Build Assets
runs-on: ubuntu-22.04
needs:
- branch_build_setup
- branch_build_push_admin
- branch_build_push_web
- branch_build_push_space
- branch_build_push_live
- branch_build_push_api
- branch_build_push_proxy
needs: [branch_build_setup, branch_build_push_admin, branch_build_push_web, branch_build_push_space, branch_build_push_live, branch_build_push_api, branch_build_push_proxy]
steps:
- name: Checkout Files
uses: actions/checkout@v4
@@ -380,13 +358,15 @@ jobs:
name: Build Release
runs-on: ubuntu-22.04
needs:
- branch_build_setup
- branch_build_push_admin
- branch_build_push_web
- branch_build_push_space
- branch_build_push_live
- branch_build_push_api
- branch_build_push_proxy
[
branch_build_setup,
branch_build_push_admin,
branch_build_push_web,
branch_build_push_space,
branch_build_push_live,
branch_build_push_api,
branch_build_push_proxy,
]
env:
REL_VERSION: ${{ needs.branch_build_setup.outputs.release_version }}
steps:
@@ -417,3 +397,4 @@ jobs:
${{ github.workspace }}/deployments/cli/community/docker-compose.yml
${{ github.workspace }}/deployments/cli/community/variables.env
${{ github.workspace }}/deployments/swarm/community/swarm.sh
-121
View File
@@ -1,121 +0,0 @@
name: Docker AIO build and smoke test
on:
workflow_dispatch:
pull_request:
branches:
- "preview"
paths:
- "apps/web/**"
- "apps/space/**"
- "apps/admin/**"
- "apps/live/**"
- "packages/**"
- "turbo.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "Dockerfile.node"
- "Dockerfile.api"
- "Dockerfile.aio"
- "docker-bake.hcl"
- ".github/workflows/docker-smoke-aio.yml"
push:
branches:
- "preview"
paths:
- "apps/web/**"
- "apps/space/**"
- "apps/admin/**"
- "apps/live/**"
- "packages/**"
- "turbo.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "Dockerfile.node"
- "Dockerfile.api"
- "Dockerfile.aio"
- "docker-bake.hcl"
- ".github/workflows/docker-smoke-aio.yml"
concurrency:
group: aio-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
determine-aio:
name: Determine if AIO needed
runs-on: ubuntu-latest
outputs:
aio_needed: ${{ steps.build-flag.outputs.aio_needed }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect changed paths
id: changes
uses: dorny/paths-filter@v3
with:
filters: |
web:
- 'apps/web/**'
space:
- 'apps/space/**'
admin:
- 'apps/admin/**'
live:
- 'apps/live/**'
common:
- 'packages/**'
- 'turbo.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'Dockerfile.node'
- 'Dockerfile.api'
- 'Dockerfile.aio'
- 'docker-bake.hcl'
- '.github/workflows/docker-smoke-aio.yml'
- name: Compute AIO flag
id: build-flag
uses: actions/github-script@v7
with:
script: |
const anyCommon = '${{ steps.changes.outputs.common }}' === 'true';
const changedWeb = '${{ steps.changes.outputs.web }}' === 'true';
const changedSpace = '${{ steps.changes.outputs.space }}' === 'true';
const changedAdmin = '${{ steps.changes.outputs.admin }}' === 'true';
const changedLive = '${{ steps.changes.outputs.live }}' === 'true';
const aioNeeded = anyCommon || changedWeb || changedSpace || changedAdmin || changedLive;
core.setOutput('aio_needed', String(aioNeeded));
aio_smoke:
name: Build and smoke test AIO
runs-on: ubuntu-latest
needs: determine-aio
if: ${{ needs.determine-aio.outputs.aio_needed == 'true' }}
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Show Docker version
run: |
docker version
docker info
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build AIO image with bake (load into local daemon)
run: |
docker buildx bake -f "./docker-bake.hcl" --load aio
- name: Run AIO smoke script
run: |
chmod +x "scripts/smoke-aio.sh"
"scripts/smoke-aio.sh"
-171
View File
@@ -1,171 +0,0 @@
name: Docker build and smoke test for apps
on:
workflow_dispatch:
pull_request:
branches:
- "preview"
paths:
- "apps/web/**"
- "apps/space/**"
- "apps/admin/**"
- "apps/live/**"
- "packages/**"
- "turbo.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "Dockerfile.node"
- "Dockerfile.api"
- "Dockerfile.aio"
- "docker-bake.hcl"
- ".github/workflows/docker-smoke.yml"
push:
branches:
- "preview"
paths:
- "apps/web/**"
- "apps/space/**"
- "apps/admin/**"
- "apps/live/**"
- "packages/**"
- "turbo.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "Dockerfile.node"
- "Dockerfile.api"
- "Dockerfile.aio"
- "docker-bake.hcl"
- ".github/workflows/docker-smoke.yml"
jobs:
determine-matrix:
name: Determine matrix
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.build-matrix.outputs.matrix }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect changed paths
id: changes
uses: dorny/paths-filter@v3
with:
filters: |
web:
- 'apps/web/**'
space:
- 'apps/space/**'
admin:
- 'apps/admin/**'
live:
- 'apps/live/**'
common:
- 'packages/**'
- 'turbo.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'Dockerfile.node'
- 'Dockerfile.api'
- 'Dockerfile.aio'
- 'docker-bake.hcl'
- '.github/workflows/docker-smoke.yml'
- name: Build matrix
id: build-matrix
uses: actions/github-script@v7
with:
script: |
const include = [];
const anyCommon = '${{ steps.changes.outputs.common }}' === 'true';
const changed = {
web: '${{ steps.changes.outputs.web }}' === 'true',
space: '${{ steps.changes.outputs.space }}' === 'true',
admin: '${{ steps.changes.outputs.admin }}' === 'true',
live: '${{ steps.changes.outputs.live }}' === 'true',
};
const add = (name, bake_target, image, container, port, path, env_flags = "") =>
include.push({ name, bake_target, image, container, host_port: port, path, env_flags });
const buildAll = anyCommon || changed.web || changed.space || changed.admin || changed.live;
if (buildAll || changed.web) add('web', 'web', 'plane-web:ci-smoke', 'plane-web-ci', 3001, '/');
if (buildAll || changed.space) add('space', 'space', 'plane-space:ci-smoke', 'plane-space-ci', 3002, '/spaces');
if (buildAll || changed.admin) add('admin', 'admin', 'plane-admin:ci-smoke', 'plane-admin-ci', 3003, '/god-mode');
if (buildAll || changed.live) add('live', 'live', 'plane-live:ci-smoke', 'plane-live-ci', 3005, '/live/health', '-e NODE_ENV=production -e LIVE_BASE_PATH=/live');
if (include.length === 0) {
// Default to web to keep job non-empty
add('web', 'web', 'plane-web:ci-smoke', 'plane-web-ci', 3001, '/');
}
core.setOutput('matrix', JSON.stringify({ include }));
smoke:
name: Build and smoke test ${{ matrix.name }}
runs-on: ubuntu-latest
needs: determine-matrix
timeout-minutes: 25
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.determine-matrix.outputs.matrix) }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Prepare build environment
run: echo "Using docker build (no buildx bake)"
- name: Show Docker version
run: |
docker version
docker info
- name: Build image (${{ matrix.name }})
shell: bash
run: |
set -euo pipefail
name="${{ matrix.name }}"
tag="${{ matrix.image }}"
if [ "$name" = "live" ]; then
docker build -f "apps/live/Dockerfile.live" -t "$tag" "."
else
docker build -f "Dockerfile.node" --target runtime --build-arg APP_SCOPE="$name" -t "$tag" "."
fi
- name: Run container (${{ matrix.name }})
run: |
docker run -d --name ${{ matrix.container }} -p ${{ matrix.host_port }}:3000 ${{ matrix.env_flags }} ${{ matrix.image }}
docker ps -a
- name: Smoke test HTTP endpoint (${{ matrix.name }})
shell: bash
run: |
set -euo pipefail
URL="http://localhost:${{ matrix.host_port }}${{ matrix.path }}"
echo "Probing $URL ..."
for i in {1..60}; do
STATUS="$(curl -sS -o /dev/null -w "%{http_code}" -L "${URL}" || true)"
if [ "${STATUS}" = "200" ]; then
echo "Success: HTTP ${STATUS} from ${URL}"
exit 0
fi
echo "Attempt ${i}: HTTP ${STATUS} (waiting 2s)"
sleep 2
done
echo "Failed to get HTTP 200 from ${URL}"
echo "::group::Container logs (${{ matrix.container }})"
docker logs ${{ matrix.container }} || true
echo "::endgroup::"
exit 1
- name: Cleanup container (${{ matrix.name }})
if: always()
run: |
docker rm -f ${{ matrix.container }} || true
@@ -3,21 +3,11 @@ name: Build and lint API
on:
workflow_dispatch:
pull_request:
branches:
- "preview"
types:
- "opened"
- "synchronize"
- "ready_for_review"
- "review_requested"
- "reopened"
branches: ["preview"]
types: ["opened", "synchronize", "ready_for_review", "review_requested", "reopened"]
paths:
- "apps/api/**"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint-api:
name: Lint API
@@ -3,18 +3,14 @@ name: Build and lint web apps
on:
workflow_dispatch:
pull_request:
branches:
- "preview"
types:
- "opened"
- "synchronize"
- "ready_for_review"
- "review_requested"
- "reopened"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
branches: ["preview"]
types: ["opened", "synchronize", "ready_for_review", "review_requested", "reopened"]
paths:
- "**.tsx?"
- "**.jsx?"
- "**.css"
- "**.json"
- "!apps/api/**"
jobs:
build-and-lint:
@@ -24,32 +20,24 @@ jobs:
if: |
github.event.pull_request.draft == false &&
github.event.pull_request.requested_reviewers != null
env:
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }}
TURBO_SCM_HEAD: ${{ github.sha }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 50
filter: blob:none
fetch-depth: 2
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- name: Enable Corepack and pnpm
run: corepack enable pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: yarn install --frozen-lockfile
- name: Lint Affected
run: pnpm turbo run check:lint --affected
- name: Build web apps
run: yarn run build
- name: Lint web apps
run: yarn run ci:lint
- name: Check Affected format
run: pnpm turbo run check:format --affected
- name: Build Affected
run: pnpm turbo run build --affected
+3 -7
View File
@@ -24,13 +24,11 @@ out/
.DS_Store
*.pem
.history
tsconfig.tsbuildinfo
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.pnpm-debug.log*
# Local env files
@@ -62,7 +60,6 @@ node_modules/
assets/dist/
npm-debug.log
yarn-error.log
pnpm-debug.log
# Editor directories and files
.idea
@@ -78,9 +75,10 @@ package-lock.json
# lock files
package-lock.json
pnpm-lock.yaml
pnpm-workspace.yaml
.npmrc
.secrets
tmp/
@@ -97,5 +95,3 @@ dev-editor
# Redis
*.rdb
*.rdb.gz
storybook-static
-34
View File
@@ -1,34 +0,0 @@
# Enforce pnpm workspace behavior and allow Turbo's lifecycle hooks if scripts are disabled
# This repo uses pnpm with workspaces.
# Prefer linking local workspace packages when available
prefer-workspace-packages=true
link-workspace-packages=true
shared-workspace-lockfile=true
# Make peer installs smoother across the monorepo
auto-install-peers=true
strict-peer-dependencies=false
# If scripts are disabled (e.g., CI with --ignore-scripts), allowlisted packages can still run their hooks
# Turbo occasionally performs postinstall tasks for optimal performance
# moved to pnpm-workspace.yaml: onlyBuiltDependencies (e.g., allow turbo)
public-hoist-pattern[]=eslint
public-hoist-pattern[]=prettier
public-hoist-pattern[]=typescript
# Reproducible installs across CI and dev
prefer-frozen-lockfile=true
# Prefer resolving to highest versions in monorepo to reduce duplication
resolution-mode=highest
# Speed up native module builds by caching side effects
side-effects-cache=true
# Speed up local dev by reusing local store when possible
prefer-offline=true
# Ensure workspace protocol is used when adding internal deps
save-workspace-protocol=true
+1
View File
@@ -0,0 +1 @@
nodeLinker: node-modules
+1 -1
View File
@@ -73,7 +73,7 @@ docker compose -f docker-compose-local.yml up
4. Start web apps:
```bash
pnpm dev
yarn dev
```
5. Open your browser to http://localhost:3001/god-mode/ and register yourself as instance admin
-120
View File
@@ -1,120 +0,0 @@
# syntax=docker/dockerfile:1.7
#
# All-in-one assembler image that composes Plane runtime from per-app images
# and the community supervisor/Caddy configuration.
#
# Build requirements:
# - Build and tag the per-app images first (or override args below):
# plane-web:latest, plane-space:latest, plane-admin:latest, plane-live:latest,
# plane-api:latest, plane-proxy:latest
#
# Example:
# docker build \
# -f plane/Dockerfile.aio \
# --build-arg WEB_IMG=plane-web:latest \
# --build-arg SPACE_IMG=plane-space:latest \
# --build-arg ADMIN_IMG=plane-admin:latest \
# --build-arg LIVE_IMG=plane-live:latest \
# --build-arg API_IMG=plane-api:latest \
# --build-arg PROXY_IMG=plane-proxy:latest \
# -t plane-aio:latest .
#
# Run:
# docker run --rm -it -p 80:80 plane-aio:latest
#
# Provide required env vars; see deployments/aio/community/README.md.
# ------------------------------------------------------------------------------
# Arguments to reference locally-built component images
# ------------------------------------------------------------------------------
# Build contexts are used for component images:
# --build-context web_ctx=target:web
# --build-context space_ctx=target:space
# --build-context admin_ctx=target:admin
# --build-context live_ctx=target:live
# --build-context api_ctx=target:api
# --build-context proxy_ctx=target:proxy
ARG NODE_VERSION=22-alpine
ARG PY_VERSION=3.12.10-alpine
# ------------------------------------------------------------------------------
# Source stages: pull artifacts from pre-built images
# ------------------------------------------------------------------------------
FROM node:${NODE_VERSION} AS node
# ------------------------------------------------------------------------------
# Final runner: Python as base (Supervisor + API deps live here)
# ------------------------------------------------------------------------------
FROM python:${PY_VERSION} AS runner
WORKDIR /app
# Base system libs for API/Caddy/Node runtime
RUN apk add --no-cache \
libpq \
libxslt \
xmlsec \
nss-tools \
bash \
curl \
ca-certificates \
openssl
# Install supervisor
RUN pip install --no-cache-dir supervisor && mkdir -p /etc/supervisor/conf.d
# Copy Node runtime into the AIO container for Next standalone apps
COPY --from=node /usr/lib /usr/lib
COPY --from=node /usr/local/lib /usr/local/lib
COPY --from=node /usr/local/include /usr/local/include
COPY --from=node /usr/local/bin /usr/local/bin
# Copy Next.js standalone app artifacts (namespaced under /app/<app>)
COPY --from=web_ctx /app /app/web
COPY --from=space_ctx /app /app/space
COPY --from=admin_ctx /app /app/admin
COPY --from=live_ctx /app /app/live
# Clean potential Next caches (optional)
RUN rm -rf /app/web/apps/web/.next/cache || true \
&& rm -rf /app/space/apps/space/.next/cache || true \
&& rm -rf /app/admin/apps/admin/.next/cache || true
# Copy Python backend code and installed packages/binaries
COPY --from=api_ctx /code /app/backend
# Match CPython version path (PY_VERSION=3.12.x-alpine)
COPY --from=api_ctx /usr/local/lib/python3.12/site-packages/ /usr/local/lib/python3.12/site-packages/
COPY --from=api_ctx /usr/local/bin/ /usr/local/bin/
# Caddy binary from proxy image
COPY --from=proxy_ctx /usr/bin/caddy /usr/bin/caddy
# Community supervisor config and startup script
COPY deployments/aio/community/start.sh /app/start.sh
COPY deployments/aio/community/supervisor.conf /etc/supervisor/conf.d/supervisor.conf
# Provide a default plane.env from variables template; start.sh will update it
COPY deployments/aio/community/variables.env /app/plane.env
# Prepare Caddy configuration:
# - Start from the repository Caddyfile.ce
# - Update upstreams to localhost-bound ports managed by supervisor
RUN mkdir -p /app/proxy
COPY apps/proxy/Caddyfile.ce /app/proxy/Caddyfile
RUN set -eux; \
sed -i 's|web:3000|localhost:3001|g' /app/proxy/Caddyfile; \
sed -i 's|space:3000|localhost:3002|g' /app/proxy/Caddyfile; \
sed -i 's|admin:3000|localhost:3003|g' /app/proxy/Caddyfile; \
sed -i 's|api:8000|localhost:3004|g' /app/proxy/Caddyfile; \
sed -i 's|live:3000|localhost:3005|g' /app/proxy/Caddyfile; \
sed -i '/plane-minio:9000/d' /app/proxy/Caddyfile
# Folders and permissions
RUN mkdir -p /app/logs/access /app/logs/error /app/data \
&& chmod +x /app/start.sh
VOLUME ["/app/data", "/app/logs"]
EXPOSE 80 443
CMD ["/app/start.sh"]
-151
View File
@@ -1,151 +0,0 @@
# syntax=docker/dockerfile:1.7
#
# Unified multi-stage Dockerfile for the Python API
# Targets:
# - dev: local development with hot reload and full build toolchain
# - runtime: production runtime using prebuilt wheels
#
# Example builds (from repo root):
# docker build -f plane/Dockerfile.api --target dev -t plane-api:dev .
# docker build -f plane/Dockerfile.api --target runtime -t plane-api:latest .
ARG PY_VERSION=python:3.12.10-alpine
# -----------------------------------------------------------------------------
# base: common runtime base (no build toolchain)
# -----------------------------------------------------------------------------
FROM ${PY_VERSION} AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
INSTANCE_CHANGELOG_URL=https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
# Runtime libraries required by the app
RUN apk add --no-cache \
libpq \
libxslt \
xmlsec \
ca-certificates \
openssl
WORKDIR /code
# -----------------------------------------------------------------------------
# builder: build Python wheels for all dependencies
# -----------------------------------------------------------------------------
FROM ${PY_VERSION} AS builder
# Full build toolchain to compile deps to wheels
RUN apk add --no-cache \
bash~=5.2 \
g++ \
gcc \
cargo \
git \
make \
postgresql-dev \
libc-dev \
linux-headers \
libffi-dev \
libxml2-dev \
libxslt-dev \
openssl-dev \
xmlsec-dev
WORKDIR /w
# Copy requirements (relative to repo root)
COPY apps/api/requirements.txt /w/requirements.txt
COPY apps/api/requirements /w/requirements
# Build wheels into /wheels to reuse in runtime
RUN --mount=type=cache,target=/root/.cache/pip \
pip wheel -r /w/requirements.txt --wheel-dir /wheels
# -----------------------------------------------------------------------------
# dev: local development image (bind mount source into /code)
# -----------------------------------------------------------------------------
FROM ${PY_VERSION} AS dev
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
INSTANCE_CHANGELOG_URL=https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
# Match prior dev environment: runtime + build deps + node
RUN apk add --no-cache \
libpq \
libxslt \
xmlsec \
nodejs-current \
bash~=5.2 \
g++ \
gcc \
cargo \
git \
make \
postgresql-dev \
libc-dev \
linux-headers \
libffi-dev
WORKDIR /code
# Copy and install local dev requirements
COPY apps/api/requirements.txt ./requirements.txt
COPY apps/api/requirements ./requirements
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements/local.txt --no-cache-dir
# Bring in the API source
COPY apps/api/ ./
# Permissions similar to existing Dockerfiles
RUN mkdir -p /code/plane/logs \
&& chmod -R +x /code/bin \
&& chmod -R 777 /code
EXPOSE 8000
CMD ["./bin/docker-entrypoint-api-local.sh"]
# -----------------------------------------------------------------------------
# runtime: production image using wheels from builder
# -----------------------------------------------------------------------------
FROM base AS runtime
# Bash needed for entrypoint scripts
RUN apk add --no-cache bash~=5.2
WORKDIR /code
# Install from wheels for reproducible, fast builds
COPY --from=builder /wheels /wheels
COPY apps/api/requirements.txt ./requirements.txt
COPY apps/api/requirements ./requirements
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt
# Copy only what is required to run
COPY apps/api/manage.py ./manage.py
COPY apps/api/plane ./plane
COPY apps/api/templates ./templates
COPY apps/api/package.json ./package.json
COPY apps/api/bin ./bin
# Create unprivileged user and set secure permissions
RUN addgroup -S plane && adduser -S -G plane -h /code -s /sbin/nologin plane \
&& chmod +x ./bin/* \
&& mkdir -p /code/plane/logs \
&& chown -R plane:plane /code \
&& chmod -R 755 /code \
&& chmod 775 /code/plane/logs
ENV GUNICORN_WORKERS=3 \
PORT=8000
USER plane
EXPOSE 8000
# Default: API server; override command for worker/beat/migrator as needed
CMD ["./bin/docker-entrypoint-api.sh"]
-138
View File
@@ -1,138 +0,0 @@
# syntax=docker/dockerfile:1.7
#
# Unified multi-stage Dockerfile for Next.js apps in this monorepo.
# Supports:
# - target=dev (hot-reload, expects bind-mounted repo)
# - target=runtime (production image using Next.js standalone output)
#
# Usage examples:
# - Build dev image for web: docker build --target dev --build-arg APP_SCOPE=web -t plane-web:dev .
# - Run dev (with compose): mount the repo into /repo and override command/ports as needed
# - Build prod image for web: docker build --target runtime --build-arg APP_SCOPE=web -t plane-web:latest .
#
# APP_SCOPE must be one of: web, space, admin
ARG NODE_VERSION=22-alpine
ARG TURBO_VERSION=2.5.6
# -----------------------------------------------------------------------------
# Base: Node + pnpm (corepack)
# -----------------------------------------------------------------------------
FROM node:${NODE_VERSION} AS base
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN corepack enable && apk add --no-cache libc6-compat
# -----------------------------------------------------------------------------
# builder: prune workspace using turbo for the selected app scope
# -----------------------------------------------------------------------------
FROM base AS builder
ARG APP_SCOPE
WORKDIR /repo
# Full context is required for turbo prune
COPY . .
RUN pnpm add -g turbo@${TURBO_VERSION}
RUN turbo prune --scope=${APP_SCOPE} --docker
# -----------------------------------------------------------------------------
# installer: install deps (offline) and build the selected scope
# -----------------------------------------------------------------------------
FROM base AS installer
WORKDIR /repo
# Seed minimal files for reproducible installs and good caching
COPY .gitignore .gitignore
COPY --from=builder /repo/out/json/ .
COPY --from=builder /repo/out/pnpm-lock.yaml ./pnpm-lock.yaml
# Fetch dependencies into a cached store layer
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm fetch --store-dir=/pnpm/store
# Bring in only the pruned workspace for fast installs/builds
COPY --from=builder /repo/out/full/ .
COPY turbo.json turbo.json
# Offline, frozen lockfile install from cached store
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store
# Build-time environment (safe to pass through; only NEXT_PUBLIC_* are embedded)
# Keep parity with existing app Dockerfiles
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
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 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 NEXT_PUBLIC_WEB_BASE_URL=""
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
ENV NEXT_TELEMETRY_DISABLED=1
ENV TURBO_TELEMETRY_DISABLED=1
# Build only the selected scope
ARG APP_SCOPE
RUN pnpm turbo run build --filter=${APP_SCOPE}
# -----------------------------------------------------------------------------
# dev: for local development with hot reload (bind-mount the repo to /repo)
# -----------------------------------------------------------------------------
FROM base AS dev
WORKDIR /repo
# Helpful global tool for selective builds/dev
RUN pnpm add -g turbo@${TURBO_VERSION}
ENV NODE_ENV=development \
NEXT_TELEMETRY_DISABLED=1 \
TURBO_TELEMETRY_DISABLED=1
# Select which app to run in dev (web|space|admin)
ARG APP_SCOPE
ENV APP_SCOPE=${APP_SCOPE}
EXPOSE 3000
# Expect the source to be bind-mounted; install deps and start dev server
CMD ["sh", "-lc", "pnpm install && pnpm dev --filter=${APP_SCOPE}"]
# -----------------------------------------------------------------------------
# runtime: minimal Next.js standalone runner for the selected scope
# -----------------------------------------------------------------------------
FROM node:${NODE_VERSION} AS runtime
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
TURBO_TELEMETRY_DISABLED=1 \
HOSTNAME=0.0.0.0 \
PORT=3000
# Drop privileges
RUN addgroup -S -g 1001 nodejs \
&& adduser -S -u 1001 -G nodejs -h /home/nextjs -D nextjs \
&& mkdir -p /home/nextjs \
&& chown -R nextjs:nodejs /home/nextjs
USER nextjs
# Copy only the built output for the selected scope
ARG APP_SCOPE
ENV APP_SCOPE=${APP_SCOPE}
# Next.js standalone layout
COPY --from=installer /repo/apps/${APP_SCOPE}/.next/standalone ./
COPY --from=installer /repo/apps/${APP_SCOPE}/.next/static ./apps/${APP_SCOPE}/.next/static
COPY --from=installer /repo/apps/${APP_SCOPE}/public ./apps/${APP_SCOPE}/public
EXPOSE 3000
CMD ["sh", "-lc", "set -e; : \"${APP_SCOPE:?APP_SCOPE env is required}\"; SERVER=\"apps/${APP_SCOPE}/server.js\"; if [ ! -f \"$SERVER\" ]; then echo \"Error: $SERVER not found.\"; ls -la \"apps/${APP_SCOPE}\" || true; echo \"Known apps:\"; ls -1 apps || true; exit 1; fi; exec node \"$SERVER\""]
+40 -23
View File
@@ -2,10 +2,11 @@
<p align="center">
<a href="https://plane.so">
<img src="https://media.docs.plane.so/logo/plane_github_readme.png" alt="Plane Logo" width="400">
<img src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_logo_.webp" alt="Plane Logo" width="70">
</a>
</p>
<p align="center"><b>Modern project management for all teams</b></p>
<h1 align="center"><b>Plane</b></h1>
<p align="center"><b>Open-source project management that unlocks customer value</b></p>
<p align="center">
<a href="https://discord.com/invite/A92xrEGCge">
@@ -24,7 +25,14 @@
<p>
<a href="https://app.plane.so/#gh-light-mode-only" target="_blank">
<img
src="https://media.docs.plane.so/GitHub-readme/github-top.webp"
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_screen.webp"
alt="Plane Screens"
width="100%"
/>
</a>
<a href="https://app.plane.so/#gh-dark-mode-only" target="_blank">
<img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_screens_dark_mode.webp"
alt="Plane Screens"
width="100%"
/>
@@ -40,13 +48,13 @@ Meet [Plane](https://plane.so/), an open-source project management tool to track
Getting started with Plane is simple. Choose the setup that works best for you:
- **Plane Cloud**
Sign up for a free account on [Plane Cloud](https://app.plane.so)—it's the fastest way to get up and running without worrying about infrastructure.
Sign up for a free account on [Plane Cloud](https://app.plane.so)—it's the fastest way to get up and running without worrying about infrastructure.
- **Self-host Plane**
Prefer full control over your data and infrastructure? Install and run Plane on your own servers. Follow our detailed [deployment guides](https://developers.plane.so/self-hosting/overview) to get started.
Prefer full control over your data and infrastructure? Install and run Plane on your own servers. Follow our detailed [deployment guides](https://developers.plane.so/self-hosting/overview) to get started.
| Installation methods | Docs link |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Installation methods | Docs link |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Docker | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://developers.plane.so/self-hosting/methods/docker-compose) |
| Kubernetes | [![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white)](https://developers.plane.so/self-hosting/methods/kubernetes) |
@@ -55,50 +63,50 @@ Getting started with Plane is simple. Choose the setup that works best for you:
## 🌟 Features
- **Issues**
Efficiently create and manage tasks with a robust rich text editor that supports file uploads. Enhance organization and tracking by adding sub-properties and referencing related issues.
Efficiently create and manage tasks with a robust rich text editor that supports file uploads. Enhance organization and tracking by adding sub-properties and referencing related issues.
- **Cycles**
Maintain your teams momentum with Cycles. Track progress effortlessly using burn-down charts and other insightful tools.
Maintain your teams momentum with Cycles. Track progress effortlessly using burn-down charts and other insightful tools.
- **Modules**
Simplify complex projects by dividing them into smaller, manageable modules.
Simplify complex projects by dividing them into smaller, manageable modules.
- **Views**
Customize your workflow by creating filters to display only the most relevant issues. Save and share these views with ease.
Customize your workflow by creating filters to display only the most relevant issues. Save and share these views with ease.
- **Pages**
Capture and organize ideas using Plane Pages, complete with AI capabilities and a rich text editor. Format text, insert images, add hyperlinks, or convert your notes into actionable items.
Capture and organize ideas using Plane Pages, complete with AI capabilities and a rich text editor. Format text, insert images, add hyperlinks, or convert your notes into actionable items.
- **Analytics**
Access real-time insights across all your Plane data. Visualize trends, remove blockers, and keep your projects moving forward.
Access real-time insights across all your Plane data. Visualize trends, remove blockers, and keep your projects moving forward.
- **Drive** (_coming soon_): The drive helps you share documents, images, videos, or any other files that make sense to you or your team and align on the problem/solution.
## 🛠️ Local development
See [CONTRIBUTING](./CONTRIBUTING.md)
## ⚙️ Built with
[![Next JS](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white)](https://nextjs.org/)
[![Django](https://img.shields.io/badge/Django-092E20?style=for-the-badge&logo=django&logoColor=green)](https://www.djangoproject.com/)
[![Node JS](https://img.shields.io/badge/node.js-339933?style=for-the-badge&logo=Node.js&logoColor=white)](https://nodejs.org/en)
## 📸 Screenshots
<p>
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://media.docs.plane.so/GitHub-readme/github-work-items.webp"
src="https://ik.imagekit.io/w2okwbtu2/Issues_rNZjrGgFl.png?updatedAt=1709298765880"
alt="Plane Views"
width="100%"
/>
</a>
</p>
<p>
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://media.docs.plane.so/GitHub-readme/github-cycles.webp"
src="https://ik.imagekit.io/w2okwbtu2/Cycles_jCDhqmTl9.png?updatedAt=1709298780697"
width="100%"
/>
</a>
@@ -106,7 +114,7 @@ See [CONTRIBUTING](./CONTRIBUTING.md)
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://media.docs.plane.so/GitHub-readme/github-modules.webp"
src="https://ik.imagekit.io/w2okwbtu2/Modules_PSCVsbSfI.png?updatedAt=1709298796783"
alt="Plane Cycles and Modules"
width="100%"
/>
@@ -115,7 +123,7 @@ See [CONTRIBUTING](./CONTRIBUTING.md)
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://media.docs.plane.so/GitHub-readme/github-views.webp"
src="https://ik.imagekit.io/w2okwbtu2/Views_uxXsRatS4.png?updatedAt=1709298834522"
alt="Plane Analytics"
width="100%"
/>
@@ -124,16 +132,25 @@ See [CONTRIBUTING](./CONTRIBUTING.md)
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://media.docs.plane.so/GitHub-readme/github-analytics.webp"
src="https://ik.imagekit.io/w2okwbtu2/Analytics_0o22gLRtp.png?updatedAt=1709298834389"
alt="Plane Pages"
width="100%"
/>
</a>
</p>
</p>
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://ik.imagekit.io/w2okwbtu2/Drive_LlfeY4xn3.png?updatedAt=1709298837917"
alt="Plane Command Menu"
width="100%"
/>
</a>
</p>
</p>
## 📝 Documentation
Explore Plane's [product documentation](https://docs.plane.so/) and [developer documentation](https://developers.plane.so/) to learn about features, setup, and usage.
## ❤️ Community
@@ -169,6 +186,6 @@ Please read [CONTRIBUTING.md](https://github.com/makeplane/plane/blob/master/CON
<img src="https://contrib.rocks/image?repo=makeplane/plane" />
</a>
## License
## License
This project is licensed under the [GNU Affero General Public License v3.0](https://github.com/makeplane/plane/blob/master/LICENSE.txt).
+2 -2
View File
@@ -2,5 +2,5 @@
.vercel
.tubro
out/
dist/
build/
dis/
build/
+94
View File
@@ -0,0 +1,94 @@
FROM node:22-alpine AS base
# *****************************************************************************
# STAGE 1: Build the project
# *****************************************************************************
FROM base AS builder
RUN apk add --no-cache libc6-compat
WORKDIR /app
RUN yarn global add turbo
COPY . .
RUN turbo prune --scope=admin --docker
# *****************************************************************************
# STAGE 2: Install dependencies & build the project
# *****************************************************************************
FROM base AS installer
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/yarn.lock ./yarn.lock
RUN yarn install --network-timeout 500000
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
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 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 NEXT_PUBLIC_WEB_BASE_URL=""
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
ENV NEXT_TELEMETRY_DISABLED=1
ENV TURBO_TELEMETRY_DISABLED=1
RUN yarn turbo run build --filter=admin
# *****************************************************************************
# STAGE 3: Copy the project and start it
# *****************************************************************************
FROM base AS runner
WORKDIR /app
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer /app/apps/admin/.next/standalone ./
COPY --from=installer /app/apps/admin/.next/static ./apps/admin/.next/static
COPY --from=installer /app/apps/admin/public ./apps/admin/public
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
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 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 NEXT_PUBLIC_WEB_BASE_URL=""
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
ENV NEXT_TELEMETRY_DISABLED=1
ENV TURBO_TELEMETRY_DISABLED=1
EXPOSE 3000
CMD ["node", "apps/admin/server.js"]
+17
View File
@@ -0,0 +1,17 @@
FROM node:22-alpine
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
COPY . .
RUN yarn global add turbo
RUN yarn install
ENV NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
EXPOSE 3000
VOLUME [ "/app/node_modules", "/app/admin/node_modules" ]
CMD ["yarn", "dev", "--filter=admin"]
@@ -42,7 +42,7 @@ export const AdminSidebarDropdown = observer(() => {
)}
>
<div className="flex flex-col gap-2.5 pb-2">
<span className="px-2 text-custom-sidebar-text-200 truncate">{currentUser?.email}</span>
<span className="px-2 text-custom-sidebar-text-200">{currentUser?.email}</span>
</div>
<div className="py-2">
<Menu.Item
@@ -1,12 +0,0 @@
"use client";
import Link from "next/link";
import { PlaneLockup } from "@plane/ui";
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>
);
+28 -2
View File
@@ -1,9 +1,35 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useTheme } from "next-themes";
// logo assets
import PlaneBackgroundPatternDark from "public/auth/background-pattern-dark.svg";
import PlaneBackgroundPattern from "public/auth/background-pattern.svg";
import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.png";
import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.png";
export default function RootLayout({ children }: { children: React.ReactNode }) {
const { resolvedTheme } = useTheme();
const patternBackground = resolvedTheme === "light" ? PlaneBackgroundPattern : PlaneBackgroundPatternDark;
const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo;
return (
<div className="relative z-10 flex flex-col items-center w-screen h-screen overflow-hidden overflow-y-auto pt-6 pb-10 px-8">
{children}
<div className="relative">
<div className="h-screen w-full overflow-hidden overflow-y-auto flex flex-col">
<div className="container h-[110px] flex-shrink-0 mx-auto px-5 lg:px-0 flex items-center justify-between gap-5 z-50">
<div className="flex items-center gap-x-2 py-10">
<Link href={`/`} className="h-[30px] w-[133px]">
<Image src={logo} alt="Plane logo" />
</Link>
</div>
</div>
<div className="absolute inset-0 z-0">
<Image src={patternBackground} className="w-screen h-full object-cover" alt="Plane background pattern" />
</div>
<div className="relative z-10 flex-grow">{children}</div>
</div>
</div>
);
}
+28 -6
View File
@@ -2,8 +2,8 @@
import { observer } from "mobx-react";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { InstanceFailureView } from "@/components/instance/failure";
import { InstanceLoading } from "@/components/instance/loading";
import { InstanceSetupForm } from "@/components/instance/setup-form";
// hooks
import { useInstance } from "@/hooks/store";
@@ -17,24 +17,46 @@ const HomePage = () => {
// if instance is not fetched, show loading
if (!instance && !error) {
return (
<div className="flex items-center justify-center h-screen w-full">
<LogoSpinner />
<div className="relative h-full w-full overflow-y-auto px-6 py-10 mx-auto flex justify-center items-center">
<InstanceLoading />
</div>
);
}
// if instance fetch fails, show failure view
if (error) {
return <InstanceFailureView />;
return (
<div className="relative h-full w-full overflow-y-auto px-6 py-10 mx-auto flex justify-center items-center">
<InstanceFailureView />
</div>
);
}
// if instance is fetched and setup is not done, show setup form
if (instance && !instance?.is_setup_done) {
return <InstanceSetupForm />;
return (
<div className="relative h-full w-full overflow-y-auto px-6 py-10 mx-auto flex justify-center items-center">
<InstanceSetupForm />
</div>
);
}
// if instance is fetched and setup is done, show sign in form
return <InstanceSignInForm />;
return (
<div className="flex-grow container mx-auto max-w-lg px-10 lg:max-w-md lg:px-5 py-10 lg:pt-28 transition-all">
<div className="relative flex flex-col space-y-6">
<div className="text-center space-y-1">
<h3 className="flex gap-4 justify-center text-3xl font-bold text-onboarding-text-100">
Manage your Plane instance
</h3>
<p className="font-medium text-onboarding-text-400">
Configure instance-wide settings to secure your instance
</p>
</div>
<InstanceSignInForm />
</div>
</div>
);
};
export default observer(HomePage);
+70 -85
View File
@@ -10,9 +10,7 @@ import { Button, Input, Spinner } from "@plane/ui";
// components
import { Banner } from "@/components/common/banner";
// local components
import { FormHeader } from "../../../core/components/instance/form-header";
import { AuthBanner } from "./auth-banner";
import { AuthHeader } from "./auth-header";
import { authErrorHandler } from "./auth-helpers";
// service initialization
@@ -103,91 +101,78 @@ export const InstanceSignInForm: FC = () => {
}, [errorCode]);
return (
<>
<AuthHeader />
<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">
<FormHeader
heading="Manage your Plane instance"
subHeading="Configure instance-wide settings to secure your instance"
<form
className="space-y-4"
method="POST"
action={`${API_BASE_URL}/api/instances/admins/sign-in/`}
onSubmit={() => setIsSubmitting(true)}
onError={() => setIsSubmitting(false)}
>
{errorData.type && errorData?.message ? (
<Banner type="error" message={errorData?.message} />
) : (
<>{errorInfo && <AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />}</>
)}
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
<div className="w-full space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="email">
Email <span className="text-red-500">*</span>
</label>
<Input
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 placeholder:text-onboarding-text-400"
id="email"
name="email"
type="email"
inputSize="md"
placeholder="name@company.com"
value={formData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
autoComplete="on"
autoFocus
/>
</div>
<div className="w-full space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="password">
Password <span className="text-red-500">*</span>
</label>
<div className="relative">
<Input
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 placeholder:text-onboarding-text-400"
id="password"
name="password"
type={showPassword ? "text" : "password"}
inputSize="md"
placeholder="Enter your password"
value={formData.password}
onChange={(e) => handleFormChange("password", e.target.value)}
autoComplete="on"
/>
<form
className="space-y-4"
method="POST"
action={`${API_BASE_URL}/api/instances/admins/sign-in/`}
onSubmit={() => setIsSubmitting(true)}
onError={() => setIsSubmitting(false)}
>
{errorData.type && errorData?.message ? (
<Banner type="error" message={errorData?.message} />
) : (
<>
{errorInfo && <AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />}
</>
)}
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
<div className="w-full space-y-1">
<label className="text-sm text-custom-text-300 font-medium" htmlFor="email">
Email <span className="text-red-500">*</span>
</label>
<Input
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
id="email"
name="email"
type="email"
inputSize="md"
placeholder="name@company.com"
value={formData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
autoComplete="on"
autoFocus
/>
</div>
<div className="w-full space-y-1">
<label className="text-sm text-custom-text-300 font-medium" htmlFor="password">
Password <span className="text-red-500">*</span>
</label>
<div className="relative">
<Input
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
id="password"
name="password"
type={showPassword ? "text" : "password"}
inputSize="md"
placeholder="Enter your password"
value={formData.password}
onChange={(e) => handleFormChange("password", e.target.value)}
autoComplete="on"
/>
{showPassword ? (
<button
type="button"
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => setShowPassword(false)}
>
<EyeOff className="h-4 w-4" />
</button>
) : (
<button
type="button"
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => setShowPassword(true)}
>
<Eye className="h-4 w-4" />
</button>
)}
</div>
</div>
<div className="py-2">
<Button type="submit" size="lg" className="w-full" disabled={isButtonDisabled}>
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Sign in"}
</Button>
</div>
</form>
{showPassword ? (
<button
type="button"
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => setShowPassword(false)}
>
<EyeOff className="h-4 w-4" />
</button>
) : (
<button
type="button"
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => setShowPassword(true)}
>
<Eye className="h-4 w-4" />
</button>
)}
</div>
</div>
</>
<div className="py-2">
<Button type="submit" size="lg" className="w-full" disabled={isButtonDisabled}>
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Sign in"}
</Button>
</div>
</form>
);
};
@@ -7,8 +7,8 @@ import { cn } from "@plane/utils";
type Props = {
name: string;
description: string;
icon: React.ReactNode;
config: React.ReactNode;
icon: JSX.Element;
config: JSX.Element;
disabled?: boolean;
withBorder?: boolean;
unavailable?: boolean;
@@ -13,7 +13,7 @@ type Props = {
type: "text" | "password";
name: string;
label: string;
description?: string | React.ReactNode;
description?: string | JSX.Element;
placeholder: string;
error: boolean;
required: boolean;
@@ -23,7 +23,7 @@ export type TControllerInputFormField = {
key: string;
type: "text" | "password";
label: string;
description?: string | React.ReactNode;
description?: string | JSX.Element;
placeholder: string;
error: boolean;
required: boolean;
@@ -9,14 +9,14 @@ import { Button, TOAST_TYPE, setToast } from "@plane/ui";
type Props = {
label: string;
url: string;
description: string | React.ReactNode;
description: string | JSX.Element;
};
export type TCopyField = {
key: string;
label: string;
url: string;
description: string | React.ReactNode;
description: string | JSX.Element;
};
export const CopyField: React.FC<Props> = (props) => {
@@ -7,11 +7,11 @@ import LogoSpinnerLight from "@/public/images/logo-spinner-light.gif";
export const LogoSpinner = () => {
const { resolvedTheme } = useTheme();
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerLight : LogoSpinnerDark;
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerDark : LogoSpinnerLight;
return (
<div className="flex items-center justify-center">
<Image src={logoSrc} alt="logo" className="h-6 w-auto sm:h-11" />
<Image src={logoSrc} alt="logo" className="w-[82px] h-[82px] mr-2" priority={false} />
</div>
);
};
+17 -21
View File
@@ -1,15 +1,13 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import { useTheme } from "next-themes";
import { Button } from "@plane/ui";
// assets
import { AuthHeader } from "@/app/(all)/(home)/auth-header";
import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg";
import InstanceFailureImage from "@/public/instance/instance-failure.svg";
export const InstanceFailureView: FC = observer(() => {
export const InstanceFailureView: FC = () => {
const { resolvedTheme } = useTheme();
const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage;
@@ -19,24 +17,22 @@ export const InstanceFailureView: FC = observer(() => {
};
return (
<>
<AuthHeader />
<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" />
<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.
</p>
</div>
<div className="flex justify-center">
<Button size="md" onClick={handleRetry}>
Retry
</Button>
</div>
<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">
<Image src={instanceImage} alt="Plane Logo" />
<h3 className="font-medium text-2xl text-white ">Unable to fetch instance details.</h3>
<p className="font-medium text-base text-center">
We were unable to fetch the details of the instance. <br />
Fret not, it might just be a connectivity issue.
</p>
</div>
<div className="flex justify-center">
<Button size="md" onClick={handleRetry}>
Retry
</Button>
</div>
</div>
</>
</div>
);
});
};
@@ -1,8 +0,0 @@
"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>
);
@@ -13,7 +13,7 @@ export const InstanceNotReady: FC = () => (
<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">
<p className="font-medium text-base text-onboarding-text-400">
Get started by setting up your instance and workspace
</p>
</div>
@@ -6,12 +6,16 @@ import LogoSpinnerLight from "@/public/images/logo-spinner-light.gif";
export const InstanceLoading = () => {
const { resolvedTheme } = useTheme();
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerLight : LogoSpinnerDark;
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerDark : LogoSpinnerLight;
return (
<div className="flex items-center justify-center">
<Image src={logoSrc} alt="logo" className="h-6 w-auto sm:h-11" />
<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">
<Image src={logoSrc} alt="logo" className="w-[82px] h-[82px] mr-2" priority={false} />
<h3 className="font-medium text-2xl text-white ">Fetching instance details...</h3>
</div>
</div>
</div>
);
};
+202 -198
View File
@@ -10,9 +10,7 @@ import { AuthService } from "@plane/services";
import { Button, Checkbox, Input, PasswordStrengthIndicator, Spinner } from "@plane/ui";
import { getPasswordStrength } from "@plane/utils";
// components
import { AuthHeader } from "@/app/(all)/(home)/auth-header";
import { Banner } from "@/components/common/banner";
import { FormHeader } from "@/components/instance/form-header";
// service initialization
const authService = new AuthService();
@@ -133,221 +131,227 @@ export const InstanceSetupForm: FC = (props) => {
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
return (
<>
<AuthHeader />
<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">
<FormHeader
heading="Setup your Plane Instance"
subHeading="Post setup you will be able to manage this Plane instance."
/>
{errorData.type &&
errorData?.message &&
![EErrorCodes.INVALID_EMAIL, EErrorCodes.INVALID_PASSWORD].includes(errorData.type) && (
<Banner type="error" message={errorData?.message} />
)}
<form
className="space-y-4"
method="POST"
action={`${API_BASE_URL}/api/instances/admins/sign-up/`}
onSubmit={() => setIsSubmitting(true)}
onError={() => setIsSubmitting(false)}
>
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
<input type="hidden" name="is_telemetry_enabled" value={formData.is_telemetry_enabled ? "True" : "False"} />
<div className="max-w-lg lg:max-w-md w-full">
<div className="relative flex flex-col space-y-6">
<div className="text-center space-y-1">
<h3 className="flex gap-4 justify-center text-3xl font-bold text-onboarding-text-100">
Setup your Plane Instance
</h3>
<p className="font-medium text-onboarding-text-400">
Post setup you will be able to manage this Plane instance.
</p>
</div>
<div className="flex flex-col sm:flex-row items-center gap-4">
<div className="w-full space-y-1">
<label className="text-sm text-custom-text-300 font-medium" htmlFor="first_name">
First name <span className="text-red-500">*</span>
</label>
<Input
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
id="first_name"
name="first_name"
type="text"
inputSize="md"
placeholder="Wilber"
value={formData.first_name}
onChange={(e) => handleFormChange("first_name", e.target.value)}
autoComplete="on"
autoFocus
/>
</div>
<div className="w-full space-y-1">
<label className="text-sm text-custom-text-300 font-medium" htmlFor="last_name">
Last name <span className="text-red-500">*</span>
</label>
<Input
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
id="last_name"
name="last_name"
type="text"
inputSize="md"
placeholder="Wright"
value={formData.last_name}
onChange={(e) => handleFormChange("last_name", e.target.value)}
autoComplete="on"
/>
</div>
</div>
{errorData.type &&
errorData?.message &&
![EErrorCodes.INVALID_EMAIL, EErrorCodes.INVALID_PASSWORD].includes(errorData.type) && (
<Banner type="error" message={errorData?.message} />
)}
<form
className="space-y-4"
method="POST"
action={`${API_BASE_URL}/api/instances/admins/sign-up/`}
onSubmit={() => setIsSubmitting(true)}
onError={() => setIsSubmitting(false)}
>
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
<input type="hidden" name="is_telemetry_enabled" value={formData.is_telemetry_enabled ? "True" : "False"} />
<div className="flex flex-col sm:flex-row items-center gap-4">
<div className="w-full space-y-1">
<label className="text-sm text-custom-text-300 font-medium" htmlFor="email">
Email <span className="text-red-500">*</span>
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="first_name">
First name <span className="text-red-500">*</span>
</label>
<Input
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
id="email"
name="email"
type="email"
inputSize="md"
placeholder="name@company.com"
value={formData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL ? true : false}
autoComplete="on"
/>
{errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL && errorData.message && (
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
)}
</div>
<div className="w-full space-y-1">
<label className="text-sm text-custom-text-300 font-medium" htmlFor="company_name">
Company name <span className="text-red-500">*</span>
</label>
<Input
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
id="company_name"
name="company_name"
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 placeholder:text-onboarding-text-400"
id="first_name"
name="first_name"
type="text"
inputSize="md"
placeholder="Company name"
value={formData.company_name}
onChange={(e) => handleFormChange("company_name", e.target.value)}
placeholder="Wilber"
value={formData.first_name}
onChange={(e) => handleFormChange("first_name", e.target.value)}
autoComplete="on"
autoFocus
/>
</div>
<div className="w-full space-y-1">
<label className="text-sm text-custom-text-300 font-medium" htmlFor="password">
Set a password <span className="text-red-500">*</span>
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="last_name">
Last name <span className="text-red-500">*</span>
</label>
<div className="relative">
<Input
className="w-full border border-custom-border-100 !bg-custom-background-100 placeholder:text-custom-text-400"
id="password"
name="password"
type={showPassword.password ? "text" : "password"}
inputSize="md"
placeholder="New password..."
value={formData.password}
onChange={(e) => handleFormChange("password", e.target.value)}
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false}
onFocus={() => setIsPasswordInputFocused(true)}
onBlur={() => setIsPasswordInputFocused(false)}
autoComplete="on"
/>
{showPassword.password ? (
<button
type="button"
tabIndex={-1}
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => handleShowPassword("password")}
>
<EyeOff className="h-4 w-4" />
</button>
) : (
<button
type="button"
tabIndex={-1}
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => handleShowPassword("password")}
>
<Eye className="h-4 w-4" />
</button>
)}
</div>
{errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD && errorData.message && (
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
)}
<PasswordStrengthIndicator password={formData.password} isFocused={isPasswordInputFocused} />
<Input
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 placeholder:text-onboarding-text-400"
id="last_name"
name="last_name"
type="text"
inputSize="md"
placeholder="Wright"
value={formData.last_name}
onChange={(e) => handleFormChange("last_name", e.target.value)}
autoComplete="on"
/>
</div>
</div>
<div className="w-full space-y-1">
<label className="text-sm text-custom-text-300 font-medium" htmlFor="confirm_password">
Confirm password <span className="text-red-500">*</span>
</label>
<div className="relative">
<Input
type={showPassword.retypePassword ? "text" : "password"}
id="confirm_password"
name="confirm_password"
inputSize="md"
value={formData.confirm_password}
onChange={(e) => handleFormChange("confirm_password", e.target.value)}
placeholder="Confirm password"
className="w-full border border-custom-border-100 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400"
onFocus={() => setIsRetryPasswordInputFocused(true)}
onBlur={() => setIsRetryPasswordInputFocused(false)}
/>
{showPassword.retypePassword ? (
<button
type="button"
tabIndex={-1}
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => handleShowPassword("retypePassword")}
>
<EyeOff className="h-4 w-4" />
</button>
) : (
<button
type="button"
tabIndex={-1}
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => handleShowPassword("retypePassword")}
>
<Eye className="h-4 w-4" />
</button>
)}
</div>
{!!formData.confirm_password &&
formData.password !== formData.confirm_password &&
renderPasswordMatchError && <span className="text-sm text-red-500">Passwords don{"'"}t match</span>}
</div>
<div className="w-full space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="email">
Email <span className="text-red-500">*</span>
</label>
<Input
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 placeholder:text-onboarding-text-400"
id="email"
name="email"
type="email"
inputSize="md"
placeholder="name@company.com"
value={formData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL ? true : false}
autoComplete="on"
/>
{errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL && errorData.message && (
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
)}
</div>
<div className="relative flex gap-2">
<div>
<Checkbox
className="w-4 h-4"
iconClassName="w-3 h-3"
id="is_telemetry_enabled"
onChange={() => handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)}
checked={formData.is_telemetry_enabled}
/>
</div>
<label className="text-sm text-custom-text-300 font-medium cursor-pointer" htmlFor="is_telemetry_enabled">
Allow Plane to anonymously collect usage events.{" "}
<a
<div className="w-full space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="company_name">
Company name <span className="text-red-500">*</span>
</label>
<Input
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 placeholder:text-onboarding-text-400"
id="company_name"
name="company_name"
type="text"
inputSize="md"
placeholder="Company name"
value={formData.company_name}
onChange={(e) => handleFormChange("company_name", e.target.value)}
/>
</div>
<div className="w-full space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="password">
Set a password <span className="text-red-500">*</span>
</label>
<div className="relative">
<Input
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 placeholder:text-onboarding-text-400"
id="password"
name="password"
type={showPassword.password ? "text" : "password"}
inputSize="md"
placeholder="New password..."
value={formData.password}
onChange={(e) => handleFormChange("password", e.target.value)}
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false}
onFocus={() => setIsPasswordInputFocused(true)}
onBlur={() => setIsPasswordInputFocused(false)}
autoComplete="on"
/>
{showPassword.password ? (
<button
type="button"
tabIndex={-1}
href="https://developers.plane.so/self-hosting/telemetry"
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-blue-500 hover:text-blue-600 flex-shrink-0"
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => handleShowPassword("password")}
>
See More
</a>
</label>
<EyeOff className="h-4 w-4" />
</button>
) : (
<button
type="button"
tabIndex={-1}
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => handleShowPassword("password")}
>
<Eye className="h-4 w-4" />
</button>
)}
</div>
{errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD && errorData.message && (
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
)}
<PasswordStrengthIndicator password={formData.password} isFocused={isPasswordInputFocused} />
</div>
<div className="py-2">
<Button type="submit" size="lg" className="w-full" disabled={isButtonDisabled}>
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Continue"}
</Button>
<div className="w-full space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="confirm_password">
Confirm password <span className="text-red-500">*</span>
</label>
<div className="relative">
<Input
type={showPassword.retypePassword ? "text" : "password"}
id="confirm_password"
name="confirm_password"
inputSize="md"
value={formData.confirm_password}
onChange={(e) => handleFormChange("confirm_password", e.target.value)}
placeholder="Confirm password"
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
onFocus={() => setIsRetryPasswordInputFocused(true)}
onBlur={() => setIsRetryPasswordInputFocused(false)}
/>
{showPassword.retypePassword ? (
<button
type="button"
tabIndex={-1}
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => handleShowPassword("retypePassword")}
>
<EyeOff className="h-4 w-4" />
</button>
) : (
<button
type="button"
tabIndex={-1}
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
onClick={() => handleShowPassword("retypePassword")}
>
<Eye className="h-4 w-4" />
</button>
)}
</div>
</form>
</div>
{!!formData.confirm_password &&
formData.password !== formData.confirm_password &&
renderPasswordMatchError && <span className="text-sm text-red-500">Passwords don{"'"}t match</span>}
</div>
<div className="relative flex items-center pt-2 gap-2">
<div>
<Checkbox
className="w-4 h-4"
iconClassName="w-3 h-3"
id="is_telemetry_enabled"
onChange={() => handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)}
checked={formData.is_telemetry_enabled}
/>
</div>
<label
className="text-sm text-onboarding-text-300 font-medium cursor-pointer"
htmlFor="is_telemetry_enabled"
>
Allow Plane to anonymously collect usage events.
</label>
<a
tabIndex={-1}
href="https://developers.plane.so/self-hosting/telemetry"
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-blue-500 hover:text-blue-600"
>
See More
</a>
</div>
<div className="py-2">
<Button type="submit" size="lg" className="w-full" disabled={isButtonDisabled}>
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Continue"}
</Button>
</div>
</form>
</div>
</>
</div>
);
};
+12 -12
View File
@@ -18,13 +18,15 @@
},
"dependencies": {
"@headlessui/react": "^1.7.19",
"@plane/constants": "workspace:*",
"@plane/hooks": "workspace:*",
"@plane/propel": "workspace:*",
"@plane/services": "workspace:*",
"@plane/types": "workspace:*",
"@plane/ui": "workspace:*",
"@plane/utils": "workspace:*",
"@plane/constants": "*",
"@plane/hooks": "*",
"@plane/propel": "*",
"@plane/services": "*",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/utils": "*",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
"axios": "1.11.0",
"lodash": "^4.17.21",
@@ -37,15 +39,13 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "7.51.5",
"sharp": "^0.33.5",
"swr": "^2.2.4",
"uuid": "^9.0.1"
},
"devDependencies": {
"@plane/eslint-config": "workspace:*",
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/lodash": "^4.17.6",
"@plane/eslint-config": "*",
"@plane/tailwind-config": "*",
"@plane/typescript-config": "*",
"@types/node": "18.16.1",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.2.18",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 761 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 919 B

+2 -2
View File
@@ -2,8 +2,8 @@
"name": "",
"short_name": "",
"icons": [
{ "src": "/favicon/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/favicon/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
{ "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 954 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+80 -37
View File
@@ -24,24 +24,24 @@
:root {
color-scheme: light !important;
--color-primary-10: 229, 243, 250;
--color-primary-20: 216, 237, 248;
--color-primary-30: 199, 229, 244;
--color-primary-40: 169, 214, 239;
--color-primary-50: 144, 202, 234;
--color-primary-60: 109, 186, 227;
--color-primary-70: 75, 170, 221;
--color-primary-80: 41, 154, 214;
--color-primary-90: 34, 129, 180;
--color-primary-100: 0, 99, 153;
--color-primary-200: 0, 92, 143;
--color-primary-300: 0, 86, 133;
--color-primary-400: 0, 77, 117;
--color-primary-500: 0, 66, 102;
--color-primary-600: 0, 53, 82;
--color-primary-700: 0, 43, 66;
--color-primary-800: 0, 33, 51;
--color-primary-900: 0, 23, 36;
--color-primary-10: 236, 241, 255;
--color-primary-20: 217, 228, 255;
--color-primary-30: 197, 214, 255;
--color-primary-40: 178, 200, 255;
--color-primary-50: 159, 187, 255;
--color-primary-60: 140, 173, 255;
--color-primary-70: 121, 159, 255;
--color-primary-80: 101, 145, 255;
--color-primary-90: 82, 132, 255;
--color-primary-100: 63, 118, 255;
--color-primary-200: 57, 106, 230;
--color-primary-300: 50, 94, 204;
--color-primary-400: 44, 83, 179;
--color-primary-500: 38, 71, 153;
--color-primary-600: 32, 59, 128;
--color-primary-700: 25, 47, 102;
--color-primary-800: 19, 35, 76;
--color-primary-900: 13, 24, 51;
--color-background-100: 255, 255, 255; /* primary bg */
--color-background-90: 247, 247, 247; /* secondary bg */
@@ -135,6 +135,28 @@
--color-border-300: 212, 212, 212; /* strong border- 1 */
--color-border-400: 185, 185, 185; /* strong border- 2 */
/* onboarding colors */
--gradient-onboarding-100: linear-gradient(106deg, #f2f6ff 29.8%, #e1eaff 99.34%);
--gradient-onboarding-200: linear-gradient(129deg, rgba(255, 255, 255, 0) -22.23%, rgba(255, 255, 255, 0.8) 62.98%);
--gradient-onboarding-300: linear-gradient(164deg, #fff 4.25%, rgba(255, 255, 255, 0.06) 93.5%);
--gradient-onboarding-400: linear-gradient(129deg, rgba(255, 255, 255, 0) -22.23%, rgba(255, 255, 255, 0.8) 62.98%);
--color-onboarding-text-100: 23, 23, 23;
--color-onboarding-text-200: 58, 58, 58;
--color-onboarding-text-300: 82, 82, 82;
--color-onboarding-text-400: 163, 163, 163;
--color-onboarding-background-100: 236, 241, 255;
--color-onboarding-background-200: 255, 255, 255;
--color-onboarding-background-300: 236, 241, 255;
--color-onboarding-background-400: 177, 206, 250;
--color-onboarding-border-100: 229, 229, 229;
--color-onboarding-border-200: 217, 228, 255;
--color-onboarding-border-300: 229, 229, 229, 0.5;
--color-onboarding-shadow-sm: 0px 4px 20px 0px rgba(126, 139, 171, 0.1);
/* toast theme */
--color-toast-success-text: 62, 155, 79;
--color-toast-error-text: 220, 62, 66;
@@ -175,25 +197,6 @@
[data-theme="dark-contrast"] {
color-scheme: dark !important;
--color-primary-10: 8, 31, 43;
--color-primary-20: 10, 37, 51;
--color-primary-30: 13, 49, 69;
--color-primary-40: 16, 58, 81;
--color-primary-50: 18, 68, 94;
--color-primary-60: 23, 86, 120;
--color-primary-70: 28, 104, 146;
--color-primary-80: 31, 116, 163;
--color-primary-90: 34, 129, 180;
--color-primary-100: 40, 146, 204;
--color-primary-200: 41, 154, 214;
--color-primary-300: 75, 170, 221;
--color-primary-400: 109, 186, 227;
--color-primary-500: 144, 202, 234;
--color-primary-600: 169, 214, 239;
--color-primary-700: 199, 229, 244;
--color-primary-800: 216, 237, 248;
--color-primary-900: 229, 243, 250;
--color-background-100: 25, 25, 25; /* primary bg */
--color-background-90: 32, 32, 32; /* secondary bg */
--color-background-80: 44, 44, 44; /* tertiary bg */
@@ -222,6 +225,27 @@
--color-border-300: 46, 46, 46; /* strong border- 1 */
--color-border-400: 58, 58, 58; /* strong border- 2 */
/* onboarding colors */
--gradient-onboarding-100: linear-gradient(106deg, #18191b 25.17%, #18191b 99.34%);
--gradient-onboarding-200: linear-gradient(129deg, rgba(47, 49, 53, 0.8) -22.23%, rgba(33, 34, 37, 0.8) 62.98%);
--gradient-onboarding-300: linear-gradient(167deg, rgba(47, 49, 53, 0.45) 19.22%, #212225 98.48%);
--color-onboarding-text-100: 237, 238, 240;
--color-onboarding-text-200: 176, 180, 187;
--color-onboarding-text-300: 118, 123, 132;
--color-onboarding-text-400: 105, 110, 119;
--color-onboarding-background-100: 54, 58, 64;
--color-onboarding-background-200: 40, 42, 45;
--color-onboarding-background-300: 40, 42, 45;
--color-onboarding-background-400: 67, 72, 79;
--color-onboarding-border-100: 54, 58, 64;
--color-onboarding-border-200: 54, 58, 64;
--color-onboarding-border-300: 34, 35, 38, 0.5;
--color-onboarding-shadow-sm: 0px 4px 20px 0px rgba(39, 44, 56, 0.1);
/* toast theme */
--color-toast-success-text: 178, 221, 181;
--color-toast-error-text: 206, 44, 49;
@@ -262,6 +286,25 @@
[data-theme="dark"],
[data-theme="light-contrast"],
[data-theme="dark-contrast"] {
--color-primary-10: 236, 241, 255;
--color-primary-20: 217, 228, 255;
--color-primary-30: 197, 214, 255;
--color-primary-40: 178, 200, 255;
--color-primary-50: 159, 187, 255;
--color-primary-60: 140, 173, 255;
--color-primary-70: 121, 159, 255;
--color-primary-80: 101, 145, 255;
--color-primary-90: 82, 132, 255;
--color-primary-100: 63, 118, 255;
--color-primary-200: 57, 106, 230;
--color-primary-300: 50, 94, 204;
--color-primary-400: 44, 83, 179;
--color-primary-500: 38, 71, 153;
--color-primary-600: 32, 59, 128;
--color-primary-700: 25, 47, 102;
--color-primary-800: 19, 35, 76;
--color-primary-900: 13, 24, 51;
--color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */
--color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */
--color-sidebar-background-80: var(--color-background-80); /* tertiary sidebar bg */
+58
View File
@@ -0,0 +1,58 @@
FROM python:3.12.10-alpine
# set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
ENV INSTANCE_CHANGELOG_URL=https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
# Update system packages for security
RUN apk update && apk upgrade
WORKDIR /code
RUN apk add --no-cache --upgrade \
"libpq" \
"libxslt" \
"xmlsec" \
"ca-certificates" \
"openssl"
COPY requirements.txt ./
COPY requirements ./requirements
RUN apk add --no-cache libffi-dev
RUN apk add --no-cache --virtual .build-deps \
"bash~=5.2" \
"g++" \
"gcc" \
"cargo" \
"git" \
"make" \
"postgresql-dev" \
"libc-dev" \
"linux-headers" \
&& \
pip install -r requirements.txt --compile --no-cache-dir \
&& \
apk del .build-deps \
&& \
rm -rf /var/cache/apk/*
# Add in Django deps and generate Django's static files
COPY manage.py manage.py
COPY plane plane/
COPY templates templates/
COPY package.json package.json
RUN apk --no-cache add "bash~=5.2"
COPY ./bin ./bin/
RUN mkdir -p /code/plane/logs
RUN chmod +x ./bin/*
RUN chmod -R 777 /code
# Expose container port and run entry point script
EXPOSE 8000
CMD ["./bin/docker-entrypoint-api.sh"]
+46
View File
@@ -0,0 +1,46 @@
FROM python:3.12.5-alpine AS backend
# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
RUN apk --no-cache add \
"bash~=5.2" \
"libpq" \
"libxslt" \
"nodejs-current" \
"xmlsec" \
"libffi-dev" \
"bash~=5.2" \
"g++" \
"gcc" \
"cargo" \
"git" \
"make" \
"postgresql-dev" \
"libc-dev" \
"linux-headers"
WORKDIR /code
COPY requirements.txt ./requirements.txt
ADD requirements ./requirements
# Install the local development settings
RUN pip install -r requirements/local.txt --compile --no-cache-dir
COPY . .
RUN mkdir -p /code/plane/logs
RUN chmod -R +x /code/bin
RUN chmod -R 777 /code
# Expose container port and run entry point script
EXPOSE 8000
CMD [ "./bin/docker-entrypoint-api-local.sh" ]
+10 -13
View File
@@ -24,6 +24,7 @@ from plane.db.models import (
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
@@ -88,24 +89,20 @@ class IssueSerializer(BaseSerializer):
raise serializers.ValidationError("Invalid HTML passed")
# Validate description content for security
if data.get("description_html"):
is_valid, error_msg, sanitized_html = validate_html_content(
data["description_html"]
)
if data.get("description"):
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
raise serializers.ValidationError({"description": error_msg})
if data.get("description_html"):
is_valid, error_msg = validate_html_content(data["description_html"])
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
if data.get("description_binary"):
is_valid, error_msg = validate_binary_data(data["description_binary"])
if not is_valid:
raise serializers.ValidationError(
{"description_binary": "Invalid binary data"}
)
raise serializers.ValidationError({"description_binary": error_msg})
# Validate assignees are from project
if data.get("assignees", []):
+17 -11
View File
@@ -12,6 +12,7 @@ from plane.db.models import (
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
)
from .base import BaseSerializer
@@ -44,10 +45,6 @@ class ProjectCreateSerializer(BaseSerializer):
"archive_in",
"close_in",
"timezone",
"logo_props",
"external_source",
"external_id",
"is_issue_type_enabled",
]
read_only_fields = [
@@ -199,18 +196,27 @@ class ProjectSerializer(BaseSerializer):
)
# Validate description content for security
if "description" in data and data["description"]:
# For Project, description might be text field, not JSON
if isinstance(data["description"], dict):
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
raise serializers.ValidationError({"description": error_msg})
if "description_text" in data and data["description_text"]:
is_valid, error_msg = validate_json_content(data["description_text"])
if not is_valid:
raise serializers.ValidationError({"description_text": error_msg})
if "description_html" in data and data["description_html"]:
if isinstance(data["description_html"], dict):
is_valid, error_msg, sanitized_html = validate_html_content(
is_valid, error_msg = validate_json_content(data["description_html"])
else:
is_valid, error_msg = validate_html_content(
str(data["description_html"])
)
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
raise serializers.ValidationError({"description_html": error_msg})
return data
+1 -3
View File
@@ -89,9 +89,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/<uuid:pk>/",
IssueAttachmentDetailAPIEndpoint.as_view(
http_method_names=["get", "patch", "delete"]
),
IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "delete"]),
name="issue-attachment",
),
]
+1 -1
View File
@@ -4,7 +4,7 @@ from plane.api.views import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/",
"workspaces/<str:slug>/projects/<str:project_id>/members/",
ProjectMemberAPIEndpoint.as_view(http_method_names=["get"]),
name="project-members",
),
+7 -90
View File
@@ -75,7 +75,7 @@ from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from .base import BaseAPIView
from plane.utils.host import base_host
from plane.bgtasks.webhook_task import model_activity
from plane.app.permissions import ROLE
from plane.utils.openapi import (
work_item_docs,
label_docs,
@@ -145,22 +145,6 @@ from plane.utils.openapi import (
)
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
def user_has_issue_permission(
user_id, project_id, issue=None, allowed_roles=None, allow_creator=True
):
if allow_creator and issue is not None and user_id == issue.created_by_id:
return True
qs = ProjectMember.objects.filter(
project_id=project_id,
member_id=user_id,
is_active=True,
)
if allowed_roles is not None:
qs = qs.filter(role__in=allowed_roles)
return qs.exists()
class WorkspaceIssueAPIEndpoint(BaseAPIView):
"""
@@ -347,10 +331,6 @@ class IssueListCreateAPIEndpoint(BaseAPIView):
)
)
total_issue_queryset = Issue.issue_objects.filter(
project_id=project_id, workspace__slug=slug
)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
@@ -410,7 +390,6 @@ class IssueListCreateAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(issue_queryset),
total_count_queryset=total_issue_queryset,
on_results=lambda issues: IssueSerializer(
issues, many=True, fields=self.fields, expand=self.expand
).data,
@@ -968,7 +947,7 @@ class LabelListCreateAPIEndpoint(BaseAPIView):
)
class LabelDetailAPIEndpoint(LabelListCreateAPIEndpoint):
class LabelDetailAPIEndpoint(BaseAPIView):
"""Label Detail Endpoint"""
serializer_class = LabelSerializer
@@ -1033,16 +1012,14 @@ class LabelDetailAPIEndpoint(LabelListCreateAPIEndpoint):
if (
str(request.data.get("external_id"))
and (label.external_id != str(request.data.get("external_id")))
and Label.objects.filter(
and Issue.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get(
"external_source", label.external_source
),
external_id=request.data.get("external_id"),
)
.exclude(id=pk)
.exists()
).exists()
):
return Response(
{
@@ -1488,7 +1465,7 @@ class IssueCommentListCreateAPIEndpoint(BaseAPIView):
# Send the model activity
model_activity.delay(
model_name="issue_comment",
model_id=str(serializer.instance.id),
model_id=str(serializer.data["id"]),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
@@ -1803,6 +1780,7 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
serializer_class = IssueAttachmentSerializer
model = FileAsset
permission_classes = [ProjectEntityPermission]
use_read_replica = True
@issue_attachment_docs(
@@ -1885,22 +1863,6 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
Generate presigned URL for uploading file attachments to a work item.
Validates file type and size before creating the attachment record.
"""
issue = Issue.objects.get(
pk=issue_id, workspace__slug=slug, project_id=project_id
)
# if the user is creator or admin,member then allow the upload
if not user_has_issue_permission(
request.user.id,
project_id=project_id,
issue=issue,
allowed_roles=[ROLE.ADMIN.value, ROLE.MEMBER.value],
allow_creator=True,
):
return Response(
{"error": "You are not allowed to upload this attachment"},
status=status.HTTP_403_FORBIDDEN,
)
name = request.data.get("name")
type = request.data.get("type", False)
size = request.data.get("size")
@@ -2025,6 +1987,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
"""Issue Attachment Detail Endpoint"""
serializer_class = IssueAttachmentSerializer
permission_classes = [ProjectEntityPermission]
model = FileAsset
use_read_replica = True
@@ -2047,22 +2010,6 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
Soft delete an attachment from a work item by marking it as deleted.
Records deletion activity and triggers metadata cleanup.
"""
issue = Issue.objects.get(
pk=issue_id, workspace__slug=slug, project_id=project_id
)
# if the request user is creator or admin then delete the attachment
if not user_has_issue_permission(
request.user,
project_id=project_id,
issue=issue,
allowed_roles=[ROLE.ADMIN.value],
allow_creator=True,
):
return Response(
{"error": "You are not allowed to delete this attachment"},
status=status.HTTP_403_FORBIDDEN,
)
issue_attachment = FileAsset.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
)
@@ -2125,19 +2072,6 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
Retrieve details of a specific attachment.
"""
# if the user is part of the project then allow the download
if not user_has_issue_permission(
request.user,
project_id=project_id,
issue=None,
allowed_roles=None,
allow_creator=False,
):
return Response(
{"error": "You are not allowed to download this attachment"},
status=status.HTTP_403_FORBIDDEN,
)
# Get the asset
asset = FileAsset.objects.get(
id=pk, workspace__slug=slug, project_id=project_id
@@ -2192,23 +2126,6 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
Mark an attachment as uploaded after successful file transfer to storage.
Triggers activity logging and metadata extraction.
"""
issue = Issue.objects.get(
pk=issue_id, workspace__slug=slug, project_id=project_id
)
# if the user is creator or admin then allow the upload
if not user_has_issue_permission(
request.user,
project_id=project_id,
issue=issue,
allowed_roles=[ROLE.ADMIN.value, ROLE.MEMBER.value],
allow_creator=True,
):
return Response(
{"error": "You are not allowed to upload this attachment"},
status=status.HTTP_403_FORBIDDEN,
)
issue_attachment = FileAsset.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
)
+11 -14
View File
@@ -23,6 +23,7 @@ from plane.db.models import (
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
from plane.app.permissions import ROLE
@@ -75,24 +76,20 @@ class DraftIssueCreateSerializer(BaseSerializer):
raise serializers.ValidationError("Start date cannot exceed target date")
# Validate description content for security
if "description_html" in attrs and attrs["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(
attrs["description_html"]
)
if "description" in attrs and attrs["description"]:
is_valid, error_msg = validate_json_content(attrs["description"])
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
# Update the attrs with sanitized HTML if available
if sanitized_html is not None:
attrs["description_html"] = sanitized_html
raise serializers.ValidationError({"description": error_msg})
if "description_html" in attrs and attrs["description_html"]:
is_valid, error_msg = validate_html_content(attrs["description_html"])
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
if "description_binary" in attrs and attrs["description_binary"]:
is_valid, error_msg = validate_binary_data(attrs["description_binary"])
if not is_valid:
raise serializers.ValidationError(
{"description_binary": "Invalid binary data"}
)
raise serializers.ValidationError({"description_binary": error_msg})
# Validate assignees are from project
if attrs.get("assignee_ids", []):
@@ -263,7 +260,7 @@ class DraftIssueCreateSerializer(BaseSerializer):
DraftIssueLabel.objects.bulk_create(
[
DraftIssueLabel(
label_id=label,
label=label,
draft_issue=instance,
workspace_id=workspace_id,
project_id=project_id,
+11 -19
View File
@@ -43,6 +43,7 @@ from plane.db.models import (
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
@@ -127,24 +128,20 @@ class IssueCreateSerializer(BaseSerializer):
raise serializers.ValidationError("Start date cannot exceed target date")
# Validate description content for security
if "description_html" in attrs and attrs["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(
attrs["description_html"]
)
if "description" in attrs and attrs["description"]:
is_valid, error_msg = validate_json_content(attrs["description"])
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
# Update the attrs with sanitized HTML if available
if sanitized_html is not None:
attrs["description_html"] = sanitized_html
raise serializers.ValidationError({"description": error_msg})
if "description_html" in attrs and attrs["description_html"]:
is_valid, error_msg = validate_html_content(attrs["description_html"])
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
if "description_binary" in attrs and attrs["description_binary"]:
is_valid, error_msg = validate_binary_data(attrs["description_binary"])
if not is_valid:
raise serializers.ValidationError(
{"description_binary": "Invalid binary data"}
)
raise serializers.ValidationError({"description_binary": error_msg})
# Validate assignees are from project
if attrs.get("assignee_ids", []):
@@ -911,14 +908,9 @@ class IssueLiteSerializer(DynamicBaseSerializer):
class IssueDetailSerializer(IssueSerializer):
description_html = serializers.CharField()
is_subscribed = serializers.BooleanField(read_only=True)
is_intake = serializers.BooleanField(read_only=True)
class Meta(IssueSerializer.Meta):
fields = IssueSerializer.Meta.fields + [
"description_html",
"is_subscribed",
"is_intake",
]
fields = IssueSerializer.Meta.fields + ["description_html", "is_subscribed"]
read_only_fields = fields
+14 -3
View File
@@ -7,6 +7,7 @@ from .base import BaseSerializer
from plane.utils.content_validator import (
validate_binary_data,
validate_html_content,
validate_json_content,
)
from plane.db.models import (
Page,
@@ -228,13 +229,23 @@ class PageBinaryUpdateSerializer(serializers.Serializer):
return value
# Use the validation function from utils
is_valid, error_message, sanitized_html = validate_html_content(value)
is_valid, error_message = validate_html_content(value)
if not is_valid:
raise serializers.ValidationError(error_message)
# Return sanitized HTML if available, otherwise return original
return sanitized_html if sanitized_html is not None else value
return value
def validate_description(self, value):
"""Validate the JSON description"""
if not value:
return value
# Use the validation function from utils
is_valid, error_message = validate_json_content(value)
if not is_valid:
raise serializers.ValidationError(error_message)
return value
def update(self, instance, validated_data):
"""Update the page instance with validated data"""
+19 -9
View File
@@ -15,6 +15,7 @@ from plane.db.models import (
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
@@ -64,18 +65,27 @@ class ProjectSerializer(BaseSerializer):
def validate(self, data):
# Validate description content for security
if "description_html" in data and data["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(
str(data["description_html"])
)
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
if "description" in data and data["description"]:
# For Project, description might be text field, not JSON
if isinstance(data["description"], dict):
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
raise serializers.ValidationError({"description": error_msg})
if "description_text" in data and data["description_text"]:
is_valid, error_msg = validate_json_content(data["description_text"])
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
raise serializers.ValidationError({"description_text": error_msg})
if "description_html" in data and data["description_html"]:
if isinstance(data["description_html"], dict):
is_valid, error_msg = validate_json_content(data["description_html"])
else:
is_valid, error_msg = validate_html_content(
str(data["description_html"])
)
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
return data
+10 -13
View File
@@ -26,6 +26,7 @@ from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.utils.url import contains_url
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
@@ -318,24 +319,20 @@ class StickySerializer(BaseSerializer):
def validate(self, data):
# Validate description content for security
if "description_html" in data and data["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(
data["description_html"]
)
if "description" in data and data["description"]:
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
raise serializers.ValidationError({"description": error_msg})
if "description_html" in data and data["description_html"]:
is_valid, error_msg = validate_html_content(data["description_html"])
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
if "description_binary" in data and data["description_binary"]:
is_valid, error_msg = validate_binary_data(data["description_binary"])
if not is_valid:
raise serializers.ValidationError(
{"description_binary": "Invalid binary data"}
)
raise serializers.ValidationError({"description_binary": error_msg})
return data
+15 -37
View File
@@ -3,7 +3,7 @@ import json
# Django imports
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import F, Func, OuterRef, Q, Prefetch, Exists, Subquery, Count
from django.db.models import F, Func, OuterRef, Q, Prefetch, Exists, Subquery
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
@@ -69,31 +69,25 @@ class IssueArchiveViewSet(BaseViewSet):
)
)
.annotate(
link_count=Subquery(
IssueLink.objects.filter(issue=OuterRef("id"))
.values("issue")
.annotate(count=Count("id"))
.values("count")
)
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=Subquery(
FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.values("issue_id")
.annotate(count=Count("id"))
.values("count")
attachment_count=FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Subquery(
Issue.issue_objects.filter(parent=OuterRef("id"))
.values("parent")
.annotate(count=Count("id"))
.values("count")
)
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
@@ -107,19 +101,6 @@ class IssueArchiveViewSet(BaseViewSet):
issue_queryset = self.get_queryset().filter(**filters)
total_issue_queryset = Issue.objects.filter(
deleted_at__isnull=True,
archived_at__isnull=False,
project_id=project_id,
workspace__slug=slug,
).filter(**filters)
total_issue_queryset = (
total_issue_queryset
if show_sub_issues == "true"
else total_issue_queryset.filter(parent__isnull=True)
)
issue_queryset = (
issue_queryset
if show_sub_issues == "true"
@@ -155,7 +136,6 @@ class IssueArchiveViewSet(BaseViewSet):
request=request,
order_by=order_by_param,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
@@ -190,7 +170,6 @@ class IssueArchiveViewSet(BaseViewSet):
request=request,
order_by=order_by_param,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
@@ -217,7 +196,6 @@ class IssueArchiveViewSet(BaseViewSet):
order_by=order_by_param,
request=request,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
+101 -135
View File
@@ -15,7 +15,6 @@ from django.db.models import (
UUIDField,
Value,
Subquery,
Count,
)
from django.db.models.functions import Coalesce
from django.utils import timezone
@@ -51,7 +50,6 @@ from plane.db.models import (
IssueRelation,
IssueAssignee,
IssueLabel,
IntakeIssue,
)
from plane.utils.grouper import (
issue_group_values,
@@ -214,33 +212,27 @@ class IssueViewSet(BaseViewSet):
)
)
.annotate(
link_count=Subquery(
IssueLink.objects.filter(issue=OuterRef("id"))
.values("issue")
.annotate(count=Count("id"))
.values("count")
)
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=Subquery(
FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.values("issue_id")
.annotate(count=Count("id"))
.values("count")
attachment_count=FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Subquery(
Issue.issue_objects.filter(parent=OuterRef("id"))
.values("parent")
.annotate(count=Count("id"))
.values("count")
)
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
).distinct()
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@@ -256,10 +248,6 @@ class IssueViewSet(BaseViewSet):
issue_queryset = self.get_queryset().filter(**filters, **extra_filters)
# Custom ordering for priority and state
total_issue_queryset = Issue.issue_objects.filter(
project_id=project_id, workspace__slug=slug
).filter(**filters, **extra_filters)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset, order_by_param=order_by_param
@@ -292,7 +280,6 @@ class IssueViewSet(BaseViewSet):
and not project.guest_view_all_features
):
issue_queryset = issue_queryset.filter(created_by=request.user)
total_issue_queryset = total_issue_queryset.filter(created_by=request.user)
if group_by:
if sub_group_by:
@@ -308,7 +295,6 @@ class IssueViewSet(BaseViewSet):
request=request,
order_by=order_by_param,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
@@ -342,7 +328,6 @@ class IssueViewSet(BaseViewSet):
request=request,
order_by=order_by_param,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
@@ -368,7 +353,6 @@ class IssueViewSet(BaseViewSet):
order_by=order_by_param,
request=request,
queryset=issue_queryset,
total_count_queryset=total_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
@@ -469,12 +453,10 @@ class IssueViewSet(BaseViewSet):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
issue = (
Issue.objects.filter(
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
pk=pk,
)
.select_related("state")
Issue.objects.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[
@@ -483,63 +465,60 @@ class IssueViewSet(BaseViewSet):
)
)
.annotate(
link_count=Subquery(
IssueLink.objects.filter(issue=OuterRef("id"))
.values("issue")
.annotate(count=Count("id"))
.values("count")
)
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=Subquery(
FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.values("issue_id")
.annotate(count=Count("id"))
.values("count")
attachment_count=FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Subquery(
Issue.issue_objects.filter(parent=OuterRef("id"))
.values("parent")
.annotate(count=Count("id"))
.values("count")
)
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(pk=pk)
.annotate(
label_ids=Coalesce(
Subquery(
IssueLabel.objects.filter(issue_id=OuterRef("pk"))
.values("issue_id")
.annotate(arr=ArrayAgg("label_id", distinct=True))
.values("arr")
ArrayAgg(
"labels__id",
distinct=True,
filter=Q(
~Q(labels__id__isnull=True)
& Q(label_issue__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
Subquery(
IssueAssignee.objects.filter(
issue_id=OuterRef("pk"),
assignee__member_project__is_active=True,
)
.values("issue_id")
.annotate(arr=ArrayAgg("assignee_id", distinct=True))
.values("arr")
ArrayAgg(
"assignees__id",
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
Subquery(
ModuleIssue.objects.filter(
issue_id=OuterRef("pk"),
module__archived_at__isnull=True,
)
.values("issue_id")
.annotate(arr=ArrayAgg("module_id", distinct=True))
.values("arr")
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=Q(
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -807,42 +786,37 @@ class IssuePaginatedViewSet(BaseViewSet):
)
return (
issue_queryset.select_related("state")
issue_queryset.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[
:1
]
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
).values("cycle_id")[:1]
)
)
.annotate(
link_count=Subquery(
IssueLink.objects.filter(issue=OuterRef("id"))
.values("issue")
.annotate(count=Count("id"))
.values("count")
)
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=Subquery(
FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.values("issue_id")
.annotate(count=Count("id"))
.values("count")
attachment_count=FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Subquery(
Issue.issue_objects.filter(parent=OuterRef("id"))
.values("parent")
.annotate(count=Count("id"))
.values("count")
)
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
).distinct()
def process_paginated_result(self, fields, results, timezone):
paginated_data = results.values(*fields)
@@ -922,35 +896,37 @@ class IssuePaginatedViewSet(BaseViewSet):
queryset = queryset.annotate(
label_ids=Coalesce(
Subquery(
IssueLabel.objects.filter(issue_id=OuterRef("pk"))
.values("issue_id")
.annotate(arr=ArrayAgg("label_id", distinct=True))
.values("arr")
ArrayAgg(
"labels__id",
distinct=True,
filter=Q(
~Q(labels__id__isnull=True)
& Q(label_issue__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
Subquery(
IssueAssignee.objects.filter(
issue_id=OuterRef("pk"),
assignee__member_project__is_active=True,
)
.values("issue_id")
.annotate(arr=ArrayAgg("assignee_id", distinct=True))
.values("arr")
ArrayAgg(
"assignees__id",
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
Subquery(
ModuleIssue.objects.filter(
issue_id=OuterRef("pk"),
module__archived_at__isnull=True,
)
.values("issue_id")
.annotate(arr=ArrayAgg("module_id", distinct=True))
.values("arr")
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=Q(
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -1224,7 +1200,7 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
# Fetch the issue
issue = (
Issue.objects.filter(project_id=project.id)
Issue.issue_objects.filter(project_id=project.id)
.filter(workspace__slug=slug)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
@@ -1316,16 +1292,6 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
)
)
)
.annotate(
is_intake=Exists(
IntakeIssue.objects.filter(
issue=OuterRef("id"),
status__in=[-2, 0],
workspace__slug=slug,
project_id=project.id,
)
)
)
).first()
# Check if the issue exists
+1 -2
View File
@@ -59,10 +59,9 @@ class IssueSearchEndpoint(BaseAPIView):
)
related_issue_ids = [item for sublist in related_issue_ids for item in sublist]
related_issue_ids.append(issue_id)
if issue:
issues = issues.exclude(pk__in=related_issue_ids)
issues = issues.filter(~Q(pk=issue_id), ~Q(pk__in=related_issue_ids))
return issues
+15
View File
@@ -0,0 +1,15 @@
from django.utils import timezone
from datetime import timedelta
from plane.db.models import APIActivityLog
from celery import shared_task
@shared_task
def delete_api_logs():
# Get the logs older than 30 days to delete
logs_to_delete = APIActivityLog.objects.filter(
created_at__lte=timezone.now() - timedelta(days=30)
)
# Delete the logs
logs_to_delete._raw_delete(logs_to_delete.db)
-423
View File
@@ -1,423 +0,0 @@
# Python imports
from datetime import timedelta
import logging
from typing import List, Dict, Any, Callable, Optional
import os
# Django imports
from django.utils import timezone
from django.db.models import F, Window, Subquery
from django.db.models.functions import RowNumber
# Third party imports
from celery import shared_task
from pymongo.errors import BulkWriteError
from pymongo.collection import Collection
from pymongo.operations import InsertOne
# Module imports
from plane.db.models import (
EmailNotificationLog,
PageVersion,
APIActivityLog,
IssueDescriptionVersion,
)
from plane.settings.mongo import MongoConnection
from plane.utils.exception_logger import log_exception
logger = logging.getLogger("plane.worker")
BATCH_SIZE = 1000
def get_mongo_collection(collection_name: str) -> Optional[Collection]:
"""Get MongoDB collection if available, otherwise return None."""
if not MongoConnection.is_configured():
logger.info("MongoDB not configured")
return None
try:
mongo_collection = MongoConnection.get_collection(collection_name)
logger.info(f"MongoDB collection '{collection_name}' connected successfully")
return mongo_collection
except Exception as e:
logger.error(f"Failed to get MongoDB collection: {str(e)}")
log_exception(e)
return None
def flush_to_mongo_and_delete(
mongo_collection: Optional[Collection],
buffer: List[Dict[str, Any]],
ids_to_delete: List[int],
model,
mongo_available: bool,
) -> None:
"""
Inserts a batch of records into MongoDB and deletes the corresponding rows from PostgreSQL.
"""
if not buffer:
logger.debug("No records to flush - buffer is empty")
return
logger.info(
f"Starting batch flush: {len(buffer)} records, {len(ids_to_delete)} IDs to delete"
)
mongo_archival_failed = False
# Try to insert into MongoDB if available
if mongo_collection and mongo_available:
try:
mongo_collection.bulk_write([InsertOne(doc) for doc in buffer])
except BulkWriteError as bwe:
logger.error(f"MongoDB bulk write error: {str(bwe)}")
log_exception(bwe)
mongo_archival_failed = True
# If MongoDB is available and archival failed, log the error and return
if mongo_available and mongo_archival_failed:
logger.error(f"MongoDB archival failed for {len(buffer)} records")
return
# Delete from PostgreSQL - delete() returns (count, {model: count})
delete_result = model.all_objects.filter(id__in=ids_to_delete).delete()
deleted_count = (
delete_result[0] if delete_result and isinstance(delete_result, tuple) else 0
)
logger.info(f"Batch flush completed: {deleted_count} records deleted")
def process_cleanup_task(
queryset_func: Callable,
transform_func: Callable[[Dict], Dict],
model,
task_name: str,
collection_name: str,
):
"""
Generic function to process cleanup tasks.
Args:
queryset_func: Function that returns the queryset to process
transform_func: Function to transform each record for MongoDB
model: Django model class
task_name: Name of the task for logging
collection_name: MongoDB collection name
"""
logger.info(f"Starting {task_name} cleanup task")
# Get MongoDB collection
mongo_collection = get_mongo_collection(collection_name)
mongo_available = mongo_collection is not None
# Get queryset
queryset = queryset_func()
# Process records in batches
buffer: List[Dict[str, Any]] = []
ids_to_delete: List[int] = []
total_processed = 0
total_batches = 0
for record in queryset:
# Transform record for MongoDB
buffer.append(transform_func(record))
ids_to_delete.append(record["id"])
# Flush batch when it reaches BATCH_SIZE
if len(buffer) >= BATCH_SIZE:
total_batches += 1
flush_to_mongo_and_delete(
mongo_collection=mongo_collection,
buffer=buffer,
ids_to_delete=ids_to_delete,
model=model,
mongo_available=mongo_available,
)
total_processed += len(buffer)
buffer.clear()
ids_to_delete.clear()
# Process final batch if any records remain
if buffer:
total_batches += 1
flush_to_mongo_and_delete(
mongo_collection=mongo_collection,
buffer=buffer,
ids_to_delete=ids_to_delete,
model=model,
mongo_available=mongo_available,
)
total_processed += len(buffer)
logger.info(
f"{task_name} cleanup task completed",
extra={
"total_records_processed": total_processed,
"total_batches": total_batches,
"mongo_available": mongo_available,
"collection_name": collection_name,
},
)
# Transform functions for each model
def transform_api_log(record: Dict) -> Dict:
"""Transform API activity log record."""
return {
"id": record["id"],
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"token_identifier": record["token_identifier"],
"path": record["path"],
"method": record["method"],
"query_params": record.get("query_params"),
"headers": record.get("headers"),
"body": record.get("body"),
"response_code": record["response_code"],
"response_body": record["response_body"],
"ip_address": record["ip_address"],
"user_agent": record["user_agent"],
"created_by_id": record["created_by_id"],
}
def transform_email_log(record: Dict) -> Dict:
"""Transform email notification log record."""
return {
"id": record["id"],
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"receiver_id": record["receiver_id"],
"triggered_by_id": record["triggered_by_id"],
"entity_identifier": record["entity_identifier"],
"entity_name": record["entity_name"],
"data": record["data"],
"processed_at": (
str(record["processed_at"]) if record.get("processed_at") else None
),
"sent_at": str(record["sent_at"]) if record.get("sent_at") else None,
"entity": record["entity"],
"old_value": record["old_value"],
"new_value": record["new_value"],
"created_by_id": record["created_by_id"],
}
def transform_page_version(record: Dict) -> Dict:
"""Transform page version record."""
return {
"id": record["id"],
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"page_id": record["page_id"],
"workspace_id": record["workspace_id"],
"owned_by_id": record["owned_by_id"],
"description_html": record["description_html"],
"description_binary": record["description_binary"],
"description_stripped": record["description_stripped"],
"description_json": record["description_json"],
"sub_pages_data": record["sub_pages_data"],
"created_by_id": record["created_by_id"],
"updated_by_id": record["updated_by_id"],
"deleted_at": str(record["deleted_at"]) if record.get("deleted_at") else None,
"last_saved_at": (
str(record["last_saved_at"]) if record.get("last_saved_at") else None
),
}
def transform_issue_description_version(record: Dict) -> Dict:
"""Transform issue description version record."""
return {
"id": record["id"],
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"issue_id": record["issue_id"],
"workspace_id": record["workspace_id"],
"project_id": record["project_id"],
"created_by_id": record["created_by_id"],
"updated_by_id": record["updated_by_id"],
"owned_by_id": record["owned_by_id"],
"last_saved_at": (
str(record["last_saved_at"]) if record.get("last_saved_at") else None
),
"description_binary": record["description_binary"],
"description_html": record["description_html"],
"description_stripped": record["description_stripped"],
"description_json": record["description_json"],
"deleted_at": str(record["deleted_at"]) if record.get("deleted_at") else None,
}
# Queryset functions for each cleanup task
def get_api_logs_queryset():
"""Get API logs older than cutoff days."""
cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30))
cutoff_time = timezone.now() - timedelta(days=cutoff_days)
logger.info(f"API logs cutoff time: {cutoff_time}")
return (
APIActivityLog.all_objects.filter(created_at__lte=cutoff_time)
.values(
"id",
"created_at",
"token_identifier",
"path",
"method",
"query_params",
"headers",
"body",
"response_code",
"response_body",
"ip_address",
"user_agent",
"created_by_id",
)
.iterator(chunk_size=BATCH_SIZE)
)
def get_email_logs_queryset():
"""Get email logs older than cutoff days."""
cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30))
cutoff_time = timezone.now() - timedelta(days=cutoff_days)
logger.info(f"Email logs cutoff time: {cutoff_time}")
return (
EmailNotificationLog.all_objects.filter(sent_at__lte=cutoff_time)
.values(
"id",
"created_at",
"receiver_id",
"triggered_by_id",
"entity_identifier",
"entity_name",
"data",
"processed_at",
"sent_at",
"entity",
"old_value",
"new_value",
"created_by_id",
)
.iterator(chunk_size=BATCH_SIZE)
)
def get_page_versions_queryset():
"""Get page versions beyond the maximum allowed (20 per page)."""
subq = (
PageVersion.all_objects.annotate(
row_num=Window(
expression=RowNumber(),
partition_by=[F("page_id")],
order_by=F("created_at").desc(),
)
)
.filter(row_num__gt=20)
.values("id")
)
return (
PageVersion.all_objects.filter(id__in=Subquery(subq))
.values(
"id",
"created_at",
"page_id",
"workspace_id",
"owned_by_id",
"description_html",
"description_binary",
"description_stripped",
"description_json",
"sub_pages_data",
"created_by_id",
"updated_by_id",
"deleted_at",
"last_saved_at",
)
.iterator(chunk_size=BATCH_SIZE)
)
def get_issue_description_versions_queryset():
"""Get issue description versions beyond the maximum allowed (20 per issue)."""
subq = (
IssueDescriptionVersion.all_objects.annotate(
row_num=Window(
expression=RowNumber(),
partition_by=[F("issue_id")],
order_by=F("created_at").desc(),
)
)
.filter(row_num__gt=20)
.values("id")
)
return (
IssueDescriptionVersion.all_objects.filter(id__in=Subquery(subq))
.values(
"id",
"created_at",
"issue_id",
"workspace_id",
"project_id",
"created_by_id",
"updated_by_id",
"owned_by_id",
"last_saved_at",
"description_binary",
"description_html",
"description_stripped",
"description_json",
"deleted_at",
)
.iterator(chunk_size=BATCH_SIZE)
)
# Celery tasks - now much simpler!
@shared_task
def delete_api_logs():
"""Delete old API activity logs."""
process_cleanup_task(
queryset_func=get_api_logs_queryset,
transform_func=transform_api_log,
model=APIActivityLog,
task_name="API Activity Log",
collection_name="api_activity_logs",
)
@shared_task
def delete_email_notification_logs():
"""Delete old email notification logs."""
process_cleanup_task(
queryset_func=get_email_logs_queryset,
transform_func=transform_email_log,
model=EmailNotificationLog,
task_name="Email Notification Log",
collection_name="email_notification_logs",
)
@shared_task
def delete_page_versions():
"""Delete excess page versions."""
process_cleanup_task(
queryset_func=get_page_versions_queryset,
transform_func=transform_page_version,
model=PageVersion,
task_name="Page Version",
collection_name="page_versions",
)
@shared_task
def delete_issue_description_versions():
"""Delete excess issue description versions."""
process_cleanup_task(
queryset_func=get_issue_description_versions_queryset,
transform_func=transform_issue_description_version,
model=IssueDescriptionVersion,
task_name="Issue Description Version",
collection_name="issue_description_versions",
)
@@ -282,7 +282,7 @@ def send_email_notification(
"project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/",
"workspace": str(issue.project.workspace.slug),
"project": str(issue.project.name),
"user_preference": f"{base_api}/{str(issue.project.workspace.slug)}/settings/account/notifications/",
"user_preference": f"{base_api}/profile/preferences/email",
"comments": comments,
"entity_type": "issue",
}
+1 -13
View File
@@ -50,21 +50,9 @@ app.conf.beat_schedule = {
"schedule": crontab(hour=2, minute=0), # UTC 02:00
},
"check-every-day-to-delete-api-logs": {
"task": "plane.bgtasks.cleanup_task.delete_api_logs",
"task": "plane.bgtasks.api_logs_task.delete_api_logs",
"schedule": crontab(hour=2, minute=30), # UTC 02:30
},
"check-every-day-to-delete-email-notification-logs": {
"task": "plane.bgtasks.cleanup_task.delete_email_notification_logs",
"schedule": crontab(hour=3, minute=0), # UTC 03:00
},
"check-every-day-to-delete-page-versions": {
"task": "plane.bgtasks.cleanup_task.delete_page_versions",
"schedule": crontab(hour=3, minute=30), # UTC 03:30
},
"check-every-day-to-delete-issue-description-versions": {
"task": "plane.bgtasks.cleanup_task.delete_issue_description_versions",
"schedule": crontab(hour=4, minute=0), # UTC 04:00
},
}
@@ -1,182 +0,0 @@
# Generated by Django 4.2.21 on 2025-08-19 11:52
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
("db", "0100_profile_has_marketing_email_consent_and_more"),
]
operations = [
migrations.CreateModel(
name="Description",
fields=[
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"deleted_at",
models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("description_json", models.JSONField(blank=True, default=dict)),
("description_html", models.TextField(blank=True, default="<p></p>")),
("description_binary", models.BinaryField(null=True)),
("description_stripped", models.TextField(blank=True, null=True)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"project",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
],
options={
"verbose_name": "Description",
"verbose_name_plural": "Descriptions",
"db_table": "descriptions",
"ordering": ("-created_at",),
},
),
migrations.CreateModel(
name="DescriptionVersion",
fields=[
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"deleted_at",
models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("description_json", models.JSONField(blank=True, default=dict)),
("description_html", models.TextField(blank=True, default="<p></p>")),
("description_binary", models.BinaryField(null=True)),
("description_stripped", models.TextField(blank=True, null=True)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"description",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="versions",
to="db.description",
),
),
(
"project",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
],
options={
"verbose_name": "Description Version",
"verbose_name_plural": "Description Versions",
"db_table": "description_versions",
"ordering": ("-created_at",),
},
),
]
-2
View File
@@ -83,5 +83,3 @@ from .label import Label
from .device import Device, DeviceSession
from .sticky import Sticky
from .description import Description, DescriptionVersion
-56
View File
@@ -1,56 +0,0 @@
from django.db import models
from django.utils.html import strip_tags
from .workspace import WorkspaceBaseModel
class Description(WorkspaceBaseModel):
description_json = models.JSONField(default=dict, blank=True)
description_html = models.TextField(blank=True, default="<p></p>")
description_binary = models.BinaryField(null=True)
description_stripped = models.TextField(blank=True, null=True)
class Meta:
verbose_name = "Description"
verbose_name_plural = "Descriptions"
db_table = "descriptions"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
# Strip the html tags using html parser
self.description_stripped = (
None
if (self.description_html == "" or self.description_html is None)
else strip_tags(self.description_html)
)
super(Description, self).save(*args, **kwargs)
class DescriptionVersion(WorkspaceBaseModel):
"""
DescriptionVersion is a model used to store historical versions of a Description.
"""
description = models.ForeignKey(
"db.Description", on_delete=models.CASCADE, related_name="versions"
)
description_json = models.JSONField(default=dict, blank=True)
description_html = models.TextField(blank=True, default="<p></p>")
description_binary = models.BinaryField(null=True)
description_stripped = models.TextField(blank=True, null=True)
class Meta:
verbose_name = "Description Version"
verbose_name_plural = "Description Versions"
db_table = "description_versions"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
# Strip the html tags using html parser
self.description_stripped = (
None
if (self.description_html == "" or self.description_html is None)
else strip_tags(self.description_html)
)
super(DescriptionVersion, self).save(*args, **kwargs)
+5 -8
View File
@@ -226,9 +226,6 @@ class Profile(TimeAuditModel):
goals = models.JSONField(default=dict)
background_color = models.CharField(max_length=255, default=get_random_color)
# marketing
has_marketing_email_consent = models.BooleanField(default=False)
class Meta:
verbose_name = "Profile"
verbose_name_plural = "Profiles"
@@ -276,9 +273,9 @@ def create_user_notification(sender, instance, created, **kwargs):
UserNotificationPreference.objects.create(
user=instance,
property_change=True,
state_change=True,
comment=True,
mention=True,
issue_completed=True,
property_change=False,
state_change=False,
comment=False,
mention=False,
issue_completed=False,
)
@@ -2,12 +2,11 @@
import json
import secrets
import os
import requests
# Django imports
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from django.conf import settings
# Module imports
from plane.license.models import Instance, InstanceEdition
@@ -21,38 +20,21 @@ class Command(BaseCommand):
# Positional argument
parser.add_argument("machine_signature", type=str, help="Machine signature")
def check_for_current_version(self):
if os.environ.get("APP_VERSION", False):
return os.environ.get("APP_VERSION")
def read_package_json(self):
with open("package.json", "r") as file:
# Load JSON content from the file
data = json.load(file)
try:
with open("package.json", "r") as file:
data = json.load(file)
return data.get("version", "v0.1.0")
except Exception:
self.stdout.write("Error checking for current version")
return "v0.1.0"
def check_for_latest_version(self, fallback_version):
try:
response = requests.get(
"https://api.github.com/repos/makeplane/plane/releases/latest",
timeout=10,
)
response.raise_for_status()
data = response.json()
return data.get("tag_name", fallback_version)
except Exception:
self.stdout.write("Error checking for latest version")
return fallback_version
payload = {
"instance_key": settings.INSTANCE_KEY,
"version": data.get("version", 0.1),
}
return payload
def handle(self, *args, **options):
# Check if the instance is registered
instance = Instance.objects.first()
current_version = self.check_for_current_version()
latest_version = self.check_for_latest_version(current_version)
# If instance is None then register this instance
if instance is None:
machine_signature = options.get("machine_signature", "machine-signature")
@@ -60,11 +42,13 @@ class Command(BaseCommand):
if not machine_signature:
raise CommandError("Machine signature is required")
payload = self.read_package_json()
instance = Instance.objects.create(
instance_name="Plane Community Edition",
instance_id=secrets.token_hex(12),
current_version=current_version,
latest_version=latest_version,
current_version=payload.get("version"),
latest_version=payload.get("version"),
last_checked_at=timezone.now(),
is_test=os.environ.get("IS_TEST", "0") == "1",
edition=InstanceEdition.PLANE_COMMUNITY.value,
@@ -73,11 +57,11 @@ class Command(BaseCommand):
self.stdout.write(self.style.SUCCESS("Instance registered"))
else:
self.stdout.write(self.style.SUCCESS("Instance already registered"))
payload = self.read_package_json()
# Update the instance details
instance.last_checked_at = timezone.now()
instance.current_version = current_version
instance.latest_version = latest_version
instance.current_version = payload.get("version")
instance.latest_version = payload.get("version")
instance.is_test = os.environ.get("IS_TEST", "0") == "1"
instance.edition = InstanceEdition.PLANE_COMMUNITY.value
instance.save()
+7 -1
View File
@@ -284,7 +284,7 @@ CELERY_IMPORTS = (
"plane.bgtasks.exporter_expired_task",
"plane.bgtasks.file_asset_task",
"plane.bgtasks.email_notification_task",
"plane.bgtasks.cleanup_task",
"plane.bgtasks.api_logs_task",
"plane.license.bgtasks.tracer",
# management tasks
"plane.bgtasks.dummy_data_task",
@@ -304,10 +304,16 @@ GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
# Posthog settings
POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False)
POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False)
# instance key
INSTANCE_KEY = os.environ.get(
"INSTANCE_KEY", "ae6517d563dfc13d8270bd45cf17b08f70b37d989128a9dab46ff687603333c3"
)
# Skip environment variable configuration
SKIP_ENV_VAR = os.environ.get("SKIP_ENV_VAR", "1") == "1"
-5
View File
@@ -73,10 +73,5 @@ LOGGING = {
"handlers": ["console"],
"propagate": False,
},
"plane.mongo": {
"level": "INFO",
"handlers": ["console"],
"propagate": False,
},
},
}
-121
View File
@@ -1,121 +0,0 @@
# Django imports
from django.conf import settings
import logging
# Third party imports
from pymongo import MongoClient
from pymongo.database import Database
from pymongo.collection import Collection
from typing import Optional, TypeVar, Type
T = TypeVar("T", bound="MongoConnection")
# Set up logger
logger = logging.getLogger("plane.mongo")
class MongoConnection:
"""
A singleton class that manages MongoDB connections.
This class ensures only one MongoDB connection is maintained throughout the application.
It provides methods to access the MongoDB client, database, and collections.
Attributes:
_instance (Optional[MongoConnection]): The singleton instance of this class
_client (Optional[MongoClient]): The MongoDB client instance
_db (Optional[Database]): The MongoDB database instance
"""
_instance: Optional["MongoConnection"] = None
_client: Optional[MongoClient] = None
_db: Optional[Database] = None
def __new__(cls: Type[T]) -> T:
"""
Creates a new instance of MongoConnection if one doesn't exist.
Returns:
MongoConnection: The singleton instance
"""
if cls._instance is None:
cls._instance = super(MongoConnection, cls).__new__(cls)
try:
mongo_url = getattr(settings, "MONGO_DB_URL", None)
mongo_db_database = getattr(settings, "MONGO_DB_DATABASE", None)
if not mongo_url or not mongo_db_database:
logger.warning(
"MongoDB connection parameters not configured. MongoDB functionality will be disabled."
)
return cls._instance
cls._client = MongoClient(mongo_url)
cls._db = cls._client[mongo_db_database]
# Test the connection
cls._client.server_info()
logger.info("MongoDB connection established successfully")
except Exception as e:
logger.warning(
f"Failed to initialize MongoDB connection: {str(e)}. MongoDB functionality will be disabled."
)
return cls._instance
@classmethod
def get_client(cls) -> Optional[MongoClient]:
"""
Returns the MongoDB client instance.
Returns:
Optional[MongoClient]: The MongoDB client instance or None if not configured
"""
if cls._client is None:
cls._instance = cls()
return cls._client
@classmethod
def get_db(cls) -> Optional[Database]:
"""
Returns the MongoDB database instance.
Returns:
Optional[Database]: The MongoDB database instance or None if not configured
"""
if cls._db is None:
cls._instance = cls()
return cls._db
@classmethod
def get_collection(cls, collection_name: str) -> Optional[Collection]:
"""
Returns a MongoDB collection by name.
Args:
collection_name (str): The name of the collection to retrieve
Returns:
Optional[Collection]: The MongoDB collection instance or None if not configured
"""
try:
db = cls.get_db()
if db is None:
logger.warning(
f"Cannot access collection '{collection_name}': MongoDB not configured"
)
return None
return db[collection_name]
except Exception as e:
logger.warning(f"Failed to access collection '{collection_name}': {str(e)}")
return None
@classmethod
def is_configured(cls) -> bool:
"""
Check if MongoDB is properly configured and connected.
Returns:
bool: True if MongoDB is configured and connected, False otherwise
"""
return cls._client is not None and cls._db is not None
-5
View File
@@ -83,10 +83,5 @@ LOGGING = {
"handlers": ["console"],
"propagate": False,
},
"plane.mongo": {
"level": "INFO",
"handlers": ["console"],
"propagate": False,
},
},
}
+10 -11
View File
@@ -30,6 +30,7 @@ from plane.db.models import (
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
@@ -289,22 +290,20 @@ class IssueCreateSerializer(BaseSerializer):
raise serializers.ValidationError("Start date cannot exceed target date")
# Validate description content for security
if "description_html" in data and data["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(
data["description_html"]
)
if "description" in data and data["description"]:
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
raise serializers.ValidationError({"description": error_msg})
if "description_html" in data and data["description_html"]:
is_valid, error_msg = validate_html_content(data["description_html"])
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
if "description_binary" in data and data["description_binary"]:
is_valid, error_msg = validate_binary_data(data["description_binary"])
if not is_valid:
raise serializers.ValidationError({"description_binary": "Invalid binary data"})
raise serializers.ValidationError({"description_binary": error_msg})
return data
@@ -1,228 +0,0 @@
import pytest
from rest_framework import status
from django.db import IntegrityError
from uuid import uuid4
from plane.db.models import Label, Project, ProjectMember
@pytest.fixture
def project(db, workspace, create_user):
"""Create a test project with the user as a member"""
project = Project.objects.create(
name="Test Project",
identifier="TP",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(
project=project,
member=create_user,
role=20, # Admin role
is_active=True,
)
return project
@pytest.fixture
def label_data():
"""Sample label data for tests"""
return {
"name": "Test Label",
"color": "#FF5733",
"description": "A test label for unit tests",
}
@pytest.fixture
def create_label(db, project, create_user):
"""Create a test label"""
return Label.objects.create(
name="Existing Label",
color="#00FF00",
description="An existing label",
project=project,
workspace=project.workspace,
created_by=create_user,
)
@pytest.mark.contract
class TestLabelListCreateAPIEndpoint:
"""Test Label List and Create API Endpoint"""
def get_label_url(self, workspace_slug, project_id):
"""Helper to get label endpoint URL"""
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/labels/"
@pytest.mark.django_db
def test_create_label_success(self, api_key_client, workspace, project, label_data):
"""Test successful label creation"""
url = self.get_label_url(workspace.slug, project.id)
response = api_key_client.post(url, label_data, format="json")
assert response.status_code == status.HTTP_201_CREATED
assert Label.objects.count() == 1
created_label = Label.objects.first()
assert created_label.name == label_data["name"]
assert created_label.color == label_data["color"]
assert created_label.description == label_data["description"]
assert created_label.project == project
@pytest.mark.django_db
def test_create_label_invalid_data(self, api_key_client, workspace, project):
"""Test label creation with invalid data"""
url = self.get_label_url(workspace.slug, project.id)
# Test with empty data
response = api_key_client.post(url, {}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
# Test with missing name
response = api_key_client.post(url, {"color": "#FF5733"}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_create_label_with_external_id(self, api_key_client, workspace, project):
"""Test creating label with external ID"""
url = self.get_label_url(workspace.slug, project.id)
label_data = {
"name": "External Label",
"color": "#FF5733",
"external_id": "ext-123",
"external_source": "github",
}
response = api_key_client.post(url, label_data, format="json")
assert response.status_code == status.HTTP_201_CREATED
created_label = Label.objects.first()
assert created_label.external_id == "ext-123"
assert created_label.external_source == "github"
@pytest.mark.django_db
def test_create_label_duplicate_external_id(
self, api_key_client, workspace, project
):
"""Test creating label with duplicate external ID"""
url = self.get_label_url(workspace.slug, project.id)
# Create first label
Label.objects.create(
name="First Label",
project=project,
workspace=workspace,
external_id="ext-123",
external_source="github",
)
# Try to create second label with same external ID
label_data = {
"name": "Second Label",
"external_id": "ext-123",
"external_source": "github",
}
response = api_key_client.post(url, label_data, format="json")
assert response.status_code == status.HTTP_409_CONFLICT
assert "same external id" in response.data["error"]
@pytest.mark.django_db
def test_list_labels_success(
self, api_key_client, workspace, project, create_label
):
"""Test successful label listing"""
url = self.get_label_url(workspace.slug, project.id)
# Create additional labels
Label.objects.create(
name="Label 2", project=project, workspace=workspace, color="#00FF00"
)
Label.objects.create(
name="Label 3", project=project, workspace=workspace, color="#0000FF"
)
response = api_key_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert "results" in response.data
assert len(response.data["results"]) == 3 # Including create_label fixture
@pytest.mark.contract
class TestLabelDetailAPIEndpoint:
"""Test Label Detail API Endpoint"""
def get_label_detail_url(self, workspace_slug, project_id, label_id):
"""Helper to get label detail endpoint URL"""
return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/labels/{label_id}/"
@pytest.mark.django_db
def test_get_label_success(self, api_key_client, workspace, project, create_label):
"""Test successful label retrieval"""
url = self.get_label_detail_url(workspace.slug, project.id, create_label.id)
response = api_key_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert response.data["id"] == create_label.id
assert response.data["name"] == create_label.name
assert response.data["color"] == create_label.color
@pytest.mark.django_db
def test_get_label_not_found(self, api_key_client, workspace, project):
"""Test getting non-existent label"""
from uuid import uuid4
fake_id = uuid4()
url = self.get_label_detail_url(workspace.slug, project.id, fake_id)
response = api_key_client.get(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
def test_update_label_success(
self, api_key_client, workspace, project, create_label
):
"""Test successful label update"""
url = self.get_label_detail_url(workspace.slug, project.id, create_label.id)
update_data = {
"name": f"Updated Label {uuid4()}",
}
response = api_key_client.patch(url, update_data, format="json")
assert response.status_code == status.HTTP_200_OK
create_label.refresh_from_db()
assert create_label.name == update_data["name"]
@pytest.mark.django_db
def test_update_label_invalid_data(
self, api_key_client, workspace, project, create_label
):
"""Test label update with invalid data"""
url = self.get_label_detail_url(workspace.slug, project.id, create_label.id)
update_data = {"name": ""}
response = api_key_client.patch(url, update_data, format="json")
# This might be 400 if name is required, or 200 if empty names are allowed
assert response.status_code in [status.HTTP_400_BAD_REQUEST, status.HTTP_200_OK]
@pytest.mark.django_db
def test_delete_label_success(
self, api_key_client, workspace, project, create_label
):
"""Test successful label deletion"""
url = self.get_label_detail_url(workspace.slug, project.id, create_label.id)
response = api_key_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not Label.objects.filter(id=create_label.id).exists()
+288 -11
View File
@@ -1,11 +1,36 @@
# Python imports
import base64
import nh3
from plane.utils.exception_logger import log_exception
import json
import re
# Maximum allowed size for binary data (10MB)
MAX_SIZE = 10 * 1024 * 1024
# Maximum recursion depth to prevent stack overflow
MAX_RECURSION_DEPTH = 20
# Dangerous text patterns that could indicate XSS or script injection
DANGEROUS_TEXT_PATTERNS = [
r"<script[^>]*>.*?</script>",
r"javascript\s*:",
r"data\s*:\s*text/html",
r"eval\s*\(",
r"document\s*\.",
r"window\s*\.",
r"location\s*\.",
]
# Dangerous attribute patterns for HTML attributes
DANGEROUS_ATTR_PATTERNS = [
r"javascript\s*:",
r"data\s*:\s*text/html",
r"eval\s*\(",
r"alert\s*\(",
r"document\s*\.",
r"window\s*\.",
]
# Suspicious patterns for binary data content
SUSPICIOUS_BINARY_PATTERNS = [
"<html",
@@ -16,6 +41,70 @@ SUSPICIOUS_BINARY_PATTERNS = [
"<iframe",
]
# Malicious HTML patterns for content validation
MALICIOUS_HTML_PATTERNS = [
# Script tags with any content
r"<script[^>]*>",
r"</script>",
# JavaScript URLs in various attributes
r'(?:href|src|action)\s*=\s*["\']?\s*javascript:',
# Data URLs with text/html (potential XSS)
r'(?:href|src|action)\s*=\s*["\']?\s*data:text/html',
# Dangerous event handlers with JavaScript-like content
r'on(?:load|error|click|focus|blur|change|submit|reset|select|resize|scroll|unload|beforeunload|hashchange|popstate|storage|message|offline|online)\s*=\s*["\']?[^"\']*(?:javascript|alert|eval|document\.|window\.|location\.|history\.)[^"\']*["\']?',
# Object and embed tags that could load external content
r"<(?:object|embed)[^>]*(?:data|src)\s*=",
# Base tag that could change relative URL resolution
r"<base[^>]*href\s*=",
# Dangerous iframe sources
r'<iframe[^>]*src\s*=\s*["\']?(?:javascript:|data:text/html)',
# Meta refresh redirects
r'<meta[^>]*http-equiv\s*=\s*["\']?refresh["\']?',
# Link tags - simplified patterns
r'<link[^>]*rel\s*=\s*["\']?stylesheet["\']?',
r'<link[^>]*href\s*=\s*["\']?https?://',
r'<link[^>]*href\s*=\s*["\']?//',
r'<link[^>]*href\s*=\s*["\']?(?:data:|javascript:)',
# Style tags with external imports
r"<style[^>]*>.*?@import.*?(?:https?://|//)",
# Link tags with dangerous rel types
r'<link[^>]*rel\s*=\s*["\']?(?:import|preload|prefetch|dns-prefetch|preconnect)["\']?',
# Forms with action attributes
r"<form[^>]*action\s*=",
]
# Dangerous JavaScript patterns for event handlers
DANGEROUS_JS_PATTERNS = [
r"alert\s*\(",
r"eval\s*\(",
r"document\s*\.",
r"window\s*\.",
r"location\s*\.",
r"fetch\s*\(",
r"XMLHttpRequest",
r"innerHTML\s*=",
r"outerHTML\s*=",
r"document\.write",
r"script\s*>",
]
# HTML self-closing tags that don't need closing tags
SELF_CLOSING_TAGS = {
"img",
"br",
"hr",
"input",
"meta",
"link",
"area",
"base",
"col",
"embed",
"source",
"track",
"wbr",
}
def validate_binary_data(data):
"""
@@ -60,21 +149,209 @@ def validate_binary_data(data):
return True, None
def validate_html_content(html_content: str):
def validate_html_content(html_content):
"""
Sanitize HTML content using nh3.
Returns a tuple: (is_valid, error_message, clean_html)
Validate that HTML content is safe and doesn't contain malicious patterns.
Args:
html_content (str): The HTML content to validate
Returns:
tuple: (is_valid: bool, error_message: str or None)
"""
if not html_content:
return True, None, None
return True, None # Empty is OK
# Size check - 10MB limit (consistent with binary validation)
if len(html_content.encode("utf-8")) > MAX_SIZE:
return False, "HTML content exceeds maximum size limit (10MB)", None
return False, "HTML content exceeds maximum size limit (10MB)"
# Check for specific malicious patterns (simplified and more reliable)
for pattern in MALICIOUS_HTML_PATTERNS:
if re.search(pattern, html_content, re.IGNORECASE | re.DOTALL):
return (
False,
f"HTML content contains potentially malicious patterns: {pattern}",
)
# Additional check for inline event handlers that contain suspicious content
# This is more permissive - only blocks if the event handler contains actual dangerous code
event_handler_pattern = r'on\w+\s*=\s*["\']([^"\']*)["\']'
event_matches = re.findall(event_handler_pattern, html_content, re.IGNORECASE)
for handler_content in event_matches:
for js_pattern in DANGEROUS_JS_PATTERNS:
if re.search(js_pattern, handler_content, re.IGNORECASE):
return (
False,
f"HTML content contains dangerous JavaScript in event handler: {handler_content[:100]}",
)
# Basic HTML structure validation - check for common malformed tags
try:
# Count opening and closing tags for basic structure validation
opening_tags = re.findall(r"<(\w+)[^>]*>", html_content)
closing_tags = re.findall(r"</(\w+)>", html_content)
# Filter out self-closing tags from opening tags
opening_tags_filtered = [
tag for tag in opening_tags if tag.lower() not in SELF_CLOSING_TAGS
]
# Basic check - if we have significantly more opening than closing tags, it might be malformed
if len(opening_tags_filtered) > len(closing_tags) + 10: # Allow some tolerance
return False, "HTML content appears to be malformed (unmatched tags)"
except Exception:
# If HTML parsing fails, we'll allow it
pass
return True, None
def validate_json_content(json_content):
"""
Validate that JSON content is safe and doesn't contain malicious patterns.
Args:
json_content (dict): The JSON content to validate
Returns:
tuple: (is_valid: bool, error_message: str or None)
"""
if not json_content:
return True, None # Empty is OK
try:
clean_html = nh3.clean(html_content)
return True, None, clean_html
# Size check - 10MB limit (consistent with other validations)
json_str = json.dumps(json_content)
if len(json_str.encode("utf-8")) > MAX_SIZE:
return False, "JSON content exceeds maximum size limit (10MB)"
# Basic structure validation for page description JSON
if isinstance(json_content, dict):
# Check for expected page description structure
# This is based on ProseMirror/Tiptap JSON structure
if "type" in json_content and json_content.get("type") == "doc":
# Valid document structure
if "content" in json_content and isinstance(
json_content["content"], list
):
# Recursively check content for suspicious patterns
is_valid, error_msg = _validate_json_content_array(
json_content["content"]
)
if not is_valid:
return False, error_msg
elif "type" not in json_content and "content" not in json_content:
# Allow other JSON structures but validate for suspicious content
is_valid, error_msg = _validate_json_content_recursive(json_content)
if not is_valid:
return False, error_msg
else:
return False, "JSON description must be a valid object"
except (TypeError, ValueError) as e:
return False, "Invalid JSON structure"
except Exception as e:
log_exception(e)
return False, "Failed to sanitize HTML", None
return False, "Failed to validate JSON content"
return True, None
def _validate_json_content_array(content, depth=0):
"""
Validate JSON content array for suspicious patterns.
Args:
content (list): Array of content nodes to validate
depth (int): Current recursion depth (default: 0)
Returns:
tuple: (is_valid: bool, error_message: str or None)
"""
# Check recursion depth to prevent stack overflow
if depth > MAX_RECURSION_DEPTH:
return False, f"Maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded"
if not isinstance(content, list):
return True, None
for node in content:
if isinstance(node, dict):
# Check text content for suspicious patterns (more targeted)
if node.get("type") == "text" and "text" in node:
text_content = node["text"]
for pattern in DANGEROUS_TEXT_PATTERNS:
if re.search(pattern, text_content, re.IGNORECASE):
return (
False,
"JSON content contains suspicious script patterns in text",
)
# Check attributes for suspicious content (more targeted)
if "attrs" in node and isinstance(node["attrs"], dict):
for attr_name, attr_value in node["attrs"].items():
if isinstance(attr_value, str):
# Only check specific attributes that could be dangerous
if attr_name.lower() in [
"href",
"src",
"action",
"onclick",
"onload",
"onerror",
]:
for pattern in DANGEROUS_ATTR_PATTERNS:
if re.search(pattern, attr_value, re.IGNORECASE):
return (
False,
f"JSON content contains dangerous pattern in {attr_name} attribute",
)
# Recursively check nested content
if "content" in node and isinstance(node["content"], list):
is_valid, error_msg = _validate_json_content_array(
node["content"], depth + 1
)
if not is_valid:
return False, error_msg
return True, None
def _validate_json_content_recursive(obj, depth=0):
"""
Recursively validate JSON object for suspicious content.
Args:
obj: JSON object (dict, list, or primitive) to validate
depth (int): Current recursion depth (default: 0)
Returns:
tuple: (is_valid: bool, error_message: str or None)
"""
# Check recursion depth to prevent stack overflow
if depth > MAX_RECURSION_DEPTH:
return False, f"Maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded"
if isinstance(obj, dict):
for key, value in obj.items():
if isinstance(value, str):
# Check for dangerous patterns using module constants
for pattern in DANGEROUS_TEXT_PATTERNS:
if re.search(pattern, value, re.IGNORECASE):
return (
False,
"JSON content contains suspicious script patterns",
)
elif isinstance(value, (dict, list)):
is_valid, error_msg = _validate_json_content_recursive(value, depth + 1)
if not is_valid:
return False, error_msg
elif isinstance(obj, list):
for item in obj:
is_valid, error_msg = _validate_json_content_recursive(item, depth + 1)
if not is_valid:
return False, error_msg
return True, None
+1 -1
View File
@@ -160,7 +160,7 @@ class OffsetPaginator:
total_count = (
self.total_count_queryset.count()
if self.total_count_queryset
else queryset.count()
else results.count()
)
# Check if there are more results available after the current page
+1 -5
View File
@@ -9,8 +9,6 @@ psycopg==3.1.18
psycopg-binary==3.1.18
psycopg-c==3.1.18
dj-database-url==2.1.0
# mongo
pymongo==4.6.3
# redis
redis==5.0.4
django-redis==5.4.0
@@ -68,6 +66,4 @@ opentelemetry-sdk==1.28.1
opentelemetry-instrumentation-django==0.49b1
opentelemetry-exporter-otlp==1.28.1
# OpenAPI Specification
drf-spectacular==0.28.0
# html sanitizer
nh3==0.2.18
drf-spectacular==0.28.0
@@ -8,8 +8,8 @@
<title>Set a new password to your Plane account</title>
<style type="text/css" emogrify="no"> #outlook a { padding: 0; } .ExternalClass { width: 100%; } .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; } table td { border-collapse: collapse; mso-line-height-rule: exactly; } .editable.image { font-size: 0 !important; line-height: 0 !important; } .nl2go_preheader { display: none !important; mso-hide: all !important; mso-line-height-rule: exactly; visibility: hidden !important; line-height: 0px !important; font-size: 0px !important; } body { width: 100% !important; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; margin: 0; padding: 0; } img { outline: none; text-decoration: none; -ms-interpolation-mode: bicubic; } a img { border: none; } table { border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; } th { font-weight: normal; text-align: left; } *[class="gmail-fix"] { display: none !important; } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: " \03D1"; } } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: " \03D1"; } .r0-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 320px !important; } .r1-i { background-color: #ffffff !important; } .r2-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 100% !important; } .r3-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 100% !important; } .r4-i { padding-bottom: 20px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 20px !important; } .r5-c { box-sizing: border-box !important; display: block !important; valign: top !important; width: 100% !important; } .r6-o { border-style: solid !important; width: 100% !important; } .r7-i { padding-left: 0px !important; padding-right: 0px !important; } .r8-c { box-sizing: border-box !important; text-align: left !important; valign: top !important; width: 100% !important; } .r9-o { border-style: solid !important; margin: 0 auto 0 0 !important; width: 100% !important; } .r10-i { padding-bottom: 10px !important; padding-left: 0px !important; padding-right: 0px !important; padding-top: 10px !important; } .r11-i { padding-bottom: 15px !important; padding-top: 15px !important; text-align: left !important; } .r12-i { background-color: #ffffff !important; padding-bottom: 15px !important; padding-top: 15px !important; text-align: left !important; } .r13-c { box-sizing: border-box !important; padding: 0 !important; text-align: center !important; valign: top !important; width: 100% !important; } .r14-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-bottom: 15px !important; margin-top: 15px !important; width: 100% !important; } .r15-i { padding: 0 !important; text-align: center !important; } .r16-r { background-color: #006399 !important; border-radius: 4px !important; border-width: 0px !important; box-sizing: border-box; height: initial !important; padding: 0 !important; padding-bottom: 12px !important; padding-top: 12px !important; text-align: center !important; width: 100% !important; } .r17-i { padding-bottom: 0px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 0px !important; } .r18-c { box-sizing: border-box !important; text-align: center !important; width: 100% !important; } .r19-i { padding-bottom: 5px !important; padding-top: 5px !important; } .r20-i { padding-bottom: 15px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 15px !important; } .r21-o { border-bottom-color: #efefef !important; border-bottom-width: 2px !important; border-left-color: #efefef !important; border-left-width: 2px !important; border-right-color: #efefef !important; border-right-width: 2px !important; border-style: solid !important; border-top-color: #efefef !important; border-top-width: 2px !important; margin: 0 auto 0 0 !important; width: 100% !important; } .r22-i { padding-bottom: 5px !important; padding-left: 5px !important; padding-right: 5px !important; padding-top: 5px !important; text-align: left !important; } .r23-i { padding-bottom: 5px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 5px !important; } .r24-c { box-sizing: border-box !important; width: 100% !important; } .r25-i { font-size: 0px !important; padding-bottom: 10px !important; padding-left: 65px !important; padding-right: 65px !important; padding-top: 10px !important; } .r26-c { box-sizing: border-box !important; width: 32px !important; } .r27-o { border-style: solid !important; margin-right: 8px !important; width: 32px !important; } .r28-o { border-style: solid !important; margin-right: 0px !important; width: 32px !important; } .r29-i { padding-bottom: 0px !important; padding-top: 5px !important; text-align: center !important; } body { -webkit-text-size-adjust: none; } .nl2go-responsive-hide { display: none; } .nl2go-body-table { min-width: unset !important; } .mobshow { height: auto !important; overflow: visible !important; max-height: unset !important; visibility: visible !important; border: none !important; } .resp-table { display: inline-table !important; } .magic-resp { display: table-cell !important; } } </style>
<style type="text/css"> p, h1, h2, h3, h4, ol, ul { margin: 0; } a, a:link { color: #006399; text-decoration: underline; } .nl2go-default-textstyle { color: #3b3f44; font-family: georgia, serif; font-size: 16px; line-height: 1.5; word-break: break-word; } .default-button { color: #ffffff; font-family: georgia, serif; font-size: 16px; font-style: normal; font-weight: normal; line-height: 1.15; text-decoration: none; word-break: break-word; } .default-heading1 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 36px; word-break: break-word; } .default-heading2 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 32px; word-break: break-word; } .default-heading3 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 24px; word-break: break-word; } .default-heading4 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 18px; word-break: break-word; } a[x-apple-data-detectors] { color: inherit !important; text-decoration: inherit !important; font-size: inherit !important; font-family: inherit !important; font-weight: inherit !important; line-height: inherit !important; } .no-show-for-you { border: none; display: none; float: none; font-size: 0; height: 0; line-height: 0; max-height: 0; mso-hide: all; overflow: hidden; table-layout: fixed; visibility: hidden; width: 0; } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: " \03D1"; } .r0-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 320px !important; } .r1-i { background-color: #ffffff !important; } .r2-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 100% !important; } .r3-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 100% !important; } .r4-i { padding-bottom: 20px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 20px !important; } .r5-c { box-sizing: border-box !important; display: block !important; valign: top !important; width: 100% !important; } .r6-o { border-style: solid !important; width: 100% !important; } .r7-i { padding-left: 0px !important; padding-right: 0px !important; } .r8-c { box-sizing: border-box !important; text-align: left !important; valign: top !important; width: 100% !important; } .r9-o { border-style: solid !important; margin: 0 auto 0 0 !important; width: 100% !important; } .r10-i { padding-bottom: 10px !important; padding-left: 0px !important; padding-right: 0px !important; padding-top: 10px !important; } .r11-i { padding-bottom: 15px !important; padding-top: 15px !important; text-align: left !important; } .r12-i { background-color: #ffffff !important; padding-bottom: 15px !important; padding-top: 15px !important; text-align: left !important; } .r13-c { box-sizing: border-box !important; padding: 0 !important; text-align: center !important; valign: top !important; width: 100% !important; } .r14-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-bottom: 15px !important; margin-top: 15px !important; width: 100% !important; } .r15-i { padding: 0 !important; text-align: center !important; } .r16-r { background-color: #3f76ff !important; border-radius: 4px !important; border-width: 0px !important; box-sizing: border-box; height: initial !important; padding: 0 !important; padding-bottom: 12px !important; padding-top: 12px !important; text-align: center !important; width: 100% !important; } .r17-i { padding-bottom: 0px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 0px !important; } .r18-c { box-sizing: border-box !important; text-align: center !important; width: 100% !important; } .r19-i { padding-bottom: 5px !important; padding-top: 5px !important; } .r20-i { padding-bottom: 15px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 15px !important; } .r21-o { border-bottom-color: #efefef !important; border-bottom-width: 2px !important; border-left-color: #efefef !important; border-left-width: 2px !important; border-right-color: #efefef !important; border-right-width: 2px !important; border-style: solid !important; border-top-color: #efefef !important; border-top-width: 2px !important; margin: 0 auto 0 0 !important; width: 100% !important; } .r22-i { padding-bottom: 5px !important; padding-left: 5px !important; padding-right: 5px !important; padding-top: 5px !important; text-align: left !important; } .r23-i { padding-bottom: 5px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 5px !important; } .r24-c { box-sizing: border-box !important; width: 100% !important; } .r25-i { font-size: 0px !important; padding-bottom: 10px !important; padding-left: 65px !important; padding-right: 65px !important; padding-top: 10px !important; } .r26-c { box-sizing: border-box !important; width: 32px !important; } .r27-o { border-style: solid !important; margin-right: 8px !important; width: 32px !important; } .r28-o { border-style: solid !important; margin-right: 0px !important; width: 32px !important; } .r29-i { padding-bottom: 0px !important; padding-top: 5px !important; text-align: center !important; } body { -webkit-text-size-adjust: none; } .nl2go-responsive-hide { display: none; } .nl2go-body-table { min-width: unset !important; } .mobshow { height: auto !important; overflow: visible !important; max-height: unset !important; visibility: visible !important; border: none !important; } .resp-table { display: inline-table !important; } .magic-resp { display: table-cell !important; } } </style>
<style type="text/css"> p, h1, h2, h3, h4, ol, ul { margin: 0; } a, a:link { color: #3f76ff; text-decoration: underline; } .nl2go-default-textstyle { color: #3b3f44; font-family: georgia, serif; font-size: 16px; line-height: 1.5; word-break: break-word; } .default-button { color: #ffffff; font-family: georgia, serif; font-size: 16px; font-style: normal; font-weight: normal; line-height: 1.15; text-decoration: none; word-break: break-word; } .default-heading1 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 36px; word-break: break-word; } .default-heading2 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 32px; word-break: break-word; } .default-heading3 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 24px; word-break: break-word; } .default-heading4 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 18px; word-break: break-word; } a[x-apple-data-detectors] { color: inherit !important; text-decoration: inherit !important; font-size: inherit !important; font-family: inherit !important; font-weight: inherit !important; line-height: inherit !important; } .no-show-for-you { border: none; display: none; float: none; font-size: 0; height: 0; line-height: 0; max-height: 0; mso-hide: all; overflow: hidden; table-layout: fixed; visibility: hidden; width: 0; } </style>
<!--[if mso ]>
<xml>
<o:OfficeDocumentSettings>
@@ -18,9 +18,9 @@
</o:OfficeDocumentSettings>
</xml>
<! [endif]-->
<style type="text/css"> a:link { color: #006399; text-decoration: underline; } </style>
<style type="text/css"> a:link { color: #3f76ff; text-decoration: underline; } </style>
</head>
<body bgcolor="#ffffff" text="#3b3f44" link="#006399" yahoo="fix" style="background-color: #ffffff" >
<body bgcolor="#ffffff" text="#3b3f44" link="#3f76ff" yahoo="fix" style="background-color: #ffffff" >
<table cellspacing="0" cellpadding="0" border="0" role="presentation" class="nl2go-body-table" width="100%" style="background-color: #ffffff; width: 100%" >
<tr>
<td>
@@ -41,7 +41,7 @@
<td class="r8-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="150" class="r9-o" style=" table-layout: fixed; width: 150px; " >
<tr>
<td style=" font-size: 0px; line-height: 0px; " > <img src="https://media.docs.plane.so/logo/new-logo-white.png" width="150" border="0" style=" display: block; width: 100%; " /> </td>
<td style=" font-size: 0px; line-height: 0px; " > <img src="https://img.mailinblue.com/5942152/images/content_library/original/64708f00b503b149db7ff135.png" width="150" border="0" style=" display: block; width: 100%; " /> </td>
</tr>
</table>
</td>
@@ -94,9 +94,9 @@
</tr>
<tr>
<td class="r13-c" align="center" style=" align: center; padding-bottom: 15px; padding-top: 15px; valign: top; " >
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="285" class="r14-o" style=" background-color: #006399; border-collapse: separate; border-color: #006399; border-radius: 4px; border-style: solid; border-width: 0px; table-layout: fixed; width: 285px; " >
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="285" class="r14-o" style=" background-color: #3f76ff; border-collapse: separate; border-color: #3f76ff; border-radius: 4px; border-style: solid; border-width: 0px; table-layout: fixed; width: 285px; " >
<tr>
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style=" word-break: break-word; background-color: #006399; border-radius: 4px; color: #ffffff; font-family: georgia, serif; font-size: 16px; font-style: normal; line-height: 1.15; padding-bottom: 12px; padding-top: 12px; text-align: center; " > <a href="{{forgot_password_url}}" class="r16-r default-button" target="_blank" data-btn="1" style=" font-style: normal; font-weight: normal; line-height: 1.15; text-decoration: none; word-break: break-word; word-wrap: break-word; display: block; -webkit-text-size-adjust: none; color: #ffffff; font-family: arial, helvetica, sans-serif; font-size: 16px; " > <span>Reset password</span></a > </td>
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style=" word-break: break-word; background-color: #3f76ff; border-radius: 4px; color: #ffffff; font-family: georgia, serif; font-size: 16px; font-style: normal; line-height: 1.15; padding-bottom: 12px; padding-top: 12px; text-align: center; " > <a href="{{forgot_password_url}}" class="r16-r default-button" target="_blank" data-btn="1" style=" font-style: normal; font-weight: normal; line-height: 1.15; text-decoration: none; word-break: break-word; word-wrap: break-word; display: block; -webkit-text-size-adjust: none; color: #ffffff; font-family: arial, helvetica, sans-serif; font-size: 16px; " > <span>Reset password</span></a > </td>
</tr>
</table>
</td>
@@ -187,7 +187,7 @@
<td class="nl2go-responsive-hide" width="2" style=" font-size: 0px; line-height: 1px; background-color: #efefef; " > ­ </td>
<td align="left" valign="top" class="r22-i nl2go-default-textstyle" style=" color: #3b3f44; font-family: georgia, serif; font-size: 16px; word-break: break-word; line-height: 1.5; padding-bottom: 5px; padding-left: 5px; padding-right: 5px; padding-top: 5px; text-align: left; " >
<div>
<p style="margin: 0"> <span style="font-size: 13px" >Despite our popularity, we are humbly early-stage. We are shipping fast, so please reach out to us with feature requests, major and minor nits, and anything else you find missing. We read every </span ><a href="https://discord.com/channels/1031547764020084846/1094927053867995176" title="Plane Support on Discod" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >message</span ></a ><span style="font-size: 13px" >, </span ><a href="http://twitter.com/planepowers" title="@planepowers" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >tweet</span ></a ><span style="font-size: 13px" >, and </span ><a href="https://github.com/makeplane/plane/issues" title="Plane's GitHub conversations" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >conversation</span ></a ><span style="font-size: 13px" > and update </span ><a href="https://plane.sh/plane/0b170a1c-0e55-47cb-9307-ea49a05672b5?board=kanban" title="Plane's roadmap" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >our public roadmap</span ></a ><span style="font-size: 13px" >.</span > </p>
<p style="margin: 0"> <span style="font-size: 13px" >Despite our popularity, we are humbly early-stage. We are shipping fast, so please reach out to us with feature requests, major and minor nits, and anything else you find missing. We read every </span ><a href="https://discord.com/channels/1031547764020084846/1094927053867995176" title="Plane Support on Discod" target="_blank" style=" color: #3f76ff; text-decoration: underline; " ><span style="font-size: 13px" >message</span ></a ><span style="font-size: 13px" >, </span ><a href="http://twitter.com/planepowers" title="@planepowers" target="_blank" style=" color: #3f76ff; text-decoration: underline; " ><span style="font-size: 13px" >tweet</span ></a ><span style="font-size: 13px" >, and </span ><a href="https://github.com/makeplane/plane/issues" title="Plane's GitHub conversations" target="_blank" style=" color: #3f76ff; text-decoration: underline; " ><span style="font-size: 13px" >conversation</span ></a ><span style="font-size: 13px" > and update </span ><a href="https://plane.sh/plane/0b170a1c-0e55-47cb-9307-ea49a05672b5?board=kanban" title="Plane's roadmap" target="_blank" style=" color: #3f76ff; text-decoration: underline; " ><span style="font-size: 13px" >our public roadmap</span ></a ><span style="font-size: 13px" >.</span > </p>
</div>
</td>
<td class="nl2go-responsive-hide" width="2" style=" font-size: 0px; line-height: 1px; background-color: #efefef; " > ­ </td>
@@ -236,7 +236,7 @@
<th width="40" class="r26-c mobshow resp-table" style=" font-weight: normal; " >
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r27-o" style=" table-layout: fixed; width: 100%; " >
<tr>
<td class="r19-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://github.com/makeplane" target="_blank" style=" color: #006399; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="r19-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://github.com/makeplane" target="_blank" style=" color: #3f76ff; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="nl2go-responsive-hide" width="8" style=" font-size: 0px; line-height: 1px; " > ­ </td>
</tr>
</table>
@@ -244,7 +244,7 @@
<th width="40" class="r26-c mobshow resp-table" style=" font-weight: normal; " >
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r27-o" style=" table-layout: fixed; width: 100%; " >
<tr>
<td class="r19-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style=" color: #006399; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="r19-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style=" color: #3f76ff; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="nl2go-responsive-hide" width="8" style=" font-size: 0px; line-height: 1px; " > ­ </td>
</tr>
</table>
@@ -252,7 +252,7 @@
<th width="40" class="r26-c mobshow resp-table" style=" font-weight: normal; " >
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r27-o" style=" table-layout: fixed; width: 100%; " >
<tr>
<td class="r19-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://twitter.com/planepowers" target="_blank" style=" color: #006399; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="r19-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://twitter.com/planepowers" target="_blank" style=" color: #3f76ff; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="nl2go-responsive-hide" width="8" style=" font-size: 0px; line-height: 1px; " > ­ </td>
</tr>
</table>
@@ -260,7 +260,7 @@
<th width="32" class="r26-c mobshow resp-table" style=" font-weight: normal; " >
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r28-o" style=" table-layout: fixed; width: 100%; " >
<tr>
<td class="r19-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://plane.so/" target="_blank" style=" color: #006399; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/website_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="r19-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://plane.so/" target="_blank" style=" color: #3f76ff; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/website_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
</tr>
</table>
</th>
@@ -9,7 +9,7 @@
<style type="text/css" emogrify="no"> #outlook a { padding: 0; } .ExternalClass { width: 100%; } .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; } table td { border-collapse: collapse; mso-line-height-rule: exactly; } .editable.image { font-size: 0 !important; line-height: 0 !important; } .nl2go_preheader { display: none !important; mso-hide: all !important; mso-line-height-rule: exactly; visibility: hidden !important; line-height: 0px !important; font-size: 0px !important; } body { width: 100% !important; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; margin: 0; padding: 0; } img { outline: none; text-decoration: none; -ms-interpolation-mode: bicubic; } a img { border: none; } table { border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; } th { font-weight: normal; text-align: left; } *[class="gmail-fix"] { display: none !important; } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: " \03D1"; } } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: " \03D1"; } .r0-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 320px !important; } .r1-i { background-color: #ffffff !important; } .r2-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 100% !important; } .r3-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 100% !important; } .r4-i { padding-bottom: 20px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 20px !important; } .r5-c { box-sizing: border-box !important; display: block !important; valign: top !important; width: 100% !important; } .r6-o { border-style: solid !important; width: 100% !important; } .r7-i { padding-left: 0px !important; padding-right: 0px !important; } .r8-c { box-sizing: border-box !important; text-align: left !important; valign: top !important; width: 100% !important; } .r9-o { border-style: solid !important; margin: 0 auto 0 0 !important; width: 100% !important; } .r10-i { padding-bottom: 15px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 15px !important; } .r11-i { padding-bottom: 10px !important; padding-top: 10px !important; text-align: left !important; } .r12-i { padding-bottom: 10px !important; padding-left: 0px !important; padding-right: 0px !important; padding-top: 10px !important; } .r13-o { border-bottom-color: #d9e4ff !important; border-bottom-width: 1px !important; border-left-color: #d9e4ff !important; border-left-width: 1px !important; border-right-color: #d9e4ff !important; border-right-width: 1px !important; border-style: solid !important; border-top-color: #d9e4ff !important; border-top-width: 1px !important; margin: 0 auto 0 0 !important; width: 100% !important; } .r14-i { background-color: #ecf1ff !important; padding-bottom: 10px !important; padding-left: 10px !important; padding-right: 10px !important; padding-top: 10px !important; text-align: left !important; } .r15-o { border-bottom-color: #efefef !important; border-bottom-width: 2px !important; border-left-color: #efefef !important; border-left-width: 2px !important; border-right-color: #efefef !important; border-right-width: 2px !important; border-style: solid !important; border-top-color: #efefef !important; border-top-width: 2px !important; margin: 0 auto 0 0 !important; width: 100% !important; } .r16-i { padding-bottom: 5px !important; padding-left: 5px !important; padding-right: 5px !important; padding-top: 5px !important; text-align: left !important; } .r17-i { padding-bottom: 5px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 5px !important; } .r18-c { box-sizing: border-box !important; text-align: center !important; width: 100% !important; } .r19-c { box-sizing: border-box !important; width: 100% !important; } .r20-i { font-size: 0px !important; padding-bottom: 10px !important; padding-left: 65px !important; padding-right: 65px !important; padding-top: 10px !important; } .r21-c { box-sizing: border-box !important; width: 32px !important; } .r22-o { border-style: solid !important; margin-right: 8px !important; width: 32px !important; } .r23-i { padding-bottom: 5px !important; padding-top: 5px !important; } .r24-o { border-style: solid !important; margin-right: 0px !important; width: 32px !important; } .r25-i { padding-bottom: 0px !important; padding-top: 5px !important; text-align: center !important; } body { -webkit-text-size-adjust: none; } .nl2go-responsive-hide { display: none; } .nl2go-body-table { min-width: unset !important; } .mobshow { height: auto !important; overflow: visible !important; max-height: unset !important; visibility: visible !important; border: none !important; } .resp-table { display: inline-table !important; } .magic-resp { display: table-cell !important; } } </style>
<style type="text/css"> p, h1, h2, h3, h4, ol, ul { margin: 0; } a, a:link { color: #006399; text-decoration: underline; } .nl2go-default-textstyle { color: #3b3f44; font-family: georgia, serif; font-size: 16px; line-height: 1.5; word-break: break-word; } .default-button { color: #ffffff; font-family: georgia, serif; font-size: 16px; font-style: normal; font-weight: normal; line-height: 1.15; text-decoration: none; word-break: break-word; } .default-heading1 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 36px; word-break: break-word; } .default-heading2 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 32px; word-break: break-word; } .default-heading3 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 24px; word-break: break-word; } .default-heading4 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 18px; word-break: break-word; } a[x-apple-data-detectors] { color: inherit !important; text-decoration: inherit !important; font-size: inherit !important; font-family: inherit !important; font-weight: inherit !important; line-height: inherit !important; } .no-show-for-you { border: none; display: none; float: none; font-size: 0; height: 0; line-height: 0; max-height: 0; mso-hide: all; overflow: hidden; table-layout: fixed; visibility: hidden; width: 0; } </style>
<style type="text/css"> p, h1, h2, h3, h4, ol, ul { margin: 0; } a, a:link { color: #3f76ff; text-decoration: underline; } .nl2go-default-textstyle { color: #3b3f44; font-family: georgia, serif; font-size: 16px; line-height: 1.5; word-break: break-word; } .default-button { color: #ffffff; font-family: georgia, serif; font-size: 16px; font-style: normal; font-weight: normal; line-height: 1.15; text-decoration: none; word-break: break-word; } .default-heading1 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 36px; word-break: break-word; } .default-heading2 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 32px; word-break: break-word; } .default-heading3 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 24px; word-break: break-word; } .default-heading4 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 18px; word-break: break-word; } a[x-apple-data-detectors] { color: inherit !important; text-decoration: inherit !important; font-size: inherit !important; font-family: inherit !important; font-weight: inherit !important; line-height: inherit !important; } .no-show-for-you { border: none; display: none; float: none; font-size: 0; height: 0; line-height: 0; max-height: 0; mso-hide: all; overflow: hidden; table-layout: fixed; visibility: hidden; width: 0; } </style>
<!--[if mso ]>
<xml>
<o:OfficeDocumentSettings>
@@ -18,9 +18,9 @@
</o:OfficeDocumentSettings>
</xml>
<! [endif]-->
<style type="text/css"> a:link { color: #006399; text-decoration: underline; } </style>
<style type="text/css"> a:link { color: #3f76ff; text-decoration: underline; } </style>
</head>
<body bgcolor="#ffffff" text="#3b3f44" link="#006399" yahoo="fix" style="background-color: #ffffff" >
<body bgcolor="#ffffff" text="#3b3f44" link="#3f76ff" yahoo="fix" style="background-color: #ffffff" >
<table cellspacing="0" cellpadding="0" border="0" role="presentation" class="nl2go-body-table" width="100%" style="background-color: #ffffff; width: 100%" >
<tr>
<td>
@@ -41,7 +41,7 @@
<td class="r8-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="150" class="r9-o" style=" table-layout: fixed; width: 150px; " >
<tr>
<td style=" font-size: 0px; line-height: 0px; " > <img src="https://media.docs.plane.so/logo/new-logo-white.png" width="150" border="0" style=" display: block; width: 100%; " /> </td>
<td style=" font-size: 0px; line-height: 0px; " > <img src="https://img.mailinblue.com/5942152/images/content_library/original/64708f00b503b149db7ff135.png" width="150" border="0" style=" display: block; width: 100%; " /> </td>
</tr>
</table>
</td>
@@ -80,7 +80,7 @@
</td>
</tr>
</table>
</td>
</td>
</tr>
</table>
</th>
@@ -145,7 +145,7 @@
<td class="nl2go-responsive-hide" width="2" style=" font-size: 0px; line-height: 1px; background-color: #efefef; " > ­ </td>
<td align="left" valign="top" class="r16-i nl2go-default-textstyle" style=" color: #3b3f44; font-family: georgia, serif; font-size: 16px; word-break: break-word; line-height: 1.5; padding-bottom: 5px; padding-left: 5px; padding-right: 5px; padding-top: 5px; text-align: left; " >
<div>
<p style="margin: 0"> <span style="font-size: 13px" >Despite our popularity, we are humbly early-stage. We are shipping fast, so please reach out to us with feature requests, major and minor nits, and anything else you find missing. We read every </span ><a href="https://discord.com/channels/1031547764020084846/1094927053867995176" title="Plane Support on Discod" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >message</span ></a ><span style="font-size: 13px" >, </span ><a href="http://twitter.com/planepowers" title="@planepowers" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >tweet</span ></a ><span style="font-size: 13px" >, and </span ><a href="https://github.com/makeplane/plane/issues" title="Plane's GitHub conversations" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >conversation</span ></a ><span style="font-size: 13px" > and update </span ><a href="https://plane.sh/plane/0b170a1c-0e55-47cb-9307-ea49a05672b5?board=kanban" title="Plane's roadmap" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >our public roadmap</span ></a ><span style="font-size: 13px" >.</span > </p>
<p style="margin: 0"> <span style="font-size: 13px" >Despite our popularity, we are humbly early-stage. We are shipping fast, so please reach out to us with feature requests, major and minor nits, and anything else you find missing. We read every </span ><a href="https://discord.com/channels/1031547764020084846/1094927053867995176" title="Plane Support on Discod" target="_blank" style=" color: #3f76ff; text-decoration: underline; " ><span style="font-size: 13px" >message</span ></a ><span style="font-size: 13px" >, </span ><a href="http://twitter.com/planepowers" title="@planepowers" target="_blank" style=" color: #3f76ff; text-decoration: underline; " ><span style="font-size: 13px" >tweet</span ></a ><span style="font-size: 13px" >, and </span ><a href="https://github.com/makeplane/plane/issues" title="Plane's GitHub conversations" target="_blank" style=" color: #3f76ff; text-decoration: underline; " ><span style="font-size: 13px" >conversation</span ></a ><span style="font-size: 13px" > and update </span ><a href="https://plane.sh/plane/0b170a1c-0e55-47cb-9307-ea49a05672b5?board=kanban" title="Plane's roadmap" target="_blank" style=" color: #3f76ff; text-decoration: underline; " ><span style="font-size: 13px" >our public roadmap</span ></a ><span style="font-size: 13px" >.</span > </p>
</div>
</td>
<td class="nl2go-responsive-hide" width="2" style=" font-size: 0px; line-height: 1px; background-color: #efefef; " > ­ </td>
@@ -194,7 +194,7 @@
<th width="40" class="r21-c mobshow resp-table" style=" font-weight: normal; " >
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r22-o" style=" table-layout: fixed; width: 100%; " >
<tr>
<td class="r23-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://github.com/makeplane" target="_blank" style=" color: #006399; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="r23-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://github.com/makeplane" target="_blank" style=" color: #3f76ff; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="nl2go-responsive-hide" width="8" style=" font-size: 0px; line-height: 1px; " > ­ </td>
</tr>
</table>
@@ -202,7 +202,7 @@
<th width="40" class="r21-c mobshow resp-table" style=" font-weight: normal; " >
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r22-o" style=" table-layout: fixed; width: 100%; " >
<tr>
<td class="r23-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style=" color: #006399; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="r23-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style=" color: #3f76ff; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="nl2go-responsive-hide" width="8" style=" font-size: 0px; line-height: 1px; " > ­ </td>
</tr>
</table>
@@ -210,7 +210,7 @@
<th width="40" class="r21-c mobshow resp-table" style=" font-weight: normal; " >
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r22-o" style=" table-layout: fixed; width: 100%; " >
<tr>
<td class="r23-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://twitter.com/planepowers" target="_blank" style=" color: #006399; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="r23-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://twitter.com/planepowers" target="_blank" style=" color: #3f76ff; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="nl2go-responsive-hide" width="8" style=" font-size: 0px; line-height: 1px; " > ­ </td>
</tr>
</table>
@@ -218,7 +218,7 @@
<th width="32" class="r21-c mobshow resp-table" style=" font-weight: normal; " >
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r24-o" style=" table-layout: fixed; width: 100%; " >
<tr>
<td class="r23-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://plane.so/" target="_blank" style=" color: #006399; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/website_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="r23-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://plane.so/" target="_blank" style=" color: #3f76ff; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/website_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
</tr>
</table>
</th>
@@ -8,7 +8,7 @@
<title> {{ first_name }} invited you to join {{ project_name }} on Plane </title>
<style type="text/css" emogrify="no"> #outlook a { padding: 0; } .ExternalClass { width: 100%; } .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; } table td { border-collapse: collapse; mso-line-height-rule: exactly; } .editable.image { font-size: 0 !important; line-height: 0 !important; } .nl2go_preheader { display: none !important; mso-hide: all !important; mso-line-height-rule: exactly; visibility: hidden !important; line-height: 0px !important; font-size: 0px !important; } body { width: 100% !important; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; margin: 0; padding: 0; } img { outline: none; text-decoration: none; -ms-interpolation-mode: bicubic; } a img { border: none; } table { border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; } th { font-weight: normal; text-align: left; } *[class="gmail-fix"] { display: none !important; } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: " \03D1"; } } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: " \03D1"; } .r0-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 320px !important; } .r1-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 320px !important; } .r2-i { background-color: #ffffff !important; } .r3-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 100% !important; } .r4-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-top: 20px !important; width: 100% !important; } .r5-i { background-color: #f8f9fa !important; padding-bottom: 20px !important; padding-left: 10px !important; padding-right: 10px !important; padding-top: 20px !important; } .r6-c { box-sizing: border-box !important; display: block !important; valign: top !important; width: 100% !important; } .r7-o { border-style: solid !important; width: 100% !important; } .r8-i { padding-left: 0px !important; padding-right: 0px !important; } .r9-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 100% !important; } .r10-i { padding-bottom: 35px !important; padding-top: 15px !important; } .r11-c { box-sizing: border-box !important; text-align: left !important; valign: top !important; width: 100% !important; } .r12-o { border-style: solid !important; margin: 0 auto 0 0 !important; width: 100% !important; } .r13-i { padding-left: 20px !important; padding-right: 20px !important; padding-top: 0px !important; text-align: center !important; } .r14-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-bottom: 20px !important; margin-top: 20px !important; width: 100% !important; } .r15-i { text-align: center !important; } .r16-r { background-color: #ffffff !important; border-color: #006399 !important; border-radius: 4px !important; border-width: 1px !important; box-sizing: border-box; height: initial !important; padding-bottom: 7px !important; padding-left: 20px !important; padding-right: 20px !important; padding-top: 7px !important; text-align: center !important; width: 100% !important; } .r17-i { padding-bottom: 15px !important; padding-left: 20px !important; padding-right: 20px !important; padding-top: 15px !important; text-align: left !important; } .r18-i { background-color: #eff2f7 !important; padding-bottom: 20px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 20px !important; } .r19-i { padding-bottom: 15px !important; padding-top: 15px !important; } .r20-i { color: #3b3f44 !important; padding-bottom: 0px !important; padding-top: 0px !important; text-align: center !important; } .r21-c { box-sizing: border-box !important; text-align: center !important; width: 100% !important; } .r22-c { box-sizing: border-box !important; width: 100% !important; } .r23-i { font-size: 0px !important; padding-bottom: 15px !important; padding-left: 65px !important; padding-right: 65px !important; padding-top: 15px !important; } .r24-c { box-sizing: border-box !important; width: 32px !important; } .r25-o { border-style: solid !important; margin-right: 8px !important; width: 32px !important; } .r26-i { padding-bottom: 5px !important; padding-top: 5px !important; } .r27-o { border-style: solid !important; margin-right: 0px !important; width: 32px !important; } .r28-i { color: #3b3f44 !important; padding-bottom: 15px !important; padding-top: 15px !important; text-align: center !important; } .r29-i { padding-bottom: 15px !important; padding-left: 0px !important; padding-right: 0px !important; padding-top: 0px !important; } .r30-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 129px !important; } .r31-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 129px !important; } body { -webkit-text-size-adjust: none; } .nl2go-responsive-hide { display: none; } .nl2go-body-table { min-width: unset !important; } .mobshow { height: auto !important; overflow: visible !important; max-height: unset !important; visibility: visible !important; border: none !important; } .resp-table { display: inline-table !important; } .magic-resp { display: table-cell !important; } } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: " \03D1"; } .r0-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 320px !important; } .r1-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 320px !important; } .r2-i { background-color: #ffffff !important; } .r3-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 100% !important; } .r4-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-top: 20px !important; width: 100% !important; } .r5-i { background-color: #f8f9fa !important; padding-bottom: 20px !important; padding-left: 10px !important; padding-right: 10px !important; padding-top: 20px !important; } .r6-c { box-sizing: border-box !important; display: block !important; valign: top !important; width: 100% !important; } .r7-o { border-style: solid !important; width: 100% !important; } .r8-i { padding-left: 0px !important; padding-right: 0px !important; } .r9-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 100% !important; } .r10-i { padding-bottom: 35px !important; padding-top: 15px !important; } .r11-c { box-sizing: border-box !important; text-align: left !important; valign: top !important; width: 100% !important; } .r12-o { border-style: solid !important; margin: 0 auto 0 0 !important; width: 100% !important; } .r13-i { padding-left: 20px !important; padding-right: 20px !important; padding-top: 0px !important; text-align: center !important; } .r14-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-bottom: 20px !important; margin-top: 20px !important; width: 100% !important; } .r15-i { text-align: center !important; } .r16-r { background-color: #ffffff !important; border-color: #3f76ff !important; border-radius: 4px !important; border-width: 1px !important; box-sizing: border-box; height: initial !important; padding-bottom: 7px !important; padding-left: 20px !important; padding-right: 20px !important; padding-top: 7px !important; text-align: center !important; width: 100% !important; } .r17-i { padding-bottom: 15px !important; padding-left: 20px !important; padding-right: 20px !important; padding-top: 15px !important; text-align: left !important; } .r18-i { background-color: #eff2f7 !important; padding-bottom: 20px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 20px !important; } .r19-i { padding-bottom: 15px !important; padding-top: 15px !important; } .r20-i { color: #3b3f44 !important; padding-bottom: 0px !important; padding-top: 0px !important; text-align: center !important; } .r21-c { box-sizing: border-box !important; text-align: center !important; width: 100% !important; } .r22-c { box-sizing: border-box !important; width: 100% !important; } .r23-i { font-size: 0px !important; padding-bottom: 15px !important; padding-left: 65px !important; padding-right: 65px !important; padding-top: 15px !important; } .r24-c { box-sizing: border-box !important; width: 32px !important; } .r25-o { border-style: solid !important; margin-right: 8px !important; width: 32px !important; } .r26-i { padding-bottom: 5px !important; padding-top: 5px !important; } .r27-o { border-style: solid !important; margin-right: 0px !important; width: 32px !important; } .r28-i { color: #3b3f44 !important; padding-bottom: 15px !important; padding-top: 15px !important; text-align: center !important; } .r29-i { padding-bottom: 15px !important; padding-left: 0px !important; padding-right: 0px !important; padding-top: 0px !important; } .r30-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 129px !important; } .r31-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 129px !important; } body { -webkit-text-size-adjust: none; } .nl2go-responsive-hide { display: none; } .nl2go-body-table { min-width: unset !important; } .mobshow { height: auto !important; overflow: visible !important; max-height: unset !important; visibility: visible !important; border: none !important; } .resp-table { display: inline-table !important; } .magic-resp { display: table-cell !important; } } </style>
<!--[if !mso]><!-->
<style type="text/css" emogrify="no"> @import url("https://fonts.googleapis.com/css2?family=Bitter&family=Roboto"); </style>
<!--<![endif]-->
@@ -58,7 +58,7 @@
<td height="15" style=" font-size: 15px; line-height: 15px; " > ­ </td>
</tr>
<tr>
<td class="r10-i" style=" font-size: 0px; line-height: 0px; " > <img src="https://media.docs.plane.so/logo/new-logo-white.png" width="120" border="0" class="" style=" display: block; width: 100%; " /> </td>
<td class="r10-i" style=" font-size: 0px; line-height: 0px; " > <img src="https://ik.imagekit.io/w2okwbtu2/Plane_Logo_pIhtbyIoa.png?ik-sdk-version=javascript-1.4.3&updatedAt=1670873447444" width="120" border="0" class="" style=" display: block; width: 100%; " /> </td>
</tr>
<tr class="nl2go-responsive-hide" >
<td height="35" style=" font-size: 35px; line-height: 35px; " > ­ </td>
@@ -91,17 +91,17 @@
<tr>
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style=" color: #3b3f44; font-family: arial, helvetica, sans-serif; font-size: 16px; line-height: 1.5; " >
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{invitation_url}}" style=" v-text-anchor: middle; height: 33px; width: 301px; " arcsize="12%" fillcolor="#ffffff" strokecolor="#006399" strokeweight="1px" data-btn="1" >
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{invitation_url}}" style=" v-text-anchor: middle; height: 33px; width: 301px; " arcsize="12%" fillcolor="#ffffff" strokecolor="#3f76ff" strokeweight="1px" data-btn="1" >
<w:anchorlock />
<div style="display: none" >
<center class="default-button" >
<p> <span style=" color: #006399; " >Accept the invite</span > </p>
<p> <span style=" color: #3f76ff; " >Accept the invite</span > </p>
</center>
</div>
</v:roundrect>
<![endif]--> <!--[if !mso]><!-- -->
<a href="{{invitation_url}}" class="r16-r default-button" target="_blank" rel="noopener noreferrer" data-btn="1" style=" font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #006399; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial, helvetica, sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 20px; padding-right: 20px; padding-top: 7px; width: 258px; " >
<p style="margin: 0"> <span style="color: #006399" >Accept the invite</span > </p>
<a href="{{invitation_url}}" class="r16-r default-button" target="_blank" rel="noopener noreferrer" data-btn="1" style=" font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #3f76ff; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial, helvetica, sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 20px; padding-right: 20px; padding-top: 7px; width: 258px; " >
<p style="margin: 0"> <span style="color: #3f76ff" >Accept the invite</span > </p>
</a>
<!--<![endif]-->
</td>
@@ -8,8 +8,8 @@
<title>{{first_name}} has invited you to join them in {{workspace_name}} on Plane.</title>
<style type="text/css" emogrify="no">#outlook a { padding:0; } .ExternalClass { width:100%; } .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; } table td { border-collapse: collapse; mso-line-height-rule: exactly; } .editable.image { font-size: 0 !important; line-height: 0 !important; } .nl2go_preheader { display: none !important; mso-hide:all !important; mso-line-height-rule: exactly; visibility: hidden !important; line-height: 0px !important; font-size: 0px !important; } body { width:100% !important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; margin:0; padding:0; } img { outline:none; text-decoration:none; -ms-interpolation-mode: bicubic; } a img { border:none; } table { border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt; } th { font-weight: normal; text-align: left; } *[class="gmail-fix"] { display: none !important; } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} } </style>
<style type="text/css" emogrify="no">@media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} .r0-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 320px !important } .r1-i { background-color: #ffffff !important } .r2-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 100% !important } .r3-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 100% !important } .r4-i { padding-bottom: 20px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 20px !important } .r5-c { box-sizing: border-box !important; display: block !important; valign: top !important; width: 100% !important } .r6-o { border-style: solid !important; width: 100% !important } .r7-i { padding-left: 0px !important; padding-right: 0px !important } .r8-i { padding-bottom: 20px !important; padding-left: 10px !important; padding-right: 10px !important; padding-top: 20px !important } .r9-c { box-sizing: border-box !important; text-align: left !important; valign: top !important; width: 100% !important } .r10-o { border-style: solid !important; margin: 0 auto 0 0 !important; width: 100% !important } .r11-i { padding-top: 15px !important; text-align: center !important } .r12-c { box-sizing: border-box !important; padding-bottom: 15px !important; padding-top: 15px !important; text-align: left !important; valign: top !important; width: 100% !important } .r13-c { box-sizing: border-box !important; padding: 0 !important; text-align: center !important; valign: top !important; width: 100% !important } .r14-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-bottom: 15px !important; margin-top: 15px !important; width: 100% !important } .r15-i { padding: 0 !important; text-align: center !important } .r16-r { background-color: #006399 !important; border-radius: 4px !important; border-width: 0px !important; box-sizing: border-box; height: initial !important; padding: 0 !important; padding-bottom: 12px !important; padding-left: 5px !important; padding-right: 5px !important; padding-top: 12px !important; text-align: center !important; width: 100% !important } .r17-c { box-sizing: border-box !important; text-align: center !important; width: 100% !important } .r18-c { box-sizing: border-box !important; width: 100% !important } .r19-i { font-size: 0px !important; padding-bottom: 15px !important; padding-left: 65px !important; padding-right: 65px !important; padding-top: 15px !important } .r20-c { box-sizing: border-box !important; width: 32px !important } .r21-o { border-style: solid !important; margin-right: 8px !important; width: 32px !important } .r22-i { padding-bottom: 5px !important; padding-top: 5px !important } .r23-o { border-style: solid !important; margin-right: 0px !important; width: 32px !important } body { -webkit-text-size-adjust: none } .nl2go-responsive-hide { display: none } .nl2go-body-table { min-width: unset !important } .mobshow { height: auto !important; overflow: visible !important; max-height: unset !important; visibility: visible !important; border: none !important } .resp-table { display: inline-table !important } .magic-resp { display: table-cell !important } } </style>
<style type="text/css">p, h1, h2, h3, h4, ol, ul, li { margin: 0; } a, a:link { color: #006399; text-decoration: underline } .nl2go-default-textstyle { color: #3b3f44; font-family: georgia, serif; font-size: 18px; line-height: 1.5; word-break: break-word } .default-button { color: #ffffff; font-family: georgia, serif; font-size: 16px; font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; word-break: break-word } .default-heading1 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 36px; word-break: break-word } .default-heading2 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 32px; word-break: break-word } .default-heading3 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 24px; word-break: break-word } .default-heading4 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 18px; word-break: break-word } a[x-apple-data-detectors] { color: inherit !important; text-decoration: inherit !important; font-size: inherit !important; font-family: inherit !important; font-weight: inherit !important; line-height: inherit !important; } .no-show-for-you { border: none; display: none; float: none; font-size: 0; height: 0; line-height: 0; max-height: 0; mso-hide: all; overflow: hidden; table-layout: fixed; visibility: hidden; width: 0; } </style>
<style type="text/css" emogrify="no">@media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} .r0-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 320px !important } .r1-i { background-color: #ffffff !important } .r2-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 100% !important } .r3-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 100% !important } .r4-i { padding-bottom: 20px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 20px !important } .r5-c { box-sizing: border-box !important; display: block !important; valign: top !important; width: 100% !important } .r6-o { border-style: solid !important; width: 100% !important } .r7-i { padding-left: 0px !important; padding-right: 0px !important } .r8-i { padding-bottom: 20px !important; padding-left: 10px !important; padding-right: 10px !important; padding-top: 20px !important } .r9-c { box-sizing: border-box !important; text-align: left !important; valign: top !important; width: 100% !important } .r10-o { border-style: solid !important; margin: 0 auto 0 0 !important; width: 100% !important } .r11-i { padding-top: 15px !important; text-align: center !important } .r12-c { box-sizing: border-box !important; padding-bottom: 15px !important; padding-top: 15px !important; text-align: left !important; valign: top !important; width: 100% !important } .r13-c { box-sizing: border-box !important; padding: 0 !important; text-align: center !important; valign: top !important; width: 100% !important } .r14-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-bottom: 15px !important; margin-top: 15px !important; width: 100% !important } .r15-i { padding: 0 !important; text-align: center !important } .r16-r { background-color: #3f76ff !important; border-radius: 4px !important; border-width: 0px !important; box-sizing: border-box; height: initial !important; padding: 0 !important; padding-bottom: 12px !important; padding-left: 5px !important; padding-right: 5px !important; padding-top: 12px !important; text-align: center !important; width: 100% !important } .r17-c { box-sizing: border-box !important; text-align: center !important; width: 100% !important } .r18-c { box-sizing: border-box !important; width: 100% !important } .r19-i { font-size: 0px !important; padding-bottom: 15px !important; padding-left: 65px !important; padding-right: 65px !important; padding-top: 15px !important } .r20-c { box-sizing: border-box !important; width: 32px !important } .r21-o { border-style: solid !important; margin-right: 8px !important; width: 32px !important } .r22-i { padding-bottom: 5px !important; padding-top: 5px !important } .r23-o { border-style: solid !important; margin-right: 0px !important; width: 32px !important } body { -webkit-text-size-adjust: none } .nl2go-responsive-hide { display: none } .nl2go-body-table { min-width: unset !important } .mobshow { height: auto !important; overflow: visible !important; max-height: unset !important; visibility: visible !important; border: none !important } .resp-table { display: inline-table !important } .magic-resp { display: table-cell !important } } </style>
<style type="text/css">p, h1, h2, h3, h4, ol, ul, li { margin: 0; } a, a:link { color: #3f76ff; text-decoration: underline } .nl2go-default-textstyle { color: #3b3f44; font-family: georgia, serif; font-size: 18px; line-height: 1.5; word-break: break-word } .default-button { color: #ffffff; font-family: georgia, serif; font-size: 16px; font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; word-break: break-word } .default-heading1 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 36px; word-break: break-word } .default-heading2 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 32px; word-break: break-word } .default-heading3 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 24px; word-break: break-word } .default-heading4 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 18px; word-break: break-word } a[x-apple-data-detectors] { color: inherit !important; text-decoration: inherit !important; font-size: inherit !important; font-family: inherit !important; font-weight: inherit !important; line-height: inherit !important; } .no-show-for-you { border: none; display: none; float: none; font-size: 0; height: 0; line-height: 0; max-height: 0; mso-hide: all; overflow: hidden; table-layout: fixed; visibility: hidden; width: 0; } </style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
@@ -18,9 +18,9 @@
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<style type="text/css">a:link{color: #006399; text-decoration: underline;}</style>
<style type="text/css">a:link{color: #3f76ff; text-decoration: underline;}</style>
</head>
<body bgcolor="#ffffff" text="#3b3f44" link="#006399" yahoo="fix" style="background-color: #ffffff;">
<body bgcolor="#ffffff" text="#3b3f44" link="#3f76ff" yahoo="fix" style="background-color: #ffffff;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" class="nl2go-body-table" width="100%" style="background-color: #ffffff; width: 100%;">
<tr>
<td>
@@ -41,7 +41,7 @@
<td class="r2-c" align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="200" class="r3-o" style="table-layout: fixed; width: 200px;">
<tr>
<td style="font-size: 0px; line-height: 0px;"> <img src="https://media.docs.plane.so/logo/new-logo-white.png" width="200" border="0" style="display: block; width: 100%;"></td>
<td style="font-size: 0px; line-height: 0px;"> <img src="https://img.mailinblue.com/5942152/images/content_library/original/64708f00b503b149db7ff135.png" width="200" border="0" style="display: block; width: 100%;"></td>
</tr>
</table>
</td>
@@ -88,9 +88,9 @@
</tr>
<tr>
<td class="r13-c" align="center" style="align: center; padding-bottom: 15px; padding-top: 15px; valign: top;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="300" class="r14-o" style="background-color: #006399; border-collapse: separate; border-color: #006399; border-radius: 4px; border-style: solid; border-width: 0px; table-layout: fixed; width: 300px;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="300" class="r14-o" style="background-color: #3f76ff; border-collapse: separate; border-color: #3f76ff; border-radius: 4px; border-style: solid; border-width: 0px; table-layout: fixed; width: 300px;">
<tr>
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="word-break: break-word; background-color: #006399; border-radius: 4px; color: #ffffff; font-family: georgia, serif; font-size: 16px; font-style: normal; line-height: 1.15; padding-bottom: 12px; padding-left: 5px; padding-right: 5px; padding-top: 12px; text-align: center;"> <a href="{{abs_url}}" class="r16-r default-button" target="_blank" data-btn="1" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; word-break: break-word; word-wrap: break-word; display: block; -webkit-text-size-adjust: none; color: #ffffff; font-family: georgia, serif; font-size: 16px;"> <span><span style="font-family: Arial, helvetica, sans-serif;">Join them on Plane</span></span></a> </td>
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="word-break: break-word; background-color: #3f76ff; border-radius: 4px; color: #ffffff; font-family: georgia, serif; font-size: 16px; font-style: normal; line-height: 1.15; padding-bottom: 12px; padding-left: 5px; padding-right: 5px; padding-top: 12px; text-align: center;"> <a href="{{abs_url}}" class="r16-r default-button" target="_blank" data-btn="1" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; word-break: break-word; word-wrap: break-word; display: block; -webkit-text-size-adjust: none; color: #ffffff; font-family: georgia, serif; font-size: 16px;"> <span><span style="font-family: Arial, helvetica, sans-serif;">Join them on Plane</span></span></a> </td>
</tr>
</table>
</td>
@@ -131,7 +131,7 @@
<th width="40" class="r20-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r21-o" style="table-layout: fixed; width: 100%;">
<tr>
<td class="r22-i" style="font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px;"> <a href="https://github.com/makeplane" target="_blank" style="color: #006399; text-decoration: underline;"> <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png" width="32" border="0" style="display: block; width: 100%;"></a> </td>
<td class="r22-i" style="font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px;"> <a href="https://github.com/makeplane" target="_blank" style="color: #3f76ff; text-decoration: underline;"> <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png" width="32" border="0" style="display: block; width: 100%;"></a> </td>
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
</table>
@@ -139,7 +139,7 @@
<th width="40" class="r20-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r21-o" style="table-layout: fixed; width: 100%;">
<tr>
<td class="r22-i" style="font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px;"> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="color: #006399; text-decoration: underline;"> <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png" width="32" border="0" style="display: block; width: 100%;"></a> </td>
<td class="r22-i" style="font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px;"> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="color: #3f76ff; text-decoration: underline;"> <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png" width="32" border="0" style="display: block; width: 100%;"></a> </td>
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
</table>
@@ -147,7 +147,7 @@
<th width="40" class="r20-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r21-o" style="table-layout: fixed; width: 100%;">
<tr>
<td class="r22-i" style="font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px;"> <a href="https://twitter.com/planepowers" target="_blank" style="color: #006399; text-decoration: underline;"> <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="32" border="0" style="display: block; width: 100%;"></a> </td>
<td class="r22-i" style="font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px;"> <a href="https://twitter.com/planepowers" target="_blank" style="color: #3f76ff; text-decoration: underline;"> <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="32" border="0" style="display: block; width: 100%;"></a> </td>
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;">­ </td>
</tr>
</table>
@@ -155,7 +155,7 @@
<th width="32" class="r20-c mobshow resp-table" style="font-weight: normal;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r23-o" style="table-layout: fixed; width: 100%;">
<tr>
<td class="r22-i" style="font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px;"> <a href="https://plane.so/" target="_blank" style="color: #006399; text-decoration: underline;"> <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/website_32px.png" width="32" border="0" style="display: block; width: 100%;"></a> </td>
<td class="r22-i" style="font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px;"> <a href="https://plane.so/" target="_blank" style="color: #3f76ff; text-decoration: underline;"> <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/website_32px.png" width="32" border="0" style="display: block; width: 100%;"></a> </td>
</tr>
</table>
</th>
@@ -8,14 +8,14 @@
<style> *[class="gmail-fix"] { display: none !important; } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: " \03D1"; } } </style>
</head>
<body bgcolor="#ffffff" text="#3b3f44" link="#006399" yahoo="fix" style="background-color: #f7f9ff; margin: 20px" >
<body bgcolor="#ffffff" text="#3b3f44" link="#3f76ff" yahoo="fix" style="background-color: #f7f9ff; margin: 20px" >
<div style=" width: 600px; table-layout: fixed; height: 100%; margin-left: auto; margin-right: auto; " >
<!-- Header -->
<div>
<table style="width: 600px" cellspacing="0">
<tr>
<td>
<div style="margin-left: 30px; margin-bottom: 20px; margin-top: 20px" > <img src="https://media.docs.plane.so/logo/new-logo-white.png" width="150" border="0" /> </div>
<div style="margin-left: 30px; margin-bottom: 20px; margin-top: 20px" > <img src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/plane-logo.png" width="130" height="40" border="0" /> </div>
</td>
</tr>
</table>
@@ -164,7 +164,7 @@
text-align: center !important;
}
.r15-r {
background-color: #006399 !important;
background-color: #3f76ff !important;
border-radius: 4px !important;
border-width: 0px !important;
box-sizing: border-box;
@@ -296,7 +296,7 @@
}
a,
a:link {
color: #006399;
color: #3f76ff;
text-decoration: underline;
}
.nl2go-default-textstyle {
@@ -372,7 +372,7 @@
[endif]-->
<style type="text/css">
a:link {
color: #006399;
color: #3f76ff;
text-decoration: underline;
}
</style>
@@ -380,7 +380,7 @@
<body
bgcolor="#ffffff"
text="#3b3f44"
link="#006399"
link="#3f76ff"
yahoo="fix"
style="background-color: #ffffff"
>
@@ -483,7 +483,7 @@
"
>
<img
src="https://media.docs.plane.so/logo/new-logo-white.png"
src="https://img.mailinblue.com/5942152/images/content_library/original/64708f00b503b149db7ff135.png"
width="150"
border="0"
style="
@@ -672,9 +672,9 @@
width="285"
class="r13-o"
style="
background-color: #006399;
background-color: #3f76ff;
border-collapse: separate;
border-color: #006399;
border-color: #3f76ff;
border-radius: 4px;
border-style: solid;
border-width: 0px;
@@ -690,7 +690,7 @@
class="r14-i nl2go-default-textstyle"
style="
word-break: break-word;
background-color: #006399;
background-color: #3f76ff;
border-radius: 4px;
color: #ffffff;
font-family: georgia, serif;
@@ -984,7 +984,7 @@
title="Plane Support on Discod"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
><span
@@ -998,7 +998,7 @@
title="@planepowers"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
><span
@@ -1012,7 +1012,7 @@
title="Plane's GitHub conversations"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
><span
@@ -1028,7 +1028,7 @@
title="Plane's roadmap"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
><span
@@ -1248,7 +1248,7 @@
href="https://github.com/makeplane"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
>
@@ -1308,7 +1308,7 @@
href="https://www.linkedin.com/company/planepowers/"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
>
@@ -1368,7 +1368,7 @@
href="https://twitter.com/planepowers"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
>
@@ -1428,7 +1428,7 @@
href="https://plane.so/"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
>
@@ -8,8 +8,8 @@
<title>{{ message }}</title>
<style type="text/css" emogrify="no"> #outlook a { padding: 0; } .ExternalClass { width: 100%; } .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; } table td { border-collapse: collapse; mso-line-height-rule: exactly; } .editable.image { font-size: 0 !important; line-height: 0 !important; } .nl2go_preheader { display: none !important; mso-hide: all !important; mso-line-height-rule: exactly; visibility: hidden !important; line-height: 0px !important; font-size: 0px !important; } body { width: 100% !important; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; margin: 0; padding: 0; } img { outline: none; text-decoration: none; -ms-interpolation-mode: bicubic; } a img { border: none; } table { border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; } th { font-weight: normal; text-align: left; } *[class="gmail-fix"] { display: none !important; } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: " \03D1"; } } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: " \03D1"; } .r0-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 320px !important; } .r1-i { background-color: #ffffff !important; } .r2-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 100% !important; } .r3-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 100% !important; } .r4-i { padding-bottom: 20px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 20px !important; } .r5-c { box-sizing: border-box !important; display: block !important; valign: top !important; width: 100% !important; } .r6-o { border-style: solid !important; width: 100% !important; } .r7-i { padding-left: 0px !important; padding-right: 0px !important; } .r8-c { box-sizing: border-box !important; text-align: left !important; valign: top !important; width: 100% !important; } .r9-o { border-style: solid !important; margin: 0 auto 0 0 !important; width: 100% !important; } .r10-i { padding-bottom: 10px !important; padding-left: 0px !important; padding-right: 0px !important; padding-top: 10px !important; } .r11-i { padding-bottom: 15px !important; padding-top: 15px !important; text-align: left !important; } .r12-c { box-sizing: border-box !important; padding: 0 !important; text-align: center !important; valign: top !important; width: 100% !important; } .r13-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-bottom: 15px !important; margin-top: 15px !important; width: 100% !important; } .r14-i { padding: 0 !important; text-align: center !important; } .r15-r { background-color: #006399 !important; border-radius: 4px !important; border-width: 0px !important; box-sizing: border-box; height: initial !important; padding: 0 !important; padding-bottom: 12px !important; padding-top: 12px !important; text-align: center !important; width: 100% !important; } .r16-i { padding-bottom: 0px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 0px !important; } .r17-c { box-sizing: border-box !important; text-align: center !important; width: 100% !important; } .r18-i { padding-bottom: 5px !important; padding-top: 5px !important; } .r19-i { padding-bottom: 15px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 15px !important; } .r20-o { border-bottom-color: #efefef !important; border-bottom-width: 2px !important; border-left-color: #efefef !important; border-left-width: 2px !important; border-right-color: #efefef !important; border-right-width: 2px !important; border-style: solid !important; border-top-color: #efefef !important; border-top-width: 2px !important; margin: 0 auto 0 0 !important; width: 100% !important; } .r21-i { padding-bottom: 5px !important; padding-left: 5px !important; padding-right: 5px !important; padding-top: 5px !important; text-align: left !important; } .r22-i { padding-bottom: 5px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 5px !important; } .r23-c { box-sizing: border-box !important; width: 100% !important; } .r24-i { font-size: 0px !important; padding-bottom: 10px !important; padding-left: 65px !important; padding-right: 65px !important; padding-top: 10px !important; } .r25-c { box-sizing: border-box !important; width: 32px !important; } .r26-o { border-style: solid !important; margin-right: 8px !important; width: 32px !important; } .r27-o { border-style: solid !important; margin-right: 0px !important; width: 32px !important; } .r28-i { padding-bottom: 0px !important; padding-top: 5px !important; text-align: center !important; } body { -webkit-text-size-adjust: none; } .nl2go-responsive-hide { display: none; } .nl2go-body-table { min-width: unset !important; } .mobshow { height: auto !important; overflow: visible !important; max-height: unset !important; visibility: visible !important; border: none !important; } .resp-table { display: inline-table !important; } .magic-resp { display: table-cell !important; } } </style>
<style type="text/css"> p, h1, h2, h3, h4, ol, ul { margin: 0; } a, a:link { color: #006399; text-decoration: underline; } .nl2go-default-textstyle { color: #3b3f44; font-family: georgia, serif; font-size: 16px; line-height: 1.5; word-break: break-word; } .default-button { color: #ffffff; font-family: georgia, serif; font-size: 16px; font-style: normal; font-weight: normal; line-height: 1.15; text-decoration: none; word-break: break-word; } .default-heading1 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 36px; word-break: break-word; } .default-heading2 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 32px; word-break: break-word; } .default-heading3 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 24px; word-break: break-word; } .default-heading4 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 18px; word-break: break-word; } a[x-apple-data-detectors] { color: inherit !important; text-decoration: inherit !important; font-size: inherit !important; font-family: inherit !important; font-weight: inherit !important; line-height: inherit !important; } .no-show-for-you { border: none; display: none; float: none; font-size: 0; height: 0; line-height: 0; max-height: 0; mso-hide: all; overflow: hidden; table-layout: fixed; visibility: hidden; width: 0; } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: " \03D1"; } .r0-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 320px !important; } .r1-i { background-color: #ffffff !important; } .r2-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 100% !important; } .r3-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 100% !important; } .r4-i { padding-bottom: 20px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 20px !important; } .r5-c { box-sizing: border-box !important; display: block !important; valign: top !important; width: 100% !important; } .r6-o { border-style: solid !important; width: 100% !important; } .r7-i { padding-left: 0px !important; padding-right: 0px !important; } .r8-c { box-sizing: border-box !important; text-align: left !important; valign: top !important; width: 100% !important; } .r9-o { border-style: solid !important; margin: 0 auto 0 0 !important; width: 100% !important; } .r10-i { padding-bottom: 10px !important; padding-left: 0px !important; padding-right: 0px !important; padding-top: 10px !important; } .r11-i { padding-bottom: 15px !important; padding-top: 15px !important; text-align: left !important; } .r12-c { box-sizing: border-box !important; padding: 0 !important; text-align: center !important; valign: top !important; width: 100% !important; } .r13-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-bottom: 15px !important; margin-top: 15px !important; width: 100% !important; } .r14-i { padding: 0 !important; text-align: center !important; } .r15-r { background-color: #3f76ff !important; border-radius: 4px !important; border-width: 0px !important; box-sizing: border-box; height: initial !important; padding: 0 !important; padding-bottom: 12px !important; padding-top: 12px !important; text-align: center !important; width: 100% !important; } .r16-i { padding-bottom: 0px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 0px !important; } .r17-c { box-sizing: border-box !important; text-align: center !important; width: 100% !important; } .r18-i { padding-bottom: 5px !important; padding-top: 5px !important; } .r19-i { padding-bottom: 15px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 15px !important; } .r20-o { border-bottom-color: #efefef !important; border-bottom-width: 2px !important; border-left-color: #efefef !important; border-left-width: 2px !important; border-right-color: #efefef !important; border-right-width: 2px !important; border-style: solid !important; border-top-color: #efefef !important; border-top-width: 2px !important; margin: 0 auto 0 0 !important; width: 100% !important; } .r21-i { padding-bottom: 5px !important; padding-left: 5px !important; padding-right: 5px !important; padding-top: 5px !important; text-align: left !important; } .r22-i { padding-bottom: 5px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 5px !important; } .r23-c { box-sizing: border-box !important; width: 100% !important; } .r24-i { font-size: 0px !important; padding-bottom: 10px !important; padding-left: 65px !important; padding-right: 65px !important; padding-top: 10px !important; } .r25-c { box-sizing: border-box !important; width: 32px !important; } .r26-o { border-style: solid !important; margin-right: 8px !important; width: 32px !important; } .r27-o { border-style: solid !important; margin-right: 0px !important; width: 32px !important; } .r28-i { padding-bottom: 0px !important; padding-top: 5px !important; text-align: center !important; } body { -webkit-text-size-adjust: none; } .nl2go-responsive-hide { display: none; } .nl2go-body-table { min-width: unset !important; } .mobshow { height: auto !important; overflow: visible !important; max-height: unset !important; visibility: visible !important; border: none !important; } .resp-table { display: inline-table !important; } .magic-resp { display: table-cell !important; } } </style>
<style type="text/css"> p, h1, h2, h3, h4, ol, ul { margin: 0; } a, a:link { color: #3f76ff; text-decoration: underline; } .nl2go-default-textstyle { color: #3b3f44; font-family: georgia, serif; font-size: 16px; line-height: 1.5; word-break: break-word; } .default-button { color: #ffffff; font-family: georgia, serif; font-size: 16px; font-style: normal; font-weight: normal; line-height: 1.15; text-decoration: none; word-break: break-word; } .default-heading1 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 36px; word-break: break-word; } .default-heading2 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 32px; word-break: break-word; } .default-heading3 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 24px; word-break: break-word; } .default-heading4 { color: #1f2d3d; font-family: arial, helvetica, sans-serif; font-size: 18px; word-break: break-word; } a[x-apple-data-detectors] { color: inherit !important; text-decoration: inherit !important; font-size: inherit !important; font-family: inherit !important; font-weight: inherit !important; line-height: inherit !important; } .no-show-for-you { border: none; display: none; float: none; font-size: 0; height: 0; line-height: 0; max-height: 0; mso-hide: all; overflow: hidden; table-layout: fixed; visibility: hidden; width: 0; } </style>
<!--[if mso ]>
<xml>
<o:OfficeDocumentSettings>
@@ -18,9 +18,9 @@
</o:OfficeDocumentSettings>
</xml>
<! [endif]-->
<style type="text/css"> a:link { color: #006399; text-decoration: underline; } </style>
<style type="text/css"> a:link { color: #3f76ff; text-decoration: underline; } </style>
</head>
<body bgcolor="#ffffff" text="#3b3f44" link="#006399" yahoo="fix" style="background-color: #ffffff" >
<body bgcolor="#ffffff" text="#3b3f44" link="#3f76ff" yahoo="fix" style="background-color: #ffffff" >
<table cellspacing="0" cellpadding="0" border="0" role="presentation" class="nl2go-body-table" width="100%" style="background-color: #ffffff; width: 100%" >
<tr>
<td>
@@ -41,7 +41,7 @@
<td class="r8-c" align="left">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="150" class="r9-o" style=" table-layout: fixed; width: 150px; " >
<tr>
<td style=" font-size: 0px; line-height: 0px; " > <img src="https://media.docs.plane.so/logo/new-logo-white.png" width="150" border="0" style=" display: block; width: 100%; " /> </td>
<td style=" font-size: 0px; line-height: 0px; " > <img src="https://img.mailinblue.com/5942152/images/content_library/original/64708f00b503b149db7ff135.png" width="150" border="0" style=" display: block; width: 100%; " /> </td>
</tr>
</table>
</td>
@@ -80,7 +80,7 @@
</td>
</tr>
<tr style=" display: flex; align-items: center; width: 100%; justify-content: center; " >
<td style=" display: flex; align-items: center; width: 100%; justify-content: center; " > <a href="{{ webhook_url }}" style=" text-decoration: none; display: flex; align-items: center; width: 100%; justify-content: center; " > <span style=" max-width: min-content; white-space: nowrap; background-color: #006399; padding: 10px 15px; border: 1px solid #2f4ba8; border-radius: 4px; margin-top: 15px; cursor: pointer; font-size: 0.8rem; color: #ffffff; display: flex; align-items: center; justify-content: center; " > View webhook </span> </a> </td>
<td style=" display: flex; align-items: center; width: 100%; justify-content: center; " > <a href="{{ webhook_url }}" style=" text-decoration: none; display: flex; align-items: center; width: 100%; justify-content: center; " > <span style=" max-width: min-content; white-space: nowrap; background-color: #3f76ff; padding: 10px 15px; border: 1px solid #2f4ba8; border-radius: 4px; margin-top: 15px; cursor: pointer; font-size: 0.8rem; color: #ffffff; display: flex; align-items: center; justify-content: center; " > View webhook </span> </a> </td>
</tr>
</table>
</td>
@@ -155,7 +155,7 @@
<td class="nl2go-responsive-hide" width="2" style=" font-size: 0px; line-height: 1px; background-color: #efefef; " > ­ </td>
<td align="left" valign="top" class="r21-i nl2go-default-textstyle" style=" color: #3b3f44; font-family: georgia, serif; font-size: 16px; word-break: break-word; line-height: 1.5; padding-bottom: 5px; padding-left: 5px; padding-right: 5px; padding-top: 5px; text-align: left; " >
<div>
<p style="margin: 0"> <span style="font-size: 13px" >Despite our popularity, we are humbly early-stage. We are shipping fast, so please reach out to us with feature requests, major and minor nits, and anything else you find missing. We read every </span ><a href="https://discord.com/channels/1031547764020084846/1094927053867995176" title="Plane Support on Discod" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >message</span ></a ><span style="font-size: 13px" >, </span ><a href="http://twitter.com/planepowers" title="@planepowers" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >tweet</span ></a ><span style="font-size: 13px" >, and </span ><a href="https://github.com/makeplane/plane/issues" title="Plane's GitHub conversations" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >conversation</span ></a ><span style="font-size: 13px" > and update </span ><a href="https://plane.sh/plane/0b170a1c-0e55-47cb-9307-ea49a05672b5?board=kanban" title="Plane's roadmap" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >our public roadmap</span ></a ><span style="font-size: 13px" >.</span > </p>
<p style="margin: 0"> <span style="font-size: 13px" >Despite our popularity, we are humbly early-stage. We are shipping fast, so please reach out to us with feature requests, major and minor nits, and anything else you find missing. We read every </span ><a href="https://discord.com/channels/1031547764020084846/1094927053867995176" title="Plane Support on Discod" target="_blank" style=" color: #3f76ff; text-decoration: underline; " ><span style="font-size: 13px" >message</span ></a ><span style="font-size: 13px" >, </span ><a href="http://twitter.com/planepowers" title="@planepowers" target="_blank" style=" color: #3f76ff; text-decoration: underline; " ><span style="font-size: 13px" >tweet</span ></a ><span style="font-size: 13px" >, and </span ><a href="https://github.com/makeplane/plane/issues" title="Plane's GitHub conversations" target="_blank" style=" color: #3f76ff; text-decoration: underline; " ><span style="font-size: 13px" >conversation</span ></a ><span style="font-size: 13px" > and update </span ><a href="https://plane.sh/plane/0b170a1c-0e55-47cb-9307-ea49a05672b5?board=kanban" title="Plane's roadmap" target="_blank" style=" color: #3f76ff; text-decoration: underline; " ><span style="font-size: 13px" >our public roadmap</span ></a ><span style="font-size: 13px" >.</span > </p>
</div>
</td>
<td class="nl2go-responsive-hide" width="2" style=" font-size: 0px; line-height: 1px; background-color: #efefef; " > ­ </td>
@@ -204,7 +204,7 @@
<th width="40" class="r25-c mobshow resp-table" style=" font-weight: normal; " >
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r26-o" style=" table-layout: fixed; width: 100%; " >
<tr>
<td class="r18-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://github.com/makeplane" target="_blank" style=" color: #006399; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="r18-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://github.com/makeplane" target="_blank" style=" color: #3f76ff; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="nl2go-responsive-hide" width="8" style=" font-size: 0px; line-height: 1px; " > ­ </td>
</tr>
</table>
@@ -212,7 +212,7 @@
<th width="40" class="r25-c mobshow resp-table" style=" font-weight: normal; " >
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r26-o" style=" table-layout: fixed; width: 100%; " >
<tr>
<td class="r18-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style=" color: #006399; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="r18-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style=" color: #3f76ff; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="nl2go-responsive-hide" width="8" style=" font-size: 0px; line-height: 1px; " > ­ </td>
</tr>
</table>
@@ -220,7 +220,7 @@
<th width="40" class="r25-c mobshow resp-table" style=" font-weight: normal; " >
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r26-o" style=" table-layout: fixed; width: 100%; " >
<tr>
<td class="r18-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://twitter.com/planepowers" target="_blank" style=" color: #006399; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="r18-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://twitter.com/planepowers" target="_blank" style=" color: #3f76ff; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="nl2go-responsive-hide" width="8" style=" font-size: 0px; line-height: 1px; " > ­ </td>
</tr>
</table>
@@ -228,7 +228,7 @@
<th width="32" class="r25-c mobshow resp-table" style=" font-weight: normal; " >
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r27-o" style=" table-layout: fixed; width: 100%; " >
<tr>
<td class="r18-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://plane.so/" target="_blank" style=" color: #006399; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/website_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="r18-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://plane.so/" target="_blank" style=" color: #3f76ff; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/website_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
</tr>
</table>
</th>
@@ -173,7 +173,7 @@
text-align: center !important;
}
.r16-r {
background-color: #006399 !important;
background-color: #3f76ff !important;
border-radius: 4px !important;
border-width: 0px !important;
box-sizing: border-box;
@@ -305,7 +305,7 @@
}
a,
a:link {
color: #006399;
color: #3f76ff;
text-decoration: underline;
}
.nl2go-default-textstyle {
@@ -382,7 +382,7 @@
<![endif]-->
<style type="text/css">
a:link {
color: #006399;
color: #3f76ff;
text-decoration: underline;
}
</style>
@@ -390,7 +390,7 @@
<body
bgcolor="#ffffff"
text="#3b3f44"
link="#006399"
link="#3f76ff"
yahoo="fix"
style="background-color: #ffffff"
>
@@ -493,7 +493,7 @@
"
>
<img
src="https://media.docs.plane.so/logo/new-logo-white.png"
src="https://img.mailinblue.com/5942152/images/content_library/original/64708f00b503b149db7ff135.png"
width="150"
border="0"
style="
@@ -651,9 +651,9 @@
width="285"
class="r14-o"
style="
background-color: #006399;
background-color: #3f76ff;
border-collapse: separate;
border-color: #006399;
border-color: #3f76ff;
border-radius: 4px;
border-style: solid;
border-width: 0px;
@@ -669,7 +669,7 @@
class="r15-i nl2go-default-textstyle"
style="
word-break: break-word;
background-color: #006399;
background-color: #3f76ff;
border-radius: 4px;
color: #ffffff;
font-family: georgia, serif;
@@ -963,7 +963,7 @@
title="Plane Support on Discod"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
><span
@@ -977,7 +977,7 @@
title="@planepowers"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
><span
@@ -991,7 +991,7 @@
title="Plane's GitHub conversations"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
><span
@@ -1007,7 +1007,7 @@
title="Plane's roadmap"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
><span
@@ -1227,7 +1227,7 @@
href="https://github.com/makeplane"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
>
@@ -1287,7 +1287,7 @@
href="https://www.linkedin.com/company/planepowers/"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
>
@@ -1347,7 +1347,7 @@
href="https://twitter.com/planepowers"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
>
@@ -1407,7 +1407,7 @@
href="https://plane.so/"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
>
@@ -173,7 +173,7 @@
text-align: center !important;
}
.r16-r {
background-color: #006399 !important;
background-color: #3f76ff !important;
border-radius: 4px !important;
border-width: 0px !important;
box-sizing: border-box;
@@ -305,7 +305,7 @@
}
a,
a:link {
color: #006399;
color: #3f76ff;
text-decoration: underline;
}
.nl2go-default-textstyle {
@@ -382,7 +382,7 @@
<![endif]-->
<style type="text/css">
a:link {
color: #006399;
color: #3f76ff;
text-decoration: underline;
}
</style>
@@ -390,7 +390,7 @@
<body
bgcolor="#ffffff"
text="#3b3f44"
link="#006399"
link="#3f76ff"
yahoo="fix"
style="background-color: #ffffff"
>
@@ -493,7 +493,7 @@
"
>
<img
src="https://media.docs.plane.so/logo/new-logo-white.png"
src="https://img.mailinblue.com/5942152/images/content_library/original/64708f00b503b149db7ff135.png"
width="150"
border="0"
style="
@@ -650,9 +650,9 @@
width="285"
class="r14-o"
style="
background-color: #006399;
background-color: #3f76ff;
border-collapse: separate;
border-color: #006399;
border-color: #3f76ff;
border-radius: 4px;
border-style: solid;
border-width: 0px;
@@ -668,7 +668,7 @@
class="r15-i nl2go-default-textstyle"
style="
word-break: break-word;
background-color: #006399;
background-color: #3f76ff;
border-radius: 4px;
color: #ffffff;
font-family: georgia, serif;
@@ -964,7 +964,7 @@
title="Plane Support on Discod"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
><span
@@ -978,7 +978,7 @@
title="@planepowers"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
><span
@@ -992,7 +992,7 @@
title="Plane's GitHub conversations"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
><span
@@ -1008,7 +1008,7 @@
title="Plane's roadmap"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
><span
@@ -1228,7 +1228,7 @@
href="https://github.com/makeplane"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
>
@@ -1288,7 +1288,7 @@
href="https://www.linkedin.com/company/planepowers/"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
>
@@ -1348,7 +1348,7 @@
href="https://twitter.com/planepowers"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
>
@@ -1408,7 +1408,7 @@
href="https://plane.so/"
target="_blank"
style="
color: #006399;
color: #3f76ff;
text-decoration: underline;
"
>
+3 -3
View File
@@ -4,12 +4,12 @@ RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY . .
RUN corepack enable pnpm && pnpm add -g turbo
RUN pnpm install
RUN yarn global add turbo
RUN yarn install
EXPOSE 3003
ENV TURBO_TELEMETRY_DISABLED=1
VOLUME [ "/app/node_modules", "/app/live/node_modules"]
CMD ["pnpm","dev", "--filter=live"]
CMD ["yarn","dev", "--filter=live"]
+7 -17
View File
@@ -1,11 +1,5 @@
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS base
# Setup pnpm package manager with corepack and configure global bin directory for caching
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
# *****************************************************************************
# STAGE 1: Prune the project
# *****************************************************************************
@@ -15,10 +9,9 @@ RUN apk update
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
ARG TURBO_VERSION=2.5.6
RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION}
RUN yarn global add turbo
COPY . .
RUN turbo prune --scope=live --docker
RUN turbo prune live --docker
# *****************************************************************************
# STAGE 2: Install dependencies & build the project
@@ -32,18 +25,16 @@ WORKDIR /app
# First install dependencies (as they change less often)
COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN corepack enable pnpm
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
COPY --from=builder /app/out/yarn.lock ./yarn.lock
RUN yarn install
# Build the project and its dependencies
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
ENV TURBO_TELEMETRY_DISABLED=1
RUN pnpm turbo run build --filter=live
RUN yarn turbo build --filter=live
# *****************************************************************************
# STAGE 3: Run the project
@@ -53,12 +44,11 @@ FROM base AS runner
WORKDIR /app
COPY --from=installer /app/packages ./packages
COPY --from=installer /app/apps/live/dist ./apps/live/dist
COPY --from=installer /app/apps/live/node_modules ./apps/live/node_modules
COPY --from=installer /app/apps/live/dist ./live
COPY --from=installer /app/node_modules ./node_modules
ENV TURBO_TELEMETRY_DISABLED=1
EXPOSE 3000
CMD ["node", "apps/live/dist/server.js"]
CMD ["node", "live/server.js"]
+5 -5
View File
@@ -24,8 +24,8 @@
"@hocuspocus/extension-logger": "^2.15.0",
"@hocuspocus/extension-redis": "^2.15.0",
"@hocuspocus/server": "^2.15.0",
"@plane/editor": "workspace:*",
"@plane/types": "workspace:*",
"@plane/editor": "*",
"@plane/types": "*",
"@tiptap/core": "^2.22.3",
"@tiptap/html": "^2.22.3",
"axios": "1.11.0",
@@ -46,15 +46,15 @@
"yjs": "^13.6.20"
},
"devDependencies": {
"@plane/eslint-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@plane/eslint-config": "*",
"@plane/typescript-config": "*",
"@types/compression": "1.8.1",
"@types/cors": "^2.8.17",
"@types/dotenv": "^8.2.0",
"@types/express": "^4.17.23",
"@types/express-ws": "^3.0.5",
"@types/node": "^20.14.9",
"@types/pino-http": "^5.8.4",
"@types/uuid": "^9.0.1",
"concurrently": "^9.0.1",
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
+2 -1
View File
@@ -1,4 +1,5 @@
import { pinoHttp } from "pino-http";
import { Logger } from "pino";
const transport = {
target: "pino-pretty",
@@ -36,4 +37,4 @@ export const logger = pinoHttp({
},
});
export const manualLogger: typeof logger.logger = logger.logger;
export const manualLogger: Logger = logger.logger;
@@ -14,7 +14,6 @@ export abstract class APIService {
this.axiosInstance = axios.create({
baseURL,
withCredentials: true,
timeout: 20000,
});
}
+1 -1
View File
@@ -5,7 +5,7 @@ RUN xcaddy build \
--with github.com/caddy-dns/digitalocean@04bde2867106aa1b44c2f9da41a285fa02e629c5 \
--with github.com/mholt/caddy-l4@4d3c80e89c5f80438a3e048a410d5543ff5fb9f4
FROM caddy:2.10.0-alpine
FROM caddy:2.10.0-builder-alpine
RUN apk add --no-cache nss-tools bash curl
+2
View File
@@ -21,6 +21,8 @@
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
+2 -3
View File
@@ -2,6 +2,5 @@
.vercel
.tubro
out/
dist/
build/
node_modules/
dis/
build/
+16
View File
@@ -0,0 +1,16 @@
FROM node:22-alpine
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
COPY . .
RUN yarn global add turbo
RUN yarn install
EXPOSE 4000
ENV NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
VOLUME [ "/app/node_modules", "/app/space/node_modules"]
CMD ["yarn","dev", "--filter=space"]
+94
View File
@@ -0,0 +1,94 @@
FROM node:22-alpine AS base
# *****************************************************************************
# STAGE 1: Build the project
# *****************************************************************************
FROM base AS builder
RUN apk add --no-cache libc6-compat
WORKDIR /app
RUN yarn global add turbo
COPY . .
RUN turbo prune --scope=space --docker
# *****************************************************************************
# STAGE 2: Install dependencies & build the project
# *****************************************************************************
FROM base AS installer
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/yarn.lock ./yarn.lock
RUN yarn install --network-timeout 500000
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
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 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 NEXT_PUBLIC_WEB_BASE_URL=""
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
ENV NEXT_TELEMETRY_DISABLED=1
ENV TURBO_TELEMETRY_DISABLED=1
RUN yarn turbo run build --filter=space
# *****************************************************************************
# STAGE 3: Copy the project and start it
# *****************************************************************************
FROM base AS runner
WORKDIR /app
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer /app/apps/space/.next/standalone ./
COPY --from=installer /app/apps/space/.next/static ./apps/space/.next/static
COPY --from=installer /app/apps/space/public ./apps/space/public
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
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 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 NEXT_PUBLIC_WEB_BASE_URL=""
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
ENV NEXT_TELEMETRY_DISABLED=1
ENV TURBO_TELEMETRY_DISABLED=1
EXPOSE 3000
CMD ["node", "apps/space/server.js"]
@@ -3,13 +3,11 @@
import { observer } from "mobx-react";
import useSWR from "swr";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { PoweredBy } from "@/components/common/powered-by";
import { LogoSpinner, PoweredBy } from "@/components/common";
import { IssuesNavbarRoot } from "@/components/issues";
import { SomethingWentWrongError } from "@/components/issues/issue-layouts/error";
import { IssuesNavbarRoot } from "@/components/issues/navbar";
// hooks
import { usePublish, usePublishList } from "@/hooks/store/publish";
import { useIssueFilter } from "@/hooks/store/use-issue-filter";
import { useIssueFilter, usePublish, usePublishList } from "@/hooks/store";
type Props = {
children: React.ReactNode;
@@ -41,13 +39,7 @@ export const IssuesClientLayout = observer((props: Props) => {
: null
);
if (!publishSettings && !error) {
return (
<div className="flex items-center justify-center h-screen w-full">
<LogoSpinner />
</div>
);
}
if (!publishSettings && !error) return <LogoSpinner />;
if (error) return <SomethingWentWrongError />;
+2 -4
View File
@@ -4,11 +4,9 @@ import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import useSWR from "swr";
// components
import { IssuesLayoutsRoot } from "@/components/issues/issue-layouts";
import { IssuesLayoutsRoot } from "@/components/issues";
// hooks
import { usePublish } from "@/hooks/store/publish";
import { useLabel } from "@/hooks/store/use-label";
import { useStates } from "@/hooks/store/use-state";
import { usePublish, useLabel, useStates } from "@/hooks/store";
type Props = {
params: {

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