Merge branch 'develop' of https://github.com/makeplane/plane into refactor-and-typos

This commit is contained in:
Ramesh Kumar Chandra 2024-04-29 12:31:45 +05:30
commit 4485a38bec
948 changed files with 21802 additions and 16189 deletions

View File

@ -23,6 +23,7 @@ jobs:
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
build_frontend: ${{ steps.changed_files.outputs.frontend_any_changed }}
build_admin: ${{ steps.changed_files.outputs.admin_any_changed }}
build_space: ${{ steps.changed_files.outputs.space_any_changed }}
build_backend: ${{ steps.changed_files.outputs.backend_any_changed }}
build_proxy: ${{ steps.changed_files.outputs.proxy_any_changed }}
@ -67,6 +68,13 @@ jobs:
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
admin:
- admin/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
backend:
- apiserver/**
proxy:
@ -124,6 +132,58 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_admin:
if: ${{ needs.branch_build_setup.outputs.build_admin== 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
ADMIN_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
steps:
- name: Set Admin Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:latest
else
TAG=${{ env.ADMIN_TAG }}
fi
echo "ADMIN_TAG=${TAG}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: ${{ env.BUILDX_DRIVER }}
version: ${{ env.BUILDX_VERSION }}
endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo
uses: actions/checkout@v4
- name: Build and Push Frontend to Docker Container Registry
uses: docker/build-push-action@v5.1.0
with:
context: .
file: ./admin/Dockerfile.admin
platforms: ${{ env.BUILDX_PLATFORMS }}
tags: ${{ env.ADMIN_TAG }}
push: true
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_space:
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04

View File

@ -13,10 +13,16 @@ on:
description: 'Build Space'
type: boolean
default: false
admin-build:
required: false
description: 'Build Admin'
type: boolean
default: false
env:
BUILD_WEB: ${{ github.event.inputs.web-build }}
BUILD_SPACE: ${{ github.event.inputs.space-build }}
BUILD_ADMIN: ${{ github.event.inputs.admin-build }}
jobs:
setup-feature-build:
@ -27,9 +33,11 @@ jobs:
run: |
echo "BUILD_WEB=$BUILD_WEB"
echo "BUILD_SPACE=$BUILD_SPACE"
echo "BUILD_ADMIN=$BUILD_ADMIN"
outputs:
web-build: ${{ env.BUILD_WEB}}
space-build: ${{env.BUILD_SPACE}}
admin-build: ${{env.BUILD_ADMIN}}
feature-build-web:
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")
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:
if: ${{ always() && (needs.setup-feature-build.outputs.web-build == 'true' || needs.setup-feature-build.outputs.space-build == 'true') }}
needs: [feature-build-web, feature-build-space]
if: ${{ always() && (needs.setup-feature-build.outputs.web-build == 'true' || needs.setup-feature-build.outputs.space-build == 'true' || needs.setup-feature-build.outputs.admin-build == 'true') }}
needs: [setup-feature-build, feature-build-web, feature-build-space, feature-build-admin]
name: Feature Deploy
runs-on: ubuntu-latest
env:
@ -164,7 +217,12 @@ jobs:
SPACE_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/space.tar.gz --expires-in 3600)
fi
if [ ${{ env.BUILD_WEB }} == true ] || [ ${{ env.BUILD_SPACE }} == true ]; then
ADMIN_S3_URL=""
if [ ${{ env.BUILD_ADMIN }} == true ]; then
ADMIN_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/admin.tar.gz --expires-in 3600)
fi
if [ ${{ env.BUILD_WEB }} == true ] || [ ${{ env.BUILD_SPACE }} == true ] || [ ${{ env.BUILD_ADMIN }} == true ]; then
helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ vars.FEATURE_PREVIEW_HELM_CHART_URL }}
@ -181,6 +239,9 @@ jobs:
--set space.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \
--set space.enabled=${{ env.BUILD_SPACE || false }} \
--set space.artifact_url=$SPACE_S3_URL \
--set admin.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \
--set admin.enabled=${{ env.BUILD_ADMIN || false }} \
--set admin.artifact_url=$ADMIN_S3_URL \
--set shared_config.deploy_script_url=$DEPLOY_SCRIPT_URL \
--set shared_config.api_base_url=${{vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL}} \
--output json \

14
admin/.eslintrc.js Normal file
View 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
View File

@ -0,0 +1,6 @@
.next
.vercel
.tubro
out/
dis/
build/

5
admin/.prettierrc Normal file
View File

@ -0,0 +1,5 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}

51
admin/Dockerfile.admin Normal file
View 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

21
admin/app/ai/layout.tsx Normal file
View File

@ -0,0 +1,21 @@
"use client";
import { ReactNode } from "react";
// layouts
import { AuthLayout } from "@/layouts";
// lib
import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers";
interface AILayoutProps {
children: ReactNode;
}
const AILayout = ({ children }: AILayoutProps) => (
<InstanceWrapper>
<AuthWrapper>
<AuthLayout>{children}</AuthLayout>
</AuthWrapper>
</InstanceWrapper>
);
export default AILayout;

47
admin/app/ai/page.tsx Normal file
View File

@ -0,0 +1,47 @@
"use client";
import useSWR from "swr";
import { observer } from "mobx-react-lite";
import { Loader } from "@plane/ui";
// components
import { InstanceAIForm } from "components/ai";
import { PageHeader } from "@/components/core";
// 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;

View File

@ -0,0 +1,112 @@
"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 { AuthenticationMethodCard, InstanceGithubConfigForm } from "components/authentication";
import { PageHeader } from "@/components/core";
// 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;

View File

@ -0,0 +1,100 @@
"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 { AuthenticationMethodCard, InstanceGoogleConfigForm } from "components/authentication";
import { PageHeader } from "@/components/core";
// 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;

View File

@ -0,0 +1,21 @@
"use client";
import { ReactNode } from "react";
// layouts
import { AuthLayout } from "@/layouts";
// lib
import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers";
interface AuthenticationLayoutProps {
children: ReactNode;
}
const AuthenticationLayout = ({ children }: AuthenticationLayoutProps) => (
<InstanceWrapper>
<AuthWrapper>
<AuthLayout>{children}</AuthLayout>
</AuthWrapper>
</InstanceWrapper>
);
export default AuthenticationLayout;

View File

@ -0,0 +1,158 @@
"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,
GoogleConfiguration,
GithubConfiguration,
} from "components/authentication";
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;

View File

@ -0,0 +1,21 @@
"use client";
import { ReactNode } from "react";
// layouts
import { AuthLayout } from "@/layouts";
// lib
import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers";
interface EmailLayoutProps {
children: ReactNode;
}
const EmailLayout = ({ children }: EmailLayoutProps) => (
<InstanceWrapper>
<AuthWrapper>
<AuthLayout>{children}</AuthLayout>
</AuthWrapper>
</InstanceWrapper>
);
export default EmailLayout;

50
admin/app/email/page.tsx Normal file
View File

@ -0,0 +1,50 @@
"use client";
import useSWR from "swr";
import { observer } from "mobx-react-lite";
import { Loader } from "@plane/ui";
// components
import { InstanceEmailForm } from "components/email";
import { PageHeader } from "@/components/core";
// 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.&nbsp;
<span className="text-red-400">Misconfigs can lead to email bounces and errors.</span>
</div>
</div>
</div>
<div className="flex-grow overflow-hidden overflow-y-auto">
{formattedConfig ? (
<InstanceEmailForm config={formattedConfig} />
) : (
<Loader className="space-y-10">
<Loader.Item height="50px" width="75%" />
<Loader.Item height="50px" width="75%" />
<Loader.Item height="50px" width="40%" />
<Loader.Item height="50px" width="40%" />
<Loader.Item height="50px" width="20%" />
</Loader>
)}
</div>
</div>
</>
);
});
export default InstanceEmailPage;

View File

@ -0,0 +1,21 @@
"use client";
import { ReactNode } from "react";
// layouts
import { AuthLayout } from "@/layouts";
// lib
import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers";
interface GeneralLayoutProps {
children: ReactNode;
}
const GeneralLayout = ({ children }: GeneralLayoutProps) => (
<InstanceWrapper>
<AuthWrapper>
<AuthLayout>{children}</AuthLayout>
</AuthWrapper>
</InstanceWrapper>
);
export default GeneralLayout;

View File

@ -0,0 +1,34 @@
"use client";
import { observer } from "mobx-react-lite";
// components
import { PageHeader } from "@/components/core";
import { InstanceGeneralForm } from "@/components/general";
// 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 && (
<InstanceGeneralForm instance={instance?.instance} instanceAdmins={instanceAdmins} />
)}
</div>
</div>
</>
);
});
export default GeneralPage;

466
admin/app/globals.css Normal file
View 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));
}

View File

@ -0,0 +1,21 @@
"use client";
import { ReactNode } from "react";
// layouts
import { AuthLayout } from "@/layouts";
// lib
import { AuthWrapper, InstanceWrapper } from "@/lib/wrappers";
interface ImageLayoutProps {
children: ReactNode;
}
const ImageLayout = ({ children }: ImageLayoutProps) => (
<InstanceWrapper>
<AuthWrapper>
<AuthLayout>{children}</AuthLayout>
</AuthWrapper>
</InstanceWrapper>
);
export default ImageLayout;

43
admin/app/image/page.tsx Normal file
View 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/image";
// 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;

27
admin/app/layout.tsx Normal file
View File

@ -0,0 +1,27 @@
"use client";
import { ReactNode } from "react";
import { ThemeProvider } from "next-themes";
// lib
import { StoreProvider } from "@/lib/store-context";
import { AppWrapper } from "@/lib/wrappers";
// styles
import "./globals.css";
interface RootLayoutProps {
children: ReactNode;
}
const RootLayout = ({ children, ...pageProps }: RootLayoutProps) => (
<html lang="en">
<body className={`antialiased`}>
<StoreProvider {...pageProps}>
<ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem>
<AppWrapper>{children}</AppWrapper>
</ThemeProvider>
</StoreProvider>
</body>
</html>
);
export default RootLayout;

View 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
View File

@ -0,0 +1,18 @@
"use client";
// layouts
import { DefaultLayout } from "@/layouts";
// components
import { PageHeader } from "@/components/core";
import { InstanceSignInForm } from "@/components/user-authentication-forms";
const LoginPage = () => (
<>
<PageHeader title="Setup - God Mode" />
<DefaultLayout>
<InstanceSignInForm />
</DefaultLayout>
</>
);
export default LoginPage;

20
admin/app/page.tsx Normal file
View 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;

View 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
View File

@ -0,0 +1,16 @@
// layouts
import { DefaultLayout } from "@/layouts";
// components
import { PageHeader } from "@/components/core";
import { InstanceSignUpForm } from "@/components/user-authentication-forms";
const SetupPage = () => (
<>
<PageHeader title="Setup - God Mode" />
<DefaultLayout>
<InstanceSignUpForm />
</DefaultLayout>
</>
);
export default SetupPage;

View 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-16 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>
);
};

View File

@ -0,0 +1 @@
export * from "./ai-config-form";

View 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/auth-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>
);
});

View File

@ -0,0 +1,126 @@
"use client";
import { FC, useState, useRef } from "react";
import { Transition } from "@headlessui/react";
import Link from "next/link";
import { FileText, HelpCircle, MoveLeft } from "lucide-react";
// hooks
import { useTheme } from "@/hooks";
// icons
import { DiscordIcon, GithubIcon } from "@plane/ui";
// assets
import packageJson from "package.json";
const helpOptions = [
{
name: "Documentation",
href: "https://docs.plane.so/",
Icon: FileText,
},
{
name: "Join our Discord",
href: "https://discord.com/invite/A92xrEGCge",
Icon: DiscordIcon,
},
{
name: "Report a bug",
href: "https://github.com/makeplane/plane/issues/new/choose",
Icon: GithubIcon,
},
];
export const HelpSection: FC = () => {
// states
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
// store
const { isSidebarCollapsed, toggleSidebar } = useTheme();
// refs
const helpOptionsRef = useRef<HTMLDivElement | null>(null);
return (
<div
className={`flex w-full items-center justify-between gap-1 self-baseline border-t border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-4 py-2 ${
isSidebarCollapsed ? "flex-col" : ""
}`}
>
<div
className={`flex items-center gap-1 ${isSidebarCollapsed ? "flex-col justify-center" : "w-full justify-end"}`}
>
<button
type="button"
className={`grid place-items-center rounded-md p-1.5 text-custom-text-200 outline-none hover:bg-custom-background-90 hover:text-custom-text-100 ${
isSidebarCollapsed ? "w-full" : ""
}`}
onClick={() => setIsNeedHelpOpen((prev) => !prev)}
>
<HelpCircle className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="grid place-items-center rounded-md p-1.5 text-custom-text-200 outline-none hover:bg-custom-background-90 hover:text-custom-text-100 md:hidden"
onClick={() => toggleSidebar(!isSidebarCollapsed)}
>
<MoveLeft className="h-3.5 w-3.5" />
</button>
<button
type="button"
className={`hidden place-items-center rounded-md p-1.5 text-custom-text-200 outline-none hover:bg-custom-background-90 hover:text-custom-text-100 md:grid ${
isSidebarCollapsed ? "w-full" : ""
}`}
onClick={() => toggleSidebar(!isSidebarCollapsed)}
>
<MoveLeft className={`h-3.5 w-3.5 duration-300 ${isSidebarCollapsed ? "rotate-180" : ""}`} />
</button>
</div>
<div className="relative">
<Transition
show={isNeedHelpOpen}
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"
>
<div
className={`absolute bottom-2 min-w-[10rem] ${
isSidebarCollapsed ? "left-full" : "-left-[75px]"
} divide-y divide-custom-border-200 whitespace-nowrap rounded bg-custom-background-100 p-1 shadow-custom-shadow-xs`}
ref={helpOptionsRef}
>
<div className="space-y-1 pb-2">
{helpOptions.map(({ name, Icon, href }) => {
if (href)
return (
<Link href={href} key={name} target="_blank">
<div className="flex 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">
<Icon className="h-3.5 w-3.5 text-custom-text-200" size={14} />
</div>
<span className="text-xs">{name}</span>
</div>
</Link>
);
else
return (
<button
key={name}
type="button"
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">
<Icon className="h-3.5 w-3.5 text-custom-text-200" />
</div>
<span className="text-xs">{name}</span>
</button>
);
})}
</div>
<div className="px-2 pb-1 pt-2 text-[10px]">Version: v{packageJson.version}</div>
</div>
</Transition>
</div>
</div>
);
};

View 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";

View 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/auth-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>
);
});

View File

@ -0,0 +1,116 @@
"use client";
import { Fragment } from "react";
// import { useRouter } from "next/navigation";
import { useTheme as useNextTheme } from "next-themes";
import { observer } from "mobx-react-lite";
// import { mutate } from "swr";
// components
import { Menu, Transition } from "@headlessui/react";
// icons
import { LogOut, UserCog2, Palette } from "lucide-react";
// hooks
import { useTheme, useUser } from "@/hooks";
// ui
import { Avatar, TOAST_TYPE, setToast } from "@plane/ui";
export const SidebarDropdown = observer(() => {
// store hooks
const { isSidebarCollapsed } = useTheme();
const { currentUser, signOut } = useUser();
// hooks
const { resolvedTheme, setTheme } = useNextTheme();
const handleSignOut = async () => {
await signOut().catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed to sign out. Please try again.",
})
);
};
const handleThemeSwitch = () => {
const newTheme = resolvedTheme === "dark" ? "light" : "dark";
setTheme(newTheme);
};
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" : ""
}`}
>
<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>
{!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"
>
<Menu.Items
className="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"
>
<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">
<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={handleSignOut}
>
<LogOut className="h-4 w-4 stroke-[1.5]" />
Sign out
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
)}
</div>
);
});

View File

@ -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>
);
});

View 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" : ""
)}
>
{<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>
);
});

View File

@ -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>
);
};

View File

@ -0,0 +1 @@
export * from "./root";

View 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}
/>
);
});

View File

@ -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>
</>
);
};

View File

@ -0,0 +1,2 @@
export * from "./root";
export * from "./github-config-form";

View 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>
)}
</>
);
});

View File

@ -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>
</>
);
};

View File

@ -0,0 +1,2 @@
export * from "./root";
export * from "./google-config-form";

View 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>
)}
</>
);
});

View File

@ -0,0 +1,5 @@
export * from "./authentication-method-card";
export * from "./email-codes";
export * from "./password";
export * from "./google";
export * from "./github";

View File

@ -0,0 +1 @@
export * from "./root";

View 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 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}
/>
);
});

View File

@ -0,0 +1,30 @@
import { FC } from "react";
import { AlertCircle, CheckCircle } 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-4 w-full ${type === "error" ? "bg-red-50" : "bg-green-50"}`}>
<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 bg-red-500 rounded-full">
<AlertCircle className="h-5 w-5 text-white" aria-hidden="true" />
</span>
) : (
<CheckCircle className="h-5 w-5 text-green-400" aria-hidden="true" />
)}
</div>
<div className="ml-3">
<p className={`text-sm font-medium ${type === "error" ? "text-red-800" : "text-green-800"} `}>{message}</p>
</div>
</div>
</div>
);
};

View 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>
);
};

View 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>
);
};

View File

@ -0,0 +1,89 @@
"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";
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="w-full rounded-md font-medium"
/>
)}
/>
{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-400">{description}</p>
)}
</div>
);
};

View 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>
);
};

View 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";

View 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>
);
};

View File

@ -0,0 +1 @@
export * from "./page-header";

View File

@ -0,0 +1,17 @@
import Head from "next/head";
type TPageHeader = {
title?: string;
description?: string;
};
export const PageHeader: React.FC<TPageHeader> = (props) => {
const { title = "God Mode - Plane", description = "Plane god mode" } = props;
return (
<Head>
<title>{title}</title>
<meta name="description" content={description} />
</Head>
);
};

View File

@ -0,0 +1,61 @@
"use client";
import React from "react";
import { observer } from "mobx-react-lite";
import Link from "next/link";
import Image from "next/image";
import { useTheme } 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";
type Props = {
isOpen: boolean;
onClose?: () => void;
};
export const CreateWorkspacePopup: React.FC<Props> = observer((props) => {
const { isOpen, onClose } = props;
// theme
const { resolvedTheme } = useTheme();
const handleClose = () => {
onClose && onClose();
};
if (!isOpen) return null;
return (
<div className="absolute bottom-8 right-6 p-6 w-96 border border-custom-border-100 shadow-md rounded-xl bg-custom-background-100 z-20">
<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">
<Link href="/create-workspace" className={getButtonStyling("primary", "sm")}>
Create workspace
</Link>
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
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>
);
});

View File

@ -0,0 +1,160 @@
import { FC, useState } from "react";
import { Controller, useForm } from "react-hook-form";
// hooks
import { useInstance } from "@/hooks";
// ui
import { Button, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
// components
import { ControllerInput, TControllerInputFormField } from "components/common";
import { SendTestEmailModal } from "./send-test-email-modal";
// types
import { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from "@plane/types";
type IInstanceEmailForm = {
config: IFormattedInstanceConfiguration;
};
type EmailFormValues = Record<TInstanceEmailConfigurationKeys, string>;
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,
control,
formState: { errors, 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_HOST_USER",
type: "text",
label: "Username",
placeholder: "getitdone@projectplane.so",
error: Boolean(errors.EMAIL_HOST_USER),
required: true,
},
{
key: "EMAIL_HOST_PASSWORD",
type: "password",
label: "Password",
placeholder: "Password",
error: Boolean(errors.EMAIL_HOST_PASSWORD),
required: true,
},
{
key: "EMAIL_FROM",
type: "text",
label: "From 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 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));
};
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-center justify-between gap-x-20 gap-y-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>
<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">
Turn TLS {Boolean(parseInt(watch("EMAIL_USE_TLS"))) ? "off" : "on"}
</div>
<div className="text-xs font-normal text-custom-text-300">
Use this if your email domain supports TLS.
</div>
</div>
<div className="shrink-0">
<Controller
control={control}
name="EMAIL_USE_TLS"
render={({ field: { value, onChange } }) => (
<ToggleSwitch
value={Boolean(parseInt(value))}
onChange={() => {
Boolean(parseInt(value)) === true ? onChange("0") : onChange("1");
}}
size="sm"
/>
)}
/>
</div>
</div>
</div>
</div>
<div className="flex max-w-4xl items-center py-1 gap-4">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
<Button variant="outline-primary" onClick={() => setIsSendTestEmailModalOpen(true)} loading={isSubmitting}>
Send test email
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,2 @@
export * from "./email-config-form";
export * from "./send-test-email-modal";

View 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>
);
};

View 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 IInstanceGeneralForm {
instance: IInstance["instance"];
instanceAdmins: IInstanceAdmin[];
}
export const InstanceGeneralForm: FC<IInstanceGeneralForm> = (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>
);
};

View File

@ -0,0 +1 @@
export * from "./general-form";

View 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.&nbsp;
<a
href="https://unsplash.com/documentation#creating-a-developer-account"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
Learn more.
</a>
</>
}
placeholder="oXgq-sdfadsaeweqasdfasdf3234234rassd"
error={Boolean(errors.UNSPLASH_ACCESS_KEY)}
required
/>
</div>
<div>
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1 @@
export * from "./image-config-form";

View File

@ -0,0 +1 @@
export * from "./instance-not-ready";

View 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">Welcome aboard Plane!</h1>
<Image src={PlaneTakeOffImage} alt="Plane Logo" />
<p className="font-medium text-base text-custom-text-400">
Get started by setting up your instance and workspace
</p>
</div>
<div>
<Link href={"/setup/?auth_enabled=0"}>
<Button size="lg" className="w-full">
Get started
</Button>
</Link>
</div>
</div>
</div>
);

View File

@ -0,0 +1,2 @@
export * from "./sign-up";
export * from "./sign-in";

View File

@ -0,0 +1,162 @@
"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 } from "@plane/ui";
// components
import { Banner } from "components/common";
// icons
import { Eye, EyeOff } from "lucide-react";
// helpers
import { API_BASE_URL, cn } 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 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(() => (formData.email && formData.password ? false : true), [formData]);
return (
<div className="relative w-full h-full overflow-hidden container mx-auto px-5 md:px-0 flex justify-center items-center">
<div className="w-full md:w-4/6 lg:w-3/6 xl:w-2/6 space-y-10">
<div className="text-center space-y-1">
<h3 className="text-3xl font-bold">Manage your Plane instance</h3>
<p className="font-medium text-custom-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/`}>
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
<div className="w-full space-y-1">
<label className="text-sm text-custom-text-300 font-medium" htmlFor="email">
Email <span className="text-red-500">*</span>
</label>
<Input
className="w-full"
id="email"
name="email"
type="email"
inputSize="md"
placeholder="name@company.com"
value={formData.email}
onChange={(e) => handleFormChange("email", e.target.value)}
/>
</div>
<div className="w-full space-y-1">
<label className="text-sm text-custom-text-300 font-medium" htmlFor="password">
Password <span className="text-red-500">*</span>
</label>
<div className="relative">
<Input
className={cn("w-full pr-10")}
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}>
Sign in
</Button>
</div>
</form>
</div>
</div>
);
};

View File

@ -0,0 +1,267 @@
"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 } from "@plane/ui";
// components
import { Banner, PasswordStrengthMeter } from "components/common";
// icons
import { Eye, EyeOff } from "lucide-react";
// helpers
import { API_BASE_URL, cn } 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;
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 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(
() =>
formData.first_name && formData.email && formData.password && getPasswordStrength(formData.password) >= 3
? false
: true,
[formData]
);
return (
<div className="relative w-full min-h-full h-auto overflow-hidden container mx-auto px-5 lg:px-0 flex justify-center items-center">
<div className="w-full md:w-4/6 lg:w-3/6 xl:w-2/6 space-y-10">
<div className="text-center space-y-1">
<h3 className="text-3xl font-bold">Setup your Plane Instance</h3>
<p className="font-medium text-custom-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/`}>
<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-custom-text-300 font-medium" htmlFor="first_name">
First name <span className="text-red-500">*</span>
</label>
<Input
className="w-full"
id="first_name"
name="first_name"
type="text"
inputSize="md"
placeholder="Wilber"
value={formData.first_name}
onChange={(e) => handleFormChange("first_name", e.target.value)}
/>
</div>
<div className="w-full space-y-1">
<label className="text-sm text-custom-text-300 font-medium" htmlFor="last_name">
Last name
</label>
<Input
className="w-full"
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-custom-text-300 font-medium" htmlFor="email">
Email <span className="text-red-500">*</span>
</label>
<Input
className="w-full"
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-custom-text-300 font-medium" htmlFor="company_name">
Company name
</label>
<Input
className="w-full"
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-custom-text-300 font-medium" htmlFor="password">
Set a password <span className="text-red-500">*</span>
</label>
<div className="relative">
<Input
className={cn("w-full pr-10")}
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}
/>
{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>
)}
<PasswordStrengthMeter password={formData.password} />
</div>
<div className="relative flex items-center pt-2 gap-2">
<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}
/>
<label className="text-sm text-custom-text-300 font-medium cursor-pointer" htmlFor="is_telemetry_enabled">
Allow Plane to anonymously collect usage events.
</label>
<a href="#" 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}>
Continue
</Button>
</div>
</form>
</div>
</div>
);
};

View File

@ -0,0 +1,8 @@
export const SWR_CONFIG = {
refreshWhenHidden: false,
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnMount: true,
refreshInterval: 600000,
errorRetryCount: 3,
};

View 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
View File

@ -0,0 +1,2 @@
export * from "./instance.helper";
export * from "./user.helper";

View File

@ -0,0 +1,9 @@
export enum EInstanceStatus {
ERROR = "ERROR",
NOT_YET_READY = "NOT_YET_READY",
}
export type TInstanceStatus = {
status: EInstanceStatus | undefined;
data?: object;
};

View 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;
};

View 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
View 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";

View 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;
};

View 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;
};

View 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;
};

View 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;

View File

@ -0,0 +1,21 @@
import { FC, ReactNode } from "react";
import { InstanceSidebar } from "@/components/auth-sidebar";
import { InstanceHeader } from "@/components/auth-header";
type TAuthLayout = {
children: ReactNode;
};
export const AuthLayout: FC<TAuthLayout> = (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>
</div>
);
};

View File

@ -0,0 +1,33 @@
"use client";
import { FC, ReactNode } from "react";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
// logo
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
type TDefaultLayout = {
children: ReactNode;
};
export const DefaultLayout: FC<TDefaultLayout> = (props) => {
const { children } = props;
const pathname = usePathname();
console.log("pathname", pathname);
return (
<div className="relative h-screen max-h-max w-full overflow-hidden overflow-y-auto flex flex-col">
<div className="flex-shrink-0 h-[120px]">
<div className="relative h-full container mx-auto px-5 lg:px-0 flex items-center justify-between gap-5 z-50">
<div className="flex items-center gap-x-2">
<Image src={BluePlaneLogoWithoutText} height={30} width={30} alt="Plane Logo" className="mr-2" />
<span className="text-2xl font-semibold sm:text-3xl">Plane</span>
</div>
</div>
</div>
<div className="w-full flex-grow">{children}</div>
</div>
);
};

2
admin/layouts/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from "./default-layout";
export * from "./auth-layout";

View 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>;
};

View 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>
);
});

View 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}</>;
});

View File

@ -0,0 +1,3 @@
export * from "./app-wrapper";
export * from "./instance-wrapper";
export * from "./auth-wrapper";

View File

@ -0,0 +1,58 @@
"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>
<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
View 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
View File

@ -0,0 +1,13 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
trailingSlash: true,
reactStrictMode: false,
swcMinify: true,
output: "standalone",
images: {
unoptimized: true,
},
basePath: process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX === "1" ? "/god-mode" : "",
};
module.exports = nextConfig;

48
admin/package.json Normal file
View File

@ -0,0 +1,48 @@
{
"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": {
"@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.1.0",
"next-themes": "^0.2.1",
"postcss": "8.4.23",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"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
View File

@ -0,0 +1,8 @@
module.exports = {
plugins: {
"postcss-import": {},
"tailwindcss/nesting": {},
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/><path d="M1 1h22v22H1z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 742 B

View File

@ -0,0 +1,35 @@
<svg width="91" height="90" viewBox="0 0 91 90" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="45.1309" cy="45" r="45" fill="#1F2D5C"/>
<path d="M61.2694 60.8093H29.1265C28.6487 60.8093 28.2578 60.4184 28.2578 59.9406V59.0719C28.2578 58.5941 28.6487 58.2031 29.1265 58.2031H61.2694C61.7472 58.2031 62.1381 58.5941 62.1381 59.0719V59.9406C62.1381 60.4184 61.7472 60.8093 61.2694 60.8093Z" fill="url(#paint0_linear_2783_1138)"/>
<path d="M51.341 35.5623L39.1059 29.8104C38.0009 29.2909 36.6891 29.5203 35.8256 30.3838L35.168 31.0414L43.387 38.1485L51.341 35.5623Z" fill="url(#paint1_linear_2783_1138)"/>
<path d="M61.0698 36.2872L59.2611 31.4023L33.7223 41.1182L31.1092 38.8178C30.389 38.1836 29.3561 38.049 28.4969 38.4772L28.324 38.5633C28.1034 38.6736 28.0069 38.9368 28.1042 39.1627L30.7825 45.4062L33.9933 46.7666L61.0698 36.2872Z" fill="url(#paint2_linear_2783_1138)"/>
<path d="M60.073 36.4877C61.5123 36.4877 62.6792 35.3209 62.6792 33.8816C62.6792 32.4422 61.5123 31.2754 60.073 31.2754C58.6336 31.2754 57.4668 32.4422 57.4668 33.8816C57.4668 35.3209 58.6336 36.4877 60.073 36.4877Z" fill="url(#paint3_linear_2783_1138)"/>
<path d="M33.1433 46.9116C34.5826 46.9116 35.7495 45.7447 35.7495 44.3054C35.7495 42.866 34.5826 41.6992 33.1433 41.6992C31.7039 41.6992 30.5371 42.866 30.5371 44.3054C30.5371 45.7447 31.7039 46.9116 33.1433 46.9116Z" fill="url(#paint4_linear_2783_1138)"/>
<path d="M45.3319 40.3661C45.0304 40.4799 44.8228 40.7587 44.7993 41.0802L43.8516 54.1501H44.7811C46.0017 54.1501 47.0919 53.3839 47.5046 52.2363L52.5154 38.3376C52.6292 38.0231 52.3208 37.7199 52.008 37.839L45.3319 40.3661Z" fill="url(#paint5_linear_2783_1138)"/>
<defs>
<linearGradient id="paint0_linear_2783_1138" x1="35.9269" y1="50.2343" x2="53.6594" y2="67.9676" gradientUnits="userSpaceOnUse">
<stop stop-color="#74A0E2"/>
<stop offset="1" stop-color="#4F90EF"/>
</linearGradient>
<linearGradient id="paint1_linear_2783_1138" x1="35.168" y1="33.8413" x2="51.341" y2="33.8413" gradientUnits="userSpaceOnUse">
<stop stop-color="#3079D6"/>
<stop offset="1" stop-color="#297CD2"/>
</linearGradient>
<linearGradient id="paint2_linear_2783_1138" x1="28.066" y1="39.0854" x2="61.0698" y2="39.0854" gradientUnits="userSpaceOnUse">
<stop stop-color="#42A3F2"/>
<stop offset="1" stop-color="#42A4EB"/>
</linearGradient>
<linearGradient id="paint3_linear_2783_1138" x1="57.4668" y1="33.8816" x2="62.6792" y2="33.8816" gradientUnits="userSpaceOnUse">
<stop stop-color="#42A3F2"/>
<stop offset="1" stop-color="#42A4EB"/>
</linearGradient>
<linearGradient id="paint4_linear_2783_1138" x1="30.5371" y1="44.3054" x2="35.7495" y2="44.3054" gradientUnits="userSpaceOnUse">
<stop stop-color="#42A3F2"/>
<stop offset="1" stop-color="#42A4EB"/>
</linearGradient>
<linearGradient id="paint5_linear_2783_1138" x1="43.8516" y1="45.9815" x2="52.5397" y2="45.9815" gradientUnits="userSpaceOnUse">
<stop stop-color="#3079D6"/>
<stop offset="1" stop-color="#297CD2"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -0,0 +1,40 @@
<svg width="91" height="90" viewBox="0 0 91 90" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_8747_25960)">
<circle cx="45.1309" cy="45" r="45" fill="#F7F9FF"/>
<path d="M61.2694 60.8093H29.1265C28.6487 60.8093 28.2578 60.4184 28.2578 59.9406V59.0719C28.2578 58.5941 28.6487 58.2031 29.1265 58.2031H61.2694C61.7472 58.2031 62.1381 58.5941 62.1381 59.0719V59.9406C62.1381 60.4184 61.7472 60.8093 61.2694 60.8093Z" fill="url(#paint0_linear_8747_25960)"/>
<path d="M51.341 35.5623L39.1059 29.8104C38.0009 29.2909 36.6891 29.5203 35.8256 30.3838L35.168 31.0414L43.387 38.1485L51.341 35.5623Z" fill="url(#paint1_linear_8747_25960)"/>
<path d="M61.0698 36.2872L59.2611 31.4023L33.7223 41.1182L31.1092 38.8178C30.389 38.1836 29.3561 38.049 28.4969 38.4772L28.324 38.5633C28.1034 38.6736 28.0069 38.9368 28.1042 39.1627L30.7825 45.4062L33.9933 46.7666L61.0698 36.2872Z" fill="url(#paint2_linear_8747_25960)"/>
<path d="M60.073 36.4858C61.5123 36.4858 62.6792 35.319 62.6792 33.8796C62.6792 32.4403 61.5123 31.2734 60.073 31.2734C58.6336 31.2734 57.4668 32.4403 57.4668 33.8796C57.4668 35.319 58.6336 36.4858 60.073 36.4858Z" fill="url(#paint3_linear_8747_25960)"/>
<path d="M33.1433 46.9116C34.5826 46.9116 35.7495 45.7447 35.7495 44.3054C35.7495 42.866 34.5826 41.6992 33.1433 41.6992C31.7039 41.6992 30.5371 42.866 30.5371 44.3054C30.5371 45.7447 31.7039 46.9116 33.1433 46.9116Z" fill="url(#paint4_linear_8747_25960)"/>
<path d="M45.3319 40.3661C45.0304 40.4799 44.8228 40.7587 44.7993 41.0802L43.8516 54.1501H44.7811C46.0017 54.1501 47.0919 53.3839 47.5046 52.2363L52.5154 38.3376C52.6292 38.0231 52.3208 37.7199 52.008 37.839L45.3319 40.3661Z" fill="url(#paint5_linear_8747_25960)"/>
</g>
<defs>
<linearGradient id="paint0_linear_8747_25960" x1="35.9269" y1="50.2343" x2="53.6594" y2="67.9676" gradientUnits="userSpaceOnUse">
<stop stop-color="#1D59B3"/>
<stop offset="1" stop-color="#195BBC"/>
</linearGradient>
<linearGradient id="paint1_linear_8747_25960" x1="35.168" y1="33.8413" x2="51.341" y2="33.8413" gradientUnits="userSpaceOnUse">
<stop stop-color="#3079D6"/>
<stop offset="1" stop-color="#297CD2"/>
</linearGradient>
<linearGradient id="paint2_linear_8747_25960" x1="28.066" y1="39.0854" x2="61.0698" y2="39.0854" gradientUnits="userSpaceOnUse">
<stop stop-color="#42A3F2"/>
<stop offset="1" stop-color="#42A4EB"/>
</linearGradient>
<linearGradient id="paint3_linear_8747_25960" x1="57.4668" y1="33.8796" x2="62.6792" y2="33.8796" gradientUnits="userSpaceOnUse">
<stop stop-color="#42A3F2"/>
<stop offset="1" stop-color="#42A4EB"/>
</linearGradient>
<linearGradient id="paint4_linear_8747_25960" x1="30.5371" y1="44.3054" x2="35.7495" y2="44.3054" gradientUnits="userSpaceOnUse">
<stop stop-color="#42A3F2"/>
<stop offset="1" stop-color="#42A4EB"/>
</linearGradient>
<linearGradient id="paint5_linear_8747_25960" x1="43.8516" y1="45.9815" x2="52.5397" y2="45.9815" gradientUnits="userSpaceOnUse">
<stop stop-color="#3079D6"/>
<stop offset="1" stop-color="#297CD2"/>
</linearGradient>
<clipPath id="clip0_8747_25960">
<rect width="90" height="90" fill="white" transform="translate(0.130859)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -0,0 +1,50 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
export abstract class APIService {
protected baseURL: string;
private axiosInstance: AxiosInstance;
constructor(baseURL: string) {
this.baseURL = baseURL;
this.axiosInstance = axios.create({
baseURL,
withCredentials: true,
});
this.setupInterceptors();
}
private setupInterceptors() {
this.axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
if (error.response && error.response.status === 401) window.location.href = "/login";
return Promise.reject(error.response?.data ?? error);
}
);
}
get<ResponseType>(url: string, params = {}): Promise<AxiosResponse<ResponseType>> {
return this.axiosInstance.get(url, { params });
}
post<RequestType, ResponseType>(url: string, data: RequestType, config = {}): Promise<AxiosResponse<ResponseType>> {
return this.axiosInstance.post(url, data, config);
}
put<RequestType, ResponseType>(url: string, data: RequestType, config = {}): Promise<AxiosResponse<ResponseType>> {
return this.axiosInstance.put(url, data, config);
}
patch<RequestType, ResponseType>(url: string, data: RequestType, config = {}): Promise<AxiosResponse<ResponseType>> {
return this.axiosInstance.patch(url, data, config);
}
delete<RequestType>(url: string, data?: RequestType, config = {}) {
return this.axiosInstance.delete(url, { data, ...config });
}
request<T>(config: AxiosRequestConfig = {}): Promise<AxiosResponse<T>> {
return this.axiosInstance(config);
}
}

View File

@ -0,0 +1,45 @@
// services
import { APIService } from "services/api.service";
// helpers
import { API_BASE_URL } from "helpers/common.helper";
type TCsrfTokenResponse = {
csrf_token: string;
};
export class AuthService extends APIService {
constructor() {
super(API_BASE_URL);
}
async requestCSRFToken(): Promise<TCsrfTokenResponse> {
return this.get<TCsrfTokenResponse>("/auth/get-csrf-token/")
.then((response) => response.data)
.catch((error) => {
throw error;
});
}
async signOut(baseUrl: string): Promise<any> {
await this.requestCSRFToken().then((data) => {
const csrfToken = data?.csrf_token;
if (!csrfToken) throw Error("CSRF token not found");
var form = document.createElement("form");
var element1 = document.createElement("input");
form.method = "POST";
form.action = `${baseUrl}/api/instances/admins/sign-out/`;
element1.value = csrfToken;
element1.name = "csrfmiddlewaretoken";
element1.type = "hidden";
form.appendChild(element1);
document.body.appendChild(form);
form.submit();
});
}
}

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