Compare commits

..

2 Commits

Author SHA1 Message Date
pablohashescobar e6db09945c dev: update issue webhooks for cycle and module 2024-05-01 15:55:08 +05:30
pablohashescobar fe68e14d8b dev: update webhook logic for issues 2024-05-01 14:04:36 +05:30
1201 changed files with 30857 additions and 35070 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ module.exports = {
extends: ["custom"],
settings: {
next: {
rootDir: ["web/", "space/", "admin/"],
rootDir: ["web/", "space/"],
},
},
};
+13 -73
View File
@@ -22,11 +22,10 @@ jobs:
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
build_proxy: ${{ steps.changed_files.outputs.proxy_any_changed }}
build_apiserver: ${{ steps.changed_files.outputs.apiserver_any_changed }}
build_admin: ${{ steps.changed_files.outputs.admin_any_changed }}
build_frontend: ${{ steps.changed_files.outputs.frontend_any_changed }}
build_space: ${{ steps.changed_files.outputs.space_any_changed }}
build_web: ${{ steps.changed_files.outputs.web_any_changed }}
build_backend: ${{ steps.changed_files.outputs.backend_any_changed }}
build_proxy: ${{ steps.changed_files.outputs.proxy_any_changed }}
steps:
- id: set_env_variables
@@ -54,12 +53,8 @@ jobs:
uses: tj-actions/changed-files@v42
with:
files_yaml: |
apiserver:
- apiserver/**
proxy:
- nginx/**
admin:
- admin/**
frontend:
- web/**
- packages/**
- 'package.json'
- 'yarn.lock'
@@ -72,16 +67,13 @@ jobs:
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
web:
- web/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
backend:
- apiserver/**
proxy:
- nginx/**
branch_build_push_web:
if: ${{ needs.branch_build_setup.outputs.build_web == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
branch_build_push_frontend:
if: ${{ needs.branch_build_setup.outputs.build_frontend == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
@@ -132,58 +124,6 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_admin:
if: ${{ needs.branch_build_setup.outputs.build_admin== 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
ADMIN_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
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 }}
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
steps:
- name: Set Admin Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:latest
else
TAG=${{ env.ADMIN_TAG }}
fi
echo "ADMIN_TAG=${TAG}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: ${{ env.BUILDX_DRIVER }}
version: ${{ env.BUILDX_VERSION }}
endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo
uses: actions/checkout@v4
- name: Build and Push Frontend to Docker Container Registry
uses: docker/build-push-action@v5.1.0
with:
context: .
file: ./admin/Dockerfile.admin
platforms: ${{ env.BUILDX_PLATFORMS }}
tags: ${{ env.ADMIN_TAG }}
push: true
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_space:
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04
@@ -236,8 +176,8 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_apiserver:
if: ${{ needs.branch_build_setup.outputs.build_apiserver == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
branch_build_push_backend:
if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
+27 -60
View File
@@ -10,32 +10,17 @@ jobs:
runs-on: ubuntu-latest
outputs:
apiserver_changed: ${{ steps.changed-files.outputs.apiserver_any_changed }}
admin_changed: ${{ steps.changed-files.outputs.admin_any_changed }}
space_changed: ${{ steps.changed-files.outputs.space_any_changed }}
web_changed: ${{ steps.changed-files.outputs.web_any_changed }}
space_changed: ${{ steps.changed-files.outputs.deploy_any_changed }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v44
uses: tj-actions/changed-files@v41
with:
files_yaml: |
apiserver:
- apiserver/**
admin:
- admin/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
space:
- space/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
web:
- web/**
- packages/**
@@ -43,17 +28,24 @@ jobs:
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
deploy:
- space/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
lint-apiserver:
needs: get-changed-files
runs-on: ubuntu-latest
if: needs.get-changed-files.outputs.apiserver_changed == 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v4
with:
python-version: "3.x" # Specify the Python version you need
python-version: '3.x' # Specify the Python version you need
- name: Install Pylint
run: python -m pip install ruff
- name: Install Apiserver Dependencies
@@ -61,77 +53,52 @@ jobs:
- name: Lint apiserver
run: ruff check --fix apiserver
lint-admin:
lint-web:
needs: get-changed-files
if: needs.get-changed-files.outputs.admin_changed == 'true'
if: needs.get-changed-files.outputs.web_changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v2
with:
node-version: 18.x
- run: yarn install
- run: yarn lint --filter=admin
- run: yarn lint --filter=web
lint-space:
needs: get-changed-files
if: needs.get-changed-files.outputs.space_changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v2
with:
node-version: 18.x
- run: yarn install
- run: yarn lint --filter=space
lint-web:
needs: get-changed-files
if: needs.get-changed-files.outputs.web_changed == 'true'
build-web:
needs: lint-web
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v2
with:
node-version: 18.x
- run: yarn install
- run: yarn lint --filter=web
build-admin:
needs: lint-admin
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18.x
- run: yarn install
- run: yarn build --filter=admin
- run: yarn build --filter=web
build-space:
needs: lint-space
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v2
with:
node-version: 18.x
- run: yarn install
- run: yarn build --filter=space
build-web:
needs: lint-web
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18.x
- run: yarn install
- run: yarn build --filter=web
+10 -77
View File
@@ -5,24 +5,18 @@ on:
inputs:
web-build:
required: false
description: "Build Web"
description: 'Build Web'
type: boolean
default: true
space-build:
required: false
description: "Build Space"
type: boolean
default: false
admin-build:
required: false
description: "Build Admin"
description: 'Build Space'
type: boolean
default: false
env:
BUILD_WEB: ${{ github.event.inputs.web-build }}
BUILD_SPACE: ${{ github.event.inputs.space-build }}
BUILD_ADMIN: ${{ github.event.inputs.admin-build }}
jobs:
setup-feature-build:
@@ -33,11 +27,9 @@ jobs:
run: |
echo "BUILD_WEB=$BUILD_WEB"
echo "BUILD_SPACE=$BUILD_SPACE"
echo "BUILD_ADMIN=$BUILD_ADMIN"
outputs:
web-build: ${{ env.BUILD_WEB}}
web-build: ${{ env.BUILD_WEB}}
space-build: ${{env.BUILD_SPACE}}
admin-build: ${{env.BUILD_ADMIN}}
feature-build-web:
if: ${{ needs.setup-feature-build.outputs.web-build == 'true' }}
@@ -53,7 +45,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
node-version: '18'
- name: Install AWS cli
run: |
sudo apt-get update
@@ -79,7 +71,7 @@ jobs:
FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ")
aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY
feature-build-space:
if: ${{ needs.setup-feature-build.outputs.space-build == 'true' }}
needs: setup-feature-build
@@ -89,7 +81,7 @@ jobs:
AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}
NEXT_PUBLIC_SPACE_BASE_PATH: "/spaces"
NEXT_PUBLIC_DEPLOY_WITH_NGINX: 1
NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }}
outputs:
do-build: ${{ needs.setup-feature-build.outputs.space-build }}
@@ -98,7 +90,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
node-version: '18'
- name: Install AWS cli
run: |
sudo apt-get update
@@ -125,60 +117,9 @@ jobs:
FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ")
aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY
feature-build-admin:
if: ${{ needs.setup-feature-build.outputs.admin-build == 'true' }}
needs: setup-feature-build
name: Feature Build Admin
runs-on: ubuntu-latest
env:
AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}
NEXT_PUBLIC_ADMIN_BASE_PATH: "/god-mode"
NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }}
outputs:
do-build: ${{ needs.setup-feature-build.outputs.admin-build }}
s3-url: ${{ steps.build-admin.outputs.S3_PRESIGNED_URL }}
steps:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
- name: Install AWS cli
run: |
sudo apt-get update
sudo apt-get install -y python3-pip
pip3 install awscli
- name: Checkout
uses: actions/checkout@v4
with:
path: plane
- name: Install Dependencies
run: |
cd $GITHUB_WORKSPACE/plane
yarn install
- name: Build Admin
id: build-admin
run: |
cd $GITHUB_WORKSPACE/plane
yarn build --filter=admin
cd $GITHUB_WORKSPACE
TAR_NAME="admin.tar.gz"
tar -czf $TAR_NAME ./plane
FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ")
aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY
feature-deploy:
if: ${{ always() && (needs.setup-feature-build.outputs.web-build == 'true' || needs.setup-feature-build.outputs.space-build == 'true' || needs.setup-feature-build.outputs.admin-build == 'true') }}
needs:
[
setup-feature-build,
feature-build-web,
feature-build-space,
feature-build-admin,
]
if: ${{ always() && (needs.setup-feature-build.outputs.web-build == 'true' || needs.setup-feature-build.outputs.space-build == 'true') }}
needs: [feature-build-web, feature-build-space]
name: Feature Deploy
runs-on: ubuntu-latest
env:
@@ -223,12 +164,7 @@ jobs:
SPACE_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/space.tar.gz --expires-in 3600)
fi
ADMIN_S3_URL=""
if [ ${{ env.BUILD_ADMIN }} == true ]; then
ADMIN_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/admin.tar.gz --expires-in 3600)
fi
if [ ${{ env.BUILD_WEB }} == true ] || [ ${{ env.BUILD_SPACE }} == true ] || [ ${{ env.BUILD_ADMIN }} == true ]; then
if [ ${{ env.BUILD_WEB }} == true ] || [ ${{ env.BUILD_SPACE }} == true ]; then
helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ vars.FEATURE_PREVIEW_HELM_CHART_URL }}
@@ -245,9 +181,6 @@ jobs:
--set space.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \
--set space.enabled=${{ env.BUILD_SPACE || false }} \
--set space.artifact_url=$SPACE_S3_URL \
--set admin.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \
--set admin.enabled=${{ env.BUILD_ADMIN || false }} \
--set admin.artifact_url=$ADMIN_S3_URL \
--set shared_config.deploy_script_url=$DEPLOY_SCRIPT_URL \
--set shared_config.api_base_url=${{vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL}} \
--output json \
-1
View File
@@ -81,4 +81,3 @@ tmp/
## packages
dist
.temp/
deploy/selfhost/plane-app/
+7 -7
View File
@@ -7,7 +7,7 @@
</p>
<h3 align="center"><b>Plane</b></h3>
<p align="center"><b>Open-source project management that unlocks customer value</b></p>
<p align="center"><b>Open-source project management that unlocks customer value.</b></p>
<p align="center">
<a href="https://discord.com/invite/A92xrEGCge">
@@ -40,22 +40,22 @@
</a>
</p>
Meet [Plane](https://dub.sh/plane-website-readme), an open-source project management tool to track issues, run ~sprints~ cycles, and manage product roadmaps without the chaos of managing the tool itself. 🧘‍♀️
Meet [Plane](https://dub.sh/plane-website-readme). An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind. 🧘‍♀️
> Plane is evolving every day. Your suggestions, ideas, and reported bugs help us immensely. Do not hesitate to join in the conversation on [Discord](https://discord.com/invite/A92xrEGCge) or raise a GitHub issue. We read everything and respond to most.
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve in our upcoming releases.
## ⚡ Installation
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account.
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account where we offer a hosted solution for users.
If you would like to self-host Plane, please see our [deployment guide](https://docs.plane.so/docker-compose).
If you want more control over your data, prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose).
| Installation methods | Docs link |
| Installation Methods | Documentation Link |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Docker | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://docs.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://docs.plane.so/kubernetes) |
`Instance admins` can configure instance settings with [God-mode](https://docs.plane.so/instance-admin).
`Instance admin` can configure instance settings using our [God-mode](https://docs.plane.so/instance-admin) feature.
## 🚀 Features
-3
View File
@@ -1,3 +0,0 @@
NEXT_PUBLIC_API_BASE_URL=""
NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
NEXT_PUBLIC_WEB_BASE_URL=""
-52
View File
@@ -1,52 +0,0 @@
module.exports = {
root: true,
extends: ["custom"],
parser: "@typescript-eslint/parser",
settings: {
"import/resolver": {
typescript: {},
node: {
moduleDirectory: ["node_modules", "."],
},
},
},
rules: {
"import/order": [
"error",
{
groups: ["builtin", "external", "internal", "parent", "sibling",],
pathGroups: [
{
pattern: "react",
group: "external",
position: "before",
},
{
pattern: "lucide-react",
group: "external",
position: "after",
},
{
pattern: "@headlessui/**",
group: "external",
position: "after",
},
{
pattern: "@plane/**",
group: "external",
position: "after",
},
{
pattern: "@/**",
group: "internal",
}
],
pathGroupsExcludedImportTypes: ["builtin", "internal", "react"],
alphabetize: {
order: "asc",
caseInsensitive: true,
},
},
],
},
}
-6
View File
@@ -1,6 +0,0 @@
.next
.vercel
.tubro
out/
dis/
build/
-5
View File
@@ -1,5 +0,0 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}
-74
View File
@@ -1,74 +0,0 @@
# *****************************************************************************
# STAGE 1: Build the project
# *****************************************************************************
FROM node:18-alpine 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 node:18-alpine 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_WEB_BASE_URL=""
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
ARG NEXT_PUBLIC_SPACE_BASE_URL="/spaces"
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
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 node:18-alpine AS runner
WORKDIR /app
COPY --from=installer /app/admin/next.config.js .
COPY --from=installer /app/admin/package.json .
COPY --from=installer /app/admin/.next/standalone ./
COPY --from=installer /app/admin/.next/static ./admin/.next/static
COPY --from=installer /app/admin/public ./admin/public
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ARG NEXT_PUBLIC_WEB_BASE_URL=""
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
ARG NEXT_PUBLIC_SPACE_BASE_URL="/spaces"
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
ENV NEXT_TELEMETRY_DISABLED 1
ENV TURBO_TELEMETRY_DISABLED 1
EXPOSE 3000
-17
View File
@@ -1,17 +0,0 @@
FROM node:18-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"]
-128
View File
@@ -1,128 +0,0 @@
import { FC } from "react";
import { useForm } from "react-hook-form";
import { Lightbulb } from "lucide-react";
import { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@plane/types";
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { ControllerInput, TControllerInputFormField } from "@/components/common";
// hooks
import { useInstance } from "@/hooks/store";
type IInstanceAIForm = {
config: IFormattedInstanceConfiguration;
};
type AIFormValues = Record<TInstanceAIConfigurationKeys, string>;
export const InstanceAIForm: FC<IInstanceAIForm> = (props) => {
const { config } = props;
// store
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
formState: { errors, isSubmitting },
} = useForm<AIFormValues>({
defaultValues: {
OPENAI_API_KEY: config["OPENAI_API_KEY"],
GPT_ENGINE: config["GPT_ENGINE"],
},
});
const aiFormFields: TControllerInputFormField[] = [
{
key: "GPT_ENGINE",
type: "text",
label: "GPT_ENGINE",
description: (
<>
Choose an OpenAI engine.{" "}
<a
href="https://platform.openai.com/docs/models/overview"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
Learn more
</a>
</>
),
placeholder: "gpt-3.5-turbo",
error: Boolean(errors.GPT_ENGINE),
required: false,
},
{
key: "OPENAI_API_KEY",
type: "password",
label: "API key",
description: (
<>
You will find your API key{" "}
<a
href="https://platform.openai.com/api-keys"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
here.
</a>
</>
),
placeholder: "sk-asddassdfasdefqsdfasd23das3dasdcasd",
error: Boolean(errors.OPENAI_API_KEY),
required: false,
},
];
const onSubmit = async (formData: AIFormValues) => {
const payload: Partial<AIFormValues> = { ...formData };
await updateInstanceConfigurations(payload)
.then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success",
message: "AI Settings updated successfully",
})
)
.catch((err) => console.error(err));
};
return (
<div className="space-y-8">
<div className="space-y-3">
<div>
<div className="pb-1 text-xl font-medium text-custom-text-100">OpenAI</div>
<div className="text-sm font-normal text-custom-text-300">If you use ChatGPT, this is for you.</div>
</div>
<div className="grid-col grid w-full grid-cols-1 items-center justify-between gap-x-12 gap-y-8 lg:grid-cols-3">
{aiFormFields.map((field) => (
<ControllerInput
key={field.key}
control={control}
type={field.type}
name={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
error={field.error}
required={field.required}
/>
))}
</div>
</div>
<div className="space-y-4">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
<div className="relative inline-flex items-center gap-2 rounded border border-custom-primary-100/20 bg-custom-primary-100/10 px-4 py-2 text-xs text-custom-primary-200">
<Lightbulb height="14" width="14" />
<div>If you have a preferred AI models vendor, please get in touch with us.</div>
</div>
</div>
</div>
);
};
-9
View File
@@ -1,9 +0,0 @@
"use client";
import { ReactNode } from "react";
// layouts
import { AdminLayout } from "@/layouts/admin-layout";
export default function AILayout({ children }: { children: ReactNode }) {
return <AdminLayout>{children}</AdminLayout>;
}
-48
View File
@@ -1,48 +0,0 @@
"use client";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
import { Loader } from "@plane/ui";
// components
import { PageHeader } from "@/components/core";
// hooks
import { useInstance } from "@/hooks/store";
// components
import { InstanceAIForm } from "./form";
const InstanceAIPage = observer(() => {
// store
const { fetchInstanceConfigurations, formattedConfig } = useInstance();
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
return (
<>
<PageHeader title="Artificial Intelligence - God Mode" />
<div className="relative container mx-auto w-full h-full p-8 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 pb-3 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">AI features for all your workspaces</div>
<div className="text-sm font-normal text-custom-text-300">
Configure your AI API credentials so Plane AI features are turned on for all your workspaces.
</div>
</div>
<div className="flex-grow overflow-hidden overflow-y-auto">
{formattedConfig ? (
<InstanceAIForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="40%" />
<div className="w-2/3 grid grid-cols-2 gap-x-8 gap-y-4">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</div>
<Loader.Item height="50px" width="20%" />
</Loader>
)}
</div>
</div>
</>
);
});
export default InstanceAIPage;
@@ -1,51 +0,0 @@
"use client";
import { FC } from "react";
// helpers
import { cn } from "helpers/common.helper";
type Props = {
name: string;
description: string;
icon: JSX.Element;
config: JSX.Element;
disabled?: boolean;
withBorder?: boolean;
};
export const AuthenticationMethodCard: FC<Props> = (props) => {
const { name, description, icon, config, disabled = false, withBorder = true } = props;
return (
<div
className={cn("w-full flex items-center gap-14 rounded", {
"px-4 py-3 border border-custom-border-200": withBorder,
})}
>
<div className="flex grow items-center gap-4">
<div className="shrink-0">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-custom-background-80">{icon}</div>
</div>
<div className="grow">
<div
className={cn("font-medium leading-5 text-custom-text-100", {
"text-sm": withBorder,
"text-xl": !withBorder,
})}
>
{name}
</div>
<div
className={cn("font-normal leading-5 text-custom-text-300", {
"text-xs": withBorder,
"text-sm": !withBorder,
})}
>
{description}
</div>
</div>
</div>
<div className={`shrink-0 ${disabled && "opacity-70"}`}>{config}</div>
</div>
);
};
@@ -1,36 +0,0 @@
"use client";
import React from "react";
import { observer } from "mobx-react-lite";
// hooks
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
import { useInstance } from "@/hooks/store";
// ui
// types
type Props = {
disabled: boolean;
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
};
export const EmailCodesConfiguration: React.FC<Props> = observer((props) => {
const { disabled, updateConfig } = props;
// store
const { formattedConfig } = useInstance();
// derived values
const enableMagicLogin = formattedConfig?.ENABLE_MAGIC_LINK_LOGIN ?? "";
return (
<ToggleSwitch
value={Boolean(parseInt(enableMagicLogin))}
onChange={() => {
Boolean(parseInt(enableMagicLogin)) === true
? updateConfig("ENABLE_MAGIC_LINK_LOGIN", "0")
: updateConfig("ENABLE_MAGIC_LINK_LOGIN", "1");
}}
size="sm"
disabled={disabled}
/>
);
});
@@ -1,59 +0,0 @@
"use client";
import React from "react";
import { observer } from "mobx-react-lite";
import Link from "next/link";
// icons
import { Settings2 } from "lucide-react";
// types
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
// ui
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
type Props = {
disabled: boolean;
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
};
export const GithubConfiguration: React.FC<Props> = observer((props) => {
const { disabled, updateConfig } = props;
// store
const { formattedConfig } = useInstance();
// derived values
const enableGithubConfig = formattedConfig?.IS_GITHUB_ENABLED ?? "";
const isGithubConfigured = !!formattedConfig?.GITHUB_CLIENT_ID && !!formattedConfig?.GITHUB_CLIENT_SECRET;
return (
<>
{isGithubConfigured ? (
<div className="flex items-center gap-4">
<Link href="/authentication/github" className={cn(getButtonStyling("link-primary", "md"), "font-medium")}>
Edit
</Link>
<ToggleSwitch
value={Boolean(parseInt(enableGithubConfig))}
onChange={() => {
Boolean(parseInt(enableGithubConfig)) === true
? updateConfig("IS_GITHUB_ENABLED", "0")
: updateConfig("IS_GITHUB_ENABLED", "1");
}}
size="sm"
disabled={disabled}
/>
</div>
) : (
<Link
href="/authentication/github"
className={cn(getButtonStyling("neutral-primary", "sm"), "text-custom-text-300")}
>
<Settings2 className="h-4 w-4 p-0.5 text-custom-text-300/80" />
Configure
</Link>
)}
</>
);
});
@@ -1,59 +0,0 @@
"use client";
import React from "react";
import { observer } from "mobx-react-lite";
import Link from "next/link";
// icons
import { Settings2 } from "lucide-react";
// types
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
// ui
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
type Props = {
disabled: boolean;
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
};
export const GoogleConfiguration: React.FC<Props> = observer((props) => {
const { disabled, updateConfig } = props;
// store
const { formattedConfig } = useInstance();
// derived values
const enableGoogleConfig = formattedConfig?.IS_GOOGLE_ENABLED ?? "";
const isGoogleConfigured = !!formattedConfig?.GOOGLE_CLIENT_ID && !!formattedConfig?.GOOGLE_CLIENT_SECRET;
return (
<>
{isGoogleConfigured ? (
<div className="flex items-center gap-4">
<Link href="/authentication/google" className={cn(getButtonStyling("link-primary", "md"), "font-medium")}>
Edit
</Link>
<ToggleSwitch
value={Boolean(parseInt(enableGoogleConfig))}
onChange={() => {
Boolean(parseInt(enableGoogleConfig)) === true
? updateConfig("IS_GOOGLE_ENABLED", "0")
: updateConfig("IS_GOOGLE_ENABLED", "1");
}}
size="sm"
disabled={disabled}
/>
</div>
) : (
<Link
href="/authentication/google"
className={cn(getButtonStyling("neutral-primary", "sm"), "text-custom-text-300")}
>
<Settings2 className="h-4 w-4 p-0.5 text-custom-text-300/80" />
Configure
</Link>
)}
</>
);
});
@@ -1,5 +0,0 @@
export * from "./email-config-switch";
export * from "./password-config-switch";
export * from "./authentication-method-card";
export * from "./github-config";
export * from "./google-config";
@@ -1,36 +0,0 @@
"use client";
import React from "react";
import { observer } from "mobx-react-lite";
// hooks
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
import { useInstance } from "@/hooks/store";
// ui
// types
type Props = {
disabled: boolean;
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
};
export const PasswordLoginConfiguration: React.FC<Props> = observer((props) => {
const { disabled, updateConfig } = props;
// store
const { formattedConfig } = useInstance();
// derived values
const enableEmailPassword = formattedConfig?.ENABLE_EMAIL_PASSWORD ?? "";
return (
<ToggleSwitch
value={Boolean(parseInt(enableEmailPassword))}
onChange={() => {
Boolean(parseInt(enableEmailPassword)) === true
? updateConfig("ENABLE_EMAIL_PASSWORD", "0")
: updateConfig("ENABLE_EMAIL_PASSWORD", "1");
}}
size="sm"
disabled={disabled}
/>
);
});
-213
View File
@@ -1,213 +0,0 @@
import { FC, useState } from "react";
import isEmpty from "lodash/isEmpty";
import Link from "next/link";
import { useForm } from "react-hook-form";
// types
import { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types";
// ui
import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
// components
import {
ConfirmDiscardModal,
ControllerInput,
CopyField,
TControllerInputFormField,
TCopyField,
} from "@/components/common";
// helpers
import { API_BASE_URL, cn } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
type Props = {
config: IFormattedInstanceConfiguration;
};
type GithubConfigFormValues = Record<TInstanceGithubAuthenticationConfigurationKeys, string>;
export const InstanceGithubConfigForm: FC<Props> = (props) => {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
// store hooks
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
reset,
formState: { errors, isDirty, isSubmitting },
} = useForm<GithubConfigFormValues>({
defaultValues: {
GITHUB_CLIENT_ID: config["GITHUB_CLIENT_ID"],
GITHUB_CLIENT_SECRET: config["GITHUB_CLIENT_SECRET"],
},
});
const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : "";
const GITHUB_FORM_FIELDS: TControllerInputFormField[] = [
{
key: "GITHUB_CLIENT_ID",
type: "text",
label: "Client ID",
description: (
<>
You will get this from your{" "}
<a
tabIndex={-1}
href="https://github.com/settings/applications/new"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
GitHub OAuth application settings.
</a>
</>
),
placeholder: "70a44354520df8bd9bcd",
error: Boolean(errors.GITHUB_CLIENT_ID),
required: true,
},
{
key: "GITHUB_CLIENT_SECRET",
type: "password",
label: "Client secret",
description: (
<>
Your client secret is also found in your{" "}
<a
tabIndex={-1}
href="https://github.com/settings/applications/new"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
GitHub OAuth application settings.
</a>
</>
),
placeholder: "9b0050f94ec1b744e32ce79ea4ffacd40d4119cb",
error: Boolean(errors.GITHUB_CLIENT_SECRET),
required: true,
},
];
const GITHUB_SERVICE_FIELD: TCopyField[] = [
{
key: "Origin_URL",
label: "Origin URL",
url: originURL,
description: (
<>
We will auto-generate this. Paste this into the Authorized origin URL field{" "}
<a
tabIndex={-1}
href="https://github.com/settings/applications/new"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
here.
</a>
</>
),
},
{
key: "Callback_URI",
label: "Callback URI",
url: `${originURL}/auth/github/callback/`,
description: (
<>
We will auto-generate this. Paste this into your Authorized Callback URI field{" "}
<a
tabIndex={-1}
href="https://github.com/settings/applications/new"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
here.
</a>
</>
),
},
];
const onSubmit = async (formData: GithubConfigFormValues) => {
const payload: Partial<GithubConfigFormValues> = { ...formData };
await updateInstanceConfigurations(payload)
.then((response = []) => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success",
message: "Github Configuration Settings updated successfully",
});
reset({
GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value,
GITHUB_CLIENT_SECRET: response.find((item) => item.key === "GITHUB_CLIENT_SECRET")?.value,
});
})
.catch((err) => console.error(err));
};
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
if (isDirty) {
e.preventDefault();
setIsDiscardChangesModalOpen(true);
}
};
return (
<>
<ConfirmDiscardModal
isOpen={isDiscardChangesModalOpen}
onDiscardHref="/authentication"
handleClose={() => setIsDiscardChangesModalOpen(false)}
/>
<div className="flex flex-col gap-8">
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1">
<div className="pt-2 text-xl font-medium">Configuration</div>
{GITHUB_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
control={control}
type={field.type}
name={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
error={field.error}
required={field.required}
/>
))}
<div className="flex flex-col gap-1 pt-4">
<div className="flex items-center gap-4">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting} disabled={!isDirty}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
<Link
href="/authentication"
className={cn(getButtonStyling("link-neutral", "md"), "font-medium")}
onClick={handleGoBack}
>
Go back
</Link>
</div>
</div>
</div>
<div className="col-span-2 md:col-span-1">
<div className="flex flex-col gap-y-4 px-6 py-4 my-2 bg-custom-background-80/60 rounded-lg">
<div className="pt-2 text-xl font-medium">Service provider details</div>
{GITHUB_SERVICE_FIELD.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
</div>
</div>
</div>
</>
);
};
-114
View File
@@ -1,114 +0,0 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react-lite";
import Image from "next/image";
import { useTheme } from "next-themes";
import useSWR from "swr";
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
// components
import { PageHeader } from "@/components/core";
// helpers
import { resolveGeneralTheme } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
// icons
import githubLightModeImage from "@/public/logos/github-black.png";
import githubDarkModeImage from "@/public/logos/github-white.png";
// local components
import { AuthenticationMethodCard } from "../components";
import { InstanceGithubConfigForm } from "./form";
const InstanceGithubAuthenticationPage = observer(() => {
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// state
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// theme
const { resolvedTheme } = useTheme();
// config
const enableGithubConfig = formattedConfig?.IS_GITHUB_ENABLED ?? "";
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
const updateConfig = async (key: "IS_GITHUB_ENABLED", value: string) => {
setIsSubmitting(true);
const payload = {
[key]: value,
};
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration...",
success: {
title: "Configuration saved",
message: () => `Github authentication is now ${value ? "active" : "disabled"}.`,
},
error: {
title: "Error",
message: () => "Failed to save configuration",
},
});
await updateConfigPromise
.then(() => {
setIsSubmitting(false);
})
.catch((err) => {
console.error(err);
setIsSubmitting(false);
});
};
return (
<>
<PageHeader title="Authentication - God Mode" />
<div className="relative container mx-auto w-full h-full p-8 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 pb-3 space-y-1 flex-shrink-0">
<AuthenticationMethodCard
name="Github"
description="Allow members to login or sign up to plane with their Github accounts."
icon={
<Image
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
height={24}
width={24}
alt="GitHub Logo"
/>
}
config={
<ToggleSwitch
value={Boolean(parseInt(enableGithubConfig))}
onChange={() => {
Boolean(parseInt(enableGithubConfig)) === true
? updateConfig("IS_GITHUB_ENABLED", "0")
: updateConfig("IS_GITHUB_ENABLED", "1");
}}
size="sm"
disabled={isSubmitting || !formattedConfig}
/>
}
disabled={isSubmitting || !formattedConfig}
withBorder={false}
/>
</div>
<div className="flex-grow overflow-hidden overflow-y-auto">
{formattedConfig ? (
<InstanceGithubConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="25%" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" width="50%" />
</Loader>
)}
</div>
</div>
</>
);
});
export default InstanceGithubAuthenticationPage;
-211
View File
@@ -1,211 +0,0 @@
import { FC, useState } from "react";
import isEmpty from "lodash/isEmpty";
import Link from "next/link";
import { useForm } from "react-hook-form";
// types
import { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types";
// ui
import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
// components
import {
ConfirmDiscardModal,
ControllerInput,
CopyField,
TControllerInputFormField,
TCopyField,
} from "@/components/common";
// helpers
import { API_BASE_URL, cn } from "@/helpers/common.helper";
// hooks
import { useInstance } from "@/hooks/store";
type Props = {
config: IFormattedInstanceConfiguration;
};
type GoogleConfigFormValues = Record<TInstanceGoogleAuthenticationConfigurationKeys, string>;
export const InstanceGoogleConfigForm: FC<Props> = (props) => {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
// store hooks
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
reset,
formState: { errors, isDirty, isSubmitting },
} = useForm<GoogleConfigFormValues>({
defaultValues: {
GOOGLE_CLIENT_ID: config["GOOGLE_CLIENT_ID"],
GOOGLE_CLIENT_SECRET: config["GOOGLE_CLIENT_SECRET"],
},
});
const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : "";
const GOOGLE_FORM_FIELDS: TControllerInputFormField[] = [
{
key: "GOOGLE_CLIENT_ID",
type: "text",
label: "Client ID",
description: (
<>
Your client ID lives in your Google API Console.{" "}
<a
tabIndex={-1}
href="https://developers.google.com/identity/protocols/oauth2/javascript-implicit-flow#creatingcred"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
Learn more
</a>
</>
),
placeholder: "840195096245-0p2tstej9j5nc4l8o1ah2dqondscqc1g.apps.googleusercontent.com",
error: Boolean(errors.GOOGLE_CLIENT_ID),
required: true,
},
{
key: "GOOGLE_CLIENT_SECRET",
type: "password",
label: "Client secret",
description: (
<>
Your client secret should also be in your Google API Console.{" "}
<a
tabIndex={-1}
href="https://developers.google.com/identity/oauth2/web/guides/get-google-api-clientid"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
Learn more
</a>
</>
),
placeholder: "GOCShX-ADp4cI0kPqav1gGCBg5bE02E",
error: Boolean(errors.GOOGLE_CLIENT_SECRET),
required: true,
},
];
const GOOGLE_SERVICE_DETAILS: TCopyField[] = [
{
key: "Origin_URL",
label: "Origin URL",
url: originURL,
description: (
<p>
We will auto-generate this. Paste this into your Authorized JavaScript origins field. For this OAuth client{" "}
<a
href="https://console.cloud.google.com/apis/credentials/oauthclient"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
here.
</a>
</p>
),
},
{
key: "Callback_URI",
label: "Callback URI",
url: `${originURL}/auth/google/callback/`,
description: (
<p>
We will auto-generate this. Paste this into your Authorized Redirect URI field. For this OAuth client{" "}
<a
href="https://console.cloud.google.com/apis/credentials/oauthclient"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
here.
</a>
</p>
),
},
];
const onSubmit = async (formData: GoogleConfigFormValues) => {
const payload: Partial<GoogleConfigFormValues> = { ...formData };
await updateInstanceConfigurations(payload)
.then((response = []) => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success",
message: "Google Configuration Settings updated successfully",
});
reset({
GOOGLE_CLIENT_ID: response.find((item) => item.key === "GOOGLE_CLIENT_ID")?.value,
GOOGLE_CLIENT_SECRET: response.find((item) => item.key === "GOOGLE_CLIENT_SECRET")?.value,
});
})
.catch((err) => console.error(err));
};
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
if (isDirty) {
e.preventDefault();
setIsDiscardChangesModalOpen(true);
}
};
return (
<>
<ConfirmDiscardModal
isOpen={isDiscardChangesModalOpen}
onDiscardHref="/authentication"
handleClose={() => setIsDiscardChangesModalOpen(false)}
/>
<div className="flex flex-col gap-8">
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1">
<div className="pt-2 text-xl font-medium">Configuration</div>
{GOOGLE_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
control={control}
type={field.type}
name={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
error={field.error}
required={field.required}
/>
))}
<div className="flex flex-col gap-1 pt-4">
<div className="flex items-center gap-4">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting} disabled={!isDirty}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
<Link
href="/authentication"
className={cn(getButtonStyling("link-neutral", "md"), "font-medium")}
onClick={handleGoBack}
>
Go back
</Link>
</div>
</div>
</div>
<div className="col-span-2 md:col-span-1">
<div className="flex flex-col gap-y-4 px-6 py-4 my-2 bg-custom-background-80/60 rounded-lg">
<div className="pt-2 text-xl font-medium">Service provider details</div>
{GOOGLE_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
</div>
</div>
</div>
</>
);
};
-102
View File
@@ -1,102 +0,0 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react-lite";
import Image from "next/image";
import useSWR from "swr";
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
// components
import { PageHeader } from "@/components/core";
// hooks
import { useInstance } from "@/hooks/store";
// icons
import GoogleLogo from "@/public/logos/google-logo.svg";
// local components
import { AuthenticationMethodCard } from "../components";
import { InstanceGoogleConfigForm } from "./form";
const InstanceGoogleAuthenticationPage = observer(() => {
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// state
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// config
const enableGoogleConfig = formattedConfig?.IS_GOOGLE_ENABLED ?? "";
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
const updateConfig = async (key: "IS_GOOGLE_ENABLED", value: string) => {
setIsSubmitting(true);
const payload = {
[key]: value,
};
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration...",
success: {
title: "Configuration saved",
message: () => `Google authentication is now ${value ? "active" : "disabled"}.`,
},
error: {
title: "Error",
message: () => "Failed to save configuration",
},
});
await updateConfigPromise
.then(() => {
setIsSubmitting(false);
})
.catch((err) => {
console.error(err);
setIsSubmitting(false);
});
};
return (
<>
<PageHeader title="Authentication - God Mode" />
<div className="relative container mx-auto w-full h-full p-8 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 pb-3 space-y-1 flex-shrink-0">
<AuthenticationMethodCard
name="Google"
description="Allow members to login or sign up to plane with their Google
accounts."
icon={<Image src={GoogleLogo} height={24} width={24} alt="Google Logo" />}
config={
<ToggleSwitch
value={Boolean(parseInt(enableGoogleConfig))}
onChange={() => {
Boolean(parseInt(enableGoogleConfig)) === true
? updateConfig("IS_GOOGLE_ENABLED", "0")
: updateConfig("IS_GOOGLE_ENABLED", "1");
}}
size="sm"
disabled={isSubmitting || !formattedConfig}
/>
}
disabled={isSubmitting || !formattedConfig}
withBorder={false}
/>
</div>
<div className="flex-grow overflow-hidden overflow-y-auto">
{formattedConfig ? (
<InstanceGoogleConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="25%" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" width="50%" />
</Loader>
)}
</div>
</div>
</>
);
});
export default InstanceGoogleAuthenticationPage;
-9
View File
@@ -1,9 +0,0 @@
"use client";
import { ReactNode } from "react";
// layouts
import { AdminLayout } from "@/layouts/admin-layout";
export default function AuthenticationLayout({ children }: { children: ReactNode }) {
return <AdminLayout>{children}</AdminLayout>;
}
-159
View File
@@ -1,159 +0,0 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react-lite";
import Image from "next/image";
import { useTheme } from "next-themes";
import useSWR from "swr";
import { Mails, KeyRound } from "lucide-react";
import { TInstanceConfigurationKeys } from "@plane/types";
import { Loader, setPromiseToast } from "@plane/ui";
// components
import { PageHeader } from "@/components/core";
// hooks
// helpers
import { resolveGeneralTheme } from "@/helpers/common.helper";
import { useInstance } from "@/hooks/store";
// images
import githubLightModeImage from "@/public/logos/github-black.png";
import githubDarkModeImage from "@/public/logos/github-white.png";
import GoogleLogo from "@/public/logos/google-logo.svg";
// local components
import {
AuthenticationMethodCard,
EmailCodesConfiguration,
PasswordLoginConfiguration,
GithubConfiguration,
GoogleConfiguration,
} from "./components";
type TInstanceAuthenticationMethodCard = {
key: string;
name: string;
description: string;
icon: JSX.Element;
config: JSX.Element;
};
const InstanceAuthenticationPage = observer(() => {
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
// state
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// theme
const { resolvedTheme } = useTheme();
const updateConfig = async (key: TInstanceConfigurationKeys, value: string) => {
setIsSubmitting(true);
const payload = {
[key]: value,
};
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration...",
success: {
title: "Success",
message: () => "Configuration saved successfully",
},
error: {
title: "Error",
message: () => "Failed to save configuration",
},
});
await updateConfigPromise
.then(() => {
setIsSubmitting(false);
})
.catch((err) => {
console.error(err);
setIsSubmitting(false);
});
};
// Authentication methods
const authenticationMethodsCard: TInstanceAuthenticationMethodCard[] = [
{
key: "email-codes",
name: "Email codes",
description: "Login or sign up using codes sent via emails. You need to have email setup here and enabled.",
icon: <Mails className="h-6 w-6 p-0.5 text-custom-text-300/80" />,
config: <EmailCodesConfiguration disabled={isSubmitting} updateConfig={updateConfig} />,
},
{
key: "password-login",
name: "Password based login",
description: "Allow members to create accounts with passwords for emails to sign in.",
icon: <KeyRound className="h-6 w-6 p-0.5 text-custom-text-300/80" />,
config: <PasswordLoginConfiguration disabled={isSubmitting} updateConfig={updateConfig} />,
},
{
key: "google",
name: "Google",
description: "Allow members to login or sign up to plane with their Google accounts.",
icon: <Image src={GoogleLogo} height={20} width={20} alt="Google Logo" />,
config: <GoogleConfiguration disabled={isSubmitting} updateConfig={updateConfig} />,
},
{
key: "github",
name: "Github",
description: "Allow members to login or sign up to plane with their Github accounts.",
icon: (
<Image
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
height={20}
width={20}
alt="GitHub Logo"
/>
),
config: <GithubConfiguration disabled={isSubmitting} updateConfig={updateConfig} />,
},
];
return (
<>
<PageHeader title="Authentication - God Mode" />
<div className="relative container mx-auto w-full h-full p-8 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 pb-3 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">Manage authentication for your instance</div>
<div className="text-sm font-normal text-custom-text-300">
Configure authentication modes for your team and restrict sign ups to be invite only.
</div>
</div>
<div className="flex-grow overflow-hidden overflow-y-auto">
{formattedConfig ? (
<div className="space-y-3">
<div className="text-lg font-medium">Authentication modes</div>
{authenticationMethodsCard.map((method) => (
<AuthenticationMethodCard
key={method.key}
name={method.name}
description={method.description}
icon={method.icon}
config={method.config}
disabled={isSubmitting}
/>
))}
</div>
) : (
<Loader className="space-y-10">
<Loader.Item height="50px" width="75%" />
<Loader.Item height="50px" width="75%" />
<Loader.Item height="50px" width="40%" />
<Loader.Item height="50px" width="40%" />
<Loader.Item height="50px" width="20%" />
</Loader>
)}
</div>
</div>
</>
);
});
export default InstanceAuthenticationPage;
-222
View File
@@ -1,222 +0,0 @@
import React, { FC, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
// types
import { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from "@plane/types";
// ui
import { Button, CustomSelect, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { ControllerInput, TControllerInputFormField } from "@/components/common";
// hooks
import { useInstance } from "@/hooks/store";
// local components
import { SendTestEmailModal } from "./test-email-modal";
type IInstanceEmailForm = {
config: IFormattedInstanceConfiguration;
};
type EmailFormValues = Record<TInstanceEmailConfigurationKeys, string>;
type TEmailSecurityKeys = "EMAIL_USE_TLS" | "EMAIL_USE_SSL" | "NONE";
const EMAIL_SECURITY_OPTIONS: { [key in TEmailSecurityKeys]: string } = {
EMAIL_USE_TLS: "TLS",
EMAIL_USE_SSL: "SSL",
NONE: "No email security",
};
export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
const { config } = props;
// states
const [isSendTestEmailModalOpen, setIsSendTestEmailModalOpen] = useState(false);
// store hooks
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
watch,
setValue,
control,
formState: { errors, isValid, isDirty, isSubmitting },
} = useForm<EmailFormValues>({
defaultValues: {
EMAIL_HOST: config["EMAIL_HOST"],
EMAIL_PORT: config["EMAIL_PORT"],
EMAIL_HOST_USER: config["EMAIL_HOST_USER"],
EMAIL_HOST_PASSWORD: config["EMAIL_HOST_PASSWORD"],
EMAIL_USE_TLS: config["EMAIL_USE_TLS"],
EMAIL_USE_SSL: config["EMAIL_USE_SSL"],
EMAIL_FROM: config["EMAIL_FROM"],
},
});
const emailFormFields: TControllerInputFormField[] = [
{
key: "EMAIL_HOST",
type: "text",
label: "Host",
placeholder: "email.google.com",
error: Boolean(errors.EMAIL_HOST),
required: true,
},
{
key: "EMAIL_PORT",
type: "text",
label: "Port",
placeholder: "8080",
error: Boolean(errors.EMAIL_PORT),
required: true,
},
{
key: "EMAIL_FROM",
type: "text",
label: "Sender email address",
description:
"This is the email address your users will see when getting emails from this instance. You will need to verify this address.",
placeholder: "no-reply@projectplane.so",
error: Boolean(errors.EMAIL_FROM),
required: true,
},
];
const OptionalEmailFormFields: TControllerInputFormField[] = [
{
key: "EMAIL_HOST_USER",
type: "text",
label: "Username",
placeholder: "getitdone@projectplane.so",
error: Boolean(errors.EMAIL_HOST_USER),
required: false,
},
{
key: "EMAIL_HOST_PASSWORD",
type: "password",
label: "Password",
placeholder: "Password",
error: Boolean(errors.EMAIL_HOST_PASSWORD),
required: false,
},
];
const onSubmit = async (formData: EmailFormValues) => {
const payload: Partial<EmailFormValues> = { ...formData };
await updateInstanceConfigurations(payload)
.then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success",
message: "Email Settings updated successfully",
})
)
.catch((err) => console.error(err));
};
const useTLSValue = watch("EMAIL_USE_TLS");
const useSSLValue = watch("EMAIL_USE_SSL");
const emailSecurityKey: TEmailSecurityKeys = useMemo(() => {
if (useTLSValue === "1") return "EMAIL_USE_TLS";
if (useSSLValue === "1") return "EMAIL_USE_SSL";
return "NONE";
}, [useTLSValue, useSSLValue]);
const handleEmailSecurityChange = (key: TEmailSecurityKeys) => {
if (key === "EMAIL_USE_SSL") {
setValue("EMAIL_USE_TLS", "0");
setValue("EMAIL_USE_SSL", "1");
}
if (key === "EMAIL_USE_TLS") {
setValue("EMAIL_USE_TLS", "1");
setValue("EMAIL_USE_SSL", "0");
}
if (key === "NONE") {
setValue("EMAIL_USE_TLS", "0");
setValue("EMAIL_USE_SSL", "0");
}
};
return (
<div className="space-y-8">
<div>
<SendTestEmailModal isOpen={isSendTestEmailModalOpen} handleClose={() => setIsSendTestEmailModalOpen(false)} />
<div className="grid-col grid w-full max-w-4xl grid-cols-1 items-start justify-between gap-10 lg:grid-cols-2">
{emailFormFields.map((field) => (
<ControllerInput
key={field.key}
control={control}
type={field.type}
name={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
error={field.error}
required={field.required}
/>
))}
<div className="flex flex-col gap-1">
<h4 className="text-sm text-custom-text-300">Email security</h4>
<CustomSelect
value={emailSecurityKey}
label={EMAIL_SECURITY_OPTIONS[emailSecurityKey]}
onChange={handleEmailSecurityChange}
buttonClassName="rounded-md border-custom-border-200"
optionsClassName="w-full"
input
>
{Object.entries(EMAIL_SECURITY_OPTIONS).map(([key, value]) => (
<CustomSelect.Option key={key} value={key} className="w-full">
{value}
</CustomSelect.Option>
))}
</CustomSelect>
</div>
</div>
<div className="flex flex-col gap-6 my-6 pt-4 border-t border-custom-border-100">
<div className="flex w-full max-w-md flex-col gap-y-10 px-1">
<div className="mr-8 flex items-center gap-10 pt-4">
<div className="grow">
<div className="text-sm font-medium text-custom-text-100">Authentication (optional)</div>
<div className="text-xs font-normal text-custom-text-300">
We recommend setting up a username password for your SMTP server
</div>
</div>
</div>
</div>
<div className="grid-col grid w-full max-w-4xl grid-cols-1 items-center justify-between gap-10 lg:grid-cols-2">
{OptionalEmailFormFields.map((field) => (
<ControllerInput
key={field.key}
control={control}
type={field.type}
name={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
error={field.error}
required={field.required}
/>
))}
</div>
</div>
</div>
<div className="flex max-w-4xl items-center py-1 gap-4">
<Button
variant="primary"
onClick={handleSubmit(onSubmit)}
loading={isSubmitting}
disabled={!isValid || !isDirty}
>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
<Button
variant="outline-primary"
onClick={() => setIsSendTestEmailModalOpen(true)}
loading={isSubmitting}
disabled={!isValid}
>
Send test email
</Button>
</div>
</div>
);
};
-13
View File
@@ -1,13 +0,0 @@
"use client";
import { ReactNode } from "react";
// layouts
import { AdminLayout } from "@/layouts/admin-layout";
interface EmailLayoutProps {
children: ReactNode;
}
const EmailLayout = ({ children }: EmailLayoutProps) => <AdminLayout>{children}</AdminLayout>;
export default EmailLayout;
-51
View File
@@ -1,51 +0,0 @@
"use client";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
import { Loader } from "@plane/ui";
// components
import { PageHeader } from "@/components/core";
// hooks
import { useInstance } from "@/hooks/store";
// components
import { InstanceEmailForm } from "./email-config-form";
const InstanceEmailPage = observer(() => {
// store
const { fetchInstanceConfigurations, formattedConfig } = useInstance();
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
return (
<>
<PageHeader title="Email - God Mode" />
<div className="relative container mx-auto w-full h-full p-8 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 pb-3 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">Secure emails from your own instance</div>
<div className="text-sm font-normal text-custom-text-300">
Plane can send useful emails to you and your users from your own instance without talking to the Internet.
<div className="text-sm font-normal text-custom-text-300">
Set it up below and please test your settings before you save them.&nbsp;
<span className="text-red-400">Misconfigs can lead to email bounces and errors.</span>
</div>
</div>
</div>
<div className="flex-grow overflow-hidden overflow-y-auto">
{formattedConfig ? (
<InstanceEmailForm config={formattedConfig} />
) : (
<Loader className="space-y-10">
<Loader.Item height="50px" width="75%" />
<Loader.Item height="50px" width="75%" />
<Loader.Item height="50px" width="40%" />
<Loader.Item height="50px" width="40%" />
<Loader.Item height="50px" width="20%" />
</Loader>
)}
</div>
</div>
</>
);
});
export default InstanceEmailPage;
-135
View File
@@ -1,135 +0,0 @@
import React, { FC, useEffect, useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
// ui
import { Button, Input } from "@plane/ui";
// services
import { InstanceService } from "@/services/instance.service";
type Props = {
isOpen: boolean;
handleClose: () => void;
};
enum ESendEmailSteps {
SEND_EMAIL = "SEND_EMAIL",
SUCCESS = "SUCCESS",
FAILED = "FAILED",
}
const instanceService = new InstanceService();
export const SendTestEmailModal: FC<Props> = (props) => {
const { isOpen, handleClose } = props;
// state
const [receiverEmail, setReceiverEmail] = useState("");
const [sendEmailStep, setSendEmailStep] = useState<ESendEmailSteps>(ESendEmailSteps.SEND_EMAIL);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
// reset state
const resetState = () => {
setReceiverEmail("");
setSendEmailStep(ESendEmailSteps.SEND_EMAIL);
setIsLoading(false);
setError("");
};
useEffect(() => {
if (!isOpen) {
resetState();
}
}, [isOpen]);
const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
setIsLoading(true);
await instanceService
.sendTestEmail(receiverEmail)
.then(() => {
setSendEmailStep(ESendEmailSteps.SUCCESS);
})
.catch((error) => {
setError(error?.message || "Failed to send email");
setSendEmailStep(ESendEmailSteps.FAILED);
})
.finally(() => {
setIsLoading(false);
});
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="my-10 flex justify-center p-4 text-center sm:p-0 md:my-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 p-5 px-4 text-left shadow-custom-shadow-md transition-all w-full sm:max-w-xl">
<h3 className="text-lg font-medium leading-6 text-custom-text-100">
{sendEmailStep === ESendEmailSteps.SEND_EMAIL
? "Send test email"
: sendEmailStep === ESendEmailSteps.SUCCESS
? "Email send"
: "Failed"}{" "}
</h3>
<div className="pt-6 pb-2">
{sendEmailStep === ESendEmailSteps.SEND_EMAIL && (
<Input
id="receiver_email"
type="email"
value={receiverEmail}
onChange={(e) => setReceiverEmail(e.target.value)}
placeholder="Receiver email"
className="w-full resize-none text-lg"
tabIndex={1}
/>
)}
{sendEmailStep === ESendEmailSteps.SUCCESS && (
<div className="flex flex-col gap-y-4 text-sm">
<p>
We have sent the test email to {receiverEmail}. Please check your spam folder if you cannot find
it.
</p>
<p>If you still cannot find it, recheck your SMTP configuration and trigger a new test email.</p>
</div>
)}
{sendEmailStep === ESendEmailSteps.FAILED && <div className="text-sm">{error}</div>}
<div className="flex items-center gap-2 justify-end mt-5">
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={2}>
{sendEmailStep === ESendEmailSteps.SEND_EMAIL ? "Cancel" : "Close"}
</Button>
{sendEmailStep === ESendEmailSteps.SEND_EMAIL && (
<Button variant="primary" size="sm" loading={isLoading} onClick={handleSubmit} tabIndex={3}>
{isLoading ? "Sending email..." : "Send email"}
</Button>
)}
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
-9
View File
@@ -1,9 +0,0 @@
"use client";
export default function RootErrorPage() {
return (
<div>
<p>Something went wrong.</p>
</div>
);
}
-140
View File
@@ -1,140 +0,0 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react-lite";
import { Controller, useForm } from "react-hook-form";
import { Telescope } from "lucide-react";
// types
import { IInstance, IInstanceAdmin } from "@plane/types";
// ui
import { Button, Input, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
// components
import { ControllerInput } from "@/components/common";
// hooks
import { useInstance } from "@/hooks/store";
export interface IGeneralConfigurationForm {
instance: IInstance["instance"];
instanceAdmins: IInstanceAdmin[];
}
export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer((props) => {
const { instance, instanceAdmins } = props;
// hooks
const { updateInstanceInfo } = useInstance();
// form data
const {
handleSubmit,
control,
formState: { errors, isSubmitting },
} = useForm<Partial<IInstance["instance"]>>({
defaultValues: {
instance_name: instance?.instance_name,
is_telemetry_enabled: instance?.is_telemetry_enabled,
},
});
const onSubmit = async (formData: Partial<IInstance["instance"]>) => {
const payload: Partial<IInstance["instance"]> = { ...formData };
console.log("payload", payload);
await updateInstanceInfo(payload)
.then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success",
message: "Settings updated successfully",
})
)
.catch((err) => console.error(err));
};
return (
<div className="space-y-8">
<div className="space-y-3">
<div className="text-lg font-medium">Instance details</div>
<div className="grid-col grid w-full grid-cols-1 items-center justify-between gap-8 md:grid-cols-2 lg:grid-cols-3">
<ControllerInput
key="instance_name"
name="instance_name"
control={control}
type="text"
label="Name of instance"
placeholder="Instance name"
error={Boolean(errors.instance_name)}
required
/>
<div className="flex flex-col gap-1">
<h4 className="text-sm text-custom-text-300">Email</h4>
<Input
id="email"
name="email"
type="email"
value={instanceAdmins[0]?.user_detail?.email ?? ""}
placeholder="Admin email"
className="w-full cursor-not-allowed !text-custom-text-400"
disabled
/>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm text-custom-text-300">Instance ID</h4>
<Input
id="instance_id"
name="instance_id"
type="text"
value={instance.instance_id}
className="w-full cursor-not-allowed rounded-md font-medium !text-custom-text-400"
disabled
/>
</div>
</div>
</div>
<div className="space-y-3">
<div className="text-lg font-medium">Telemetry</div>
<div className="flex items-center gap-14 px-4 py-3 border border-custom-border-200 rounded">
<div className="grow flex items-center gap-4">
<div className="shrink-0">
<div className="flex items-center justify-center w-10 h-10 bg-custom-background-80 rounded-full">
<Telescope className="w-6 h-6 text-custom-text-300/80 p-0.5" />
</div>
</div>
<div className="grow">
<div className="text-sm font-medium text-custom-text-100 leading-5">
Allow Plane to collect anonymous usage events
</div>
<div className="text-xs font-normal text-custom-text-300 leading-5">
We collect usage events without any PII to analyse and improve Plane.{" "}
<a
href="https://docs.plane.so/self-hosting/telemetry"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
Know more.
</a>
</div>
</div>
</div>
<div className={`shrink-0 ${isSubmitting && "opacity-70"}`}>
<Controller
control={control}
name="is_telemetry_enabled"
render={({ field: { value, onChange } }) => (
<ToggleSwitch value={value ?? false} onChange={onChange} size="sm" disabled={isSubmitting} />
)}
/>
</div>
</div>
</div>
<div>
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
</div>
</div>
);
});
-12
View File
@@ -1,12 +0,0 @@
import { ReactNode } from "react";
import { Metadata } from "next";
// components
import { AdminLayout } from "@/layouts/admin-layout";
export const metadata: Metadata = {
title: "General Settings - God Mode",
};
export default function GeneralLayout({ children }: { children: ReactNode }) {
return <AdminLayout>{children}</AdminLayout>;
}
-31
View File
@@ -1,31 +0,0 @@
"use client";
import { observer } from "mobx-react-lite";
// hooks
import { useInstance } from "@/hooks/store";
// components
import { GeneralConfigurationForm } from "./form";
function GeneralPage() {
const { instance, instanceAdmins } = useInstance();
console.log("instance", instanceAdmins);
return (
<>
<div className="relative container mx-auto w-full h-full p-8 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 pb-3 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">General settings</div>
<div className="text-sm font-normal text-custom-text-300">
Change the name of your instance and instance admin e-mail addresses. Enable or disable telemetry in your
instance.
</div>
</div>
<div className="flex-grow overflow-hidden overflow-y-auto">
{instance?.instance && instanceAdmins && (
<GeneralConfigurationForm instance={instance.instance} instanceAdmins={instanceAdmins} />
)}
</div>
</div>
</>
);
}
export default observer(GeneralPage);
-466
View File
@@ -1,466 +0,0 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700;800&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@48,400,0,0&display=swap");
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.text-1\.5xl {
font-size: 1.375rem;
line-height: 1.875rem;
}
.text-2\.5xl {
font-size: 1.75rem;
line-height: 2.25rem;
}
}
@layer base {
html {
font-family: "Inter", sans-serif;
}
:root {
color-scheme: light !important;
--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: 250, 250, 250; /* secondary bg */
--color-background-80: 245, 245, 245; /* tertiary bg */
--color-text-100: 23, 23, 23; /* primary text */
--color-text-200: 58, 58, 58; /* secondary text */
--color-text-300: 82, 82, 82; /* tertiary text */
--color-text-400: 163, 163, 163; /* placeholder text */
--color-scrollbar: 163, 163, 163; /* scrollbar thumb */
--color-border-100: 245, 245, 245; /* subtle border= 1 */
--color-border-200: 229, 229, 229; /* subtle border- 2 */
--color-border-300: 212, 212, 212; /* strong border- 1 */
--color-border-400: 185, 185, 185; /* strong border- 2 */
--color-shadow-2xs: 0px 0px 1px 0px rgba(23, 23, 23, 0.06),
0px 1px 2px 0px rgba(23, 23, 23, 0.06),
0px 1px 2px 0px rgba(23, 23, 23, 0.14);
--color-shadow-xs: 0px 1px 2px 0px rgba(0, 0, 0, 0.16),
0px 2px 4px 0px rgba(16, 24, 40, 0.12),
0px 1px 8px -1px rgba(16, 24, 40, 0.1);
--color-shadow-sm: 0px 1px 4px 0px rgba(0, 0, 0, 0.01),
0px 4px 8px 0px rgba(0, 0, 0, 0.02), 0px 1px 12px 0px rgba(0, 0, 0, 0.12);
--color-shadow-rg: 0px 3px 6px 0px rgba(0, 0, 0, 0.1),
0px 4px 4px 0px rgba(16, 24, 40, 0.08),
0px 1px 12px 0px rgba(16, 24, 40, 0.04);
--color-shadow-md: 0px 4px 8px 0px rgba(0, 0, 0, 0.12),
0px 6px 12px 0px rgba(16, 24, 40, 0.12),
0px 1px 16px 0px rgba(16, 24, 40, 0.12);
--color-shadow-lg: 0px 6px 12px 0px rgba(0, 0, 0, 0.12),
0px 8px 16px 0px rgba(0, 0, 0, 0.12),
0px 1px 24px 0px rgba(16, 24, 40, 0.12);
--color-shadow-xl: 0px 0px 18px 0px rgba(0, 0, 0, 0.16),
0px 0px 24px 0px rgba(16, 24, 40, 0.16),
0px 0px 52px 0px rgba(16, 24, 40, 0.16);
--color-shadow-2xl: 0px 8px 16px 0px rgba(0, 0, 0, 0.12),
0px 12px 24px 0px rgba(16, 24, 40, 0.12),
0px 1px 32px 0px rgba(16, 24, 40, 0.12);
--color-shadow-3xl: 0px 12px 24px 0px rgba(0, 0, 0, 0.12),
0px 16px 32px 0px rgba(0, 0, 0, 0.12),
0px 1px 48px 0px rgba(16, 24, 40, 0.12);
--color-shadow-4xl: 0px 8px 40px 0px rgba(0, 0, 61, 0.05),
0px 12px 32px -16px rgba(0, 0, 0, 0.05);
--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 */
--color-sidebar-text-100: var(--color-text-100); /* primary sidebar text */
--color-sidebar-text-200: var(
--color-text-200
); /* secondary sidebar text */
--color-sidebar-text-300: var(--color-text-300); /* tertiary sidebar text */
--color-sidebar-text-400: var(
--color-text-400
); /* sidebar placeholder text */
--color-sidebar-border-100: var(
--color-border-100
); /* subtle sidebar border= 1 */
--color-sidebar-border-200: var(
--color-border-100
); /* subtle sidebar border- 2 */
--color-sidebar-border-300: var(
--color-border-100
); /* strong sidebar border- 1 */
--color-sidebar-border-400: var(
--color-border-100
); /* strong sidebar border- 2 */
--color-sidebar-shadow-2xs: var(--color-shadow-2xs);
--color-sidebar-shadow-xs: var(--color-shadow-xs);
--color-sidebar-shadow-sm: var(--color-shadow-sm);
--color-sidebar-shadow-rg: var(--color-shadow-rg);
--color-sidebar-shadow-md: var(--color-shadow-md);
--color-sidebar-shadow-lg: var(--color-shadow-lg);
--color-sidebar-shadow-xl: var(--color-shadow-xl);
--color-sidebar-shadow-2xl: var(--color-shadow-2xl);
--color-sidebar-shadow-3xl: var(--color-shadow-3xl);
--color-sidebar-shadow-4xl: var(--color-shadow-4xl);
}
[data-theme="light"],
[data-theme="light-contrast"] {
color-scheme: light !important;
--color-background-100: 255, 255, 255; /* primary bg */
--color-background-90: 250, 250, 250; /* secondary bg */
--color-background-80: 245, 245, 245; /* tertiary bg */
}
[data-theme="light"] {
--color-text-100: 23, 23, 23; /* primary text */
--color-text-200: 58, 58, 58; /* secondary text */
--color-text-300: 82, 82, 82; /* tertiary text */
--color-text-400: 163, 163, 163; /* placeholder text */
--color-scrollbar: 163, 163, 163; /* scrollbar thumb */
--color-border-100: 245, 245, 245; /* subtle border= 1 */
--color-border-200: 229, 229, 229; /* subtle border- 2 */
--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;
--color-toast-warning-text: 255, 186, 24;
--color-toast-info-text: 51, 88, 212;
--color-toast-loading-text: 28, 32, 36;
--color-toast-secondary-text: 128, 131, 141;
--color-toast-tertiary-text: 96, 100, 108;
--color-toast-success-background: 253, 253, 254;
--color-toast-error-background: 255, 252, 252;
--color-toast-warning-background: 254, 253, 251;
--color-toast-info-background: 253, 253, 254;
--color-toast-loading-background: 253, 253, 254;
--color-toast-success-border: 218, 241, 219;
--color-toast-error-border: 255, 219, 220;
--color-toast-warning-border: 255, 247, 194;
--color-toast-info-border: 210, 222, 255;
--color-toast-loading-border: 224, 225, 230;
}
[data-theme="light-contrast"] {
--color-text-100: 11, 11, 11; /* primary text */
--color-text-200: 38, 38, 38; /* secondary text */
--color-text-300: 58, 58, 58; /* tertiary text */
--color-text-400: 115, 115, 115; /* placeholder text */
--color-scrollbar: 115, 115, 115; /* scrollbar thumb */
--color-border-100: 34, 34, 34; /* subtle border= 1 */
--color-border-200: 38, 38, 38; /* subtle border- 2 */
--color-border-300: 46, 46, 46; /* strong border- 1 */
--color-border-400: 58, 58, 58; /* strong border- 2 */
}
[data-theme="dark"],
[data-theme="dark-contrast"] {
color-scheme: dark !important;
--color-background-100: 7, 7, 7; /* primary bg */
--color-background-90: 11, 11, 11; /* secondary bg */
--color-background-80: 23, 23, 23; /* tertiary bg */
--color-shadow-2xs: 0px 0px 1px 0px rgba(0, 0, 0, 0.15),
0px 1px 3px 0px rgba(0, 0, 0, 0.5);
--color-shadow-xs: 0px 0px 2px 0px rgba(0, 0, 0, 0.2),
0px 2px 4px 0px rgba(0, 0, 0, 0.5);
--color-shadow-sm: 0px 0px 4px 0px rgba(0, 0, 0, 0.2),
0px 2px 6px 0px rgba(0, 0, 0, 0.5);
--color-shadow-rg: 0px 0px 6px 0px rgba(0, 0, 0, 0.2),
0px 4px 6px 0px rgba(0, 0, 0, 0.5);
--color-shadow-md: 0px 2px 8px 0px rgba(0, 0, 0, 0.2),
0px 4px 8px 0px rgba(0, 0, 0, 0.5);
--color-shadow-lg: 0px 4px 12px 0px rgba(0, 0, 0, 0.25),
0px 4px 10px 0px rgba(0, 0, 0, 0.55);
--color-shadow-xl: 0px 0px 14px 0px rgba(0, 0, 0, 0.25),
0px 6px 10px 0px rgba(0, 0, 0, 0.55);
--color-shadow-2xl: 0px 0px 18px 0px rgba(0, 0, 0, 0.25),
0px 8px 12px 0px rgba(0, 0, 0, 0.6);
--color-shadow-3xl: 0px 4px 24px 0px rgba(0, 0, 0, 0.3),
0px 12px 40px 0px rgba(0, 0, 0, 0.65);
}
[data-theme="dark"] {
--color-text-100: 229, 229, 229; /* primary text */
--color-text-200: 163, 163, 163; /* secondary text */
--color-text-300: 115, 115, 115; /* tertiary text */
--color-text-400: 82, 82, 82; /* placeholder text */
--color-scrollbar: 82, 82, 82; /* scrollbar thumb */
--color-border-100: 34, 34, 34; /* subtle border= 1 */
--color-border-200: 38, 38, 38; /* subtle border- 2 */
--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;
--color-toast-warning-text: 255, 186, 24;
--color-toast-info-text: 141, 164, 239;
--color-toast-loading-text: 255, 255, 255;
--color-toast-secondary-text: 185, 187, 198;
--color-toast-tertiary-text: 139, 141, 152;
--color-toast-success-background: 46, 46, 46;
--color-toast-error-background: 46, 46, 46;
--color-toast-warning-background: 46, 46, 46;
--color-toast-info-background: 46, 46, 46;
--color-toast-loading-background: 46, 46, 46;
--color-toast-success-border: 42, 126, 59;
--color-toast-error-border: 100, 23, 35;
--color-toast-warning-border: 79, 52, 34;
--color-toast-info-border: 58, 91, 199;
--color-toast-loading-border: 96, 100, 108;
}
[data-theme="dark-contrast"] {
--color-text-100: 250, 250, 250; /* primary text */
--color-text-200: 241, 241, 241; /* secondary text */
--color-text-300: 212, 212, 212; /* tertiary text */
--color-text-400: 115, 115, 115; /* placeholder text */
--color-scrollbar: 115, 115, 115; /* scrollbar thumb */
--color-border-100: 245, 245, 245; /* subtle border= 1 */
--color-border-200: 229, 229, 229; /* subtle border- 2 */
--color-border-300: 212, 212, 212; /* strong border- 1 */
--color-border-400: 185, 185, 185; /* strong border- 2 */
}
[data-theme="light"],
[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 */
--color-sidebar-text-100: var(--color-text-100); /* primary sidebar text */
--color-sidebar-text-200: var(
--color-text-200
); /* secondary sidebar text */
--color-sidebar-text-300: var(--color-text-300); /* tertiary sidebar text */
--color-sidebar-text-400: var(
--color-text-400
); /* sidebar placeholder text */
--color-sidebar-border-100: var(
--color-border-100
); /* subtle sidebar border= 1 */
--color-sidebar-border-200: var(
--color-border-200
); /* subtle sidebar border- 2 */
--color-sidebar-border-300: var(
--color-border-300
); /* strong sidebar border- 1 */
--color-sidebar-border-400: var(
--color-border-400
); /* strong sidebar border- 2 */
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
font-variant-ligatures: none;
-webkit-font-variant-ligatures: none;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
}
body {
color: rgba(var(--color-text-100));
}
/* scrollbar style */
::-webkit-scrollbar {
display: none;
}
.horizontal-scroll-enable {
overflow-x: scroll;
}
.horizontal-scroll-enable::-webkit-scrollbar {
display: block;
height: 7px;
width: 0;
}
.horizontal-scroll-enable::-webkit-scrollbar-track {
height: 7px;
background-color: rgba(var(--color-background-100));
}
.horizontal-scroll-enable::-webkit-scrollbar-thumb {
border-radius: 5px;
background-color: rgba(var(--color-scrollbar));
}
.vertical-scroll-enable::-webkit-scrollbar {
display: block;
width: 5px;
}
.vertical-scroll-enable::-webkit-scrollbar-track {
width: 5px;
}
.vertical-scroll-enable::-webkit-scrollbar-thumb {
border-radius: 5px;
background-color: rgba(var(--color-background-90));
}
/* end scrollbar style */
/* progress bar */
.progress-bar {
fill: currentColor;
color: rgba(var(--color-sidebar-background-100));
}
::-webkit-input-placeholder,
::placeholder,
:-ms-input-placeholder {
color: rgb(var(--color-text-400));
}
-79
View File
@@ -1,79 +0,0 @@
import { FC } from "react";
import { useForm } from "react-hook-form";
import { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types";
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { ControllerInput } from "@/components/common";
// hooks
import { useInstance } from "@/hooks/store";
type IInstanceImageConfigForm = {
config: IFormattedInstanceConfiguration;
};
type ImageConfigFormValues = Record<TInstanceImageConfigurationKeys, string>;
export const InstanceImageConfigForm: FC<IInstanceImageConfigForm> = (props) => {
const { config } = props;
// store hooks
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
formState: { errors, isSubmitting },
} = useForm<ImageConfigFormValues>({
defaultValues: {
UNSPLASH_ACCESS_KEY: config["UNSPLASH_ACCESS_KEY"],
},
});
const onSubmit = async (formData: ImageConfigFormValues) => {
const payload: Partial<ImageConfigFormValues> = { ...formData };
await updateInstanceConfigurations(payload)
.then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success",
message: "Image Configuration Settings updated successfully",
})
)
.catch((err) => console.error(err));
};
return (
<div className="space-y-8">
<div className="grid-col grid w-full grid-cols-1 items-center justify-between gap-x-16 gap-y-8 lg:grid-cols-2">
<ControllerInput
control={control}
type="password"
name="UNSPLASH_ACCESS_KEY"
label="Access key from your Unsplash account"
description={
<>
You will find your access key in your Unsplash developer console.&nbsp;
<a
href="https://unsplash.com/documentation#creating-a-developer-account"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
Learn more.
</a>
</>
}
placeholder="oXgq-sdfadsaeweqasdfasdf3234234rassd"
error={Boolean(errors.UNSPLASH_ACCESS_KEY)}
required
/>
</div>
<div>
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
</div>
</div>
);
};
-11
View File
@@ -1,11 +0,0 @@
import { ReactNode } from "react";
// layouts
import { AdminLayout } from "@/layouts/admin-layout";
interface ImageLayoutProps {
children: ReactNode;
}
const ImageLayout = ({ children }: ImageLayoutProps) => <AdminLayout>{children}</AdminLayout>;
export default ImageLayout;
-44
View File
@@ -1,44 +0,0 @@
"use client";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
import { Loader } from "@plane/ui";
// components
import { PageHeader } from "@/components/core";
// hooks
import { useInstance } from "@/hooks/store";
// local
import { InstanceImageConfigForm } from "./form";
const InstanceImagePage = observer(() => {
// store
const { formattedConfig, fetchInstanceConfigurations } = useInstance();
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
return (
<>
<PageHeader title="Image - God Mode" />
<div className="relative container mx-auto w-full h-full p-8 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 pb-3 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">Third-party image libraries</div>
<div className="text-sm font-normal text-custom-text-300">
Let your users search and choose images from third-party libraries
</div>
</div>
<div className="flex-grow overflow-hidden overflow-y-auto">
{formattedConfig ? (
<InstanceImageConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="50%" />
<Loader.Item height="50px" width="20%" />
</Loader>
)}
</div>
</div>
</>
);
});
export default InstanceImagePage;
-72
View File
@@ -1,72 +0,0 @@
import { ReactNode } from "react";
import { Metadata } from "next";
// components
import { InstanceFailureView, InstanceSetupForm } from "@/components/instance";
// helpers
import { ASSET_PREFIX } from "@/helpers/common.helper";
// layout
import { DefaultLayout } from "@/layouts/default-layout";
// lib
import { AppProvider } from "@/lib/app-providers";
// styles
import "./globals.css";
// services
import { InstanceService } from "@/services/instance.service";
const instanceService = new InstanceService();
export const metadata: Metadata = {
title: "Plane | Simple, extensible, open-source project management tool.",
description:
"Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.",
openGraph: {
title: "Plane | Simple, extensible, open-source project management tool.",
description:
"Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.",
url: "https://plane.so/",
},
keywords:
"software development, customer feedback, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration",
twitter: {
site: "@planepowers",
},
};
export default async function RootLayout({ children }: { children: ReactNode }) {
const instanceDetails = await instanceService.getInstanceInfo().catch(() => null);
return (
<html lang="en">
<head>
<link rel="apple-touch-icon" sizes="180x180" href={`${ASSET_PREFIX}/favicon/apple-touch-icon.png`} />
<link rel="icon" type="image/png" sizes="32x32" href={`${ASSET_PREFIX}/favicon/favicon-32x32.png`} />
<link rel="icon" type="image/png" sizes="16x16" href={`${ASSET_PREFIX}/favicon/favicon-16x16.png`} />
<link rel="manifest" href={`${ASSET_PREFIX}/site.webmanifest.json`} />
<link rel="shortcut icon" href={`${ASSET_PREFIX}/favicon/favicon.ico`} />
</head>
<body className={`antialiased`}>
<AppProvider initialState={{ instance: instanceDetails }}>
{instanceDetails ? (
<>
{instanceDetails?.instance?.is_setup_done ? (
<>{children}</>
) : (
<DefaultLayout>
<div className="relative w-screen min-h-screen overflow-y-auto px-5 py-10 mx-auto flex justify-center items-center">
<InstanceSetupForm />
</div>
</DefaultLayout>
)}
</>
) : (
<DefaultLayout>
<div className="relative w-screen min-h-[500px] overflow-y-auto px-5 mx-auto flex justify-center items-center">
<InstanceFailureView />
</div>
</DefaultLayout>
)}
</AppProvider>
</body>
</html>
);
}
-11
View File
@@ -1,11 +0,0 @@
import { InstanceSignInForm } from "@/components/login";
// layouts
import { DefaultLayout } from "@/layouts/default-layout";
export default async function LoginPage() {
return (
<DefaultLayout>
<InstanceSignInForm />
</DefaultLayout>
);
}
-5
View File
@@ -1,5 +0,0 @@
export * from "./root";
export * from "./help-section";
export * from "./sidebar-menu";
export * from "./sidebar-dropdown";
export * from "./sidebar-menu-hamburger-toogle";
-57
View File
@@ -1,57 +0,0 @@
"use client";
import { FC, useEffect, useRef } from "react";
import { observer } from "mobx-react-lite";
// hooks
import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar";
import { useTheme } from "@/hooks/store";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
export interface IInstanceSidebar {}
export const InstanceSidebar: FC<IInstanceSidebar> = observer(() => {
// store
const { isSidebarCollapsed, toggleSidebar } = useTheme();
const ref = useRef<HTMLDivElement>(null);
useOutsideClickDetector(ref, () => {
if (isSidebarCollapsed === false) {
if (window.innerWidth < 768) {
toggleSidebar(!isSidebarCollapsed);
}
}
});
useEffect(() => {
const handleResize = () => {
if (window.innerWidth <= 768) {
toggleSidebar(true);
}
};
handleResize();
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, [toggleSidebar]);
return (
<div
className={`inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-custom-sidebar-border-200 bg-custom-sidebar-background-100 duration-300
fixed md:relative
${isSidebarCollapsed ? "-ml-[280px]" : ""}
sm:${isSidebarCollapsed ? "-ml-[280px]" : ""}
md:ml-0 ${isSidebarCollapsed ? "w-[80px]" : "w-[280px]"}
lg:ml-0 ${isSidebarCollapsed ? "w-[80px]" : "w-[280px]"}
`}
>
<div ref={ref} className="flex h-full w-full flex-1 flex-col">
<SidebarDropdown />
<SidebarMenu />
<HelpSection />
</div>
</div>
);
});
@@ -1,147 +0,0 @@
"use client";
import { Fragment, useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { useTheme as useNextTheme } from "next-themes";
import { LogOut, UserCog2, Palette } from "lucide-react";
import { Menu, Transition } from "@headlessui/react";
import { Avatar } from "@plane/ui";
// hooks
import { API_BASE_URL, cn } from "@/helpers/common.helper";
import { useTheme, useUser } from "@/hooks/store";
// helpers
// services
import { AuthService } from "@/services/auth.service";
// service initialization
const authService = new AuthService();
export const SidebarDropdown = observer(() => {
// store hooks
const { isSidebarCollapsed } = useTheme();
const { currentUser, signOut } = useUser();
// hooks
const { resolvedTheme, setTheme } = useNextTheme();
// state
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const handleThemeSwitch = () => {
const newTheme = resolvedTheme === "dark" ? "light" : "dark";
setTheme(newTheme);
};
const handleSignOut = () => signOut();
const getSidebarMenuItems = () => (
<Menu.Items
className={cn(
"absolute left-0 z-20 mt-1.5 flex w-52 flex-col divide-y divide-custom-sidebar-border-100 rounded-md border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-1 py-2 text-xs shadow-lg outline-none",
{
"left-4": isSidebarCollapsed,
}
)}
>
<div className="flex flex-col gap-2.5 pb-2">
<span className="px-2 text-custom-sidebar-text-200">{currentUser?.email}</span>
</div>
<div className="py-2">
<Menu.Item
as="button"
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80"
onClick={handleThemeSwitch}
>
<Palette className="h-4 w-4 stroke-[1.5]" />
Switch to {resolvedTheme === "dark" ? "light" : "dark"} mode
</Menu.Item>
</div>
<div className="py-2">
<form method="POST" action={`${API_BASE_URL}/api/instances/admins/sign-out/`} onSubmit={handleSignOut}>
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
<Menu.Item
as="button"
type="submit"
className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80"
>
<LogOut className="h-4 w-4 stroke-[1.5]" />
Sign out
</Menu.Item>
</form>
</div>
</Menu.Items>
);
useEffect(() => {
if (csrfToken === undefined)
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
}, [csrfToken]);
return (
<div className="flex max-h-[3.75rem] items-center gap-x-5 gap-y-2 border-b border-custom-sidebar-border-200 px-4 py-3.5">
<div className="h-full w-full truncate">
<div
className={`flex flex-grow items-center gap-x-2 truncate rounded py-1 ${
isSidebarCollapsed ? "justify-center" : ""
}`}
>
<Menu as="div" className="flex-shrink-0">
<Menu.Button
className={cn("grid place-items-center outline-none", {
"cursor-default": !isSidebarCollapsed,
})}
>
<div className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded bg-custom-sidebar-background-80">
<UserCog2 className="h-5 w-5 text-custom-text-200" />
</div>
</Menu.Button>
{isSidebarCollapsed && (
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
{getSidebarMenuItems()}
</Transition>
)}
</Menu>
{!isSidebarCollapsed && (
<div className="flex w-full gap-2">
<h4 className="grow truncate text-base font-medium text-custom-text-200">Instance admin</h4>
</div>
)}
</div>
</div>
{!isSidebarCollapsed && currentUser && (
<Menu as="div" className="relative flex-shrink-0">
<Menu.Button className="grid place-items-center outline-none">
<Avatar
name={currentUser.display_name}
src={currentUser.avatar ?? undefined}
size={24}
shape="square"
className="!text-base"
/>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
{getSidebarMenuItems()}
</Transition>
</Menu>
)}
</div>
);
});
@@ -1,20 +0,0 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react-lite";
// hooks
import { Menu } from "lucide-react";
import { useTheme } from "@/hooks/store";
// icons
export const SidebarHamburgerToggle: FC = observer(() => {
const { isSidebarCollapsed, toggleSidebar } = useTheme();
return (
<div
className="w-7 h-7 rounded flex justify-center items-center bg-custom-background-80 transition-all hover:bg-custom-background-90 cursor-pointer group md:hidden"
onClick={() => toggleSidebar(!isSidebarCollapsed)}
>
<Menu size={14} className="text-custom-text-200 group-hover:text-custom-text-100 transition-all" />
</div>
);
});
@@ -1,104 +0,0 @@
"use client";
import { observer } from "mobx-react-lite";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react";
import { Tooltip } from "@plane/ui";
// hooks
import { cn } from "@/helpers/common.helper";
import { useTheme } from "@/hooks/store";
// helpers
const INSTANCE_ADMIN_LINKS = [
{
Icon: Cog,
name: "General",
description: "Identify your instances and get key details",
href: `/general/`,
},
{
Icon: Mail,
name: "Email",
description: "Set up emails to your users",
href: `/email/`,
},
{
Icon: Lock,
name: "Authentication",
description: "Configure authentication modes",
href: `/authentication/`,
},
{
Icon: BrainCog,
name: "Artificial intelligence",
description: "Configure your OpenAI creds",
href: `/ai/`,
},
{
Icon: Image,
name: "Images in Plane",
description: "Allow third-party image libraries",
href: `/image/`,
},
];
export const SidebarMenu = observer(() => {
// store hooks
const { isSidebarCollapsed, toggleSidebar } = useTheme();
// router
const pathName = usePathname();
const handleItemClick = () => {
if (window.innerWidth < 768) {
toggleSidebar(!isSidebarCollapsed);
}
};
return (
<div className="flex h-full w-full flex-col gap-2.5 overflow-y-auto px-4 py-4">
{INSTANCE_ADMIN_LINKS.map((item, index) => {
const isActive = item.href === pathName || pathName.includes(item.href);
return (
<Link key={index} href={item.href} onClick={handleItemClick}>
<div>
<Tooltip tooltipContent={item.name} position="right" className="ml-2" disabled={!isSidebarCollapsed}>
<div
className={cn(
`group flex w-full items-center gap-3 rounded-md px-3 py-2 outline-none transition-colors`,
isActive
? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80",
isSidebarCollapsed ? "justify-center" : "w-[260px]"
)}
>
{<item.Icon className="h-4 w-4 flex-shrink-0" />}
{!isSidebarCollapsed && (
<div className="w-full ">
<div
className={cn(
`text-sm font-medium transition-colors`,
isActive ? "text-custom-primary-100" : "text-custom-sidebar-text-200"
)}
>
{item.name}
</div>
<div
className={cn(
`text-[10px] transition-colors`,
isActive ? "text-custom-primary-90" : "text-custom-sidebar-text-400"
)}
>
{item.description}
</div>
</div>
)}
</div>
</Tooltip>
</div>
</Link>
);
})}
</div>
);
});
-90
View File
@@ -1,90 +0,0 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react-lite";
import { usePathname } from "next/navigation";
// mobx
// ui
import { Settings } from "lucide-react";
// icons
import { Breadcrumbs } from "@plane/ui";
// components
import { SidebarHamburgerToggle } from "@/components/admin-sidebar";
import { BreadcrumbLink } from "components/common";
export const InstanceHeader: FC = observer(() => {
const pathName = usePathname();
const getHeaderTitle = (pathName: string) => {
switch (pathName) {
case "general":
return "General";
case "ai":
return "Artificial Intelligence";
case "email":
return "Email";
case "authentication":
return "Authentication";
case "image":
return "Image";
case "google":
return "Google";
case "github":
return "Github";
default:
return pathName.toUpperCase();
}
};
// Function to dynamically generate breadcrumb items based on pathname
const generateBreadcrumbItems = (pathname: string) => {
const pathSegments = pathname.split("/").slice(1); // removing the first empty string.
pathSegments.pop();
let currentUrl = "";
const breadcrumbItems = pathSegments.map((segment) => {
currentUrl += "/" + segment;
return {
title: getHeaderTitle(segment),
href: currentUrl,
};
});
return breadcrumbItems;
};
const breadcrumbItems = generateBreadcrumbItems(pathName);
return (
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-sidebar-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<SidebarHamburgerToggle />
{breadcrumbItems.length >= 0 && (
<div>
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href="/general/"
label="Settings"
icon={<Settings className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
{breadcrumbItems.map(
(item) =>
item.title && (
<Breadcrumbs.BreadcrumbItem
key={item.title}
type="text"
link={<BreadcrumbLink href={item.href} label={item.title} />}
/>
)
)}
</Breadcrumbs>
</div>
)}
</div>
</div>
);
});
-32
View File
@@ -1,32 +0,0 @@
import { FC } from "react";
import { AlertCircle, CheckCircle2 } from "lucide-react";
type TBanner = {
type: "success" | "error";
message: string;
};
export const Banner: FC<TBanner> = (props) => {
const { type, message } = props;
return (
<div
className={`rounded-md p-2 w-full border ${type === "error" ? "bg-red-500/5 border-red-400" : "bg-green-500/5 border-green-400"}`}
>
<div className="flex items-center justify-center">
<div className="flex-shrink-0">
{type === "error" ? (
<span className="flex items-center justify-center h-6 w-6 rounded-full">
<AlertCircle className="h-5 w-5 text-red-600" aria-hidden="true" />
</span>
) : (
<CheckCircle2 className="h-5 w-5 text-green-600" aria-hidden="true" />
)}
</div>
<div className="ml-1">
<p className={`text-sm font-medium ${type === "error" ? "text-red-600" : "text-green-600"}`}>{message}</p>
</div>
</div>
</div>
);
};
@@ -1,36 +0,0 @@
import Link from "next/link";
import { Tooltip } from "@plane/ui";
type Props = {
label?: string;
href?: string;
icon?: React.ReactNode | undefined;
};
export const BreadcrumbLink: React.FC<Props> = (props) => {
const { href, label, icon } = props;
return (
<Tooltip tooltipContent={label} position="bottom">
<li className="flex items-center space-x-2" tabIndex={-1}>
<div className="flex flex-wrap items-center gap-2.5">
{href ? (
<Link
className="flex items-center gap-1 text-sm font-medium text-custom-text-300 hover:text-custom-text-100"
href={href}
>
{icon && (
<div className="flex h-5 w-5 items-center justify-center overflow-hidden !text-[1rem]">{icon}</div>
)}
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{label}</div>
</Link>
) : (
<div className="flex cursor-default items-center gap-1 text-sm font-medium text-custom-text-100">
{icon && <div className="flex h-5 w-5 items-center justify-center overflow-hidden">{icon}</div>}
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{label}</div>
</div>
)}
</div>
</li>
</Tooltip>
);
};
@@ -1,83 +0,0 @@
import React from "react";
import Link from "next/link";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// ui
import { Button, getButtonStyling } from "@plane/ui";
type Props = {
isOpen: boolean;
handleClose: () => void;
onDiscardHref: string;
};
export const ConfirmDiscardModal: React.FC<Props> = (props) => {
const { isOpen, handleClose, onDiscardHref } = props;
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-50" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-32">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-[30rem]">
<div className="px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mt-3 text-center sm:mt-0 sm:text-left">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-custom-text-300"
>
You have unsaved changes
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-custom-text-400">
Changes you made will be lost if you go back. Do you
wish to go back?
</p>
</div>
</div>
</div>
</div>
<div className="flex justify-end items-center p-4 sm:px-6 gap-2">
<Button
variant="neutral-primary"
size="sm"
onClick={handleClose}
>
Keep editing
</Button>
<Link
href={onDiscardHref}
className={getButtonStyling("primary", "sm")}
>
Go back
</Link>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
@@ -1,84 +0,0 @@
"use client";
import React, { useState } from "react";
import { Controller, Control } from "react-hook-form";
// ui
import { Eye, EyeOff } from "lucide-react";
import { Input } from "@plane/ui";
// icons
// helpers
import { cn } from "@/helpers/common.helper";
type Props = {
control: Control<any>;
type: "text" | "password";
name: string;
label: string;
description?: string | JSX.Element;
placeholder: string;
error: boolean;
required: boolean;
};
export type TControllerInputFormField = {
key: string;
type: "text" | "password";
label: string;
description?: string | JSX.Element;
placeholder: string;
error: boolean;
required: boolean;
};
export const ControllerInput: React.FC<Props> = (props) => {
const { name, control, type, label, description, placeholder, error, required } = props;
// states
const [showPassword, setShowPassword] = useState(false);
return (
<div className="flex flex-col gap-1">
<h4 className="text-sm text-custom-text-300">{label}</h4>
<div className="relative">
<Controller
control={control}
name={name}
rules={{ required: required ? `${label} is required.` : false }}
render={({ field: { value, onChange, ref } }) => (
<Input
id={name}
name={name}
type={type === "password" && showPassword ? "text" : type}
value={value}
onChange={onChange}
ref={ref}
hasError={error}
placeholder={placeholder}
className={cn("w-full rounded-md font-medium", {
"pr-10": type === "password",
})}
/>
)}
/>
{type === "password" &&
(showPassword ? (
<button
tabIndex={-1}
className="absolute right-3 top-2.5 flex items-center justify-center text-custom-text-400"
onClick={() => setShowPassword(false)}
>
<EyeOff className="h-4 w-4" />
</button>
) : (
<button
tabIndex={-1}
className="absolute right-3 top-2.5 flex items-center justify-center text-custom-text-400"
onClick={() => setShowPassword(true)}
>
<Eye className="h-4 w-4" />
</button>
))}
</div>
{description && <p className="text-xs text-custom-text-300">{description}</p>}
</div>
);
};
-46
View File
@@ -1,46 +0,0 @@
"use client";
import React from "react";
// ui
import { Copy } from "lucide-react";
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// icons
type Props = {
label: string;
url: string;
description: string | JSX.Element;
};
export type TCopyField = {
key: string;
label: string;
url: string;
description: string | JSX.Element;
};
export const CopyField: React.FC<Props> = (props) => {
const { label, url, description } = props;
return (
<div className="flex flex-col gap-1">
<h4 className="text-sm text-custom-text-300">{label}</h4>
<Button
variant="neutral-primary"
className="flex items-center justify-between py-2"
onClick={() => {
navigator.clipboard.writeText(url);
setToast({
type: TOAST_TYPE.INFO,
title: "Copied to clipboard",
message: `The ${label} has been successfully copied to your clipboard`,
});
}}
>
<p className="text-sm font-medium">{url}</p>
<Copy size={18} color="#B9B9B9" />
</Button>
<div className="text-xs text-custom-text-400">{description}</div>
</div>
);
};
-46
View File
@@ -1,46 +0,0 @@
import React from "react";
import Image from "next/image";
import { Button } from "@plane/ui";
type Props = {
title: string;
description?: React.ReactNode;
image?: any;
primaryButton?: {
icon?: any;
text: string;
onClick: () => void;
};
secondaryButton?: React.ReactNode;
disabled?: boolean;
};
export const EmptyState: React.FC<Props> = ({
title,
description,
image,
primaryButton,
secondaryButton,
disabled = false,
}) => (
<div className={`flex h-full w-full items-center justify-center`}>
<div className="flex w-full flex-col items-center text-center">
{image && <Image src={image} className="w-52 sm:w-60" alt={primaryButton?.text || "button image"} />}
<h6 className="mb-3 mt-6 text-xl font-semibold sm:mt-8">{title}</h6>
{description && <p className="mb-7 px-5 text-custom-text-300 sm:mb-8">{description}</p>}
<div className="flex items-center gap-4">
{primaryButton && (
<Button
variant="primary"
prependIcon={primaryButton.icon}
onClick={primaryButton.onClick}
disabled={disabled}
>
{primaryButton.text}
</Button>
)}
{secondaryButton}
</div>
</div>
</div>
);
-8
View File
@@ -1,8 +0,0 @@
export * from "./breadcrumb-link";
export * from "./confirm-discard-modal";
export * from "./controller-input";
export * from "./copy-field";
export * from "./password-strength-meter";
export * from "./banner";
export * from "./empty-state";
export * from "./logo-spinner";
-17
View File
@@ -1,17 +0,0 @@
import Image from "next/image";
import { useTheme } from "next-themes";
// assets
import LogoSpinnerDark from "@/public/images/logo-spinner-dark.gif";
import LogoSpinnerLight from "@/public/images/logo-spinner-light.gif";
export const LogoSpinner = () => {
const { resolvedTheme } = useTheme();
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerDark : LogoSpinnerLight;
return (
<div className="flex items-center justify-center">
<Image src={logoSrc} alt="logo" className="w-[82px] h-[82px] mr-2" />
</div>
);
};
@@ -1,69 +0,0 @@
"use client";
// helpers
import { CircleCheck } from "lucide-react";
import { cn } from "@/helpers/common.helper";
import { getPasswordStrength } from "@/helpers/password.helper";
// icons
type Props = {
password: string;
};
export const PasswordStrengthMeter: React.FC<Props> = (props: Props) => {
const { password } = props;
const strength = getPasswordStrength(password);
let bars = [];
let text = "";
let textColor = "";
if (password.length === 0) {
bars = [`bg-[#F0F0F3]`, `bg-[#F0F0F3]`, `bg-[#F0F0F3]`];
text = "Password requirements";
} else if (password.length < 8) {
bars = [`bg-[#DC3E42]`, `bg-[#F0F0F3]`, `bg-[#F0F0F3]`];
text = "Password is too short";
textColor = `text-[#DC3E42]`;
} else if (strength < 3) {
bars = [`bg-[#FFBA18]`, `bg-[#FFBA18]`, `bg-[#F0F0F3]`];
text = "Password is weak";
textColor = `text-[#FFBA18]`;
} else {
bars = [`bg-[#3E9B4F]`, `bg-[#3E9B4F]`, `bg-[#3E9B4F]`];
text = "Password is strong";
textColor = `text-[#3E9B4F]`;
}
const criteria = [
{ label: "Min 8 characters", isValid: password.length >= 8 },
{ label: "Min 1 upper-case letter", isValid: /[A-Z]/.test(password) },
{ label: "Min 1 number", isValid: /\d/.test(password) },
{ label: "Min 1 special character", isValid: /[!@#$%^&*]/.test(password) },
];
return (
<div className="w-full p-1">
<div className="flex w-full gap-1.5">
{bars.map((color, index) => (
<div key={index} className={cn("w-full h-1 rounded-full", color)} />
))}
</div>
<p className={cn("text-xs font-medium py-1", textColor)}>{text}</p>
<div className="flex flex-wrap gap-x-4 gap-y-2">
{criteria.map((criterion, index) => (
<div
key={index}
className={cn(
"flex items-center gap-1 text-xs font-medium",
criterion.isValid ? `text-[#3E9B4F]` : "text-custom-text-400"
)}
>
<CircleCheck width={14} height={14} />
{criterion.label}
</div>
))}
</div>
</div>
);
};
-1
View File
@@ -1 +0,0 @@
export * from "./page-header";
-17
View File
@@ -1,17 +0,0 @@
"use client";
type TPageHeader = {
title?: string;
description?: string;
};
export const PageHeader: React.FC<TPageHeader> = (props) => {
const { title = "God Mode - Plane", description = "Plane god mode" } = props;
return (
<>
<title>{title}</title>
<meta name="description" content={description} />
</>
);
};
-3
View File
@@ -1,3 +0,0 @@
export * from "./instance-not-ready";
export * from "./instance-failure-view";
export * from "./setup-form";
@@ -1,42 +0,0 @@
"use client";
import { FC } from "react";
import Image from "next/image";
import { useTheme } from "next-themes";
import { Button } from "@plane/ui";
// assets
import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg";
import InstanceFailureImage from "@/public/instance/instance-failure.svg";
type InstanceFailureViewProps = {
// mutate: () => void;
};
export const InstanceFailureView: FC<InstanceFailureViewProps> = () => {
const { resolvedTheme } = useTheme();
const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage;
const handleRetry = () => {
window.location.reload();
};
return (
<div className="h-full w-full relative container px-5 mx-auto flex justify-center items-center mt-10">
<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,30 +0,0 @@
"use client";
import { FC } from "react";
import Image from "next/image";
import Link from "next/link";
import { Button } from "@plane/ui";
// assets
import PlaneTakeOffImage from "@/public/images/plane-takeoff.png";
export const InstanceNotReady: FC = () => (
<div className="h-full w-full relative container px-5 mx-auto flex justify-center items-center">
<div className="w-auto max-w-2xl relative space-y-8 py-10">
<div className="relative flex flex-col justify-center items-center space-y-4">
<h1 className="text-3xl font-bold pb-3">Welcome aboard Plane!</h1>
<Image src={PlaneTakeOffImage} alt="Plane Logo" />
<p className="font-medium text-base text-onboarding-text-400">
Get started by setting up your instance and workspace
</p>
</div>
<div>
<Link href={"/setup/?auth_enabled=0"}>
<Button size="lg" className="w-full">
Get started
</Button>
</Link>
</div>
</div>
</div>
);
-344
View File
@@ -1,344 +0,0 @@
"use client";
import { FC, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
// icons
import { Eye, EyeOff } from "lucide-react";
// ui
import { Button, Checkbox, Input, Spinner } from "@plane/ui";
// components
import { Banner, PasswordStrengthMeter } from "@/components/common";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
import { getPasswordStrength } from "@/helpers/password.helper";
// services
import { AuthService } from "@/services/auth.service";
// service initialization
const authService = new AuthService();
// error codes
enum EErrorCodes {
INSTANCE_NOT_CONFIGURED = "INSTANCE_NOT_CONFIGURED",
ADMIN_ALREADY_EXIST = "ADMIN_ALREADY_EXIST",
REQUIRED_EMAIL_PASSWORD_FIRST_NAME = "REQUIRED_EMAIL_PASSWORD_FIRST_NAME",
INVALID_EMAIL = "INVALID_EMAIL",
INVALID_PASSWORD = "INVALID_PASSWORD",
USER_ALREADY_EXISTS = "USER_ALREADY_EXISTS",
}
type TError = {
type: EErrorCodes | undefined;
message: string | undefined;
};
// form data
type TFormData = {
first_name: string;
last_name: string;
email: string;
company_name: string;
password: string;
confirm_password?: string;
is_telemetry_enabled: boolean;
};
const defaultFromData: TFormData = {
first_name: "",
last_name: "",
email: "",
company_name: "",
password: "",
is_telemetry_enabled: true,
};
export const InstanceSetupForm: FC = (props) => {
const {} = props;
// search params
const searchParams = useSearchParams();
const firstNameParam = searchParams.get("first_name") || undefined;
const lastNameParam = searchParams.get("last_name") || undefined;
const companyParam = searchParams.get("company") || undefined;
const emailParam = searchParams.get("email") || undefined;
const isTelemetryEnabledParam = (searchParams.get("is_telemetry_enabled") === "True" ? true : false) || true;
const errorCode = searchParams.get("error_code") || undefined;
const errorMessage = searchParams.get("error_message") || undefined;
// state
const [showPassword, setShowPassword] = useState(false);
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const [formData, setFormData] = useState<TFormData>(defaultFromData);
const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false);
const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
setFormData((prev) => ({ ...prev, [key]: value }));
useEffect(() => {
if (csrfToken === undefined)
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
}, [csrfToken]);
useEffect(() => {
if (firstNameParam) setFormData((prev) => ({ ...prev, first_name: firstNameParam }));
if (lastNameParam) setFormData((prev) => ({ ...prev, last_name: lastNameParam }));
if (companyParam) setFormData((prev) => ({ ...prev, company_name: companyParam }));
if (emailParam) setFormData((prev) => ({ ...prev, email: emailParam }));
if (isTelemetryEnabledParam) setFormData((prev) => ({ ...prev, is_telemetry_enabled: isTelemetryEnabledParam }));
}, [firstNameParam, lastNameParam, companyParam, emailParam, isTelemetryEnabledParam]);
// derived values
const errorData: TError = useMemo(() => {
if (errorCode && errorMessage) {
switch (errorCode) {
case EErrorCodes.INSTANCE_NOT_CONFIGURED:
return { type: EErrorCodes.INSTANCE_NOT_CONFIGURED, message: errorMessage };
case EErrorCodes.ADMIN_ALREADY_EXIST:
return { type: EErrorCodes.ADMIN_ALREADY_EXIST, message: errorMessage };
case EErrorCodes.REQUIRED_EMAIL_PASSWORD_FIRST_NAME:
return { type: EErrorCodes.REQUIRED_EMAIL_PASSWORD_FIRST_NAME, message: errorMessage };
case EErrorCodes.INVALID_EMAIL:
return { type: EErrorCodes.INVALID_EMAIL, message: errorMessage };
case EErrorCodes.INVALID_PASSWORD:
return { type: EErrorCodes.INVALID_PASSWORD, message: errorMessage };
case EErrorCodes.USER_ALREADY_EXISTS:
return { type: EErrorCodes.USER_ALREADY_EXISTS, message: errorMessage };
default:
return { type: undefined, message: undefined };
}
} else return { type: undefined, message: undefined };
}, [errorCode, errorMessage]);
const isButtonDisabled = useMemo(
() =>
!isSubmitting &&
formData.first_name &&
formData.email &&
formData.password &&
getPasswordStrength(formData.password) >= 3 &&
formData.password === formData.confirm_password
? false
: true,
[formData.confirm_password, formData.email, formData.first_name, formData.password, isSubmitting]
);
return (
<div className="max-w-lg px-10 lg:max-w-md lg:px-5">
<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>
{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} />
<div className="flex items-center gap-4">
<div className="w-full space-y-1">
<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-onboarding-border-100 !bg-onboarding-background-200 placeholder:text-onboarding-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)}
autoFocus
/>
</div>
<div className="w-full space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="last_name">
Last 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="last_name"
name="last_name"
type="text"
inputSize="md"
placeholder="Wright"
value={formData.last_name}
onChange={(e) => handleFormChange("last_name", e.target.value)}
/>
</div>
</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}
/>
{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-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 ? "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)}
/>
{showPassword ? (
<button
type="button"
tabIndex={-1}
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"
tabIndex={-1}
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>
{errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD && errorData.message && (
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
)}
{isPasswordInputFocused && <PasswordStrengthMeter password={formData.password} />}
</div>
<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 ? "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 ? (
<button
type="button"
tabIndex={-1}
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"
tabIndex={-1}
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>
{!!formData.confirm_password &&
formData.password !== formData.confirm_password &&
!isRetryPasswordInputFocused && <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
id="is_telemetry_enabled"
name="is_telemetry_enabled"
value={formData.is_telemetry_enabled ? "True" : "False"}
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://docs.plane.so/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>
);
};
-179
View File
@@ -1,179 +0,0 @@
"use client";
import { FC, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
// services
import { Eye, EyeOff } from "lucide-react";
import { Button, Input, Spinner } from "@plane/ui";
// components
import { Banner } from "@/components/common";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
import { AuthService } from "@/services/auth.service";
// ui
// icons
// service initialization
const authService = new AuthService();
// error codes
enum EErrorCodes {
INSTANCE_NOT_CONFIGURED = "INSTANCE_NOT_CONFIGURED",
REQUIRED_EMAIL_PASSWORD = "REQUIRED_EMAIL_PASSWORD",
INVALID_EMAIL = "INVALID_EMAIL",
USER_DOES_NOT_EXIST = "USER_DOES_NOT_EXIST",
AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED",
}
type TError = {
type: EErrorCodes | undefined;
message: string | undefined;
};
// form data
type TFormData = {
email: string;
password: string;
};
const defaultFromData: TFormData = {
email: "",
password: "",
};
export const InstanceSignInForm: FC = (props) => {
const {} = props;
// search params
const searchParams = useSearchParams();
const emailParam = searchParams.get("email") || undefined;
const errorCode = searchParams.get("error_code") || undefined;
const errorMessage = searchParams.get("error_message") || undefined;
// state
const [showPassword, setShowPassword] = useState(false);
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const [formData, setFormData] = useState<TFormData>(defaultFromData);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
setFormData((prev) => ({ ...prev, [key]: value }));
console.log("csrfToken", csrfToken);
useEffect(() => {
if (csrfToken === undefined)
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
}, [csrfToken]);
useEffect(() => {
if (emailParam) setFormData((prev) => ({ ...prev, email: emailParam }));
}, [emailParam]);
// derived values
const errorData: TError = useMemo(() => {
if (errorCode && errorMessage) {
switch (errorCode) {
case EErrorCodes.INSTANCE_NOT_CONFIGURED:
return { type: EErrorCodes.INVALID_EMAIL, message: errorMessage };
case EErrorCodes.REQUIRED_EMAIL_PASSWORD:
return { type: EErrorCodes.REQUIRED_EMAIL_PASSWORD, message: errorMessage };
case EErrorCodes.INVALID_EMAIL:
return { type: EErrorCodes.INVALID_EMAIL, message: errorMessage };
case EErrorCodes.USER_DOES_NOT_EXIST:
return { type: EErrorCodes.USER_DOES_NOT_EXIST, message: errorMessage };
case EErrorCodes.AUTHENTICATION_FAILED:
return { type: EErrorCodes.AUTHENTICATION_FAILED, message: errorMessage };
default:
return { type: undefined, message: undefined };
}
} else return { type: undefined, message: undefined };
}, [errorCode, errorMessage]);
const isButtonDisabled = useMemo(
() => (!isSubmitting && formData.email && formData.password ? false : true),
[formData.email, formData.password, isSubmitting]
);
return (
<div className="relative w-full h-full overflow-hidden container mx-auto max-w-lg px-10 lg:max-w-md lg:px-5 py-10 flex flex-col justify-center items-center">
<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>
{errorData.type && errorData?.message && <Banner type="error" message={errorData?.message} />}
<form
className="space-y-4"
method="POST"
action={`${API_BASE_URL}/api/instances/admins/sign-in/`}
onSubmit={() => setIsSubmitting(true)}
onError={() => setIsSubmitting(false)}
>
<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)}
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)}
/>
{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>
</div>
</div>
);
};
-56
View File
@@ -1,56 +0,0 @@
"use client";
import React from "react";
import { observer } from "mobx-react-lite";
import Image from "next/image";
import { useTheme as nextUseTheme } from "next-themes";
// ui
import { Button, getButtonStyling } from "@plane/ui";
// helpers
import { resolveGeneralTheme } from "helpers/common.helper";
// hooks
import { useInstance, useTheme } from "@/hooks/store";
// icons
import TakeoffIconLight from "/public/logos/takeoff-icon-light.svg";
import TakeoffIconDark from "/public/logos/takeoff-icon-dark.svg";
export const NewUserPopup: React.FC = observer(() => {
// hooks
const { isNewUserPopup, toggleNewUserPopup } = useTheme();
const { instance } = useInstance();
// theme
const { resolvedTheme } = nextUseTheme();
const redirectionLink = `${instance?.config?.app_base_url ? `${instance?.config?.app_base_url}/create-workspace` : `/god-mode/`}`;
if (!isNewUserPopup) return <></>;
return (
<div className="absolute bottom-8 right-8 p-6 w-96 border border-custom-border-100 shadow-md rounded-lg bg-custom-background-100">
<div className="flex gap-4">
<div className="grow">
<div className="text-base font-semibold">Create workspace</div>
<div className="py-2 text-sm font-medium text-custom-text-300">
Instance setup done! Welcome to Plane instance portal. Start your journey with by creating your first
workspace, you will need to login again.
</div>
<div className="flex items-center gap-4 pt-2">
<a href={redirectionLink} className={getButtonStyling("primary", "sm")}>
Create workspace
</a>
<Button variant="neutral-primary" size="sm" onClick={toggleNewUserPopup}>
Close
</Button>
</div>
</div>
<div className="shrink-0 flex items-center justify-center">
<Image
src={resolveGeneralTheme(resolvedTheme) === "dark" ? TakeoffIconDark : TakeoffIconLight}
height={80}
width={80}
alt="Plane icon"
/>
</div>
</div>
</div>
);
});
-8
View File
@@ -1,8 +0,0 @@
export const SITE_NAME = "Plane | Simple, extensible, open-source project management tool.";
export const SITE_TITLE = "Plane | Simple, extensible, open-source project management tool.";
export const SITE_DESCRIPTION =
"Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.";
export const SITE_KEYWORDS =
"software development, plan, ship, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration";
export const SITE_URL = "https://app.plane.so/";
export const TWITTER_USER_NAME = "Plane | Simple, extensible, open-source project management tool.";
-8
View File
@@ -1,8 +0,0 @@
export const SWR_CONFIG = {
refreshWhenHidden: false,
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnMount: true,
refreshInterval: 600000,
errorRetryCount: 3,
};
-127
View File
@@ -1,127 +0,0 @@
import { ReactNode } from "react";
import Link from "next/link";
export enum EPageTypes {
PUBLIC = "PUBLIC",
NON_AUTHENTICATED = "NON_AUTHENTICATED",
SET_PASSWORD = "SET_PASSWORD",
ONBOARDING = "ONBOARDING",
AUTHENTICATED = "AUTHENTICATED",
}
export enum EAuthModes {
SIGN_IN = "SIGN_IN",
SIGN_UP = "SIGN_UP",
}
export enum EAuthSteps {
EMAIL = "EMAIL",
PASSWORD = "PASSWORD",
UNIQUE_CODE = "UNIQUE_CODE",
}
export enum EErrorAlertType {
BANNER_ALERT = "BANNER_ALERT",
INLINE_FIRST_NAME = "INLINE_FIRST_NAME",
INLINE_EMAIL = "INLINE_EMAIL",
INLINE_PASSWORD = "INLINE_PASSWORD",
INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE",
}
export enum EAuthenticationErrorCodes {
// Admin
ADMIN_ALREADY_EXIST = "5150",
REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155",
INVALID_ADMIN_EMAIL = "5160",
INVALID_ADMIN_PASSWORD = "5165",
REQUIRED_ADMIN_EMAIL_PASSWORD = "5170",
ADMIN_AUTHENTICATION_FAILED = "5175",
ADMIN_USER_ALREADY_EXIST = "5180",
ADMIN_USER_DOES_NOT_EXIST = "5185",
}
export type TAuthErrorInfo = {
type: EErrorAlertType;
code: EAuthenticationErrorCodes;
title: string;
message: ReactNode;
};
const errorCodeMessages: {
[key in EAuthenticationErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode };
} = {
// admin
[EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST]: {
title: `Admin already exists`,
message: () => `Admin already exists. Please try again.`,
},
[EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: {
title: `Email, password and first name required`,
message: () => `Email, password and first name required. Please try again.`,
},
[EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL]: {
title: `Invalid admin email`,
message: () => `Invalid admin email. Please try again.`,
},
[EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD]: {
title: `Invalid admin password`,
message: () => `Invalid admin password. Please try again.`,
},
[EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: {
title: `Email and password required`,
message: () => `Email and password required. Please try again.`,
},
[EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED]: {
title: `Authentication failed`,
message: () => `Authentication failed. Please try again.`,
},
[EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST]: {
title: `Admin user already exists`,
message: () => (
<div>
Admin user already exists.&nbsp;
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
Sign In
</Link>
&nbsp;now.
</div>
),
},
[EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: {
title: `Admin user does not exist`,
message: () => (
<div>
Admin user does not exist.&nbsp;
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
Sign In
</Link>
&nbsp;now.
</div>
),
},
};
export const authErrorHandler = (
errorCode: EAuthenticationErrorCodes,
email?: string | undefined
): TAuthErrorInfo | undefined => {
const bannerAlertErrorCodes = [
EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL,
EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD,
EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD,
EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED,
EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST,
EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST,
];
if (bannerAlertErrorCodes.includes(errorCode))
return {
type: EErrorAlertType.BANNER_ALERT,
code: errorCode,
title: errorCodeMessages[errorCode]?.title || "Error",
message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.",
};
return undefined;
};
-18
View File
@@ -1,18 +0,0 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "";
export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";
export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || "";
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "";
export const WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL || "";
export const ASSET_PREFIX = ADMIN_BASE_PATH;
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
export const resolveGeneralTheme = (resolvedTheme: string | undefined) =>
resolvedTheme?.includes("light") ? "light" : resolvedTheme?.includes("dark") ? "dark" : "system";
-2
View File
@@ -1,2 +0,0 @@
export * from "./instance.helper";
export * from "./user.helper";
-9
View File
@@ -1,9 +0,0 @@
export enum EInstanceStatus {
ERROR = "ERROR",
NOT_YET_READY = "NOT_YET_READY",
}
export type TInstanceStatus = {
status: EInstanceStatus | undefined;
data?: object;
};
-16
View File
@@ -1,16 +0,0 @@
import zxcvbn from "zxcvbn";
export const isPasswordCriteriaMet = (password: string) => {
const criteria = [password.length >= 8, /[A-Z]/.test(password), /\d/.test(password), /[!@#$%^&*]/.test(password)];
return criteria.every((criterion) => criterion);
};
export const getPasswordStrength = (password: string) => {
if (password.length === 0) return 0;
if (password.length < 8) return 1;
if (!isPasswordCriteriaMet(password)) return 2;
const result = zxcvbn(password);
return result.score;
};
-21
View File
@@ -1,21 +0,0 @@
export enum EAuthenticationPageType {
STATIC = "STATIC",
NOT_AUTHENTICATED = "NOT_AUTHENTICATED",
AUTHENTICATED = "AUTHENTICATED",
}
export enum EInstancePageType {
PRE_SETUP = "PRE_SETUP",
POST_SETUP = "POST_SETUP",
}
export enum EUserStatus {
ERROR = "ERROR",
AUTHENTICATION_NOT_DONE = "AUTHENTICATION_NOT_DONE",
NOT_YET_READY = "NOT_YET_READY",
}
export type TUserStatus = {
status: EUserStatus | undefined;
message?: string;
};
-3
View File
@@ -1,3 +0,0 @@
export * from "./use-theme";
export * from "./use-instance";
export * from "./use-user";
-10
View File
@@ -1,10 +0,0 @@
import { useContext } from "react";
// store
import { StoreContext } from "@/lib/app-providers";
import { IInstanceStore } from "@/store/instance.store";
export const useInstance = (): IInstanceStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useInstance must be used within StoreProvider");
return context.instance;
};
-10
View File
@@ -1,10 +0,0 @@
import { useContext } from "react";
// store
import { StoreContext } from "@/lib/app-providers";
import { IThemeStore } from "@/store/theme.store";
export const useTheme = (): IThemeStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useTheme must be used within StoreProvider");
return context.theme;
};
-10
View File
@@ -1,10 +0,0 @@
import { useContext } from "react";
// store
import { StoreContext } from "@/lib/app-providers";
import { IUserStore } from "@/store/user.store";
export const useUser = (): IUserStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useUser must be used within StoreProvider");
return context.user;
};
@@ -1,21 +0,0 @@
"use client";
import React, { useEffect } from "react";
const useOutsideClickDetector = (ref: React.RefObject<HTMLElement>, callback: () => void) => {
const handleClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback();
}
};
useEffect(() => {
document.addEventListener("mousedown", handleClick);
return () => {
document.removeEventListener("mousedown", handleClick);
};
});
};
export default useOutsideClickDetector;
-56
View File
@@ -1,56 +0,0 @@
"use client";
import { FC, ReactNode, useEffect } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/navigation";
import useSWR from "swr";
// components
import { InstanceSidebar } from "@/components/admin-sidebar";
import { InstanceHeader } from "@/components/auth-header";
import { LogoSpinner } from "@/components/common";
import { NewUserPopup } from "@/components/new-user-popup";
// hooks
import { useInstance, useUser } from "@/hooks/store";
type TAdminLayout = {
children: ReactNode;
};
export const AdminLayout: FC<TAdminLayout> = observer((props) => {
const { children } = props;
// router
const router = useRouter();
// hooks
const { fetchInstanceAdmins } = useInstance();
const { fetchCurrentUser, isUserLoggedIn } = useUser();
useSWR("INSTANCE_ADMINS", () => fetchInstanceAdmins());
useSWR("CURRENT_USER", () => fetchCurrentUser(), {
shouldRetryOnError: false,
});
useEffect(() => {
if (isUserLoggedIn === false) {
router.push("/");
}
}, [router, isUserLoggedIn]);
if (isUserLoggedIn === undefined) {
return (
<div className="relative flex h-screen w-full items-center justify-center">
<LogoSpinner />
</div>
);
}
return (
<div className="relative flex h-screen w-screen overflow-hidden">
<InstanceSidebar />
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
<InstanceHeader />
<div className="h-full w-full overflow-hidden">{children}</div>
</main>
<NewUserPopup />
</div>
);
});
-40
View File
@@ -1,40 +0,0 @@
"use client";
import { FC, ReactNode } from "react";
import Image from "next/image";
import { useTheme } from "next-themes";
// logo/ images
import PlaneBackgroundPatternDark from "public/auth/background-pattern-dark.svg";
import PlaneBackgroundPattern from "public/auth/background-pattern.svg";
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
type TDefaultLayout = {
children: ReactNode;
withoutBackground?: boolean;
};
export const DefaultLayout: FC<TDefaultLayout> = (props) => {
const { children, withoutBackground = false } = props;
// hooks
const { resolvedTheme } = useTheme();
const patternBackground = resolvedTheme === "dark" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern;
return (
<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">
<Image src={BluePlaneLogoWithoutText} height={30} width={30} alt="Plane Logo" />
<span className="text-2xl font-semibold sm:text-3xl">Plane</span>
</div>
</div>
{!withoutBackground && (
<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 mb-[110px] flex-grow">{children}</div>
</div>
</div>
);
};
-43
View File
@@ -1,43 +0,0 @@
"use client";
import { ReactNode, createContext } from "react";
import { ThemeProvider } from "next-themes";
// store
import { RootStore } from "@/store/root.store";
// store initialization
import { AppWrapper } from "./app-wrapper";
let rootStore = new RootStore();
export const StoreContext = createContext(rootStore);
function initializeStore(initialData = {}) {
const singletonRootStore = rootStore ?? new RootStore();
// If your page has Next.js data fetching methods that use a Mobx store, it will
// get hydrated here, check `pages/ssg.js` and `pages/ssr.js` for more details
if (initialData) {
singletonRootStore.hydrate(initialData);
}
// For SSG and SSR always create a new store
if (typeof window === "undefined") return singletonRootStore;
// Create the store once in the client
if (!rootStore) rootStore = singletonRootStore;
return singletonRootStore;
}
export type AppProviderProps = {
children: ReactNode;
initialState: any;
};
export const AppProvider = ({ children, initialState = {} }: AppProviderProps) => {
const store = initializeStore(initialState);
return (
<ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem>
<StoreContext.Provider value={store}>
<AppWrapper>{children}</AppWrapper>
</StoreContext.Provider>
</ThemeProvider>
);
};
-36
View File
@@ -1,36 +0,0 @@
"use client";
import { FC, ReactNode, useEffect, Suspense } from "react";
import { observer } from "mobx-react-lite";
import { SWRConfig } from "swr";
// ui
import { Toast } from "@plane/ui";
// constants
import { SWR_CONFIG } from "@/constants/swr-config";
// helpers
import { resolveGeneralTheme } from "@/helpers/common.helper";
// hooks
import { useTheme, useUser } from "@/hooks/store";
interface IAppWrapper {
children: ReactNode;
}
export const AppWrapper: FC<IAppWrapper> = observer(({ children }) => {
// hooks
const { theme, isSidebarCollapsed, toggleSidebar } = useTheme();
const { currentUser } = useUser();
useEffect(() => {
const localValue = localStorage && localStorage.getItem("god_mode_sidebar_collapsed");
const localBoolValue = localValue ? (localValue === "true" ? true : false) : false;
if (isSidebarCollapsed === undefined && localBoolValue != isSidebarCollapsed) toggleSidebar(localBoolValue);
}, [isSidebarCollapsed, currentUser, toggleSidebar]);
return (
<Suspense>
<Toast theme={resolveGeneralTheme(theme)} />
<SWRConfig value={SWR_CONFIG}>{children}</SWRConfig>
</Suspense>
);
});
-5
View File
@@ -1,5 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
-13
View File
@@ -1,13 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
trailingSlash: true,
reactStrictMode: false,
swcMinify: true,
output: "standalone",
images: {
unoptimized: true,
},
basePath: process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "",
};
module.exports = nextConfig;
-50
View File
@@ -1,50 +0,0 @@
{
"name": "admin",
"version": "0.17.0",
"private": true,
"scripts": {
"dev": "turbo run develop",
"develop": "next dev --port 3001",
"build": "next build",
"preview": "next build && next start",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@headlessui/react": "^1.7.19",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/constants": "*",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
"axios": "^1.6.7",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"lucide-react": "^0.356.0",
"mobx": "^6.12.0",
"mobx-react-lite": "^4.0.5",
"next": "^14.2.3",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.51.0",
"swr": "^2.2.4",
"tailwindcss": "3.3.2",
"uuid": "^9.0.1",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@types/js-cookie": "^3.0.6",
"@types/node": "18.16.1",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.8",
"@types/zxcvbn": "^4.4.4",
"eslint-config-custom": "*",
"tailwind-config-custom": "*",
"tsconfig": "*",
"typescript": "^5.4.2"
}
}
-8
View File
@@ -1,8 +0,0 @@
module.exports = {
plugins: {
"postcss-import": {},
"tailwindcss/nesting": {},
tailwindcss: {},
autoprefixer: {},
},
};
@@ -1,68 +0,0 @@
<svg width="1512" height="900" viewBox="0 0 1512 900" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4817_18724)">
<rect width="1512" height="900" fill="#1B1C1E"/>
<g opacity="0.09">
<line x1="-10.6172" y1="624.328" x2="1500.96" y2="624.328" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="-10.6172" y1="301.59" x2="1500.96" y2="301.59" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="-10.6172" y1="462.958" x2="1500.96" y2="462.958" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="-10.6172" y1="785.696" x2="1500.96" y2="785.696" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="-10.6172" y1="140.22" x2="1500.96" y2="140.22" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="-10.6172" y1="543.642" x2="1500.96" y2="543.642" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="-10.6172" y1="866.381" x2="1500.96" y2="866.381" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="-10.6172" y1="220.904" x2="1500.96" y2="220.904" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="-10.6172" y1="382.272" x2="1500.96" y2="382.272" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="-10.6172" y1="705.012" x2="1500.96" y2="705.013" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="-10.6172" y1="59.534" x2="1500.96" y2="59.534" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="36.3273" y1="-49.8457" x2="36.3273" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="681.808" y1="-49.8457" x2="681.808" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="359.068" y1="-49.8457" x2="359.068" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="1004.54" y1="-49.8457" x2="1004.54" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="1327.28" y1="-49.8457" x2="1327.28" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="197.698" y1="-49.8457" x2="197.698" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="843.173" y1="-49.8457" x2="843.173" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="520.439" y1="-49.8457" x2="520.439" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="1165.92" y1="-49.8457" x2="1165.92" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="1488.66" y1="-49.8457" x2="1488.66" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="117.015" y1="-49.8457" x2="117.015" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="762.491" y1="-49.8457" x2="762.491" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="439.751" y1="-49.8457" x2="439.751" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="1085.23" y1="-49.8457" x2="1085.23" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="1407.97" y1="-49.8457" x2="1407.97" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="278.384" y1="-49.8457" x2="278.384" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="923.861" y1="-49.8457" x2="923.86" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="601.12" y1="-49.8457" x2="601.12" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
<line x1="1246.6" y1="-49.8457" x2="1246.6" y2="1117.56" stroke="white" stroke-opacity="0.18" stroke-width="0.6"/>
</g>
<g opacity="0.5">
<rect x="440.141" y="785.309" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="1165.39" y="221.433" width="80.8965" height="80" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="520.367" y="463.207" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="1085.39" y="301.659" width="80.2262" height="80.3408" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="1166" y="382" width="80.2262" height="80.08" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="1247" y="301" width="80.2262" height="81.08" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="439.994" y="221.433" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="1085.39" y="221.433" width="80" height="80" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="439.994" y="463.207" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="359.914" y="785.309" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="359.914" y="865.535" width="80.2262" height="81.3723" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="198.314" y="705.082" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="1005.16" y="59.835" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="924.059" y="-20.8525" width="80.2262" height="79.5062" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="924.059" y="59.4053" width="80.2262" height="80.0285" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="116.943" y="58.6885" width="81.3723" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="36.7168" y="59.835" width="81.3723" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="116.943" y="138.915" width="81.3723" height="82.5184" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="440.141" y="-21.5381" width="81.3723" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="198.316" y="300.513" width="80.2262" height="81.3723" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="1166.76" y="705.082" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="1246.99" y="785.309" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="37" y="220" width="81" height="82" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="-44" y="140" width="81" height="82" fill="#3F76FF" fill-opacity="0.07"/>
</g>
</g>
<defs>
<clipPath id="clip0_4817_18724">
<rect width="1512" height="900" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 6.0 KiB

-68
View File
@@ -1,68 +0,0 @@
<svg width="1512" height="900" viewBox="0 0 1512 900" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4817_18582)">
<rect width="1512" height="900" fill="white"/>
<g opacity="0.09">
<line x1="-10.6172" y1="625.328" x2="1500.96" y2="625.328" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="-10.6172" y1="302.59" x2="1500.96" y2="302.59" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="-10.6172" y1="463.958" x2="1500.96" y2="463.958" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="-10.6172" y1="786.696" x2="1500.96" y2="786.696" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="-10.6172" y1="141.22" x2="1500.96" y2="141.22" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="-10.6172" y1="544.642" x2="1500.96" y2="544.642" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="-10.6172" y1="867.381" x2="1500.96" y2="867.381" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="-10.6172" y1="221.904" x2="1500.96" y2="221.904" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="-10.6172" y1="383.272" x2="1500.96" y2="383.272" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="-10.6172" y1="706.012" x2="1500.96" y2="706.013" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="-10.6172" y1="60.534" x2="1500.96" y2="60.534" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="36.3273" y1="-48.8457" x2="36.3273" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="681.808" y1="-48.8457" x2="681.808" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="359.068" y1="-48.8457" x2="359.068" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="1004.54" y1="-48.8457" x2="1004.54" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="1327.28" y1="-48.8457" x2="1327.28" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="197.698" y1="-48.8457" x2="197.698" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="843.173" y1="-48.8457" x2="843.173" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="520.439" y1="-48.8457" x2="520.439" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="1165.92" y1="-48.8457" x2="1165.92" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="1488.66" y1="-48.8457" x2="1488.66" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="117.015" y1="-48.8457" x2="117.015" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="762.491" y1="-48.8457" x2="762.491" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="439.751" y1="-48.8457" x2="439.751" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="1085.23" y1="-48.8457" x2="1085.23" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="1407.97" y1="-48.8457" x2="1407.97" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="278.384" y1="-48.8457" x2="278.384" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="923.861" y1="-48.8457" x2="923.86" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="601.12" y1="-48.8457" x2="601.12" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
<line x1="1246.6" y1="-48.8457" x2="1246.6" y2="1118.56" stroke="#1F2D5C" stroke-opacity="0.4" stroke-width="0.6"/>
</g>
<g opacity="0.5">
<rect x="440.141" y="786.309" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="1166.76" y="222.433" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="520.367" y="464.207" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="1085.39" y="302.659" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="1166" y="383" width="80.2262" height="80.08" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="1247" y="302" width="80.2262" height="81.08" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="439.994" y="222.433" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="1085.39" y="222.433" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="439.994" y="464.207" width="80.2262" height="79.0801" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="359.914" y="786.309" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="359.914" y="866.535" width="80.2262" height="81.3723" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="198.314" y="706.082" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="1005.16" y="60.835" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="924.059" y="-19.8525" width="80.2262" height="79.5062" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="924.059" y="60.4053" width="80.2262" height="80.0285" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="116.943" y="59.6885" width="81.3723" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="36.7168" y="60.835" width="81.3723" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="116.943" y="139.915" width="81.3723" height="82.5184" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="440.141" y="-20.5381" width="81.3723" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="198.316" y="301.513" width="80.2262" height="81.3723" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="1166.76" y="706.082" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="1246.99" y="786.309" width="80.2262" height="80.2262" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="37" y="221" width="81" height="82" fill="#3F76FF" fill-opacity="0.07"/>
<rect x="-44" y="141" width="81" height="82" fill="#3F76FF" fill-opacity="0.07"/>
</g>
</g>
<defs>
<clipPath id="clip0_4817_18582">
<rect width="1512" height="900" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 919 B

-11
View File
@@ -1,11 +0,0 @@
{
"name": "",
"short_name": "",
"icons": [
{ "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",
"display": "standalone"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

@@ -1,40 +0,0 @@
<svg width="210" height="206" viewBox="0 0 210 206" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="107.5" cy="103" r="102.5" fill="#24252C"/>
<path d="M140.625 162.125V148.875C138.868 148.875 137.183 148.177 135.94 146.935C134.698 145.692 134 144.007 134 142.25V135.625C134 132.111 135.396 128.741 137.881 126.256C140.366 123.771 143.736 122.375 147.25 122.375H160.5C164.014 122.375 167.384 123.771 169.869 126.256C172.354 128.741 173.75 132.111 173.75 135.625V142.25C173.75 144.007 173.052 145.692 171.81 146.935C170.567 148.177 168.882 148.875 167.125 148.875" stroke="#454961" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M153.875 122.375V66.0625C153.875 59.9128 151.432 54.015 147.084 49.6665C142.735 45.318 136.837 42.875 130.687 42.875C124.538 42.875 118.64 45.318 114.291 49.6665C109.943 54.015 107.5 59.9128 107.5 66.0625M107.5 138.937C107.5 145.087 105.057 150.985 100.709 155.334C96.36 159.682 90.4622 162.125 84.3125 162.125C78.1628 162.125 72.265 159.682 67.9165 155.334C63.568 150.985 61.125 145.087 61.125 138.937V82.625" stroke="#454961" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M167.125 162.125V148.875H140.625" stroke="#454961" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M47.875 56.125H74.375V42.875" stroke="#454961" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M74.375 56.125C76.1321 56.125 77.8172 56.823 79.0596 58.0654C80.302 59.3078 81 60.9929 81 62.75V69.375C81 72.8891 79.604 76.2593 77.1192 78.7442C74.6343 81.229 71.2641 82.625 67.75 82.625H54.5C50.9859 82.625 47.6157 81.229 45.1308 78.7442C42.646 76.2593 41.25 72.8891 41.25 69.375V62.75C41.25 60.9929 41.948 59.3078 43.1904 58.0654C44.4328 56.823 46.1179 56.125 47.875 56.125V42.875" stroke="#454961" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<g filter="url(#filter0_ddd_11437_265561)">
<circle cx="107.911" cy="102.911" r="23.7938" fill="#3A5BC7"/>
<path d="M114.051 96.7712L101.771 109.052" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M101.771 96.7712L114.051 109.052" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<filter id="filter0_ddd_11437_265561" x="76.1172" y="74.1177" width="63.5879" height="64.5876" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="2" operator="erode" in="SourceAlpha" result="effect1_dropShadow_11437_265561"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0 0 0 0.051 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_11437_265561"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect2_dropShadow_11437_265561"/>
<feOffset dy="3"/>
<feGaussianBlur stdDeviation="6"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.055 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_11437_265561" result="effect2_dropShadow_11437_265561"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="8" operator="erode" in="SourceAlpha" result="effect3_dropShadow_11437_265561"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="8"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.078 0"/>
<feBlend mode="normal" in2="effect2_dropShadow_11437_265561" result="effect3_dropShadow_11437_265561"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect3_dropShadow_11437_265561" result="shape"/>
</filter>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

@@ -1,40 +0,0 @@
<svg width="210" height="206" viewBox="0 0 210 206" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="107.5" cy="103" r="102.5" fill="#F3F6FF"/>
<path d="M140.625 162.125V148.875C138.868 148.875 137.183 148.177 135.94 146.935C134.698 145.692 134 144.007 134 142.25V135.625C134 132.111 135.396 128.741 137.881 126.256C140.366 123.771 143.736 122.375 147.25 122.375H160.5C164.014 122.375 167.384 123.771 169.869 126.256C172.354 128.741 173.75 132.111 173.75 135.625V142.25C173.75 144.007 173.052 145.692 171.81 146.935C170.567 148.177 168.882 148.875 167.125 148.875" stroke="#3E63DD" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M153.875 122.375V66.0625C153.875 59.9128 151.432 54.015 147.084 49.6665C142.735 45.318 136.837 42.875 130.687 42.875C124.538 42.875 118.64 45.318 114.291 49.6665C109.943 54.015 107.5 59.9128 107.5 66.0625M107.5 138.937C107.5 145.087 105.057 150.985 100.709 155.334C96.36 159.682 90.4622 162.125 84.3125 162.125C78.1628 162.125 72.265 159.682 67.9165 155.334C63.568 150.985 61.125 145.087 61.125 138.937V82.625" stroke="#3E63DD" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M167.125 162.125V148.875H140.625" stroke="#3E63DD" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M47.875 56.125H74.375V42.875" stroke="#3E63DD" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M74.375 56.125C76.1321 56.125 77.8172 56.823 79.0596 58.0654C80.302 59.3078 81 60.9929 81 62.75V69.375C81 72.8891 79.604 76.2593 77.1192 78.7442C74.6343 81.229 71.2641 82.625 67.75 82.625H54.5C50.9859 82.625 47.6157 81.229 45.1308 78.7442C42.646 76.2593 41.25 72.8891 41.25 69.375V62.75C41.25 60.9929 41.948 59.3078 43.1904 58.0654C44.4328 56.823 46.1179 56.125 47.875 56.125V42.875" stroke="#3E63DD" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<g filter="url(#filter0_ddd_11424_265422)">
<circle cx="107.911" cy="102.911" r="23.7938" fill="#3A5BC7"/>
<path d="M114.051 96.7712L101.771 109.052" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M101.771 96.7712L114.051 109.052" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<filter id="filter0_ddd_11424_265422" x="76.1172" y="74.1177" width="63.5879" height="64.5876" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="2" operator="erode" in="SourceAlpha" result="effect1_dropShadow_11424_265422"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0 0 0 0.051 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_11424_265422"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect2_dropShadow_11424_265422"/>
<feOffset dy="3"/>
<feGaussianBlur stdDeviation="6"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.055 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_11424_265422" result="effect2_dropShadow_11424_265422"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="8" operator="erode" in="SourceAlpha" result="effect3_dropShadow_11424_265422"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="8"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.078 0"/>
<feBlend mode="normal" in2="effect2_dropShadow_11424_265422" result="effect3_dropShadow_11424_265422"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect3_dropShadow_11424_265422" result="shape"/>
</filter>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

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