forked from github/plane
commit
1dcea9bcc8
59
.eslintrc-staged.js
Normal file
59
.eslintrc-staged.js
Normal file
@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Adds three new lint plugins over the existing configuration:
|
||||
* This is used to lint staged files only.
|
||||
* We should remove this file once the entire codebase follows these rules.
|
||||
*/
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
@ -4,7 +4,7 @@ module.exports = {
|
||||
extends: ["custom"],
|
||||
settings: {
|
||||
next: {
|
||||
rootDir: ["web/", "space/"],
|
||||
rootDir: ["web/", "space/", "admin/"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
86
.github/workflows/build-branch.yml
vendored
86
.github/workflows/build-branch.yml
vendored
@ -22,10 +22,11 @@ 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_frontend: ${{ steps.changed_files.outputs.frontend_any_changed }}
|
||||
build_space: ${{ steps.changed_files.outputs.space_any_changed }}
|
||||
build_backend: ${{ steps.changed_files.outputs.backend_any_changed }}
|
||||
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_space: ${{ steps.changed_files.outputs.space_any_changed }}
|
||||
build_web: ${{ steps.changed_files.outputs.web_any_changed }}
|
||||
|
||||
steps:
|
||||
- id: set_env_variables
|
||||
@ -53,8 +54,12 @@ jobs:
|
||||
uses: tj-actions/changed-files@v42
|
||||
with:
|
||||
files_yaml: |
|
||||
frontend:
|
||||
- web/**
|
||||
apiserver:
|
||||
- apiserver/**
|
||||
proxy:
|
||||
- nginx/**
|
||||
admin:
|
||||
- admin/**
|
||||
- packages/**
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
@ -67,13 +72,16 @@ jobs:
|
||||
- 'yarn.lock'
|
||||
- 'tsconfig.json'
|
||||
- 'turbo.json'
|
||||
backend:
|
||||
- apiserver/**
|
||||
proxy:
|
||||
- nginx/**
|
||||
web:
|
||||
- web/**
|
||||
- packages/**
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
- 'tsconfig.json'
|
||||
- 'turbo.json'
|
||||
|
||||
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' }}
|
||||
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' }}
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
@ -124,6 +132,58 @@ 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
|
||||
@ -176,8 +236,8 @@ jobs:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
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' }}
|
||||
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' }}
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
|
79
.github/workflows/build-test-pull-request.yml
vendored
79
.github/workflows/build-test-pull-request.yml
vendored
@ -10,42 +10,50 @@ 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@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v41
|
||||
uses: tj-actions/changed-files@v44
|
||||
with:
|
||||
files_yaml: |
|
||||
apiserver:
|
||||
- apiserver/**
|
||||
web:
|
||||
- web/**
|
||||
admin:
|
||||
- admin/**
|
||||
- packages/**
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
- 'tsconfig.json'
|
||||
- 'turbo.json'
|
||||
deploy:
|
||||
space:
|
||||
- space/**
|
||||
- packages/**
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
- 'tsconfig.json'
|
||||
- 'turbo.json'
|
||||
web:
|
||||
- web/**
|
||||
- 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@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
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
|
||||
@ -53,52 +61,77 @@ jobs:
|
||||
- name: Lint apiserver
|
||||
run: ruff check --fix apiserver
|
||||
|
||||
lint-web:
|
||||
lint-admin:
|
||||
needs: get-changed-files
|
||||
if: needs.get-changed-files.outputs.web_changed == 'true'
|
||||
if: needs.get-changed-files.outputs.admin_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
- run: yarn install
|
||||
- run: yarn lint --filter=web
|
||||
- run: yarn lint --filter=admin
|
||||
|
||||
lint-space:
|
||||
needs: get-changed-files
|
||||
if: needs.get-changed-files.outputs.space_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
- run: yarn install
|
||||
- run: yarn lint --filter=space
|
||||
|
||||
build-web:
|
||||
needs: lint-web
|
||||
lint-web:
|
||||
needs: get-changed-files
|
||||
if: needs.get-changed-files.outputs.web_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
- run: yarn install
|
||||
- run: yarn build --filter=web
|
||||
- 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
|
||||
|
||||
build-space:
|
||||
needs: lint-space
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v4
|
||||
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
|
||||
|
2
.github/workflows/create-sync-pr.yml
vendored
2
.github/workflows/create-sync-pr.yml
vendored
@ -64,6 +64,6 @@ jobs:
|
||||
echo "Pull Request already exists: $PR_EXISTS"
|
||||
else
|
||||
echo "Creating new pull request"
|
||||
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH --title "sync: merge conflicts need to be resolved" --body "")
|
||||
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH --title "sync: community changes" --body "")
|
||||
echo "Pull Request created: $PR_URL"
|
||||
fi
|
||||
|
87
.github/workflows/feature-deployment.yml
vendored
87
.github/workflows/feature-deployment.yml
vendored
@ -5,18 +5,24 @@ on:
|
||||
inputs:
|
||||
web-build:
|
||||
required: false
|
||||
description: 'Build Web'
|
||||
description: "Build Web"
|
||||
type: boolean
|
||||
default: true
|
||||
space-build:
|
||||
required: false
|
||||
description: 'Build Space'
|
||||
description: "Build Space"
|
||||
type: boolean
|
||||
default: false
|
||||
admin-build:
|
||||
required: false
|
||||
description: "Build Admin"
|
||||
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:
|
||||
@ -27,9 +33,11 @@ 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' }}
|
||||
@ -45,7 +53,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
|
||||
@ -71,7 +79,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
|
||||
@ -81,7 +89,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_DEPLOY_WITH_NGINX: 1
|
||||
NEXT_PUBLIC_SPACE_BASE_PATH: "/spaces"
|
||||
NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }}
|
||||
outputs:
|
||||
do-build: ${{ needs.setup-feature-build.outputs.space-build }}
|
||||
@ -90,7 +98,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
|
||||
@ -117,9 +125,60 @@ 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: [feature-build-web, feature-build-space]
|
||||
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,
|
||||
]
|
||||
name: Feature Deploy
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
@ -164,7 +223,12 @@ jobs:
|
||||
SPACE_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/space.tar.gz --expires-in 3600)
|
||||
fi
|
||||
|
||||
if [ ${{ env.BUILD_WEB }} == true ] || [ ${{ env.BUILD_SPACE }} == true ]; then
|
||||
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
|
||||
|
||||
helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ vars.FEATURE_PREVIEW_HELM_CHART_URL }}
|
||||
|
||||
@ -181,6 +245,9 @@ 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 \
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -81,3 +81,7 @@ tmp/
|
||||
## packages
|
||||
dist
|
||||
.temp/
|
||||
deploy/selfhost/plane-app/
|
||||
## Storybook
|
||||
*storybook.log
|
||||
output.css
|
||||
|
0
.husky/pre-commit
Normal file
0
.husky/pre-commit
Normal file
3
.lintstagedrc.json
Normal file
3
.lintstagedrc.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"*.{ts,tsx,js,jsx}": ["eslint -c ./.eslintrc-staged.js", "prettier --check"]
|
||||
}
|
@ -82,7 +82,7 @@ COPY apiserver/templates templates/
|
||||
RUN apk --no-cache add "bash~=5.2"
|
||||
COPY apiserver/bin ./bin/
|
||||
|
||||
RUN chmod +x ./bin/takeoff ./bin/worker
|
||||
RUN chmod +x ./bin/*
|
||||
RUN chmod -R 777 /code
|
||||
|
||||
# Expose container port and run entry point script
|
||||
|
14
README.md
14
README.md
@ -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 software development tool to manage issues, sprints, and product roadmaps with peace of mind. 🧘♀️
|
||||
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. 🧘♀️
|
||||
|
||||
> 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.
|
||||
> 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.
|
||||
|
||||
## ⚡ Installation
|
||||
|
||||
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.
|
||||
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account.
|
||||
|
||||
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).
|
||||
If you would like to self-host Plane, please see our [deployment guide](https://docs.plane.so/docker-compose).
|
||||
|
||||
| Installation Methods | Documentation Link |
|
||||
| Installation methods | Docs 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 admin` can configure instance settings using our [God-mode](https://docs.plane.so/instance-admin) feature.
|
||||
`Instance admins` can configure instance settings with [God-mode](https://docs.plane.so/instance-admin).
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
|
3
admin/.env.example
Normal file
3
admin/.env.example
Normal file
@ -0,0 +1,3 @@
|
||||
NEXT_PUBLIC_API_BASE_URL=""
|
||||
NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
NEXT_PUBLIC_WEB_BASE_URL=""
|
52
admin/.eslintrc.js
Normal file
52
admin/.eslintrc.js
Normal file
@ -0,0 +1,52 @@
|
||||
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
admin/.prettierignore
Normal file
6
admin/.prettierignore
Normal file
@ -0,0 +1,6 @@
|
||||
.next
|
||||
.vercel
|
||||
.tubro
|
||||
out/
|
||||
dis/
|
||||
build/
|
5
admin/.prettierrc
Normal file
5
admin/.prettierrc
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
86
admin/Dockerfile.admin
Normal file
86
admin/Dockerfile.admin
Normal file
@ -0,0 +1,86 @@
|
||||
# *****************************************************************************
|
||||
# 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_ADMIN_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_WEB_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV TURBO_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN yarn turbo run build --filter=admin
|
||||
|
||||
# *****************************************************************************
|
||||
# STAGE 3: Copy the project and start it
|
||||
# *****************************************************************************
|
||||
FROM 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_ADMIN_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
|
||||
|
||||
ARG NEXT_PUBLIC_WEB_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV TURBO_TELEMETRY_DISABLED 1
|
||||
|
||||
EXPOSE 3000
|
17
admin/Dockerfile.dev
Normal file
17
admin/Dockerfile.dev
Normal file
@ -0,0 +1,17 @@
|
||||
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
admin/app/ai/form.tsx
Normal file
128
admin/app/ai/form.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
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>
|
||||
);
|
||||
};
|
11
admin/app/ai/layout.tsx
Normal file
11
admin/app/ai/layout.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
import { AdminLayout } from "@/layouts/admin-layout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AI Settings - God Mode",
|
||||
};
|
||||
|
||||
export default function AILayout({ children }: { children: ReactNode }) {
|
||||
return <AdminLayout>{children}</AdminLayout>;
|
||||
}
|
48
admin/app/ai/page.tsx
Normal file
48
admin/app/ai/page.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
"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;
|
@ -0,0 +1,51 @@
|
||||
"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>
|
||||
);
|
||||
};
|
36
admin/app/authentication/components/email-config-switch.tsx
Normal file
36
admin/app/authentication/components/email-config-switch.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
"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}
|
||||
/>
|
||||
);
|
||||
});
|
59
admin/app/authentication/components/github-config.tsx
Normal file
59
admin/app/authentication/components/github-config.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
"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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
59
admin/app/authentication/components/google-config.tsx
Normal file
59
admin/app/authentication/components/google-config.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
"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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
5
admin/app/authentication/components/index.ts
Normal file
5
admin/app/authentication/components/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from "./email-config-switch";
|
||||
export * from "./password-config-switch";
|
||||
export * from "./authentication-method-card";
|
||||
export * from "./github-config";
|
||||
export * from "./google-config";
|
@ -0,0 +1,36 @@
|
||||
"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
admin/app/authentication/github/form.tsx
Normal file
213
admin/app/authentication/github/form.tsx
Normal file
@ -0,0 +1,213 @@
|
||||
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
admin/app/authentication/github/page.tsx
Normal file
114
admin/app/authentication/github/page.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
"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
admin/app/authentication/google/form.tsx
Normal file
211
admin/app/authentication/google/form.tsx
Normal file
@ -0,0 +1,211 @@
|
||||
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
admin/app/authentication/google/page.tsx
Normal file
102
admin/app/authentication/google/page.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
"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;
|
11
admin/app/authentication/layout.tsx
Normal file
11
admin/app/authentication/layout.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
import { AdminLayout } from "@/layouts/admin-layout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Authentication Settings - God Mode",
|
||||
};
|
||||
|
||||
export default function AuthenticationLayout({ children }: { children: ReactNode }) {
|
||||
return <AdminLayout>{children}</AdminLayout>;
|
||||
}
|
159
admin/app/authentication/page.tsx
Normal file
159
admin/app/authentication/page.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
"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
admin/app/email/email-config-form.tsx
Normal file
222
admin/app/email/email-config-form.tsx
Normal file
@ -0,0 +1,222 @@
|
||||
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>
|
||||
);
|
||||
};
|
15
admin/app/email/layout.tsx
Normal file
15
admin/app/email/layout.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
import { AdminLayout } from "@/layouts/admin-layout";
|
||||
|
||||
interface EmailLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Email Settings - God Mode",
|
||||
};
|
||||
|
||||
const EmailLayout = ({ children }: EmailLayoutProps) => <AdminLayout>{children}</AdminLayout>;
|
||||
|
||||
export default EmailLayout;
|
51
admin/app/email/page.tsx
Normal file
51
admin/app/email/page.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
"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.
|
||||
<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
admin/app/email/test-email-modal.tsx
Normal file
135
admin/app/email/test-email-modal.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
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
admin/app/error.tsx
Normal file
9
admin/app/error.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
export default function RootErrorPage() {
|
||||
return (
|
||||
<div>
|
||||
<p>Something went wrong.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
140
admin/app/general/form.tsx
Normal file
140
admin/app/general/form.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
"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;
|
||||
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>>({
|
||||
defaultValues: {
|
||||
instance_name: instance?.instance_name,
|
||||
is_telemetry_enabled: instance?.is_telemetry_enabled,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: Partial<IInstance>) => {
|
||||
const payload: Partial<IInstance> = { ...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>
|
||||
);
|
||||
});
|
11
admin/app/general/layout.tsx
Normal file
11
admin/app/general/layout.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
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
admin/app/general/page.tsx
Normal file
31
admin/app/general/page.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
"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", instance);
|
||||
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 && instanceAdmins && (
|
||||
<GeneralConfigurationForm instance={instance} instanceAdmins={instanceAdmins} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(GeneralPage);
|
384
admin/app/globals.css
Normal file
384
admin/app/globals.css
Normal file
@ -0,0 +1,384 @@
|
||||
@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: 247, 247, 247; /* secondary bg */
|
||||
--color-background-80: 232, 232, 232; /* 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: 247, 247, 247; /* secondary bg */
|
||||
--color-background-80: 232, 232, 232; /* 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: 25, 25, 25; /* primary bg */
|
||||
--color-background-90: 32, 32, 32; /* secondary bg */
|
||||
--color-background-80: 44, 44, 44; /* 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
admin/app/image/form.tsx
Normal file
79
admin/app/image/form.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
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.
|
||||
<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>
|
||||
);
|
||||
};
|
15
admin/app/image/layout.tsx
Normal file
15
admin/app/image/layout.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Metadata } from "next";
|
||||
import { AdminLayout } from "@/layouts/admin-layout";
|
||||
|
||||
interface ImageLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Images Settings - God Mode",
|
||||
};
|
||||
|
||||
const ImageLayout = ({ children }: ImageLayoutProps) => <AdminLayout>{children}</AdminLayout>;
|
||||
|
||||
export default ImageLayout;
|
44
admin/app/image/page.tsx
Normal file
44
admin/app/image/page.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
"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;
|
48
admin/app/layout.tsx
Normal file
48
admin/app/layout.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { ThemeProvider, useTheme } from "next-themes";
|
||||
import { SWRConfig } from "swr";
|
||||
// ui
|
||||
import { Toast } from "@plane/ui";
|
||||
// constants
|
||||
import { SWR_CONFIG } from "@/constants/swr-config";
|
||||
// helpers
|
||||
import { ASSET_PREFIX, resolveGeneralTheme } from "@/helpers/common.helper";
|
||||
// lib
|
||||
import { InstanceProvider } from "@/lib/instance-provider";
|
||||
import { StoreProvider } from "@/lib/store-provider";
|
||||
import { UserProvider } from "@/lib/user-provider";
|
||||
// styles
|
||||
import "./globals.css";
|
||||
|
||||
function RootLayout({ children }: { children: ReactNode }) {
|
||||
// themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
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`}>
|
||||
<ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem>
|
||||
<Toast theme={resolveGeneralTheme(resolvedTheme)} />
|
||||
<SWRConfig value={SWR_CONFIG}>
|
||||
<StoreProvider>
|
||||
<InstanceProvider>
|
||||
<UserProvider>{children}</UserProvider>
|
||||
</InstanceProvider>
|
||||
</StoreProvider>
|
||||
</SWRConfig>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export default RootLayout;
|
30
admin/app/page.tsx
Normal file
30
admin/app/page.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { Metadata } from "next";
|
||||
// components
|
||||
import { InstanceSignInForm } from "@/components/login";
|
||||
// layouts
|
||||
import { DefaultLayout } from "@/layouts/default-layout";
|
||||
|
||||
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 LoginPage() {
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<InstanceSignInForm />
|
||||
</DefaultLayout>
|
||||
);
|
||||
}
|
@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { FC, useState, useRef } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import Link from "next/link";
|
||||
import { FileText, HelpCircle, MessagesSquare, MoveLeft } from "lucide-react";
|
||||
import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react";
|
||||
import { Transition } from "@headlessui/react";
|
||||
import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui";
|
||||
// hooks
|
||||
import { DiscordIcon, GithubIcon } from "@plane/ui";
|
||||
import { useApplication } from "@/hooks/store";
|
||||
// icons
|
||||
import { WEB_BASE_URL } from "@/helpers/common.helper";
|
||||
import { useTheme } from "@/hooks/store";
|
||||
// assets
|
||||
import packageJson from "package.json";
|
||||
|
||||
@ -25,56 +28,56 @@ const helpOptions = [
|
||||
href: "https://github.com/makeplane/plane/issues/new/choose",
|
||||
Icon: GithubIcon,
|
||||
},
|
||||
{
|
||||
name: "Chat with us",
|
||||
href: null,
|
||||
onClick: () => (window as any).$crisp.push(["do", "chat:show"]),
|
||||
Icon: MessagesSquare,
|
||||
},
|
||||
];
|
||||
|
||||
export const InstanceHelpSection: FC = () => {
|
||||
export const HelpSection: FC = observer(() => {
|
||||
// states
|
||||
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
|
||||
// store
|
||||
const {
|
||||
theme: { sidebarCollapsed, toggleSidebar },
|
||||
} = useApplication();
|
||||
const { isSidebarCollapsed, toggleSidebar } = useTheme();
|
||||
// refs
|
||||
const helpOptionsRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const redirectionLink = encodeURI(WEB_BASE_URL + "/create-workspace");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex w-full items-center justify-between gap-1 self-baseline border-t border-custom-border-200 bg-custom-sidebar-background-100 px-4 py-2 ${
|
||||
sidebarCollapsed ? "flex-col" : ""
|
||||
className={`flex w-full items-center justify-between gap-1 self-baseline border-t border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-4 py-2 ${
|
||||
isSidebarCollapsed ? "flex-col" : ""
|
||||
}`}
|
||||
>
|
||||
<div className={`flex items-center gap-1 ${sidebarCollapsed ? "flex-col justify-center" : "w-full justify-end"}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`grid place-items-center rounded-md p-1.5 text-custom-text-200 outline-none hover:bg-custom-background-90 hover:text-custom-text-100 ${
|
||||
sidebarCollapsed ? "w-full" : ""
|
||||
}`}
|
||||
onClick={() => setIsNeedHelpOpen((prev) => !prev)}
|
||||
>
|
||||
<HelpCircle className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center rounded-md p-1.5 text-custom-text-200 outline-none hover:bg-custom-background-90 hover:text-custom-text-100 md:hidden"
|
||||
onClick={() => toggleSidebar()}
|
||||
>
|
||||
<MoveLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`hidden place-items-center rounded-md p-1.5 text-custom-text-200 outline-none hover:bg-custom-background-90 hover:text-custom-text-100 md:grid ${
|
||||
sidebarCollapsed ? "w-full" : ""
|
||||
}`}
|
||||
onClick={() => toggleSidebar()}
|
||||
>
|
||||
<MoveLeft className={`h-3.5 w-3.5 duration-300 ${sidebarCollapsed ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
<div className={`flex items-center gap-1 ${isSidebarCollapsed ? "flex-col justify-center" : "w-full"}`}>
|
||||
<Tooltip tooltipContent="Redirect to plane" position="right" className="ml-4" disabled={!isSidebarCollapsed}>
|
||||
<a
|
||||
href={redirectionLink}
|
||||
className={`relative px-2 py-1.5 flex items-center gap-2 font-medium rounded border border-custom-primary-100/20 bg-custom-primary-100/10 text-xs text-custom-primary-200 whitespace-nowrap`}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{!isSidebarCollapsed && "Redirect to plane"}
|
||||
</a>
|
||||
</Tooltip>
|
||||
<Tooltip tooltipContent="Help" position={isSidebarCollapsed ? "right" : "top"} className="ml-4">
|
||||
<button
|
||||
type="button"
|
||||
className={`ml-auto grid place-items-center rounded-md p-1.5 text-custom-text-200 outline-none hover:bg-custom-background-90 hover:text-custom-text-100 ${
|
||||
isSidebarCollapsed ? "w-full" : ""
|
||||
}`}
|
||||
onClick={() => setIsNeedHelpOpen((prev) => !prev)}
|
||||
>
|
||||
<HelpCircle className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip tooltipContent="Toggle sidebar" position={isSidebarCollapsed ? "right" : "top"} className="ml-4">
|
||||
<button
|
||||
type="button"
|
||||
className={`grid place-items-center rounded-md p-1.5 text-custom-text-200 outline-none hover:bg-custom-background-90 hover:text-custom-text-100 ${
|
||||
isSidebarCollapsed ? "w-full" : ""
|
||||
}`}
|
||||
onClick={() => toggleSidebar(!isSidebarCollapsed)}
|
||||
>
|
||||
<MoveLeft className={`h-3.5 w-3.5 duration-300 ${isSidebarCollapsed ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
@ -89,12 +92,12 @@ export const InstanceHelpSection: FC = () => {
|
||||
>
|
||||
<div
|
||||
className={`absolute bottom-2 min-w-[10rem] ${
|
||||
sidebarCollapsed ? "left-full" : "-left-[75px]"
|
||||
isSidebarCollapsed ? "left-full" : "-left-[75px]"
|
||||
} divide-y divide-custom-border-200 whitespace-nowrap rounded bg-custom-background-100 p-1 shadow-custom-shadow-xs`}
|
||||
ref={helpOptionsRef}
|
||||
>
|
||||
<div className="space-y-1 pb-2">
|
||||
{helpOptions.map(({ name, Icon, href, onClick }) => {
|
||||
{helpOptions.map(({ name, Icon, href }) => {
|
||||
if (href)
|
||||
return (
|
||||
<Link href={href} key={name} target="_blank">
|
||||
@ -111,7 +114,6 @@ export const InstanceHelpSection: FC = () => {
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={onClick ?? undefined}
|
||||
className="flex w-full items-center gap-x-2 rounded px-2 py-1 text-xs hover:bg-custom-background-80"
|
||||
>
|
||||
<div className="grid flex-shrink-0 place-items-center">
|
||||
@ -128,4 +130,4 @@ export const InstanceHelpSection: FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
5
admin/components/admin-sidebar/index.ts
Normal file
5
admin/components/admin-sidebar/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from "./root";
|
||||
export * from "./help-section";
|
||||
export * from "./sidebar-menu";
|
||||
export * from "./sidebar-dropdown";
|
||||
export * from "./sidebar-menu-hamburger-toogle";
|
57
admin/components/admin-sidebar/root.tsx
Normal file
57
admin/components/admin-sidebar/root.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
"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>
|
||||
);
|
||||
});
|
147
admin/components/admin-sidebar/sidebar-dropdown.tsx
Normal file
147
admin/components/admin-sidebar/sidebar-dropdown.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
"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>
|
||||
);
|
||||
});
|
@ -0,0 +1,20 @@
|
||||
"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>
|
||||
);
|
||||
});
|
104
admin/components/admin-sidebar/sidebar-menu.tsx
Normal file
104
admin/components/admin-sidebar/sidebar-menu.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
"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
admin/components/auth-header.tsx
Normal file
90
admin/components/auth-header.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
"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
admin/components/common/banner.tsx
Normal file
32
admin/components/common/banner.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
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>
|
||||
);
|
||||
};
|
36
admin/components/common/breadcrumb-link.tsx
Normal file
36
admin/components/common/breadcrumb-link.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
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>
|
||||
);
|
||||
};
|
83
admin/components/common/confirm-discard-modal.tsx
Normal file
83
admin/components/common/confirm-discard-modal.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
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>
|
||||
);
|
||||
};
|
86
admin/components/common/controller-input.tsx
Normal file
86
admin/components/common/controller-input.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Controller, Control } from "react-hook-form";
|
||||
// icons
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
// ui
|
||||
import { Input } from "@plane/ui";
|
||||
// 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} {!required && "(optional)"}
|
||||
</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
admin/components/common/copy-field.tsx
Normal file
46
admin/components/common/copy-field.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
"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
admin/components/common/empty-state.tsx
Normal file
46
admin/components/common/empty-state.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
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>
|
||||
);
|
9
admin/components/common/index.ts
Normal file
9
admin/components/common/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
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";
|
||||
export * from "./toast";
|
17
admin/components/common/logo-spinner.tsx
Normal file
17
admin/components/common/logo-spinner.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
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" priority={false} />
|
||||
</div>
|
||||
);
|
||||
};
|
69
admin/components/common/password-strength-meter.tsx
Normal file
69
admin/components/common/password-strength-meter.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
"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">
|
||||
<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>
|
||||
);
|
||||
};
|
11
admin/components/common/toast.tsx
Normal file
11
admin/components/common/toast.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { useTheme } from "next-themes";
|
||||
// ui
|
||||
import { Toast as ToastComponent } from "@plane/ui";
|
||||
// helpers
|
||||
import { resolveGeneralTheme } from "@/helpers/common.helper";
|
||||
|
||||
export const Toast = () => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return <ToastComponent theme={resolveGeneralTheme(theme)} />;
|
||||
};
|
1
admin/components/core/index.ts
Normal file
1
admin/components/core/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./page-header";
|
17
admin/components/core/page-header.tsx
Normal file
17
admin/components/core/page-header.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
"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
admin/components/instance/index.ts
Normal file
3
admin/components/instance/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./instance-not-ready";
|
||||
export * from "./instance-failure-view";
|
||||
export * from "./setup-form";
|
42
admin/components/instance/instance-failure-view.tsx
Normal file
42
admin/components/instance/instance-failure-view.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
"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">
|
||||
<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>
|
||||
);
|
||||
};
|
30
admin/components/instance/instance-not-ready.tsx
Normal file
30
admin/components/instance/instance-not-ready.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
"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>
|
||||
);
|
354
admin/components/instance/setup-form.tsx
Normal file
354
admin/components/instance/setup-form.tsx
Normal file
@ -0,0 +1,354 @@
|
||||
"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({
|
||||
password: false,
|
||||
retypePassword: 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 handleShowPassword = (key: keyof typeof showPassword) =>
|
||||
setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
const password = formData?.password ?? "";
|
||||
const confirmPassword = formData?.confirm_password ?? "";
|
||||
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
|
||||
|
||||
return (
|
||||
<div className="max-w-lg lg:max-w-md w-full">
|
||||
<div className="relative flex flex-col space-y-6">
|
||||
<div className="text-center space-y-1">
|
||||
<h3 className="flex gap-4 justify-center text-3xl font-bold text-onboarding-text-100">
|
||||
Setup your Plane Instance
|
||||
</h3>
|
||||
<p className="font-medium text-onboarding-text-400">
|
||||
Post setup you will be able to manage this Plane instance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{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 flex-col sm:flex-row 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.password ? "text" : "password"}
|
||||
inputSize="md"
|
||||
placeholder="New password..."
|
||||
value={formData.password}
|
||||
onChange={(e) => handleFormChange("password", e.target.value)}
|
||||
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false}
|
||||
onFocus={() => setIsPasswordInputFocused(true)}
|
||||
onBlur={() => setIsPasswordInputFocused(false)}
|
||||
/>
|
||||
{showPassword.password ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD && errorData.message && (
|
||||
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
|
||||
)}
|
||||
{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.retypePassword ? "text" : "password"}
|
||||
id="confirm_password"
|
||||
name="confirm_password"
|
||||
inputSize="md"
|
||||
value={formData.confirm_password}
|
||||
onChange={(e) => handleFormChange("confirm_password", e.target.value)}
|
||||
placeholder="Confirm password"
|
||||
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||
onFocus={() => setIsRetryPasswordInputFocused(true)}
|
||||
onBlur={() => setIsRetryPasswordInputFocused(false)}
|
||||
/>
|
||||
{showPassword.retypePassword ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-custom-text-400"
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!!formData.confirm_password &&
|
||||
formData.password !== formData.confirm_password &&
|
||||
renderPasswordMatchError && <span className="text-sm text-red-500">Passwords don{"'"}t match</span>}
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center pt-2 gap-2">
|
||||
<div>
|
||||
<Checkbox
|
||||
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>
|
||||
);
|
||||
};
|
@ -1,2 +1 @@
|
||||
export * from "./root";
|
||||
export * from "./sign-in-form";
|
179
admin/components/login/sign-in-form.tsx
Normal file
179
admin/components/login/sign-in-form.tsx
Normal file
@ -0,0 +1,179 @@
|
||||
"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="flex-grow container mx-auto max-w-lg px-10 lg:max-w-md lg:px-5 py-10 lg:pt-28 transition-all">
|
||||
<div className="relative flex flex-col space-y-6">
|
||||
<div className="text-center space-y-1">
|
||||
<h3 className="flex gap-4 justify-center text-3xl font-bold text-onboarding-text-100">
|
||||
Manage your Plane instance
|
||||
</h3>
|
||||
<p className="font-medium text-onboarding-text-400">
|
||||
Configure instance-wide settings to secure your instance
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{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
admin/components/new-user-popup.tsx
Normal file
56
admin/components/new-user-popup.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
"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 { config } = useInstance();
|
||||
// theme
|
||||
const { resolvedTheme } = nextUseTheme();
|
||||
|
||||
const redirectionLink = `${config?.app_base_url ? `${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
admin/constants/seo.ts
Normal file
8
admin/constants/seo.ts
Normal file
@ -0,0 +1,8 @@
|
||||
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
admin/constants/swr-config.ts
Normal file
8
admin/constants/swr-config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export const SWR_CONFIG = {
|
||||
refreshWhenHidden: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnMount: true,
|
||||
refreshInterval: 600000,
|
||||
errorRetryCount: 3,
|
||||
};
|
136
admin/helpers/authentication.helper.tsx
Normal file
136
admin/helpers/authentication.helper.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
// helpers
|
||||
import { SUPPORT_EMAIL } from "./common.helper";
|
||||
|
||||
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",
|
||||
ADMIN_USER_DEACTIVATED = "5190",
|
||||
}
|
||||
|
||||
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.
|
||||
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
|
||||
Sign In
|
||||
</Link>
|
||||
now.
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: {
|
||||
title: `Admin user does not exist`,
|
||||
message: () => (
|
||||
<div>
|
||||
Admin user does not exist.
|
||||
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
|
||||
Sign In
|
||||
</Link>
|
||||
now.
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[EAuthenticationErrorCodes.ADMIN_USER_DEACTIVATED]: {
|
||||
title: `User account deactivated`,
|
||||
message: () => `User account deactivated. Please contact ${!!SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`,
|
||||
},
|
||||
};
|
||||
|
||||
export const authErrorHandler = (
|
||||
errorCode: EAuthenticationErrorCodes,
|
||||
email?: string | undefined
|
||||
): TAuthErrorInfo | undefined => {
|
||||
const bannerAlertErrorCodes = [
|
||||
EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST,
|
||||
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,
|
||||
EAuthenticationErrorCodes.ADMIN_USER_DEACTIVATED,
|
||||
];
|
||||
|
||||
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;
|
||||
};
|
20
admin/helpers/common.helper.ts
Normal file
20
admin/helpers/common.helper.ts
Normal file
@ -0,0 +1,20 @@
|
||||
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 SUPPORT_EMAIL = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "";
|
||||
|
||||
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
admin/helpers/index.ts
Normal file
2
admin/helpers/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./instance.helper";
|
||||
export * from "./user.helper";
|
9
admin/helpers/instance.helper.ts
Normal file
9
admin/helpers/instance.helper.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export enum EInstanceStatus {
|
||||
ERROR = "ERROR",
|
||||
NOT_YET_READY = "NOT_YET_READY",
|
||||
}
|
||||
|
||||
export type TInstanceStatus = {
|
||||
status: EInstanceStatus | undefined;
|
||||
data?: object;
|
||||
};
|
16
admin/helpers/password.helper.ts
Normal file
16
admin/helpers/password.helper.ts
Normal file
@ -0,0 +1,16 @@
|
||||
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
admin/helpers/user.helper.ts
Normal file
21
admin/helpers/user.helper.ts
Normal file
@ -0,0 +1,21 @@
|
||||
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
admin/hooks/store/index.ts
Normal file
3
admin/hooks/store/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./use-theme";
|
||||
export * from "./use-instance";
|
||||
export * from "./use-user";
|
10
admin/hooks/store/use-instance.tsx
Normal file
10
admin/hooks/store/use-instance.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/lib/store-provider";
|
||||
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
admin/hooks/store/use-theme.tsx
Normal file
10
admin/hooks/store/use-theme.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/lib/store-provider";
|
||||
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
admin/hooks/store/use-user.tsx
Normal file
10
admin/hooks/store/use-user.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/lib/store-provider";
|
||||
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;
|
||||
};
|
21
admin/hooks/use-outside-click-detector.tsx
Normal file
21
admin/hooks/use-outside-click-detector.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
"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;
|
47
admin/layouts/admin-layout.tsx
Normal file
47
admin/layouts/admin-layout.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
import { FC, ReactNode, useEffect } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/navigation";
|
||||
// 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 { useUser } from "@/hooks/store";
|
||||
|
||||
type TAdminLayout = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const AdminLayout: FC<TAdminLayout> = observer((props) => {
|
||||
const { children } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { isUserLoggedIn } = useUser();
|
||||
|
||||
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>
|
||||
);
|
||||
});
|
45
admin/layouts/default-layout.tsx
Normal file
45
admin/layouts/default-layout.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { FC, ReactNode } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
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 BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.png";
|
||||
import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.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;
|
||||
|
||||
const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo;
|
||||
|
||||
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">
|
||||
<Link href={`/`} className="h-[30px] w-[133px]">
|
||||
<Image src={logo} alt="Plane logo" />
|
||||
</Link>
|
||||
</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 flex-grow">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
55
admin/lib/instance-provider.tsx
Normal file
55
admin/lib/instance-provider.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { FC, ReactNode } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common";
|
||||
import { InstanceSetupForm, InstanceFailureView } from "@/components/instance";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// layout
|
||||
import { DefaultLayout } from "@/layouts/default-layout";
|
||||
|
||||
type InstanceProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const InstanceProvider: FC<InstanceProviderProps> = observer((props) => {
|
||||
const { children } = props;
|
||||
// store hooks
|
||||
const { instance, error, fetchInstanceInfo } = useInstance();
|
||||
// fetching instance details
|
||||
useSWR("INSTANCE_DETAILS", () => fetchInstanceInfo(), {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
errorRetryCount: 0,
|
||||
});
|
||||
|
||||
if (!instance && !error)
|
||||
return (
|
||||
<div className="flex h-screen min-h-[500px] w-full justify-center items-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<div className="relative h-full w-full overflow-y-auto px-6 py-10 mx-auto flex justify-center items-center">
|
||||
<InstanceFailureView />
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!instance?.is_setup_done) {
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<div className="relative h-full w-full overflow-y-auto px-6 py-10 mx-auto flex justify-center items-center">
|
||||
<InstanceSetupForm />
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
});
|
34
admin/lib/store-provider.tsx
Normal file
34
admin/lib/store-provider.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, createContext } from "react";
|
||||
// store
|
||||
import { RootStore } from "@/store/root.store";
|
||||
|
||||
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 StoreProviderProps = {
|
||||
children: ReactNode;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
initialState?: any;
|
||||
};
|
||||
|
||||
export const StoreProvider = ({ children, initialState = {} }: StoreProviderProps) => {
|
||||
const store = initializeStore(initialState);
|
||||
return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
|
||||
};
|
31
admin/lib/user-provider.tsx
Normal file
31
admin/lib/user-provider.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { FC, ReactNode, useEffect } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import { useInstance, useTheme, useUser } from "@/hooks/store";
|
||||
|
||||
interface IUserProvider {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const UserProvider: FC<IUserProvider> = observer(({ children }) => {
|
||||
// hooks
|
||||
const { isSidebarCollapsed, toggleSidebar } = useTheme();
|
||||
const { currentUser, fetchCurrentUser } = useUser();
|
||||
const { fetchInstanceAdmins } = useInstance();
|
||||
|
||||
useSWR("CURRENT_USER", () => fetchCurrentUser(), {
|
||||
shouldRetryOnError: false,
|
||||
});
|
||||
useSWR("INSTANCE_ADMINS", () => fetchInstanceAdmins());
|
||||
|
||||
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 <>{children}</>;
|
||||
});
|
5
admin/next-env.d.ts
vendored
Normal file
5
admin/next-env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/// <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
admin/next.config.js
Normal file
13
admin/next.config.js
Normal file
@ -0,0 +1,13 @@
|
||||
/** @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
admin/package.json
Normal file
50
admin/package.json
Normal file
@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"version": "0.20.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
admin/postcss.config.js
Normal file
8
admin/postcss.config.js
Normal file
@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"postcss-import": {},
|
||||
"tailwindcss/nesting": {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
68
admin/public/auth/background-pattern-dark.svg
Normal file
68
admin/public/auth/background-pattern-dark.svg
Normal file
@ -0,0 +1,68 @@
|
||||
<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>
|
After Width: | Height: | Size: 6.0 KiB |
68
admin/public/auth/background-pattern.svg
Normal file
68
admin/public/auth/background-pattern.svg
Normal file
@ -0,0 +1,68 @@
|
||||
<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>
|
After Width: | Height: | Size: 6.1 KiB |
BIN
admin/public/favicon/android-chrome-192x192.png
Normal file
BIN
admin/public/favicon/android-chrome-192x192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.2 KiB |
BIN
admin/public/favicon/android-chrome-512x512.png
Normal file
BIN
admin/public/favicon/android-chrome-512x512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.7 KiB |
BIN
admin/public/favicon/apple-touch-icon.png
Normal file
BIN
admin/public/favicon/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.1 KiB |
BIN
admin/public/favicon/favicon-16x16.png
Normal file
BIN
admin/public/favicon/favicon-16x16.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user