mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
feat: session auth implementation (#4411)
* feat: session authentication and god-mode implementation (#4302) * dev: move authentication to base class for credentials * chore: new account creation * dev: return error as query parameter * dev: accounts and profile endpoints for user * fix: user store updates * fix: store fixes * fix: type fixes * dev: set is_password_autoset and is_email_verifier for auth providers * dev: move all auth configuration to different apps * dev: fix circular imports * dev: remove unused imports * dev: fix imports for authentication * dev: update endpoints to use rest framework api viewa * fix: onboarding fixes * dev: session model changes * fix: session model and add check for last name first name and avatar * dev: fix referer redirect * dev: remove auth imports * dev: fix imports * dev: update migrations * fix: instance admin login * comflict: conflicts resolved * dev: fix import errors and email check endpoint * fix: error messages and redirects after login * dev: configs api * fix: is github enabled boolean * dev: merge config and instance api * conflict: merge conflict resolved * dev: instance admin sign up endpoint * dev: enable magic link login * dev: configure instance variables for github and google enabled * chore: typo fixes * fix: god mode docker file changes * build-error: resolved build errors * fix: docker compose changes * dev: add email credential check endpoint * fix: minor package changes * fix: docker related changes * dev: add nginx rules in the nginx template * dev: refactor the url patterns * fix: docker changes * fix: docker files for god-mode * fix: static export * fix: nginx conf * dev: smtp sender refused exception * fix: godmode fixes * chore: god mode revamp. * dev: add csrf secured flag * fix: oauth redirect uri and session settings * chore: god mode app changes. (#3982) * chore: send test email functionality. * style: authentication methods page UI revamp. * chore: create workspace popup. * fix: user me endpoint * dev: fix redirection after authentication * dev: handle god mode redirection * fix: redirections * fix: auth related hooks * fix: store related fixes * dev: fix session authentication for rest apis * fix: linting errors * fix: removing references of useStore= * dev: fix redirection and password validation * dev: add useUser hook * fix: build fixes and lint issues * fix: removing useApplication hook * fix: build errors * fix: delete unused files * fix: auth build fixes * fix: bugfixes * dev: alter avatar to support more than 255 chars * dev: fix profile endpoint and increase session expiry time and update session on every request * chore: resolved the migration * chore: resolved merge conflicts * dev: error codes and error messages for the auth flow * dev: instance admin sign up and sign in endpoint * dev: use zxcvbn to validate password strength * dev: add extra parameters when error handling on instance god mode * chore: auth init * chore: signin/ signup form ui updates and password strength meter. * chore: update password fields. * chore: validations and error handling. * chore: updated sign-up form * chore: updated workflow and updated the code structure * chore: instance empty state for god-mode. * chore: instance and auth wrappers update * fix: renaming godmode * fix: docker changes * chore: updated authentication wrappers * chore: updated the authentication workflow and rendered all pages * fix: build errors * fix: docker related fixes * fix: tailing slash added to space and admin for valid nginx locations * chore: seperate pages for signup and login * git-action modified for admin file changes * feature build action updated for admin app * self host modified * chore: resolved build errors and handled signin and signup in a seperate route * chore: sign-in and sign-up revamp. * fix: migration conflicts * dev: migrations * chore: handled redirection * dev: admin url * dev: create seperate endpoint for instance admin me * dev: instance admin endpoint * git action fixed * chore: handled auth wrappers * dev: add serializer and remove print logs * fix: build errors * dev: fix migrations * dev: instance folder structuring * fix: linting errors * chore: resolved build errors * chore: updated store and auth workflow and updates api service types * chore: Replaced Next Link with Anchoer tag for god-mode redirection * add 3333 port to allowed origins * make password login working again * dev: fix redirection, add admin signout endpoint and fix email credential check endpoint * fix unique code sign in * fix small build error * enable sign out * dev: add google client secret variable to configure instance * dev: add referer for redirection * fix origin urls for oauths * admin setup and login separation * dev: fix user redirection and tour completed endpoint * fix build errors * dev: add set password endpoint * dev: remove user creation logic for redirection * fix unique code page * fix forgot password * chore: onboarding revamp. * dev: fix workspace slug redirection in login * chore: invited user onboarding flow update. * chore: fix switch or delete account modal. * fix members exception * refactor auth flows and add invitations to auth flow * fix sig in sign up url * fix action url * fix build errors * dev: fix user set password when logging in * dev: reset password endpoint * chore: confirm password validation for signup and onboarding. * enable reset password * fix build error * chore: minor UI updates. * chore: forgot and reset password UI revamp. * fix authentication re directions * dev: auth redirections * change url paths for signup and signin * dev: make the user logged in when changing passwords * dev: next path redirection for web and space app * dev: next path for magic sign in endpoint * dev: github space endpoint * chore: minor ui updates and fixes in web app. * set password screen * fix multiple unique code generation * dev: next path base redirection * dev: remove print logs * dev: auth space endpoints * fix build errors * dev: invalidate cache on configuration update, god mode exception errors and authentication failed code * dev: fix space endpoints and add extra endpoints * chore: space auth revamp. * dev: add sign up for space app * fix: build errors. * fix: auth redirection logic. * chore: space app onboarding revamp. --------- Co-authored-by: pablohashescobar <nikhilschacko@gmail.com> Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> Co-authored-by: gurusainath <gurusainath007@gmail.com> Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com> Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com> Co-authored-by: Manish Gupta <manish@mgupta.me> Co-authored-by: = <=> Co-authored-by: rahulramesha <rahulramesham@gmail.com> * chore: updated file structure for admin * chore: updated admin-sidebar * chore: auth error handling * chore: onboarding UI updates and dark mode fixes. * chore: add `user personalization` step to onboarding profile setup screen. * chore: fix minor UI bugs * chore: authentication workflow changes * chore: handled signin workflow * style: switch or delete account workflow * chore: god mode redirection URL * feat(dashboard): improve label readability (#4321) change none label for all time in dashbard filters * chore: god-mode redirection * chore: onboarding ui updates and accept invitation workflow updates. * chore: rename unique code auth form. * style: space auth ux copy. * chore: updated intance and auth wrapper logic * chore: update default layout style. * chore: update confirm password. * chore: backend redirection * style: update banner ui * chore: minor ui updates and validation fix. * chore: removed old auth hook * chore: handled auth wrapper * chore: handled store loaders in the user * chore: handled logs * chore: add loading spinners for all auth and onboarding form buttons. * chore: add background pattern in admin auth forms and minor ui fixes. * chore: UI changes and revamp components for authentication * chore: auth UI consistency in web, space and admin. * chore: resolved build errors * chore: removed old auth hooks * chore: handled lint errors in use accounts * chore: updated authentication wrapper logic in web app * [WEB -1149] dev: update dependencies (#4333) * dev: upgrade dependencies remove unwanted dependency and add ruff as local dependency * dev: add comments * chore: authentication wrapper fetch user * chore: updated store loader * chore: removed old auth wrapper and replaced the imports with new auth wrapper * chore: join workspace invitation workflow updates * chore: build error resolved in deploy * chore: handled onboarding step error in web app * chore: SMTP Name and Password validation removed * chore: handled seo and signout logic and new user popup * chore: added redirection to plane in the sidebar * chore: resolved build errors * dev: admin session cookie update * chore: updated cookie session time for admin * dev: add start date and end date to projects (#4355) * chore: add email security dropdown and remove SMTP username and password validation. * chore: add tooltip to admin sidebar help-section. * chore: add dropdown to collapsed admin sidebar. * chore: profile themning * chore: updated page error messages and theme in command palette * dev: add email validation in email check apis * dev: remove start date and end date from project * chore: updated space folder structure and updated the store hooks * dev: error codes for authentication * chore: handled authentication in space and web apps * chore: banner redirect handling the email * dev: god mode error codes * chore: updated error codes * chore: updated onboarding images * dev: signout endpoints and saving login domain while creating sessions * feat: Self Host Data Backup (#4383) * feat: implemented backup , support for docker-compose tool, readme updated * minor fix in shell script * codacy fixes * chore: handled build errors in web * chore: updated react, react-dom, and next versions * chore: updated password autioset in the signin * dev: add logo prop to views and pages * chore: updated api service and handled the set password in store * chore: handled build errors and code cleanup * dev: return 401 when the session is not valid * dev: users/me exception for api * chore: installed lodash in space app * dev: add auth route in nginx --------- Co-authored-by: pablohashescobar <nikhilschacko@gmail.com> Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> Co-authored-by: gurusainath <gurusainath007@gmail.com> Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com> Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com> Co-authored-by: Manish Gupta <manish@mgupta.me> Co-authored-by: rahulramesha <rahulramesham@gmail.com> Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com> Co-authored-by: Daniel Alba <56451942+redrum15@users.noreply.github.com> Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com>
This commit is contained in:
parent
ae43d05714
commit
59335618b4
60
.github/workflows/build-branch.yml
vendored
60
.github/workflows/build-branch.yml
vendored
@ -23,6 +23,7 @@ jobs:
|
|||||||
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
|
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
|
||||||
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
|
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
|
||||||
build_frontend: ${{ steps.changed_files.outputs.frontend_any_changed }}
|
build_frontend: ${{ steps.changed_files.outputs.frontend_any_changed }}
|
||||||
|
build_admin: ${{ steps.changed_files.outputs.admin_any_changed }}
|
||||||
build_space: ${{ steps.changed_files.outputs.space_any_changed }}
|
build_space: ${{ steps.changed_files.outputs.space_any_changed }}
|
||||||
build_backend: ${{ steps.changed_files.outputs.backend_any_changed }}
|
build_backend: ${{ steps.changed_files.outputs.backend_any_changed }}
|
||||||
build_proxy: ${{ steps.changed_files.outputs.proxy_any_changed }}
|
build_proxy: ${{ steps.changed_files.outputs.proxy_any_changed }}
|
||||||
@ -67,6 +68,13 @@ jobs:
|
|||||||
- 'yarn.lock'
|
- 'yarn.lock'
|
||||||
- 'tsconfig.json'
|
- 'tsconfig.json'
|
||||||
- 'turbo.json'
|
- 'turbo.json'
|
||||||
|
admin:
|
||||||
|
- admin/**
|
||||||
|
- packages/**
|
||||||
|
- 'package.json'
|
||||||
|
- 'yarn.lock'
|
||||||
|
- 'tsconfig.json'
|
||||||
|
- 'turbo.json'
|
||||||
backend:
|
backend:
|
||||||
- apiserver/**
|
- apiserver/**
|
||||||
proxy:
|
proxy:
|
||||||
@ -124,6 +132,58 @@ jobs:
|
|||||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
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:
|
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' }}
|
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
|
runs-on: ubuntu-20.04
|
||||||
|
67
.github/workflows/feature-deployment.yml
vendored
67
.github/workflows/feature-deployment.yml
vendored
@ -13,10 +13,16 @@ on:
|
|||||||
description: 'Build Space'
|
description: 'Build Space'
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
admin-build:
|
||||||
|
required: false
|
||||||
|
description: 'Build Admin'
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
env:
|
env:
|
||||||
BUILD_WEB: ${{ github.event.inputs.web-build }}
|
BUILD_WEB: ${{ github.event.inputs.web-build }}
|
||||||
BUILD_SPACE: ${{ github.event.inputs.space-build }}
|
BUILD_SPACE: ${{ github.event.inputs.space-build }}
|
||||||
|
BUILD_ADMIN: ${{ github.event.inputs.admin-build }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
setup-feature-build:
|
setup-feature-build:
|
||||||
@ -27,9 +33,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "BUILD_WEB=$BUILD_WEB"
|
echo "BUILD_WEB=$BUILD_WEB"
|
||||||
echo "BUILD_SPACE=$BUILD_SPACE"
|
echo "BUILD_SPACE=$BUILD_SPACE"
|
||||||
|
echo "BUILD_ADMIN=$BUILD_ADMIN"
|
||||||
outputs:
|
outputs:
|
||||||
web-build: ${{ env.BUILD_WEB}}
|
web-build: ${{ env.BUILD_WEB}}
|
||||||
space-build: ${{env.BUILD_SPACE}}
|
space-build: ${{env.BUILD_SPACE}}
|
||||||
|
admin-build: ${{env.BUILD_ADMIN}}
|
||||||
|
|
||||||
feature-build-web:
|
feature-build-web:
|
||||||
if: ${{ needs.setup-feature-build.outputs.web-build == 'true' }}
|
if: ${{ needs.setup-feature-build.outputs.web-build == 'true' }}
|
||||||
@ -117,9 +125,54 @@ jobs:
|
|||||||
FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ")
|
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
|
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_DEPLOY_WITH_NGINX: 1
|
||||||
|
NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }}
|
||||||
|
outputs:
|
||||||
|
do-build: ${{ needs.setup-feature-build.outputs.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:
|
feature-deploy:
|
||||||
if: ${{ always() && (needs.setup-feature-build.outputs.web-build == 'true' || needs.setup-feature-build.outputs.space-build == 'true') }}
|
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: [feature-build-web, feature-build-space]
|
needs: [setup-feature-build, feature-build-web, feature-build-space, feature-build-admin]
|
||||||
name: Feature Deploy
|
name: Feature Deploy
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
@ -164,7 +217,12 @@ jobs:
|
|||||||
SPACE_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/space.tar.gz --expires-in 3600)
|
SPACE_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/space.tar.gz --expires-in 3600)
|
||||||
fi
|
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 }}
|
helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ vars.FEATURE_PREVIEW_HELM_CHART_URL }}
|
||||||
|
|
||||||
@ -181,6 +239,9 @@ jobs:
|
|||||||
--set space.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \
|
--set space.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \
|
||||||
--set space.enabled=${{ env.BUILD_SPACE || false }} \
|
--set space.enabled=${{ env.BUILD_SPACE || false }} \
|
||||||
--set space.artifact_url=$SPACE_S3_URL \
|
--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.deploy_script_url=$DEPLOY_SCRIPT_URL \
|
||||||
--set shared_config.api_base_url=${{vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL}} \
|
--set shared_config.api_base_url=${{vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL}} \
|
||||||
--output json \
|
--output json \
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -81,3 +81,4 @@ tmp/
|
|||||||
## packages
|
## packages
|
||||||
dist
|
dist
|
||||||
.temp/
|
.temp/
|
||||||
|
deploy/selfhost/plane-app/
|
2
admin/.env.example
Normal file
2
admin/.env.example
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
NEXT_PUBLIC_APP_URL=
|
||||||
|
NEXT_PUBLIC_API_BASE_URL=
|
14
admin/.eslintrc.js
Normal file
14
admin/.eslintrc.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
extends: ["custom"],
|
||||||
|
parser: "@typescript-eslint/parser",
|
||||||
|
settings: {
|
||||||
|
"import/resolver": {
|
||||||
|
typescript: {},
|
||||||
|
node: {
|
||||||
|
moduleDirectory: ["node_modules", "."],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {}
|
||||||
|
}
|
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"
|
||||||
|
}
|
51
admin/Dockerfile.admin
Normal file
51
admin/Dockerfile.admin
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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=""
|
||||||
|
ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1
|
||||||
|
|
||||||
|
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||||
|
ENV NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX
|
||||||
|
|
||||||
|
RUN yarn turbo run build --filter=admin
|
||||||
|
|
||||||
|
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=""
|
||||||
|
ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1
|
||||||
|
|
||||||
|
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||||
|
ENV NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX
|
||||||
|
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
ENV TURBO_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
EXPOSE 3000
|
128
admin/app/ai/components/ai-config-form.tsx
Normal file
128
admin/app/ai/components/ai-config-form.tsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { Lightbulb } from "lucide-react";
|
||||||
|
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||||
|
import { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@plane/types";
|
||||||
|
// components
|
||||||
|
import { ControllerInput, TControllerInputFormField } from "components/common";
|
||||||
|
// hooks
|
||||||
|
import { useInstance } from "@/hooks";
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
1
admin/app/ai/components/index.ts
Normal file
1
admin/app/ai/components/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./ai-config-form";
|
21
admin/app/ai/layout.tsx
Normal file
21
admin/app/ai/layout.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
// layouts
|
||||||
|
import { AdminLayout } from "@/layouts";
|
||||||
|
// lib
|
||||||
|
import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers";
|
||||||
|
|
||||||
|
interface AILayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AILayout = ({ children }: AILayoutProps) => (
|
||||||
|
<InstanceWrapper>
|
||||||
|
<AuthWrapper>
|
||||||
|
<AdminLayout>{children}</AdminLayout>
|
||||||
|
</AuthWrapper>
|
||||||
|
</InstanceWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AILayout;
|
47
admin/app/ai/page.tsx
Normal file
47
admin/app/ai/page.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Loader } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { PageHeader } from "@/components/core";
|
||||||
|
import { InstanceAIForm } from "./components";
|
||||||
|
// hooks
|
||||||
|
import { useInstance } from "@/hooks";
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
1
admin/app/authentication/components/common/index.ts
Normal file
1
admin/app/authentication/components/common/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./authentication-method-card";
|
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 { useInstance } from "@/hooks";
|
||||||
|
// ui
|
||||||
|
import { ToggleSwitch } from "@plane/ui";
|
||||||
|
// types
|
||||||
|
import { TInstanceAuthenticationMethodKeys } from "@plane/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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
3
admin/app/authentication/components/index.ts
Normal file
3
admin/app/authentication/components/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./common";
|
||||||
|
export * from "./email-config-switch";
|
||||||
|
export * from "./password-config-switch";
|
@ -0,0 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
// hooks
|
||||||
|
import { useInstance } from "@/hooks";
|
||||||
|
// ui
|
||||||
|
import { ToggleSwitch } from "@plane/ui";
|
||||||
|
// types
|
||||||
|
import { TInstanceAuthenticationMethodKeys } from "@plane/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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
@ -0,0 +1,206 @@
|
|||||||
|
import { FC, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import Link from "next/link";
|
||||||
|
// hooks
|
||||||
|
import { useInstance } from "@/hooks";
|
||||||
|
// ui
|
||||||
|
import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import {
|
||||||
|
ConfirmDiscardModal,
|
||||||
|
ControllerInput,
|
||||||
|
CopyField,
|
||||||
|
TControllerInputFormField,
|
||||||
|
TCopyField,
|
||||||
|
} from "components/common";
|
||||||
|
// types
|
||||||
|
import { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types";
|
||||||
|
// helpers
|
||||||
|
import { API_BASE_URL, cn } from "helpers/common.helper";
|
||||||
|
import isEmpty from "lodash/isEmpty";
|
||||||
|
|
||||||
|
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 githubFormFields: TControllerInputFormField[] = [
|
||||||
|
{
|
||||||
|
key: "GITHUB_CLIENT_ID",
|
||||||
|
type: "text",
|
||||||
|
label: "Client ID",
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
You will get this from your{" "}
|
||||||
|
<a
|
||||||
|
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
|
||||||
|
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 githubCopyFields: TCopyField[] = [
|
||||||
|
{
|
||||||
|
key: "Origin_URL",
|
||||||
|
label: "Origin URL",
|
||||||
|
url: originURL,
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
We will auto-generate this. Paste this into the Authorized origin URL field{" "}
|
||||||
|
<a
|
||||||
|
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
|
||||||
|
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(() => {
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.SUCCESS,
|
||||||
|
title: "Success",
|
||||||
|
message: "Github Configuration Settings updated successfully",
|
||||||
|
});
|
||||||
|
reset();
|
||||||
|
})
|
||||||
|
.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>
|
||||||
|
{githubFormFields.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>
|
||||||
|
{githubCopyFields.map((field) => (
|
||||||
|
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
2
admin/app/authentication/github/components/index.ts
Normal file
2
admin/app/authentication/github/components/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./root";
|
||||||
|
export * from "./github-config-form";
|
59
admin/app/authentication/github/components/root.tsx
Normal file
59
admin/app/authentication/github/components/root.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
// hooks
|
||||||
|
import { useInstance } from "@/hooks";
|
||||||
|
// ui
|
||||||
|
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
|
||||||
|
// icons
|
||||||
|
import { Settings2 } from "lucide-react";
|
||||||
|
// types
|
||||||
|
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
|
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
113
admin/app/authentication/github/page.tsx
Normal file
113
admin/app/authentication/github/page.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { PageHeader } from "@/components/core";
|
||||||
|
import { AuthenticationMethodCard } from "../components";
|
||||||
|
import { InstanceGithubConfigForm } from "./components";
|
||||||
|
// hooks
|
||||||
|
import { useInstance } from "@/hooks";
|
||||||
|
// helpers
|
||||||
|
import { resolveGeneralTheme } from "@/helpers/common.helper";
|
||||||
|
// icons
|
||||||
|
import githubLightModeImage from "@/public/logos/github-black.png";
|
||||||
|
import githubDarkModeImage from "@/public/logos/github-white.png";
|
||||||
|
|
||||||
|
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;
|
@ -0,0 +1,206 @@
|
|||||||
|
import { FC, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import Link from "next/link";
|
||||||
|
// hooks
|
||||||
|
import { useInstance } from "@/hooks";
|
||||||
|
// ui
|
||||||
|
import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import {
|
||||||
|
ConfirmDiscardModal,
|
||||||
|
ControllerInput,
|
||||||
|
CopyField,
|
||||||
|
TControllerInputFormField,
|
||||||
|
TCopyField,
|
||||||
|
} from "components/common";
|
||||||
|
// types
|
||||||
|
import { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types";
|
||||||
|
// helpers
|
||||||
|
import { API_BASE_URL, cn } from "helpers/common.helper";
|
||||||
|
import isEmpty from "lodash/isEmpty";
|
||||||
|
|
||||||
|
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 googleFormFields: TControllerInputFormField[] = [
|
||||||
|
{
|
||||||
|
key: "GOOGLE_CLIENT_ID",
|
||||||
|
type: "text",
|
||||||
|
label: "Client ID",
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
Your client ID lives in your Google API Console.{" "}
|
||||||
|
<a
|
||||||
|
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
|
||||||
|
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 googleCopyFeilds: 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(() => {
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.SUCCESS,
|
||||||
|
title: "Success",
|
||||||
|
message: "Google Configuration Settings updated successfully",
|
||||||
|
});
|
||||||
|
reset();
|
||||||
|
})
|
||||||
|
.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>
|
||||||
|
{googleFormFields.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>
|
||||||
|
{googleCopyFeilds.map((field) => (
|
||||||
|
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
2
admin/app/authentication/google/components/index.ts
Normal file
2
admin/app/authentication/google/components/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./root";
|
||||||
|
export * from "./google-config-form";
|
59
admin/app/authentication/google/components/root.tsx
Normal file
59
admin/app/authentication/google/components/root.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
// hooks
|
||||||
|
import { useInstance } from "@/hooks";
|
||||||
|
// ui
|
||||||
|
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
|
||||||
|
// icons
|
||||||
|
import { Settings2 } from "lucide-react";
|
||||||
|
// types
|
||||||
|
import { TInstanceAuthenticationMethodKeys } from "@plane/types";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
|
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
101
admin/app/authentication/google/page.tsx
Normal file
101
admin/app/authentication/google/page.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { PageHeader } from "@/components/core";
|
||||||
|
import { AuthenticationMethodCard } from "../components";
|
||||||
|
import { InstanceGoogleConfigForm } from "./components";
|
||||||
|
// hooks
|
||||||
|
import { useInstance } from "@/hooks";
|
||||||
|
// icons
|
||||||
|
import GoogleLogo from "@/public/logos/google-logo.svg";
|
||||||
|
|
||||||
|
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;
|
21
admin/app/authentication/layout.tsx
Normal file
21
admin/app/authentication/layout.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
// layouts
|
||||||
|
import { AdminLayout } from "@/layouts";
|
||||||
|
// lib
|
||||||
|
import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers";
|
||||||
|
|
||||||
|
interface AuthenticationLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthenticationLayout = ({ children }: AuthenticationLayoutProps) => (
|
||||||
|
<InstanceWrapper>
|
||||||
|
<AuthWrapper>
|
||||||
|
<AdminLayout>{children}</AdminLayout>
|
||||||
|
</AuthWrapper>
|
||||||
|
</InstanceWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AuthenticationLayout;
|
154
admin/app/authentication/page.tsx
Normal file
154
admin/app/authentication/page.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Mails, KeyRound } from "lucide-react";
|
||||||
|
import { Loader, setPromiseToast } from "@plane/ui";
|
||||||
|
import { TInstanceConfigurationKeys } from "@plane/types";
|
||||||
|
// components
|
||||||
|
import { AuthenticationMethodCard, EmailCodesConfiguration, PasswordLoginConfiguration } from "./components";
|
||||||
|
import { GoogleConfiguration } from "./google/components";
|
||||||
|
import { GithubConfiguration } from "./github/components";
|
||||||
|
import { PageHeader } from "@/components/core";
|
||||||
|
// hooks
|
||||||
|
import { useInstance } from "@/hooks";
|
||||||
|
// helpers
|
||||||
|
import { resolveGeneralTheme } from "@/helpers/common.helper";
|
||||||
|
// images
|
||||||
|
import GoogleLogo from "@/public/logos/google-logo.svg";
|
||||||
|
import githubLightModeImage from "@/public/logos/github-black.png";
|
||||||
|
import githubDarkModeImage from "@/public/logos/github-white.png";
|
||||||
|
|
||||||
|
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;
|
221
admin/app/email/components/email-config-form.tsx
Normal file
221
admin/app/email/components/email-config-form.tsx
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import React, { FC, useMemo, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
// hooks
|
||||||
|
import { useInstance } from "@/hooks";
|
||||||
|
// ui
|
||||||
|
import { Button, CustomSelect, TOAST_TYPE, setToast } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { ControllerInput, TControllerInputFormField } from "components/common";
|
||||||
|
import { SendTestEmailModal } from "./test-email-modal";
|
||||||
|
// types
|
||||||
|
import { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from "@plane/types";
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
2
admin/app/email/components/index.ts
Normal file
2
admin/app/email/components/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./email-config-form";
|
||||||
|
export * from "./test-email-modal";
|
135
admin/app/email/components/test-email-modal.tsx
Normal file
135
admin/app/email/components/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>
|
||||||
|
);
|
||||||
|
};
|
21
admin/app/email/layout.tsx
Normal file
21
admin/app/email/layout.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
// layouts
|
||||||
|
import { AdminLayout } from "@/layouts";
|
||||||
|
// lib
|
||||||
|
import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers";
|
||||||
|
|
||||||
|
interface EmailLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmailLayout = ({ children }: EmailLayoutProps) => (
|
||||||
|
<InstanceWrapper>
|
||||||
|
<AuthWrapper>
|
||||||
|
<AdminLayout>{children}</AdminLayout>
|
||||||
|
</AuthWrapper>
|
||||||
|
</InstanceWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default EmailLayout;
|
50
admin/app/email/page.tsx
Normal file
50
admin/app/email/page.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Loader } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { PageHeader } from "@/components/core";
|
||||||
|
import { InstanceEmailForm } from "./components";
|
||||||
|
// hooks
|
||||||
|
import { useInstance } from "@/hooks";
|
||||||
|
|
||||||
|
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;
|
136
admin/app/general/components/general-config-form.tsx
Normal file
136
admin/app/general/components/general-config-form.tsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { Telescope } from "lucide-react";
|
||||||
|
import { IInstance, IInstanceAdmin } from "@plane/types";
|
||||||
|
import { Button, Input, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { ControllerInput } from "components/common";
|
||||||
|
// hooks
|
||||||
|
import { useInstance } from "@/hooks";
|
||||||
|
|
||||||
|
export interface IGeneralConfigurationForm {
|
||||||
|
instance: IInstance["instance"];
|
||||||
|
instanceAdmins: IInstanceAdmin[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = (props) => {
|
||||||
|
const { instance, instanceAdmins } = props;
|
||||||
|
// hooks
|
||||||
|
const { updateInstanceInfo } = useInstance();
|
||||||
|
// form data
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<Partial<IInstance["instance"]>>({
|
||||||
|
defaultValues: {
|
||||||
|
instance_name: instance.instance_name,
|
||||||
|
is_telemetry_enabled: instance.is_telemetry_enabled,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (formData: Partial<IInstance["instance"]>) => {
|
||||||
|
const payload: Partial<IInstance["instance"]> = { ...formData };
|
||||||
|
|
||||||
|
console.log("payload", payload);
|
||||||
|
|
||||||
|
await updateInstanceInfo(payload)
|
||||||
|
.then(() =>
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.SUCCESS,
|
||||||
|
title: "Success",
|
||||||
|
message: "Settings updated successfully",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch((err) => console.error(err));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-lg font-medium">Instance details</div>
|
||||||
|
<div className="grid-col grid w-full grid-cols-1 items-center justify-between gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<ControllerInput
|
||||||
|
key="instance_name"
|
||||||
|
name="instance_name"
|
||||||
|
control={control}
|
||||||
|
type="text"
|
||||||
|
label="Name of instance"
|
||||||
|
placeholder="Instance name"
|
||||||
|
error={Boolean(errors.instance_name)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h4 className="text-sm text-custom-text-300">Email</h4>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
value={instanceAdmins[0]?.user_detail?.email ?? ""}
|
||||||
|
placeholder="Admin email"
|
||||||
|
className="w-full cursor-not-allowed !text-custom-text-400"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h4 className="text-sm text-custom-text-300">Instance ID</h4>
|
||||||
|
<Input
|
||||||
|
id="instance_id"
|
||||||
|
name="instance_id"
|
||||||
|
type="text"
|
||||||
|
value={instance.instance_id}
|
||||||
|
className="w-full cursor-not-allowed rounded-md font-medium !text-custom-text-400"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-lg font-medium">Telemetry</div>
|
||||||
|
<div className="flex items-center gap-14 px-4 py-3 border border-custom-border-200 rounded">
|
||||||
|
<div className="grow flex items-center gap-4">
|
||||||
|
<div className="shrink-0">
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 bg-custom-background-80 rounded-full">
|
||||||
|
<Telescope className="w-6 h-6 text-custom-text-300/80 p-0.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grow">
|
||||||
|
<div className="text-sm font-medium text-custom-text-100 leading-5">
|
||||||
|
Allow Plane to collect anonymous usage events
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-normal text-custom-text-300 leading-5">
|
||||||
|
We collect usage events without any PII to analyse and improve Plane.{" "}
|
||||||
|
<a
|
||||||
|
href="https://docs.plane.so/self-hosting/telemetry"
|
||||||
|
target="_blank"
|
||||||
|
className="text-custom-primary-100 hover:underline"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Know more.
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`shrink-0 ${isSubmitting && "opacity-70"}`}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="is_telemetry_enabled"
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<ToggleSwitch value={value ?? false} onChange={onChange} size="sm" disabled={isSubmitting} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
|
||||||
|
{isSubmitting ? "Saving..." : "Save changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
1
admin/app/general/components/index.ts
Normal file
1
admin/app/general/components/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./general-config-form";
|
21
admin/app/general/layout.tsx
Normal file
21
admin/app/general/layout.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
// layouts
|
||||||
|
import { AdminLayout } from "@/layouts";
|
||||||
|
// lib
|
||||||
|
import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers";
|
||||||
|
|
||||||
|
interface GeneralLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GeneralLayout = ({ children }: GeneralLayoutProps) => (
|
||||||
|
<InstanceWrapper>
|
||||||
|
<AuthWrapper>
|
||||||
|
<AdminLayout>{children}</AdminLayout>
|
||||||
|
</AuthWrapper>
|
||||||
|
</InstanceWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default GeneralLayout;
|
34
admin/app/general/page.tsx
Normal file
34
admin/app/general/page.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
// components
|
||||||
|
import { PageHeader } from "@/components/core";
|
||||||
|
import { GeneralConfigurationForm } from "./components";
|
||||||
|
// hooks
|
||||||
|
import { useInstance } from "@/hooks";
|
||||||
|
|
||||||
|
const GeneralPage = observer(() => {
|
||||||
|
const { instance, instanceAdmins } = useInstance();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="General Settings - 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">General settings</div>
|
||||||
|
<div className="text-sm font-normal text-custom-text-300">
|
||||||
|
Change the name of your instance and instance admin e-mail addresses. Enable or disable telemetry in your
|
||||||
|
instance.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow overflow-hidden overflow-y-auto">
|
||||||
|
{instance?.instance && instanceAdmins && instanceAdmins?.length > 0 && (
|
||||||
|
<GeneralConfigurationForm instance={instance?.instance} instanceAdmins={instanceAdmins} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default GeneralPage;
|
466
admin/app/globals.css
Normal file
466
admin/app/globals.css
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700;800&display=swap");
|
||||||
|
@import url("https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@48,400,0,0&display=swap");
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.text-1\.5xl {
|
||||||
|
font-size: 1.375rem;
|
||||||
|
line-height: 1.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-2\.5xl {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
line-height: 2.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
font-family: "Inter", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light !important;
|
||||||
|
|
||||||
|
--color-primary-10: 236, 241, 255;
|
||||||
|
--color-primary-20: 217, 228, 255;
|
||||||
|
--color-primary-30: 197, 214, 255;
|
||||||
|
--color-primary-40: 178, 200, 255;
|
||||||
|
--color-primary-50: 159, 187, 255;
|
||||||
|
--color-primary-60: 140, 173, 255;
|
||||||
|
--color-primary-70: 121, 159, 255;
|
||||||
|
--color-primary-80: 101, 145, 255;
|
||||||
|
--color-primary-90: 82, 132, 255;
|
||||||
|
--color-primary-100: 63, 118, 255;
|
||||||
|
--color-primary-200: 57, 106, 230;
|
||||||
|
--color-primary-300: 50, 94, 204;
|
||||||
|
--color-primary-400: 44, 83, 179;
|
||||||
|
--color-primary-500: 38, 71, 153;
|
||||||
|
--color-primary-600: 32, 59, 128;
|
||||||
|
--color-primary-700: 25, 47, 102;
|
||||||
|
--color-primary-800: 19, 35, 76;
|
||||||
|
--color-primary-900: 13, 24, 51;
|
||||||
|
|
||||||
|
--color-background-100: 255, 255, 255; /* primary bg */
|
||||||
|
--color-background-90: 250, 250, 250; /* secondary bg */
|
||||||
|
--color-background-80: 245, 245, 245; /* tertiary bg */
|
||||||
|
|
||||||
|
--color-text-100: 23, 23, 23; /* primary text */
|
||||||
|
--color-text-200: 58, 58, 58; /* secondary text */
|
||||||
|
--color-text-300: 82, 82, 82; /* tertiary text */
|
||||||
|
--color-text-400: 163, 163, 163; /* placeholder text */
|
||||||
|
|
||||||
|
--color-scrollbar: 163, 163, 163; /* scrollbar thumb */
|
||||||
|
|
||||||
|
--color-border-100: 245, 245, 245; /* subtle border= 1 */
|
||||||
|
--color-border-200: 229, 229, 229; /* subtle border- 2 */
|
||||||
|
--color-border-300: 212, 212, 212; /* strong border- 1 */
|
||||||
|
--color-border-400: 185, 185, 185; /* strong border- 2 */
|
||||||
|
|
||||||
|
--color-shadow-2xs: 0px 0px 1px 0px rgba(23, 23, 23, 0.06),
|
||||||
|
0px 1px 2px 0px rgba(23, 23, 23, 0.06),
|
||||||
|
0px 1px 2px 0px rgba(23, 23, 23, 0.14);
|
||||||
|
--color-shadow-xs: 0px 1px 2px 0px rgba(0, 0, 0, 0.16),
|
||||||
|
0px 2px 4px 0px rgba(16, 24, 40, 0.12),
|
||||||
|
0px 1px 8px -1px rgba(16, 24, 40, 0.1);
|
||||||
|
--color-shadow-sm: 0px 1px 4px 0px rgba(0, 0, 0, 0.01),
|
||||||
|
0px 4px 8px 0px rgba(0, 0, 0, 0.02), 0px 1px 12px 0px rgba(0, 0, 0, 0.12);
|
||||||
|
--color-shadow-rg: 0px 3px 6px 0px rgba(0, 0, 0, 0.1),
|
||||||
|
0px 4px 4px 0px rgba(16, 24, 40, 0.08),
|
||||||
|
0px 1px 12px 0px rgba(16, 24, 40, 0.04);
|
||||||
|
--color-shadow-md: 0px 4px 8px 0px rgba(0, 0, 0, 0.12),
|
||||||
|
0px 6px 12px 0px rgba(16, 24, 40, 0.12),
|
||||||
|
0px 1px 16px 0px rgba(16, 24, 40, 0.12);
|
||||||
|
--color-shadow-lg: 0px 6px 12px 0px rgba(0, 0, 0, 0.12),
|
||||||
|
0px 8px 16px 0px rgba(0, 0, 0, 0.12),
|
||||||
|
0px 1px 24px 0px rgba(16, 24, 40, 0.12);
|
||||||
|
--color-shadow-xl: 0px 0px 18px 0px rgba(0, 0, 0, 0.16),
|
||||||
|
0px 0px 24px 0px rgba(16, 24, 40, 0.16),
|
||||||
|
0px 0px 52px 0px rgba(16, 24, 40, 0.16);
|
||||||
|
--color-shadow-2xl: 0px 8px 16px 0px rgba(0, 0, 0, 0.12),
|
||||||
|
0px 12px 24px 0px rgba(16, 24, 40, 0.12),
|
||||||
|
0px 1px 32px 0px rgba(16, 24, 40, 0.12);
|
||||||
|
--color-shadow-3xl: 0px 12px 24px 0px rgba(0, 0, 0, 0.12),
|
||||||
|
0px 16px 32px 0px rgba(0, 0, 0, 0.12),
|
||||||
|
0px 1px 48px 0px rgba(16, 24, 40, 0.12);
|
||||||
|
--color-shadow-4xl: 0px 8px 40px 0px rgba(0, 0, 61, 0.05),
|
||||||
|
0px 12px 32px -16px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
--color-sidebar-background-100: var(
|
||||||
|
--color-background-100
|
||||||
|
); /* primary sidebar bg */
|
||||||
|
--color-sidebar-background-90: var(
|
||||||
|
--color-background-90
|
||||||
|
); /* secondary sidebar bg */
|
||||||
|
--color-sidebar-background-80: var(
|
||||||
|
--color-background-80
|
||||||
|
); /* tertiary sidebar bg */
|
||||||
|
|
||||||
|
--color-sidebar-text-100: var(--color-text-100); /* primary sidebar text */
|
||||||
|
--color-sidebar-text-200: var(
|
||||||
|
--color-text-200
|
||||||
|
); /* secondary sidebar text */
|
||||||
|
--color-sidebar-text-300: var(--color-text-300); /* tertiary sidebar text */
|
||||||
|
--color-sidebar-text-400: var(
|
||||||
|
--color-text-400
|
||||||
|
); /* sidebar placeholder text */
|
||||||
|
|
||||||
|
--color-sidebar-border-100: var(
|
||||||
|
--color-border-100
|
||||||
|
); /* subtle sidebar border= 1 */
|
||||||
|
--color-sidebar-border-200: var(
|
||||||
|
--color-border-100
|
||||||
|
); /* subtle sidebar border- 2 */
|
||||||
|
--color-sidebar-border-300: var(
|
||||||
|
--color-border-100
|
||||||
|
); /* strong sidebar border- 1 */
|
||||||
|
--color-sidebar-border-400: var(
|
||||||
|
--color-border-100
|
||||||
|
); /* strong sidebar border- 2 */
|
||||||
|
|
||||||
|
--color-sidebar-shadow-2xs: var(--color-shadow-2xs);
|
||||||
|
--color-sidebar-shadow-xs: var(--color-shadow-xs);
|
||||||
|
--color-sidebar-shadow-sm: var(--color-shadow-sm);
|
||||||
|
--color-sidebar-shadow-rg: var(--color-shadow-rg);
|
||||||
|
--color-sidebar-shadow-md: var(--color-shadow-md);
|
||||||
|
--color-sidebar-shadow-lg: var(--color-shadow-lg);
|
||||||
|
--color-sidebar-shadow-xl: var(--color-shadow-xl);
|
||||||
|
--color-sidebar-shadow-2xl: var(--color-shadow-2xl);
|
||||||
|
--color-sidebar-shadow-3xl: var(--color-shadow-3xl);
|
||||||
|
--color-sidebar-shadow-4xl: var(--color-shadow-4xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"],
|
||||||
|
[data-theme="light-contrast"] {
|
||||||
|
color-scheme: light !important;
|
||||||
|
|
||||||
|
--color-background-100: 255, 255, 255; /* primary bg */
|
||||||
|
--color-background-90: 250, 250, 250; /* secondary bg */
|
||||||
|
--color-background-80: 245, 245, 245; /* tertiary bg */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--color-text-100: 23, 23, 23; /* primary text */
|
||||||
|
--color-text-200: 58, 58, 58; /* secondary text */
|
||||||
|
--color-text-300: 82, 82, 82; /* tertiary text */
|
||||||
|
--color-text-400: 163, 163, 163; /* placeholder text */
|
||||||
|
|
||||||
|
--color-scrollbar: 163, 163, 163; /* scrollbar thumb */
|
||||||
|
|
||||||
|
--color-border-100: 245, 245, 245; /* subtle border= 1 */
|
||||||
|
--color-border-200: 229, 229, 229; /* subtle border- 2 */
|
||||||
|
--color-border-300: 212, 212, 212; /* strong border- 1 */
|
||||||
|
--color-border-400: 185, 185, 185; /* strong border- 2 */
|
||||||
|
|
||||||
|
/* onboarding colors */
|
||||||
|
--gradient-onboarding-100: linear-gradient(
|
||||||
|
106deg,
|
||||||
|
#f2f6ff 29.8%,
|
||||||
|
#e1eaff 99.34%
|
||||||
|
);
|
||||||
|
--gradient-onboarding-200: linear-gradient(
|
||||||
|
129deg,
|
||||||
|
rgba(255, 255, 255, 0) -22.23%,
|
||||||
|
rgba(255, 255, 255, 0.8) 62.98%
|
||||||
|
);
|
||||||
|
--gradient-onboarding-300: linear-gradient(
|
||||||
|
164deg,
|
||||||
|
#fff 4.25%,
|
||||||
|
rgba(255, 255, 255, 0.06) 93.5%
|
||||||
|
);
|
||||||
|
--gradient-onboarding-400: linear-gradient(
|
||||||
|
129deg,
|
||||||
|
rgba(255, 255, 255, 0) -22.23%,
|
||||||
|
rgba(255, 255, 255, 0.8) 62.98%
|
||||||
|
);
|
||||||
|
|
||||||
|
--color-onboarding-text-100: 23, 23, 23;
|
||||||
|
--color-onboarding-text-200: 58, 58, 58;
|
||||||
|
--color-onboarding-text-300: 82, 82, 82;
|
||||||
|
--color-onboarding-text-400: 163, 163, 163;
|
||||||
|
|
||||||
|
--color-onboarding-background-100: 236, 241, 255;
|
||||||
|
--color-onboarding-background-200: 255, 255, 255;
|
||||||
|
--color-onboarding-background-300: 236, 241, 255;
|
||||||
|
--color-onboarding-background-400: 177, 206, 250;
|
||||||
|
|
||||||
|
--color-onboarding-border-100: 229, 229, 229;
|
||||||
|
--color-onboarding-border-200: 217, 228, 255;
|
||||||
|
--color-onboarding-border-300: 229, 229, 229, 0.5;
|
||||||
|
|
||||||
|
--color-onboarding-shadow-sm: 0px 4px 20px 0px rgba(126, 139, 171, 0.1);
|
||||||
|
|
||||||
|
/* toast theme */
|
||||||
|
--color-toast-success-text: 62, 155, 79;
|
||||||
|
--color-toast-error-text: 220, 62, 66;
|
||||||
|
--color-toast-warning-text: 255, 186, 24;
|
||||||
|
--color-toast-info-text: 51, 88, 212;
|
||||||
|
--color-toast-loading-text: 28, 32, 36;
|
||||||
|
--color-toast-secondary-text: 128, 131, 141;
|
||||||
|
--color-toast-tertiary-text: 96, 100, 108;
|
||||||
|
|
||||||
|
--color-toast-success-background: 253, 253, 254;
|
||||||
|
--color-toast-error-background: 255, 252, 252;
|
||||||
|
--color-toast-warning-background: 254, 253, 251;
|
||||||
|
--color-toast-info-background: 253, 253, 254;
|
||||||
|
--color-toast-loading-background: 253, 253, 254;
|
||||||
|
|
||||||
|
--color-toast-success-border: 218, 241, 219;
|
||||||
|
--color-toast-error-border: 255, 219, 220;
|
||||||
|
--color-toast-warning-border: 255, 247, 194;
|
||||||
|
--color-toast-info-border: 210, 222, 255;
|
||||||
|
--color-toast-loading-border: 224, 225, 230;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light-contrast"] {
|
||||||
|
--color-text-100: 11, 11, 11; /* primary text */
|
||||||
|
--color-text-200: 38, 38, 38; /* secondary text */
|
||||||
|
--color-text-300: 58, 58, 58; /* tertiary text */
|
||||||
|
--color-text-400: 115, 115, 115; /* placeholder text */
|
||||||
|
|
||||||
|
--color-scrollbar: 115, 115, 115; /* scrollbar thumb */
|
||||||
|
|
||||||
|
--color-border-100: 34, 34, 34; /* subtle border= 1 */
|
||||||
|
--color-border-200: 38, 38, 38; /* subtle border- 2 */
|
||||||
|
--color-border-300: 46, 46, 46; /* strong border- 1 */
|
||||||
|
--color-border-400: 58, 58, 58; /* strong border- 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"],
|
||||||
|
[data-theme="dark-contrast"] {
|
||||||
|
color-scheme: dark !important;
|
||||||
|
|
||||||
|
--color-background-100: 7, 7, 7; /* primary bg */
|
||||||
|
--color-background-90: 11, 11, 11; /* secondary bg */
|
||||||
|
--color-background-80: 23, 23, 23; /* tertiary bg */
|
||||||
|
|
||||||
|
--color-shadow-2xs: 0px 0px 1px 0px rgba(0, 0, 0, 0.15),
|
||||||
|
0px 1px 3px 0px rgba(0, 0, 0, 0.5);
|
||||||
|
--color-shadow-xs: 0px 0px 2px 0px rgba(0, 0, 0, 0.2),
|
||||||
|
0px 2px 4px 0px rgba(0, 0, 0, 0.5);
|
||||||
|
--color-shadow-sm: 0px 0px 4px 0px rgba(0, 0, 0, 0.2),
|
||||||
|
0px 2px 6px 0px rgba(0, 0, 0, 0.5);
|
||||||
|
--color-shadow-rg: 0px 0px 6px 0px rgba(0, 0, 0, 0.2),
|
||||||
|
0px 4px 6px 0px rgba(0, 0, 0, 0.5);
|
||||||
|
--color-shadow-md: 0px 2px 8px 0px rgba(0, 0, 0, 0.2),
|
||||||
|
0px 4px 8px 0px rgba(0, 0, 0, 0.5);
|
||||||
|
--color-shadow-lg: 0px 4px 12px 0px rgba(0, 0, 0, 0.25),
|
||||||
|
0px 4px 10px 0px rgba(0, 0, 0, 0.55);
|
||||||
|
--color-shadow-xl: 0px 0px 14px 0px rgba(0, 0, 0, 0.25),
|
||||||
|
0px 6px 10px 0px rgba(0, 0, 0, 0.55);
|
||||||
|
--color-shadow-2xl: 0px 0px 18px 0px rgba(0, 0, 0, 0.25),
|
||||||
|
0px 8px 12px 0px rgba(0, 0, 0, 0.6);
|
||||||
|
--color-shadow-3xl: 0px 4px 24px 0px rgba(0, 0, 0, 0.3),
|
||||||
|
0px 12px 40px 0px rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--color-text-100: 229, 229, 229; /* primary text */
|
||||||
|
--color-text-200: 163, 163, 163; /* secondary text */
|
||||||
|
--color-text-300: 115, 115, 115; /* tertiary text */
|
||||||
|
--color-text-400: 82, 82, 82; /* placeholder text */
|
||||||
|
|
||||||
|
--color-scrollbar: 82, 82, 82; /* scrollbar thumb */
|
||||||
|
|
||||||
|
--color-border-100: 34, 34, 34; /* subtle border= 1 */
|
||||||
|
--color-border-200: 38, 38, 38; /* subtle border- 2 */
|
||||||
|
--color-border-300: 46, 46, 46; /* strong border- 1 */
|
||||||
|
--color-border-400: 58, 58, 58; /* strong border- 2 */
|
||||||
|
|
||||||
|
/* onboarding colors */
|
||||||
|
--gradient-onboarding-100: linear-gradient(
|
||||||
|
106deg,
|
||||||
|
#18191b 25.17%,
|
||||||
|
#18191b 99.34%
|
||||||
|
);
|
||||||
|
--gradient-onboarding-200: linear-gradient(
|
||||||
|
129deg,
|
||||||
|
rgba(47, 49, 53, 0.8) -22.23%,
|
||||||
|
rgba(33, 34, 37, 0.8) 62.98%
|
||||||
|
);
|
||||||
|
--gradient-onboarding-300: linear-gradient(
|
||||||
|
167deg,
|
||||||
|
rgba(47, 49, 53, 0.45) 19.22%,
|
||||||
|
#212225 98.48%
|
||||||
|
);
|
||||||
|
|
||||||
|
--color-onboarding-text-100: 237, 238, 240;
|
||||||
|
--color-onboarding-text-200: 176, 180, 187;
|
||||||
|
--color-onboarding-text-300: 118, 123, 132;
|
||||||
|
--color-onboarding-text-400: 105, 110, 119;
|
||||||
|
|
||||||
|
--color-onboarding-background-100: 54, 58, 64;
|
||||||
|
--color-onboarding-background-200: 40, 42, 45;
|
||||||
|
--color-onboarding-background-300: 40, 42, 45;
|
||||||
|
--color-onboarding-background-400: 67, 72, 79;
|
||||||
|
|
||||||
|
--color-onboarding-border-100: 54, 58, 64;
|
||||||
|
--color-onboarding-border-200: 54, 58, 64;
|
||||||
|
--color-onboarding-border-300: 34, 35, 38, 0.5;
|
||||||
|
|
||||||
|
--color-onboarding-shadow-sm: 0px 4px 20px 0px rgba(39, 44, 56, 0.1);
|
||||||
|
|
||||||
|
/* toast theme */
|
||||||
|
--color-toast-success-text: 178, 221, 181;
|
||||||
|
--color-toast-error-text: 206, 44, 49;
|
||||||
|
--color-toast-warning-text: 255, 186, 24;
|
||||||
|
--color-toast-info-text: 141, 164, 239;
|
||||||
|
--color-toast-loading-text: 255, 255, 255;
|
||||||
|
--color-toast-secondary-text: 185, 187, 198;
|
||||||
|
--color-toast-tertiary-text: 139, 141, 152;
|
||||||
|
|
||||||
|
--color-toast-success-background: 46, 46, 46;
|
||||||
|
--color-toast-error-background: 46, 46, 46;
|
||||||
|
--color-toast-warning-background: 46, 46, 46;
|
||||||
|
--color-toast-info-background: 46, 46, 46;
|
||||||
|
--color-toast-loading-background: 46, 46, 46;
|
||||||
|
|
||||||
|
--color-toast-success-border: 42, 126, 59;
|
||||||
|
--color-toast-error-border: 100, 23, 35;
|
||||||
|
--color-toast-warning-border: 79, 52, 34;
|
||||||
|
--color-toast-info-border: 58, 91, 199;
|
||||||
|
--color-toast-loading-border: 96, 100, 108;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark-contrast"] {
|
||||||
|
--color-text-100: 250, 250, 250; /* primary text */
|
||||||
|
--color-text-200: 241, 241, 241; /* secondary text */
|
||||||
|
--color-text-300: 212, 212, 212; /* tertiary text */
|
||||||
|
--color-text-400: 115, 115, 115; /* placeholder text */
|
||||||
|
|
||||||
|
--color-scrollbar: 115, 115, 115; /* scrollbar thumb */
|
||||||
|
|
||||||
|
--color-border-100: 245, 245, 245; /* subtle border= 1 */
|
||||||
|
--color-border-200: 229, 229, 229; /* subtle border- 2 */
|
||||||
|
--color-border-300: 212, 212, 212; /* strong border- 1 */
|
||||||
|
--color-border-400: 185, 185, 185; /* strong border- 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"],
|
||||||
|
[data-theme="dark"],
|
||||||
|
[data-theme="light-contrast"],
|
||||||
|
[data-theme="dark-contrast"] {
|
||||||
|
--color-primary-10: 236, 241, 255;
|
||||||
|
--color-primary-20: 217, 228, 255;
|
||||||
|
--color-primary-30: 197, 214, 255;
|
||||||
|
--color-primary-40: 178, 200, 255;
|
||||||
|
--color-primary-50: 159, 187, 255;
|
||||||
|
--color-primary-60: 140, 173, 255;
|
||||||
|
--color-primary-70: 121, 159, 255;
|
||||||
|
--color-primary-80: 101, 145, 255;
|
||||||
|
--color-primary-90: 82, 132, 255;
|
||||||
|
--color-primary-100: 63, 118, 255;
|
||||||
|
--color-primary-200: 57, 106, 230;
|
||||||
|
--color-primary-300: 50, 94, 204;
|
||||||
|
--color-primary-400: 44, 83, 179;
|
||||||
|
--color-primary-500: 38, 71, 153;
|
||||||
|
--color-primary-600: 32, 59, 128;
|
||||||
|
--color-primary-700: 25, 47, 102;
|
||||||
|
--color-primary-800: 19, 35, 76;
|
||||||
|
--color-primary-900: 13, 24, 51;
|
||||||
|
|
||||||
|
--color-sidebar-background-100: var(
|
||||||
|
--color-background-100
|
||||||
|
); /* primary sidebar bg */
|
||||||
|
--color-sidebar-background-90: var(
|
||||||
|
--color-background-90
|
||||||
|
); /* secondary sidebar bg */
|
||||||
|
--color-sidebar-background-80: var(
|
||||||
|
--color-background-80
|
||||||
|
); /* tertiary sidebar bg */
|
||||||
|
|
||||||
|
--color-sidebar-text-100: var(--color-text-100); /* primary sidebar text */
|
||||||
|
--color-sidebar-text-200: var(
|
||||||
|
--color-text-200
|
||||||
|
); /* secondary sidebar text */
|
||||||
|
--color-sidebar-text-300: var(--color-text-300); /* tertiary sidebar text */
|
||||||
|
--color-sidebar-text-400: var(
|
||||||
|
--color-text-400
|
||||||
|
); /* sidebar placeholder text */
|
||||||
|
|
||||||
|
--color-sidebar-border-100: var(
|
||||||
|
--color-border-100
|
||||||
|
); /* subtle sidebar border= 1 */
|
||||||
|
--color-sidebar-border-200: var(
|
||||||
|
--color-border-200
|
||||||
|
); /* subtle sidebar border- 2 */
|
||||||
|
--color-sidebar-border-300: var(
|
||||||
|
--color-border-300
|
||||||
|
); /* strong sidebar border- 1 */
|
||||||
|
--color-sidebar-border-400: var(
|
||||||
|
--color-border-400
|
||||||
|
); /* strong sidebar border- 2 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
font-variant-ligatures: none;
|
||||||
|
-webkit-font-variant-ligatures: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: rgba(var(--color-text-100));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* scrollbar style */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-scroll-enable {
|
||||||
|
overflow-x: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-scroll-enable::-webkit-scrollbar {
|
||||||
|
display: block;
|
||||||
|
height: 7px;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-scroll-enable::-webkit-scrollbar-track {
|
||||||
|
height: 7px;
|
||||||
|
background-color: rgba(var(--color-background-100));
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-scroll-enable::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: rgba(var(--color-scrollbar));
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-scroll-enable::-webkit-scrollbar {
|
||||||
|
display: block;
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-scroll-enable::-webkit-scrollbar-track {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-scroll-enable::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: rgba(var(--color-background-90));
|
||||||
|
}
|
||||||
|
/* end scrollbar style */
|
||||||
|
|
||||||
|
/* progress bar */
|
||||||
|
.progress-bar {
|
||||||
|
fill: currentColor;
|
||||||
|
color: rgba(var(--color-sidebar-background-100));
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-input-placeholder,
|
||||||
|
::placeholder,
|
||||||
|
:-ms-input-placeholder {
|
||||||
|
color: rgb(var(--color-text-400));
|
||||||
|
}
|
79
admin/app/image/components/image-config-form.tsx
Normal file
79
admin/app/image/components/image-config-form.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||||
|
import { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types";
|
||||||
|
// components
|
||||||
|
import { ControllerInput } from "components/common";
|
||||||
|
// hooks
|
||||||
|
import { useInstance } from "@/hooks";
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
1
admin/app/image/components/index.ts
Normal file
1
admin/app/image/components/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./image-config-form";
|
21
admin/app/image/layout.tsx
Normal file
21
admin/app/image/layout.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
// layouts
|
||||||
|
import { AdminLayout } from "@/layouts";
|
||||||
|
// lib
|
||||||
|
import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers";
|
||||||
|
|
||||||
|
interface ImageLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageLayout = ({ children }: ImageLayoutProps) => (
|
||||||
|
<InstanceWrapper>
|
||||||
|
<AuthWrapper>
|
||||||
|
<AdminLayout>{children}</AdminLayout>
|
||||||
|
</AuthWrapper>
|
||||||
|
</InstanceWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ImageLayout;
|
43
admin/app/image/page.tsx
Normal file
43
admin/app/image/page.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Loader } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { PageHeader } from "@/components/core";
|
||||||
|
import { InstanceImageConfigForm } from "./components";
|
||||||
|
// hooks
|
||||||
|
import { useInstance } from "@/hooks";
|
||||||
|
|
||||||
|
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 } from "next-themes";
|
||||||
|
// lib
|
||||||
|
import { StoreProvider } from "@/lib/store-context";
|
||||||
|
import { AppWrapper } from "@/lib/wrappers";
|
||||||
|
// constants
|
||||||
|
import { SITE_NAME, SITE_DESCRIPTION, SITE_URL, TWITTER_USER_NAME, SITE_KEYWORDS, SITE_TITLE } from "@/constants/seo";
|
||||||
|
// styles
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
interface RootLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RootLayout = ({ children, ...pageProps }: RootLayoutProps) => {
|
||||||
|
const prefix = parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0") === 0 ? "/" : "/god-mode/";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>{SITE_TITLE}</title>
|
||||||
|
<meta property="og:site_name" content={SITE_NAME} />
|
||||||
|
<meta property="og:title" content={SITE_TITLE} />
|
||||||
|
<meta property="og:url" content={SITE_URL} />
|
||||||
|
<meta name="description" content={SITE_DESCRIPTION} />
|
||||||
|
<meta property="og:description" content={SITE_DESCRIPTION} />
|
||||||
|
<meta name="keywords" content={SITE_KEYWORDS} />
|
||||||
|
<meta name="twitter:site" content={`@${TWITTER_USER_NAME}`} />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href={`${prefix}favicon/apple-touch-icon.png`} />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href={`${prefix}favicon/favicon-32x32.png`} />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href={`${prefix}favicon/favicon-16x16.png`} />
|
||||||
|
<link rel="manifest" href={`${prefix}site.webmanifest.json`} />
|
||||||
|
<link rel="shortcut icon" href={`${prefix}favicon/favicon.ico`} />
|
||||||
|
</head>
|
||||||
|
<body className={`antialiased`}>
|
||||||
|
<StoreProvider {...pageProps}>
|
||||||
|
<ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem>
|
||||||
|
<AppWrapper>{children}</AppWrapper>
|
||||||
|
</ThemeProvider>
|
||||||
|
</StoreProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RootLayout;
|
@ -1,2 +1 @@
|
|||||||
export * from "./root";
|
|
||||||
export * from "./sign-in-form";
|
export * from "./sign-in-form";
|
177
admin/app/login/components/sign-in-form.tsx
Normal file
177
admin/app/login/components/sign-in-form.tsx
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FC, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
// services
|
||||||
|
import { AuthService } from "@/services/auth.service";
|
||||||
|
// ui
|
||||||
|
import { Button, Input, Spinner } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { Banner } from "components/common";
|
||||||
|
// icons
|
||||||
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
|
// helpers
|
||||||
|
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||||
|
|
||||||
|
// 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 }));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (csrfToken === undefined)
|
||||||
|
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
|
||||||
|
}, [csrfToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (emailParam) setFormData((prev) => ({ ...prev, email: emailParam }));
|
||||||
|
}, [emailParam]);
|
||||||
|
|
||||||
|
// derived values
|
||||||
|
const errorData: TError = useMemo(() => {
|
||||||
|
if (errorCode && errorMessage) {
|
||||||
|
switch (errorCode) {
|
||||||
|
case EErrorCodes.INSTANCE_NOT_CONFIGURED:
|
||||||
|
return { type: EErrorCodes.INVALID_EMAIL, message: errorMessage };
|
||||||
|
case EErrorCodes.REQUIRED_EMAIL_PASSWORD:
|
||||||
|
return { type: EErrorCodes.REQUIRED_EMAIL_PASSWORD, message: errorMessage };
|
||||||
|
case EErrorCodes.INVALID_EMAIL:
|
||||||
|
return { type: EErrorCodes.INVALID_EMAIL, message: errorMessage };
|
||||||
|
case EErrorCodes.USER_DOES_NOT_EXIST:
|
||||||
|
return { type: EErrorCodes.USER_DOES_NOT_EXIST, message: errorMessage };
|
||||||
|
case EErrorCodes.AUTHENTICATION_FAILED:
|
||||||
|
return { type: EErrorCodes.AUTHENTICATION_FAILED, message: errorMessage };
|
||||||
|
default:
|
||||||
|
return { type: undefined, message: undefined };
|
||||||
|
}
|
||||||
|
} else return { type: undefined, message: undefined };
|
||||||
|
}, [errorCode, errorMessage]);
|
||||||
|
|
||||||
|
const isButtonDisabled = useMemo(
|
||||||
|
() => (!isSubmitting && formData.email && formData.password ? false : true),
|
||||||
|
[formData.email, formData.password, isSubmitting]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full overflow-hidden container mx-auto max-w-lg px-10 lg:max-w-md lg:px-5 py-10 flex flex-col justify-center items-center">
|
||||||
|
<div className="relative flex flex-col space-y-6">
|
||||||
|
<div className="text-center space-y-1">
|
||||||
|
<h3 className="flex gap-4 justify-center text-3xl font-bold text-onboarding-text-100">
|
||||||
|
Manage your Plane instance
|
||||||
|
</h3>
|
||||||
|
<p className="font-medium text-onboarding-text-400">
|
||||||
|
Configure instance-wide settings to secure your instance
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorData.type && errorData?.message && <Banner type="error" message={errorData?.message} />}
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
method="POST"
|
||||||
|
action={`${API_BASE_URL}/api/instances/admins/sign-in/`}
|
||||||
|
onSubmit={() => setIsSubmitting(true)}
|
||||||
|
onError={() => setIsSubmitting(false)}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
|
||||||
|
|
||||||
|
<div className="w-full space-y-1">
|
||||||
|
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="email">
|
||||||
|
Email <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 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 pr-12 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>
|
||||||
|
);
|
||||||
|
};
|
19
admin/app/login/layout.tsx
Normal file
19
admin/app/login/layout.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
// lib
|
||||||
|
import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers";
|
||||||
|
// helpers
|
||||||
|
import { EAuthenticationPageType, EInstancePageType } from "@/helpers";
|
||||||
|
|
||||||
|
interface LoginLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoginLayout = ({ children }: LoginLayoutProps) => (
|
||||||
|
<InstanceWrapper pageType={EInstancePageType.POST_SETUP}>
|
||||||
|
<AuthWrapper authType={EAuthenticationPageType.NOT_AUTHENTICATED}>{children}</AuthWrapper>
|
||||||
|
</InstanceWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default LoginLayout;
|
18
admin/app/login/page.tsx
Normal file
18
admin/app/login/page.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// layouts
|
||||||
|
import { DefaultLayout } from "@/layouts";
|
||||||
|
// components
|
||||||
|
import { PageHeader } from "@/components/core";
|
||||||
|
import { InstanceSignInForm } from "./components";
|
||||||
|
|
||||||
|
const LoginPage = () => (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Setup - God Mode" />
|
||||||
|
<DefaultLayout>
|
||||||
|
<InstanceSignInForm />
|
||||||
|
</DefaultLayout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default LoginPage;
|
20
admin/app/page.tsx
Normal file
20
admin/app/page.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
// components
|
||||||
|
import { PageHeader } from "@/components/core";
|
||||||
|
|
||||||
|
const RootPage = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => router.push("/login"), [router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Plane - God Mode" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RootPage;
|
1
admin/app/setup/components/index.ts
Normal file
1
admin/app/setup/components/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./sign-up-form";
|
331
admin/app/setup/components/sign-up-form.tsx
Normal file
331
admin/app/setup/components/sign-up-form.tsx
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FC, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
// services
|
||||||
|
import { AuthService } from "@/services/auth.service";
|
||||||
|
// ui
|
||||||
|
import { Button, Checkbox, Input, Spinner } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { Banner, PasswordStrengthMeter } from "components/common";
|
||||||
|
// icons
|
||||||
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
|
// helpers
|
||||||
|
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||||
|
import { getPasswordStrength } from "@/helpers/password.helper";
|
||||||
|
|
||||||
|
// 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 InstanceSignUpForm: FC = (props) => {
|
||||||
|
const {} = props;
|
||||||
|
// search params
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const firstNameParam = searchParams.get("first_name") || undefined;
|
||||||
|
const lastNameParam = searchParams.get("last_name") || undefined;
|
||||||
|
const companyParam = searchParams.get("company") || undefined;
|
||||||
|
const emailParam = searchParams.get("email") || undefined;
|
||||||
|
const isTelemetryEnabledParam = (searchParams.get("is_telemetry_enabled") === "True" ? true : false) || true;
|
||||||
|
const errorCode = searchParams.get("error_code") || undefined;
|
||||||
|
const errorMessage = searchParams.get("error_message") || undefined;
|
||||||
|
// state
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
|
||||||
|
const [formData, setFormData] = useState<TFormData>(defaultFromData);
|
||||||
|
const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
|
||||||
|
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (csrfToken === undefined)
|
||||||
|
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
|
||||||
|
}, [csrfToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (firstNameParam) setFormData((prev) => ({ ...prev, first_name: firstNameParam }));
|
||||||
|
if (lastNameParam) setFormData((prev) => ({ ...prev, last_name: lastNameParam }));
|
||||||
|
if (companyParam) setFormData((prev) => ({ ...prev, company_name: companyParam }));
|
||||||
|
if (emailParam) setFormData((prev) => ({ ...prev, email: emailParam }));
|
||||||
|
if (isTelemetryEnabledParam) setFormData((prev) => ({ ...prev, is_telemetry_enabled: isTelemetryEnabledParam }));
|
||||||
|
}, [firstNameParam, lastNameParam, companyParam, emailParam, isTelemetryEnabledParam]);
|
||||||
|
|
||||||
|
// derived values
|
||||||
|
const errorData: TError = useMemo(() => {
|
||||||
|
if (errorCode && errorMessage) {
|
||||||
|
switch (errorCode) {
|
||||||
|
case EErrorCodes.INSTANCE_NOT_CONFIGURED:
|
||||||
|
return { type: EErrorCodes.INSTANCE_NOT_CONFIGURED, message: errorMessage };
|
||||||
|
case EErrorCodes.ADMIN_ALREADY_EXIST:
|
||||||
|
return { type: EErrorCodes.ADMIN_ALREADY_EXIST, message: errorMessage };
|
||||||
|
case EErrorCodes.REQUIRED_EMAIL_PASSWORD_FIRST_NAME:
|
||||||
|
return { type: EErrorCodes.REQUIRED_EMAIL_PASSWORD_FIRST_NAME, message: errorMessage };
|
||||||
|
case EErrorCodes.INVALID_EMAIL:
|
||||||
|
return { type: EErrorCodes.INVALID_EMAIL, message: errorMessage };
|
||||||
|
case EErrorCodes.INVALID_PASSWORD:
|
||||||
|
return { type: EErrorCodes.INVALID_PASSWORD, message: errorMessage };
|
||||||
|
case EErrorCodes.USER_ALREADY_EXISTS:
|
||||||
|
return { type: EErrorCodes.USER_ALREADY_EXISTS, message: errorMessage };
|
||||||
|
default:
|
||||||
|
return { type: undefined, message: undefined };
|
||||||
|
}
|
||||||
|
} else return { type: undefined, message: undefined };
|
||||||
|
}, [errorCode, errorMessage]);
|
||||||
|
|
||||||
|
const isButtonDisabled = useMemo(
|
||||||
|
() =>
|
||||||
|
!isSubmitting &&
|
||||||
|
formData.first_name &&
|
||||||
|
formData.email &&
|
||||||
|
formData.password &&
|
||||||
|
getPasswordStrength(formData.password) >= 3 &&
|
||||||
|
formData.password === formData.confirm_password
|
||||||
|
? false
|
||||||
|
: true,
|
||||||
|
[formData.confirm_password, formData.email, formData.first_name, formData.password, isSubmitting]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full overflow-hidden container mx-auto max-w-lg px-10 lg:max-w-md lg:px-5 flex flex-col justify-center items-center">
|
||||||
|
<div className="relative flex flex-col space-y-6">
|
||||||
|
<div className="text-center space-y-1">
|
||||||
|
<h3 className="flex gap-4 justify-center text-3xl font-bold text-onboarding-text-100">
|
||||||
|
Setup your Plane Instance
|
||||||
|
</h3>
|
||||||
|
<p className="font-medium text-onboarding-text-400">
|
||||||
|
Post setup you will be able to manage this Plane instance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorData.type &&
|
||||||
|
errorData?.message &&
|
||||||
|
![EErrorCodes.INVALID_EMAIL, EErrorCodes.INVALID_PASSWORD].includes(errorData.type) && (
|
||||||
|
<Banner type="error" message={errorData?.message} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
method="POST"
|
||||||
|
action={`${API_BASE_URL}/api/instances/admins/sign-up/`}
|
||||||
|
onSubmit={() => setIsSubmitting(true)}
|
||||||
|
onError={() => setIsSubmitting(false)}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-full space-y-1">
|
||||||
|
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="first_name">
|
||||||
|
First name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 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
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 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 pr-12 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
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 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 pr-12 placeholder:text-onboarding-text-400"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
inputSize="md"
|
||||||
|
placeholder="New password..."
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => handleFormChange("password", e.target.value)}
|
||||||
|
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false}
|
||||||
|
onFocus={() => setIsPasswordInputFocused(true)}
|
||||||
|
onBlur={() => setIsPasswordInputFocused(false)}
|
||||||
|
/>
|
||||||
|
{showPassword ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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>
|
||||||
|
{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
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
id="confirm_password"
|
||||||
|
name="confirm_password"
|
||||||
|
inputSize="md"
|
||||||
|
value={formData.confirm_password}
|
||||||
|
onChange={(e) => handleFormChange("confirm_password", e.target.value)}
|
||||||
|
placeholder="Confirm password"
|
||||||
|
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
|
||||||
|
/>
|
||||||
|
{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>
|
||||||
|
{!!formData.confirm_password && formData.password !== formData.confirm_password && (
|
||||||
|
<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 href="https://docs.plane.so/telemetry" 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>
|
||||||
|
);
|
||||||
|
};
|
19
admin/app/setup/layout.tsx
Normal file
19
admin/app/setup/layout.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
// lib
|
||||||
|
import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers";
|
||||||
|
// helpers
|
||||||
|
import { EAuthenticationPageType, EInstancePageType } from "@/helpers";
|
||||||
|
|
||||||
|
interface SetupLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SetupLayout = ({ children }: SetupLayoutProps) => (
|
||||||
|
<InstanceWrapper pageType={EInstancePageType.PRE_SETUP}>
|
||||||
|
<AuthWrapper authType={EAuthenticationPageType.NOT_AUTHENTICATED}>{children}</AuthWrapper>
|
||||||
|
</InstanceWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SetupLayout;
|
16
admin/app/setup/page.tsx
Normal file
16
admin/app/setup/page.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// layouts
|
||||||
|
import { DefaultLayout } from "@/layouts";
|
||||||
|
// components
|
||||||
|
import { PageHeader } from "@/components/core";
|
||||||
|
import { InstanceSignUpForm } from "./components";
|
||||||
|
|
||||||
|
const SetupPage = () => (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Setup - God Mode" />
|
||||||
|
<DefaultLayout>
|
||||||
|
<InstanceSignUpForm />
|
||||||
|
</DefaultLayout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SetupPage;
|
@ -1,11 +1,12 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { FC, useState, useRef } from "react";
|
import { FC, useState, useRef } from "react";
|
||||||
import Link from "next/link";
|
|
||||||
import { FileText, HelpCircle, MessagesSquare, MoveLeft } from "lucide-react";
|
|
||||||
import { Transition } from "@headlessui/react";
|
import { Transition } from "@headlessui/react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react";
|
||||||
|
import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui";
|
||||||
// hooks
|
// hooks
|
||||||
import { DiscordIcon, GithubIcon } from "@plane/ui";
|
import { useTheme } from "@/hooks";
|
||||||
import { useApplication } from "@/hooks/store";
|
|
||||||
// icons
|
|
||||||
// assets
|
// assets
|
||||||
import packageJson from "package.json";
|
import packageJson from "package.json";
|
||||||
|
|
||||||
@ -25,56 +26,56 @@ const helpOptions = [
|
|||||||
href: "https://github.com/makeplane/plane/issues/new/choose",
|
href: "https://github.com/makeplane/plane/issues/new/choose",
|
||||||
Icon: GithubIcon,
|
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 = () => {
|
||||||
// states
|
// states
|
||||||
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
|
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
|
||||||
// store
|
// store
|
||||||
const {
|
const { isSidebarCollapsed, toggleSidebar } = useTheme();
|
||||||
theme: { sidebarCollapsed, toggleSidebar },
|
|
||||||
} = useApplication();
|
|
||||||
// refs
|
// refs
|
||||||
const helpOptionsRef = useRef<HTMLDivElement | null>(null);
|
const helpOptionsRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const redirectionLink = `${process.env.NEXT_PUBLIC_APP_URL ? `${process.env.NEXT_PUBLIC_APP_URL}/create-workspace` : `${process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX === "1" ? `/god-mode/` : `/`}`}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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 ${
|
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 ${
|
||||||
sidebarCollapsed ? "flex-col" : ""
|
isSidebarCollapsed ? "flex-col" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className={`flex items-center gap-1 ${sidebarCollapsed ? "flex-col justify-center" : "w-full justify-end"}`}>
|
<div className={`flex items-center gap-1 ${isSidebarCollapsed ? "flex-col justify-center" : "w-full"}`}>
|
||||||
<button
|
<Tooltip tooltipContent="Redirect to plane" position="right" className="ml-4" disabled={!isSidebarCollapsed}>
|
||||||
type="button"
|
<a
|
||||||
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 ${
|
href={redirectionLink}
|
||||||
sidebarCollapsed ? "w-full" : ""
|
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`}
|
||||||
}`}
|
>
|
||||||
onClick={() => setIsNeedHelpOpen((prev) => !prev)}
|
<ExternalLink size={14} />
|
||||||
>
|
{!isSidebarCollapsed && "Redirect to plane"}
|
||||||
<HelpCircle className="h-3.5 w-3.5" />
|
</a>
|
||||||
</button>
|
</Tooltip>
|
||||||
<button
|
<Tooltip tooltipContent="Help" position={isSidebarCollapsed ? "right" : "top"} className="ml-4">
|
||||||
type="button"
|
<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"
|
type="button"
|
||||||
onClick={() => toggleSidebar()}
|
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" : ""
|
||||||
<MoveLeft className="h-3.5 w-3.5" />
|
}`}
|
||||||
</button>
|
onClick={() => setIsNeedHelpOpen((prev) => !prev)}
|
||||||
<button
|
>
|
||||||
type="button"
|
<HelpCircle className="h-3.5 w-3.5" />
|
||||||
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 ${
|
</button>
|
||||||
sidebarCollapsed ? "w-full" : ""
|
</Tooltip>
|
||||||
}`}
|
<Tooltip tooltipContent="Toggle sidebar" position={isSidebarCollapsed ? "right" : "top"} className="ml-4">
|
||||||
onClick={() => toggleSidebar()}
|
<button
|
||||||
>
|
type="button"
|
||||||
<MoveLeft className={`h-3.5 w-3.5 duration-300 ${sidebarCollapsed ? "rotate-180" : ""}`} />
|
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 ${
|
||||||
</button>
|
isSidebarCollapsed ? "w-full" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleSidebar(!isSidebarCollapsed)}
|
||||||
|
>
|
||||||
|
<MoveLeft className={`h-3.5 w-3.5 duration-300 ${isSidebarCollapsed ? "rotate-180" : ""}`} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@ -89,12 +90,12 @@ export const InstanceHelpSection: FC = () => {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`absolute bottom-2 min-w-[10rem] ${
|
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`}
|
} divide-y divide-custom-border-200 whitespace-nowrap rounded bg-custom-background-100 p-1 shadow-custom-shadow-xs`}
|
||||||
ref={helpOptionsRef}
|
ref={helpOptionsRef}
|
||||||
>
|
>
|
||||||
<div className="space-y-1 pb-2">
|
<div className="space-y-1 pb-2">
|
||||||
{helpOptions.map(({ name, Icon, href, onClick }) => {
|
{helpOptions.map(({ name, Icon, href }) => {
|
||||||
if (href)
|
if (href)
|
||||||
return (
|
return (
|
||||||
<Link href={href} key={name} target="_blank">
|
<Link href={href} key={name} target="_blank">
|
||||||
@ -111,7 +112,6 @@ export const InstanceHelpSection: FC = () => {
|
|||||||
<button
|
<button
|
||||||
key={name}
|
key={name}
|
||||||
type="button"
|
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"
|
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">
|
<div className="grid flex-shrink-0 place-items-center">
|
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 { useTheme } from "@/hooks";
|
||||||
|
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||||
|
// components
|
||||||
|
import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar";
|
||||||
|
|
||||||
|
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 { useTheme as useNextTheme } from "next-themes";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { LogOut, UserCog2, Palette } from "lucide-react";
|
||||||
|
import { Menu, Transition } from "@headlessui/react";
|
||||||
|
import { Avatar } from "@plane/ui";
|
||||||
|
// hooks
|
||||||
|
import { useTheme, useUser } from "@/hooks";
|
||||||
|
// helpers
|
||||||
|
import { API_BASE_URL, cn } from "@/helpers/common.helper";
|
||||||
|
// services
|
||||||
|
import { AuthService } from "@/services";
|
||||||
|
|
||||||
|
// 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 { useTheme } from "@/hooks";
|
||||||
|
// icons
|
||||||
|
import { Menu } from "lucide-react";
|
||||||
|
|
||||||
|
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 Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react";
|
||||||
|
import { Tooltip } from "@plane/ui";
|
||||||
|
// hooks
|
||||||
|
import { useTheme } from "@/hooks";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "@/helpers/common.helper";
|
||||||
|
|
||||||
|
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 { usePathname } from "next/navigation";
|
||||||
|
// mobx
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
// ui
|
||||||
|
import { Settings } from "lucide-react";
|
||||||
|
// icons
|
||||||
|
import { Breadcrumbs } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { BreadcrumbLink } from "components/common";
|
||||||
|
import { SidebarHamburgerToggle } from "@/components/admin-sidebar";
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
82
admin/components/common/controller-input.tsx
Normal file
82
admin/components/common/controller-input.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Controller, Control } from "react-hook-form";
|
||||||
|
// ui
|
||||||
|
import { Input } from "@plane/ui";
|
||||||
|
// icons
|
||||||
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "@/helpers/common.helper";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
control: Control<any>;
|
||||||
|
type: "text" | "password";
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
description?: string | JSX.Element;
|
||||||
|
placeholder: string;
|
||||||
|
error: boolean;
|
||||||
|
required: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TControllerInputFormField = {
|
||||||
|
key: string;
|
||||||
|
type: "text" | "password";
|
||||||
|
label: string;
|
||||||
|
description?: string | JSX.Element;
|
||||||
|
placeholder: string;
|
||||||
|
error: boolean;
|
||||||
|
required: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ControllerInput: React.FC<Props> = (props) => {
|
||||||
|
const { name, control, type, label, description, placeholder, error, required } = props;
|
||||||
|
// states
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h4 className="text-sm text-custom-text-300">{label}</h4>
|
||||||
|
<div className="relative">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={name}
|
||||||
|
rules={{ required: required ? `${label} is required.` : false }}
|
||||||
|
render={({ field: { value, onChange, ref } }) => (
|
||||||
|
<Input
|
||||||
|
id={name}
|
||||||
|
name={name}
|
||||||
|
type={type === "password" && showPassword ? "text" : type}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
ref={ref}
|
||||||
|
hasError={error}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={cn("w-full rounded-md font-medium", {
|
||||||
|
"pr-10": type === "password",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{type === "password" &&
|
||||||
|
(showPassword ? (
|
||||||
|
<button
|
||||||
|
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
|
||||||
|
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 { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||||
|
// icons
|
||||||
|
import { Copy } from "lucide-react";
|
||||||
|
|
||||||
|
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>
|
||||||
|
<p className="text-xs text-custom-text-400">{description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
6
admin/components/common/index.ts
Normal file
6
admin/components/common/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
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";
|
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 { cn } from "@/helpers/common.helper";
|
||||||
|
import { getPasswordStrength } from "@/helpers/password.helper";
|
||||||
|
// icons
|
||||||
|
import { CircleCheck } from "lucide-react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PasswordStrengthMeter: React.FC<Props> = (props: Props) => {
|
||||||
|
const { password } = props;
|
||||||
|
|
||||||
|
const strength = getPasswordStrength(password);
|
||||||
|
let bars = [];
|
||||||
|
let text = "";
|
||||||
|
let textColor = "";
|
||||||
|
|
||||||
|
if (password.length === 0) {
|
||||||
|
bars = [`bg-[#F0F0F3]`, `bg-[#F0F0F3]`, `bg-[#F0F0F3]`];
|
||||||
|
text = "Password requirements";
|
||||||
|
} else if (password.length < 8) {
|
||||||
|
bars = [`bg-[#DC3E42]`, `bg-[#F0F0F3]`, `bg-[#F0F0F3]`];
|
||||||
|
text = "Password is too short";
|
||||||
|
textColor = `text-[#DC3E42]`;
|
||||||
|
} else if (strength < 3) {
|
||||||
|
bars = [`bg-[#FFBA18]`, `bg-[#FFBA18]`, `bg-[#F0F0F3]`];
|
||||||
|
text = "Password is weak";
|
||||||
|
textColor = `text-[#FFBA18]`;
|
||||||
|
} else {
|
||||||
|
bars = [`bg-[#3E9B4F]`, `bg-[#3E9B4F]`, `bg-[#3E9B4F]`];
|
||||||
|
text = "Password is strong";
|
||||||
|
textColor = `text-[#3E9B4F]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const criteria = [
|
||||||
|
{ label: "Min 8 characters", isValid: password.length >= 8 },
|
||||||
|
{ label: "Min 1 upper-case letter", isValid: /[A-Z]/.test(password) },
|
||||||
|
{ label: "Min 1 number", isValid: /\d/.test(password) },
|
||||||
|
{ label: "Min 1 special character", isValid: /[!@#$%^&*]/.test(password) },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full p-1">
|
||||||
|
<div className="flex w-full gap-1.5">
|
||||||
|
{bars.map((color, index) => (
|
||||||
|
<div key={index} className={cn("w-full h-1 rounded-full", color)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className={cn("text-xs font-medium py-1", textColor)}>{text}</p>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-2">
|
||||||
|
{criteria.map((criterion, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 text-xs font-medium",
|
||||||
|
criterion.isValid ? `text-[#3E9B4F]` : "text-custom-text-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CircleCheck width={14} height={14} />
|
||||||
|
{criterion.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
1
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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
1
admin/components/instance/index.ts
Normal file
1
admin/components/instance/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./instance-not-ready";
|
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 Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
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>
|
||||||
|
);
|
54
admin/components/new-user-popup.tsx
Normal file
54
admin/components/new-user-popup.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"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";
|
||||||
|
// icons
|
||||||
|
import TakeoffIconLight from "/public/logos/takeoff-icon-light.svg";
|
||||||
|
import TakeoffIconDark from "/public/logos/takeoff-icon-dark.svg";
|
||||||
|
import { useTheme } from "@/hooks";
|
||||||
|
|
||||||
|
export const NewUserPopup: React.FC = observer(() => {
|
||||||
|
// hooks
|
||||||
|
const { isNewUserPopup, toggleNewUserPopup } = useTheme();
|
||||||
|
// theme
|
||||||
|
const { resolvedTheme } = nextUseTheme();
|
||||||
|
|
||||||
|
const redirectionLink = `${process.env.NEXT_PUBLIC_APP_URL ? `${process.env.NEXT_PUBLIC_APP_URL}/create-workspace` : `${process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX === "1" ? `/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,
|
||||||
|
};
|
126
admin/helpers/authentication.helper.tsx
Normal file
126
admin/helpers/authentication.helper.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
export enum EPageTypes {
|
||||||
|
"PUBLIC" = "PUBLIC",
|
||||||
|
"NON_AUTHENTICATED" = "NON_AUTHENTICATED",
|
||||||
|
"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 EAuthenticationErrorCodes {
|
||||||
|
INSTANCE_NOT_CONFIGURED = "5000",
|
||||||
|
// Admin
|
||||||
|
ADMIN_ALREADY_EXIST = "5029",
|
||||||
|
REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5030",
|
||||||
|
INVALID_ADMIN_EMAIL = "5031",
|
||||||
|
INVALID_ADMIN_PASSWORD = "5032",
|
||||||
|
REQUIRED_ADMIN_EMAIL_PASSWORD = "5033",
|
||||||
|
ADMIN_AUTHENTICATION_FAILED = "5034",
|
||||||
|
ADMIN_USER_ALREADY_EXIST = "5035",
|
||||||
|
ADMIN_USER_DOES_NOT_EXIST = "5036",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum EErrorAlertType {
|
||||||
|
BANNER_ALERT = "BANNER_ALERT",
|
||||||
|
TOAST_ALERT = "TOAST_ALERT",
|
||||||
|
INLINE_FIRST_NAME = "INLINE_FIRST_NAME",
|
||||||
|
INLINE_EMAIL = "INLINE_EMAIL",
|
||||||
|
INLINE_PASSWORD = "INLINE_PASSWORD",
|
||||||
|
INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TAuthErrorInfo = {
|
||||||
|
type: EErrorAlertType;
|
||||||
|
code: EAuthenticationErrorCodes;
|
||||||
|
title: string;
|
||||||
|
message: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorCodeMessages: {
|
||||||
|
[key in EAuthenticationErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode };
|
||||||
|
} = {
|
||||||
|
[EAuthenticationErrorCodes.INSTANCE_NOT_CONFIGURED]: {
|
||||||
|
title: "Instance not configured",
|
||||||
|
message: () => "Please contact your administrator to configure the instance.",
|
||||||
|
},
|
||||||
|
[EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST]: {
|
||||||
|
title: "Admin already exists",
|
||||||
|
message: () => "Admin already exists. Please sign in.",
|
||||||
|
},
|
||||||
|
[EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: {
|
||||||
|
title: "Required",
|
||||||
|
message: () => "Please enter email, password and first name.",
|
||||||
|
},
|
||||||
|
[EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL]: {
|
||||||
|
title: "Invalid email",
|
||||||
|
message: () => "Please enter a valid email.",
|
||||||
|
},
|
||||||
|
[EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD]: {
|
||||||
|
title: "Invalid password",
|
||||||
|
message: () => "Password must be at least 8 characters long.",
|
||||||
|
},
|
||||||
|
[EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: {
|
||||||
|
title: "Required",
|
||||||
|
message: () => "Please enter email and password.",
|
||||||
|
},
|
||||||
|
[EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED]: {
|
||||||
|
title: "Authentication failed",
|
||||||
|
message: () => "Please check your email and password and try again.",
|
||||||
|
},
|
||||||
|
[EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST]: {
|
||||||
|
title: "User already exists",
|
||||||
|
message: () => "User already exists. Please sign in.",
|
||||||
|
},
|
||||||
|
[EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: {
|
||||||
|
title: "User does not exist",
|
||||||
|
message: () => "User does not exist. Please sign up.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const authErrorHandler = (
|
||||||
|
errorCode: EAuthenticationErrorCodes,
|
||||||
|
email?: string | undefined
|
||||||
|
): TAuthErrorInfo | undefined => {
|
||||||
|
const toastAlertErrorCodes = [
|
||||||
|
EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST,
|
||||||
|
EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL,
|
||||||
|
EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD,
|
||||||
|
EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED,
|
||||||
|
];
|
||||||
|
const bannerAlertErrorCodes = [
|
||||||
|
EAuthenticationErrorCodes.INSTANCE_NOT_CONFIGURED,
|
||||||
|
EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
|
||||||
|
EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD,
|
||||||
|
EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST,
|
||||||
|
EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (toastAlertErrorCodes.includes(errorCode))
|
||||||
|
return {
|
||||||
|
type: EErrorAlertType.TOAST_ALERT,
|
||||||
|
code: errorCode,
|
||||||
|
title: errorCodeMessages[errorCode]?.title || "Error",
|
||||||
|
message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.",
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
9
admin/helpers/common.helper.ts
Normal file
9
admin/helpers/common.helper.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ? process.env.NEXT_PUBLIC_API_BASE_URL : "";
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
6
admin/hooks/index.ts
Normal file
6
admin/hooks/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export * from "./use-outside-click-detector";
|
||||||
|
|
||||||
|
// store-hooks
|
||||||
|
export * from "./store/use-theme";
|
||||||
|
export * from "./store/use-instance";
|
||||||
|
export * from "./store/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-context";
|
||||||
|
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-context";
|
||||||
|
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-context";
|
||||||
|
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;
|
24
admin/layouts/admin-layout.tsx
Normal file
24
admin/layouts/admin-layout.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { FC, ReactNode } from "react";
|
||||||
|
// components
|
||||||
|
import { InstanceSidebar } from "@/components/admin-sidebar";
|
||||||
|
import { InstanceHeader } from "@/components/auth-header";
|
||||||
|
import { NewUserPopup } from "@/components/new-user-popup";
|
||||||
|
|
||||||
|
type TAdminLayout = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdminLayout: FC<TAdminLayout> = (props) => {
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
43
admin/layouts/default-layout.tsx
Normal file
43
admin/layouts/default-layout.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FC, ReactNode } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
// logo/ images
|
||||||
|
import PlaneBackgroundPatternDark from "public/auth/background-pattern-dark.svg";
|
||||||
|
import PlaneBackgroundPattern from "public/auth/background-pattern.svg";
|
||||||
|
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
|
||||||
|
|
||||||
|
type TDefaultLayout = {
|
||||||
|
children: ReactNode;
|
||||||
|
withoutBackground?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DefaultLayout: FC<TDefaultLayout> = (props) => {
|
||||||
|
const { children, withoutBackground = false } = props;
|
||||||
|
// hooks
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="h-screen w-full overflow-hidden overflow-y-auto flex flex-col">
|
||||||
|
<div className="container h-[110px] flex-shrink-0 mx-auto px-5 lg:px-0 flex items-center justify-between gap-5 z-50">
|
||||||
|
<div className="flex items-center gap-x-2 py-10">
|
||||||
|
<Image src={BluePlaneLogoWithoutText} height={30} width={30} alt="Plane Logo" />
|
||||||
|
<span className="text-2xl font-semibold sm:text-3xl">Plane</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!withoutBackground && (
|
||||||
|
<div className="absolute inset-0 z-0">
|
||||||
|
<Image
|
||||||
|
src={resolvedTheme === "dark" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern}
|
||||||
|
className="w-screen h-full object-cover"
|
||||||
|
alt="Plane background pattern"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="relative z-10 mb-[110px] flex-grow">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
2
admin/layouts/index.ts
Normal file
2
admin/layouts/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./default-layout";
|
||||||
|
export * from "./admin-layout";
|
21
admin/lib/store-context.tsx
Normal file
21
admin/lib/store-context.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactElement, createContext } from "react";
|
||||||
|
// mobx store
|
||||||
|
import { RootStore } from "@/store/root-store";
|
||||||
|
|
||||||
|
let rootStore = new RootStore();
|
||||||
|
|
||||||
|
export const StoreContext = createContext<RootStore>(rootStore);
|
||||||
|
|
||||||
|
const initializeStore = () => {
|
||||||
|
const newRootStore = rootStore ?? new RootStore();
|
||||||
|
if (typeof window === "undefined") return newRootStore;
|
||||||
|
if (!rootStore) rootStore = newRootStore;
|
||||||
|
return newRootStore;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StoreProvider = ({ children }: { children: ReactElement }) => {
|
||||||
|
const store = initializeStore();
|
||||||
|
return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
|
||||||
|
};
|
36
admin/lib/wrappers/app-wrapper.tsx
Normal file
36
admin/lib/wrappers/app-wrapper.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FC, ReactNode, useEffect, Suspense } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { SWRConfig } from "swr";
|
||||||
|
// hooks
|
||||||
|
import { useTheme, useUser } from "@/hooks";
|
||||||
|
// ui
|
||||||
|
import { Toast } from "@plane/ui";
|
||||||
|
// constants
|
||||||
|
import { SWR_CONFIG } from "constants/swr-config";
|
||||||
|
// helpers
|
||||||
|
import { resolveGeneralTheme } from "helpers/common.helper";
|
||||||
|
|
||||||
|
interface IAppWrapper {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppWrapper: FC<IAppWrapper> = observer(({ children }) => {
|
||||||
|
// hooks
|
||||||
|
const { theme, isSidebarCollapsed, toggleSidebar } = useTheme();
|
||||||
|
const { currentUser } = useUser();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const localValue = localStorage && localStorage.getItem("god_mode_sidebar_collapsed");
|
||||||
|
const localBoolValue = localValue ? (localValue === "true" ? true : false) : false;
|
||||||
|
if (isSidebarCollapsed === undefined && localBoolValue != isSidebarCollapsed) toggleSidebar(localBoolValue);
|
||||||
|
}, [isSidebarCollapsed, currentUser, toggleSidebar]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<Toast theme={resolveGeneralTheme(theme)} />
|
||||||
|
<SWRConfig value={SWR_CONFIG}>{children}</SWRConfig>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
});
|
59
admin/lib/wrappers/auth-wrapper.tsx
Normal file
59
admin/lib/wrappers/auth-wrapper.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FC, ReactNode } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { Spinner } from "@plane/ui";
|
||||||
|
// hooks
|
||||||
|
import { useInstance, useUser } from "@/hooks";
|
||||||
|
// helpers
|
||||||
|
import { EAuthenticationPageType, EUserStatus } from "@/helpers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export interface IAuthWrapper {
|
||||||
|
children: ReactNode;
|
||||||
|
authType?: EAuthenticationPageType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthWrapper: FC<IAuthWrapper> = observer((props) => {
|
||||||
|
const { children, authType = EAuthenticationPageType.AUTHENTICATED } = props;
|
||||||
|
// hooks
|
||||||
|
const { instance, fetchInstanceAdmins } = useInstance();
|
||||||
|
const { isLoading, userStatus, currentUser, fetchCurrentUser } = useUser();
|
||||||
|
|
||||||
|
useSWR("CURRENT_USER_DETAILS", () => fetchCurrentUser(), {
|
||||||
|
shouldRetryOnError: false,
|
||||||
|
});
|
||||||
|
useSWR("INSTANCE_ADMINS", () => fetchInstanceAdmins(), {
|
||||||
|
shouldRetryOnError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-screen w-full items-center justify-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userStatus && userStatus?.status === EUserStatus.ERROR)
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-screen w-screen items-center justify-center">
|
||||||
|
Something went wrong. please try again later
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if ([EAuthenticationPageType.AUTHENTICATED, EAuthenticationPageType.NOT_AUTHENTICATED].includes(authType)) {
|
||||||
|
if (authType === EAuthenticationPageType.NOT_AUTHENTICATED) {
|
||||||
|
if (currentUser === undefined) return <>{children}</>;
|
||||||
|
else redirect("/general/");
|
||||||
|
} else {
|
||||||
|
if (currentUser) return <>{children}</>;
|
||||||
|
else {
|
||||||
|
if (instance?.instance?.is_setup_done) redirect("/login/");
|
||||||
|
else redirect("/setup/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
});
|
3
admin/lib/wrappers/index.ts
Normal file
3
admin/lib/wrappers/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./app-wrapper";
|
||||||
|
export * from "./instance-wrapper";
|
||||||
|
export * from "./auth-wrapper";
|
59
admin/lib/wrappers/instance-wrapper.tsx
Normal file
59
admin/lib/wrappers/instance-wrapper.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FC, ReactNode } from "react";
|
||||||
|
import { redirect, useSearchParams } from "next/navigation";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { Spinner } from "@plane/ui";
|
||||||
|
// layouts
|
||||||
|
import { DefaultLayout } from "@/layouts";
|
||||||
|
// components
|
||||||
|
import { InstanceNotReady } from "@/components/instance";
|
||||||
|
// hooks
|
||||||
|
import { useInstance } from "@/hooks";
|
||||||
|
// helpers
|
||||||
|
import { EInstancePageType, EInstanceStatus } from "@/helpers";
|
||||||
|
|
||||||
|
type TInstanceWrapper = {
|
||||||
|
children: ReactNode;
|
||||||
|
pageType?: EInstancePageType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InstanceWrapper: FC<TInstanceWrapper> = observer((props) => {
|
||||||
|
const { children, pageType } = props;
|
||||||
|
const searchparams = useSearchParams();
|
||||||
|
const authEnabled = searchparams.get("auth_enabled") || "1";
|
||||||
|
// hooks
|
||||||
|
const { isLoading, instanceStatus, instance, fetchInstanceInfo } = useInstance();
|
||||||
|
|
||||||
|
useSWR("INSTANCE_INFORMATION", () => fetchInstanceInfo(), {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-screen w-full items-center justify-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (instanceStatus && instanceStatus?.status === EInstanceStatus.ERROR)
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-screen w-screen items-center justify-center">
|
||||||
|
Something went wrong. please try again later
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (instance?.instance?.is_setup_done === false && authEnabled === "1")
|
||||||
|
return (
|
||||||
|
<DefaultLayout withoutBackground>
|
||||||
|
<InstanceNotReady />
|
||||||
|
</DefaultLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (instance?.instance?.is_setup_done && pageType === EInstancePageType.PRE_SETUP) redirect("/");
|
||||||
|
|
||||||
|
if (!instance?.instance?.is_setup_done && pageType === EInstancePageType.POST_SETUP) redirect("/setup");
|
||||||
|
|
||||||
|
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: "/god-mode",
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
49
admin/package.json
Normal file
49
admin/package.json
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "admin",
|
||||||
|
"version": "0.17.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "turbo run develop",
|
||||||
|
"develop": "next dev --port 3333",
|
||||||
|
"build": "next build",
|
||||||
|
"preview": "next build && next start",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@headlessui/react": "^1.7.19",
|
||||||
|
"@plane/types": "*",
|
||||||
|
"@plane/ui": "*",
|
||||||
|
"@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.23",
|
||||||
|
"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 |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user