diff --git a/app/.env.example b/app/.env.example new file mode 100644 index 000000000..cdf30fc72 --- /dev/null +++ b/app/.env.example @@ -0,0 +1,11 @@ +# Replace with your instance Public IP +# NEXT_PUBLIC_API_BASE_URL = "http://localhost" +NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS= +NEXT_PUBLIC_GOOGLE_CLIENTID="" +NEXT_PUBLIC_GITHUB_APP_NAME="" +NEXT_PUBLIC_GITHUB_ID="" +NEXT_PUBLIC_SENTRY_DSN="" +NEXT_PUBLIC_ENABLE_OAUTH=0 +NEXT_PUBLIC_ENABLE_SENTRY=0 +NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0 +NEXT_PUBLIC_TRACK_EVENTS=0 diff --git a/app/.eslintrc.js b/app/.eslintrc.js new file mode 100644 index 000000000..21eb74abb --- /dev/null +++ b/app/.eslintrc.js @@ -0,0 +1,15 @@ +module.exports = { + extends: ["next", "prettier"], + parser: "@typescript-eslint/parser", + plugins: ["react", "@typescript-eslint"], + rules: { + "@next/next/no-html-link-for-pages": "off", + "react/jsx-key": "off", + "prefer-const": "error", + "no-irregular-whitespace": "error", + "no-trailing-spaces": "error", + "no-duplicate-imports": "error", + "arrow-body-style": ["error", "as-needed"], + "react/self-closing-comp": ["error", { component: true, html: true }], + }, +}; diff --git a/app/.prettierrc b/app/.prettierrc new file mode 100644 index 000000000..d5cb26e54 --- /dev/null +++ b/app/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/app/Dockerfile.dev b/app/Dockerfile.dev new file mode 100644 index 000000000..d6c1ba274 --- /dev/null +++ b/app/Dockerfile.dev @@ -0,0 +1,13 @@ +FROM node:18-alpine +RUN apk add --no-cache libc6-compat +RUN apk update +# Set working directory +WORKDIR /app + +COPY . . + +RUN yarn install + +EXPOSE 3000 + + diff --git a/app/Dockerfile.web b/app/Dockerfile.web new file mode 100644 index 000000000..35e3a72dd --- /dev/null +++ b/app/Dockerfile.web @@ -0,0 +1,30 @@ +FROM node:18-alpine AS builder +RUN apk add --no-cache libc6-compat +RUN apk update +# Set working directory +WORKDIR /app + +# Install dependencies +RUN yarn install --frozen-lockfile +COPY . . + +# build +RUN yarn build + +FROM node:18-alpine AS runner +WORKDIR /app + +# Don't run production as root +RUN addgroup --system --gid 1001 plane +RUN adduser --system --uid 1001 captain +USER captain + +COPY --from=builder /app/next.config.js . +COPY --from=builder /app/package.json . +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public + +ENV NEXT_TELEMETRY_DISABLED 1 + +EXPOSE 3000 diff --git a/app/components/account/email-code-form.tsx b/app/components/account/email-code-form.tsx new file mode 100644 index 000000000..389153d60 --- /dev/null +++ b/app/components/account/email-code-form.tsx @@ -0,0 +1,199 @@ +import React, { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +// ui +import { CheckCircleIcon } from "@heroicons/react/20/solid"; +import { Input, PrimaryButton, SecondaryButton } from "components/ui"; +// services +import authenticationService from "services/authentication.service"; +import useToast from "hooks/use-toast"; +import useTimer from "hooks/use-timer"; +// icons + +// types +type EmailCodeFormValues = { + email: string; + key?: string; + token?: string; +}; + +export const EmailCodeForm = ({ onSuccess }: any) => { + const [codeSent, setCodeSent] = useState(false); + const [codeResent, setCodeResent] = useState(false); + const [isCodeResending, setIsCodeResending] = useState(false); + const [errorResendingCode, setErrorResendingCode] = useState(false); + + const { setToastAlert } = useToast(); + const { timer: resendCodeTimer, setTimer: setResendCodeTimer } = useTimer(); + + const { + register, + handleSubmit, + setError, + setValue, + getValues, + formState: { errors, isSubmitting, isValid, isDirty }, + } = useForm({ + defaultValues: { + email: "", + key: "", + token: "", + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + const isResendDisabled = + resendCodeTimer > 0 || isCodeResending || isSubmitting || errorResendingCode; + + const onSubmit = async ({ email }: EmailCodeFormValues) => { + setErrorResendingCode(false); + await authenticationService + .emailCode({ email }) + .then((res) => { + setValue("key", res.key); + setCodeSent(true); + }) + .catch((err) => { + setErrorResendingCode(true); + setToastAlert({ + title: "Oops!", + type: "error", + message: err?.error, + }); + }); + }; + + const handleSignin = async (formData: EmailCodeFormValues) => { + await authenticationService + .magicSignIn(formData) + .then((response) => { + onSuccess(response); + }) + .catch((error) => { + setToastAlert({ + title: "Oops!", + type: "error", + message: error?.response?.data?.error ?? "Enter the correct code to sign in", + }); + setError("token" as keyof EmailCodeFormValues, { + type: "manual", + message: error.error, + }); + }); + }; + + const emailOld = getValues("email"); + + useEffect(() => { + setErrorResendingCode(false); + }, [emailOld]); + + return ( + <> +
+ {(codeSent || codeResent) && ( +
+
+
+
+
+

+ {codeResent + ? "Please check your mail for new code." + : "Please check your mail for code."} +

+
+
+
+ )} +
+ + /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( + value + ) || "Email ID is not valid", + }} + error={errors.email} + placeholder="Enter your Email ID" + /> +
+ + {codeSent && ( +
+ + +
+ )} +
+ {codeSent ? ( + + {isSubmitting ? "Signing in..." : "Sign in"} + + ) : ( + { + handleSubmit(onSubmit)().then(() => { + setResendCodeTimer(30); + }); + }} + loading={isSubmitting || (!isValid && isDirty)} + > + {isSubmitting ? "Sending code..." : "Send code"} + + )} +
+
+ + ); +}; diff --git a/app/components/account/email-password-form.tsx b/app/components/account/email-password-form.tsx new file mode 100644 index 000000000..d35abdfe8 --- /dev/null +++ b/app/components/account/email-password-form.tsx @@ -0,0 +1,113 @@ +import React from "react"; + +import Link from "next/link"; + +// react hook form +import { useForm } from "react-hook-form"; +// services +import authenticationService from "services/authentication.service"; +// hooks +import useToast from "hooks/use-toast"; +// ui +import { Input, SecondaryButton } from "components/ui"; +// types +type EmailPasswordFormValues = { + email: string; + password?: string; + medium?: string; +}; + +export const EmailPasswordForm = ({ onSuccess }: any) => { + const { setToastAlert } = useToast(); + const { + register, + handleSubmit, + setError, + formState: { errors, isSubmitting, isValid, isDirty }, + } = useForm({ + defaultValues: { + email: "", + password: "", + medium: "email", + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + const onSubmit = (formData: EmailPasswordFormValues) => { + authenticationService + .emailLogin(formData) + .then((response) => { + onSuccess(response); + }) + .catch((error) => { + console.log(error); + setToastAlert({ + title: "Oops!", + type: "error", + message: "Enter the correct email address and password to sign in", + }); + if (!error?.response?.data) return; + Object.keys(error.response.data).forEach((key) => { + const err = error.response.data[key]; + console.log(err); + setError(key as keyof EmailPasswordFormValues, { + type: "manual", + message: Array.isArray(err) ? err.join(", ") : err, + }); + }); + }); + }; + return ( + <> +
+
+ + /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( + value + ) || "Email ID is not valid", + }} + error={errors.email} + placeholder="Enter your Email ID" + /> +
+
+ +
+
+
+ + Forgot your password? + +
+
+
+ + {isSubmitting ? "Signing in..." : "Sign In"} + +
+
+ + ); +}; diff --git a/app/components/account/email-signin-form.tsx b/app/components/account/email-signin-form.tsx new file mode 100644 index 000000000..e2f81d50c --- /dev/null +++ b/app/components/account/email-signin-form.tsx @@ -0,0 +1,24 @@ +import { useState, FC } from "react"; +import { KeyIcon } from "@heroicons/react/24/outline"; +// components +import { EmailCodeForm, EmailPasswordForm } from "components/account"; + +export interface EmailSignInFormProps { + handleSuccess: () => void; +} + +export const EmailSignInForm: FC = (props) => { + const { handleSuccess } = props; + // states + const [useCode, setUseCode] = useState(true); + + return ( + <> + {useCode ? ( + + ) : ( + + )} + + ); +}; diff --git a/app/components/account/github-login-button.tsx b/app/components/account/github-login-button.tsx new file mode 100644 index 000000000..5b49208bb --- /dev/null +++ b/app/components/account/github-login-button.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState, FC } from "react"; +import Link from "next/link"; +import Image from "next/image"; +import { useRouter } from "next/router"; +// images +import githubImage from "/public/logos/github-black.png"; + +const { NEXT_PUBLIC_GITHUB_ID } = process.env; + +export interface GithubLoginButtonProps { + handleSignIn: React.Dispatch; +} + +export const GithubLoginButton: FC = (props) => { + const { handleSignIn } = props; + // router + const { + query: { code }, + } = useRouter(); + // states + const [loginCallBackURL, setLoginCallBackURL] = useState(undefined); + + useEffect(() => { + if (code) { + handleSignIn(code.toString()); + } + }, [code, handleSignIn]); + + useEffect(() => { + const origin = + typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; + setLoginCallBackURL(`${origin}/signin` as any); + }, []); + + return ( +
+ + + +
+ ); +}; diff --git a/app/components/account/google-login.tsx b/app/components/account/google-login.tsx new file mode 100644 index 000000000..478ffc67e --- /dev/null +++ b/app/components/account/google-login.tsx @@ -0,0 +1,53 @@ +import { FC, CSSProperties, useEffect, useRef, useCallback, useState } from "react"; +// next +import Script from "next/script"; + +export interface IGoogleLoginButton { + text?: string; + handleSignIn: React.Dispatch; + styles?: CSSProperties; +} + +export const GoogleLoginButton: FC = (props) => { + const { handleSignIn } = props; + + const googleSignInButton = useRef(null); + const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false); + + const loadScript = useCallback(() => { + if (!googleSignInButton.current || gsiScriptLoaded) return; + window?.google?.accounts.id.initialize({ + client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "", + callback: handleSignIn, + }); + window?.google?.accounts.id.renderButton( + googleSignInButton.current, + { + type: "standard", + theme: "outline", + size: "large", + logo_alignment: "center", + width: "410", + text: "continue_with", + } as GsiButtonConfiguration // customization attributes + ); + window?.google?.accounts.id.prompt(); // also display the One Tap dialog + setGsiScriptLoaded(true); + }, [handleSignIn, gsiScriptLoaded]); + + useEffect(() => { + if (window?.google?.accounts?.id) { + loadScript(); + } + return () => { + window?.google?.accounts.id.cancel(); + }; + }, [loadScript]); + + return ( + <> +