Feat: allow email password sign in/up for selfhosters (#54)
* feat: add password reset functionality with email template and credentials config * feat: add authentication configuration options and improve development setup * feat: add password-based authentication and signup control flags * feat: update auth form to support name field * fix: prevent password icon from overlaying the input text --------- Co-authored-by: Henry <henry_ball@hotmail.co.uk>
This commit is contained in:
@@ -9,6 +9,8 @@ SMTP_PASSWORD=
|
|||||||
NEXT_PUBLIC_BASE_URL=
|
NEXT_PUBLIC_BASE_URL=
|
||||||
NEXT_PUBLIC_STORAGE_URL=
|
NEXT_PUBLIC_STORAGE_URL=
|
||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
||||||
|
NEXT_PUBLIC_ALLOW_CREDENTIALS=
|
||||||
|
NEXT_PUBLIC_DISABLE_SIGN_UP=
|
||||||
|
|
||||||
S3_REGION=
|
S3_REGION=
|
||||||
S3_ENDPOINT=
|
S3_ENDPOINT=
|
||||||
|
|||||||
52
README.md
52
README.md
@@ -105,31 +105,33 @@ pnpm dev
|
|||||||
|
|
||||||
## Environment Variables 🔐
|
## Environment Variables 🔐
|
||||||
|
|
||||||
| Variable | Description | Required | Example |
|
| Variable | Description | Required | Example |
|
||||||
| -------------------------------- | ----------------------------- | ----------------- | --------------------------------------------- |
|
| -------------------------------- | ----------------------------- | ------------------ | --------------------------------------------- |
|
||||||
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
|
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
|
||||||
| `EMAIL_FROM` | Sender email address | Yes | `"Kan <hello@mail.kan.bn>"` |
|
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
||||||
| `SMTP_HOST` | SMTP server hostname | Yes | `smtp.resend.com` |
|
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
||||||
| `SMTP_PORT` | SMTP server port | Yes | `465` |
|
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
||||||
| `SMTP_USER` | SMTP username/email | Yes | `resend` |
|
| `SMTP_USER` | SMTP username/email | For Email | `resend` |
|
||||||
| `SMTP_PASSWORD` | SMTP password/token | Yes | `re_xxxx` |
|
| `SMTP_PASSWORD` | SMTP password/token | For Email | `re_xxxx` |
|
||||||
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
||||||
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
||||||
| `BETTER_AUTH_URL` | Auth callback URL | Yes | Same as `NEXT_PUBLIC_BASE_URL` |
|
| `BETTER_AUTH_URL` | Auth callback URL | Yes | Same as `NEXT_PUBLIC_BASE_URL` |
|
||||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | Yes | `http://localhost:3000,http://localhost:3001` |
|
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | Yes | `http://localhost:3000,http://localhost:3001` |
|
||||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
||||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
||||||
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
||||||
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
||||||
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
||||||
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
||||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
||||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
||||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
|
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
|
||||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
|
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
|
||||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
||||||
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
||||||
|
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
||||||
|
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
||||||
|
|
||||||
See `.env.example` for a complete list of supported environment variables.
|
See `.env.example` for a complete list of supported environment variables.
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,52 @@
|
|||||||
|
import type { SocialProvider } from "better-auth/social-providers";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { FaDiscord, FaGithub, FaGoogle, FaApple, FaMicrosoft, FaFacebook, FaSpotify, FaTwitch, FaTwitter, FaDropbox, FaLinkedin, FaGitlab, FaTiktok, FaReddit, FaVk } from "react-icons/fa";
|
import {
|
||||||
|
FaApple,
|
||||||
|
FaDiscord,
|
||||||
|
FaDropbox,
|
||||||
|
FaFacebook,
|
||||||
|
FaGithub,
|
||||||
|
FaGitlab,
|
||||||
|
FaGoogle,
|
||||||
|
FaLinkedin,
|
||||||
|
FaMicrosoft,
|
||||||
|
FaReddit,
|
||||||
|
FaSpotify,
|
||||||
|
FaTiktok,
|
||||||
|
FaTwitch,
|
||||||
|
FaTwitter,
|
||||||
|
FaVk,
|
||||||
|
} from "react-icons/fa";
|
||||||
import { SiRoblox, SiZoom } from "react-icons/si";
|
import { SiRoblox, SiZoom } from "react-icons/si";
|
||||||
import { TbBrandKick } from "react-icons/tb";
|
import { TbBrandKick } from "react-icons/tb";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { SocialProvider } from "better-auth/social-providers";
|
|
||||||
|
|
||||||
import { authClient } from "@kan/auth/client";
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
|
import { usePopup } from "~/providers/popup";
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
|
name?: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthProps {
|
interface AuthProps {
|
||||||
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
|
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
|
||||||
|
isSignUp?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EmailSchema = z.object({ email: z.string().email() });
|
const EmailSchema = z.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const availableSocialProviders = {
|
const availableSocialProviders = {
|
||||||
google: {
|
google: {
|
||||||
@@ -114,19 +139,22 @@ const availableSocialProviders = {
|
|||||||
name: "Zoom",
|
name: "Zoom",
|
||||||
icon: SiZoom,
|
icon: SiZoom,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||||
const [isLoginWithProviderPending, setIsLoginWithProviderPending] = useState<
|
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
|
||||||
null | SocialProvider
|
useState<null | SocialProvider>(null);
|
||||||
>(null);
|
const isCredentialsEnabled =
|
||||||
|
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
||||||
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
|
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
|
||||||
const [loginError, setLoginError] = useState<string | null>(null);
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
|
const { showPopup } = usePopup();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
|
watch,
|
||||||
} = useForm<FormValues>({
|
} = useForm<FormValues>({
|
||||||
resolver: zodResolver(EmailSchema),
|
resolver: zodResolver(EmailSchema),
|
||||||
});
|
});
|
||||||
@@ -136,27 +164,67 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
|||||||
queryFn: () => authClient.getSocialProviders(),
|
queryFn: () => authClient.getSocialProviders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleLoginWithEmail = async (email: string) => {
|
const handleLoginWithEmail = async (
|
||||||
|
email: string,
|
||||||
|
password?: string,
|
||||||
|
name?: string,
|
||||||
|
) => {
|
||||||
setIsLoginWithEmailPending(true);
|
setIsLoginWithEmailPending(true);
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
const { error } = await authClient.signIn.magicLink({
|
if (password) {
|
||||||
email,
|
if (isSignUp && name) {
|
||||||
callbackURL: "/boards",
|
await authClient.signUp.email(
|
||||||
});
|
{
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
callbackURL: "/boards",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () =>
|
||||||
|
showPopup({
|
||||||
|
header: "Success",
|
||||||
|
message: "You have been signed up successfully.",
|
||||||
|
icon: "success",
|
||||||
|
}),
|
||||||
|
onError: ({ error }) => setLoginError(error.message),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await authClient.signIn.email(
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
callbackURL: "/boards",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () =>
|
||||||
|
showPopup({
|
||||||
|
header: "Success",
|
||||||
|
message: "You have been logged in successfully.",
|
||||||
|
icon: "success",
|
||||||
|
}),
|
||||||
|
onError: ({ error }) => setLoginError(error.message),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await authClient.signIn.magicLink(
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
callbackURL: "/boards",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => setIsMagicLinkSent(true, email),
|
||||||
|
onError: ({ error }) => setLoginError(error.message),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoginWithEmailPending(false);
|
setIsLoginWithEmailPending(false);
|
||||||
|
|
||||||
if (error) {
|
|
||||||
setLoginError(
|
|
||||||
"Something went wrong, please try again later or contact customer support.",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setIsMagicLinkSent(true, email);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoginWithProvider = async (
|
const handleLoginWithProvider = async (provider: SocialProvider) => {
|
||||||
provider: SocialProvider) => {
|
|
||||||
setIsLoginWithProviderPending(provider);
|
setIsLoginWithProviderPending(provider);
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
const { error } = await authClient.signIn.social({
|
const { error } = await authClient.signIn.social({
|
||||||
@@ -174,9 +242,11 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (values: FormValues) => {
|
const onSubmit = async (values: FormValues) => {
|
||||||
await handleLoginWithEmail(values.email);
|
await handleLoginWithEmail(values.email, values.password, values.name);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const password = watch("password");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{socialProviders?.length !== 0 && (
|
{socialProviders?.length !== 0 && (
|
||||||
@@ -186,16 +256,17 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleLoginWithProvider(key as SocialProvider)}
|
onClick={() => handleLoginWithProvider(key as SocialProvider)}
|
||||||
isLoading={isLoginWithProviderPending === key}
|
isLoading={isLoginWithProviderPending === key}
|
||||||
iconLeft={<provider.icon />}
|
iconLeft={<provider.icon />}
|
||||||
fullWidth
|
fullWidth
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
Continue with {provider.name}
|
Continue with {provider.name}
|
||||||
</Button>
|
</Button>
|
||||||
)})}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
@@ -208,26 +279,60 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
|||||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Input
|
<div className="space-y-2">
|
||||||
{...register("email", { required: true })}
|
{isSignUp && isCredentialsEnabled && (
|
||||||
placeholder="Enter your email address"
|
<div>
|
||||||
/>
|
<Input
|
||||||
{errors.email && (
|
{...register("name", { required: true })}
|
||||||
<p className="mt-2 text-xs text-red-400">
|
placeholder="Enter your name"
|
||||||
Please enter a valid email address
|
/>
|
||||||
</p>
|
{errors.name && (
|
||||||
)}
|
<p className="mt-2 text-xs text-red-400">
|
||||||
{loginError && (
|
Please enter a valid name
|
||||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="mt-[1.5rem]">
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
{...register("email", { required: true })}
|
||||||
|
placeholder="Enter your email address"
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="mt-2 text-xs text-red-400">
|
||||||
|
Please enter a valid email address
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isCredentialsEnabled && (
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
{...register("password", { required: true })}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="mt-2 text-xs text-red-400">
|
||||||
|
Please enter a valid password
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loginError && (
|
||||||
|
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-[1.5rem] flex items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
isLoading={isLoginWithEmailPending}
|
isLoading={isLoginWithEmailPending}
|
||||||
fullWidth
|
fullWidth
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
>
|
>
|
||||||
Continue with email
|
{isSignUp ? "Sign up with " : "Continue with "}
|
||||||
|
{!isCredentialsEnabled || (password && password.length !== 0)
|
||||||
|
? "email"
|
||||||
|
: "magic link"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
className={twMerge(
|
className={twMerge(
|
||||||
"block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6",
|
"block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6",
|
||||||
prefix && "rounded-l-none",
|
prefix && "rounded-l-none",
|
||||||
|
type === "password" && "pr-8",
|
||||||
className && className,
|
className && className,
|
||||||
)}
|
)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
@@ -75,7 +76,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
{type === "password" && (
|
{type === "password" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 bg-light-50 pl-1 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-light-900 dark:text-dark-900"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onClick={() => setShowPassword((v) => !v)}
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -15,8 +15,12 @@ export const env = createEnv({
|
|||||||
*/
|
*/
|
||||||
server: {
|
server: {
|
||||||
BETTER_AUTH_SECRET: z.string(),
|
BETTER_AUTH_SECRET: z.string(),
|
||||||
BETTER_AUTH_URL: z.string(),
|
BETTER_AUTH_URL: z.string().url(),
|
||||||
BETTER_AUTH_TRUSTED_ORIGINS: z.string(),
|
BETTER_AUTH_TRUSTED_ORIGINS: z
|
||||||
|
.string()
|
||||||
|
.refine((s) =>
|
||||||
|
s.split(",").every((l) => z.string().url().safeParse(l).success),
|
||||||
|
),
|
||||||
POSTGRES_URL: z.string().url(),
|
POSTGRES_URL: z.string().url(),
|
||||||
STRIPE_SECRET_KEY: z.string().optional(),
|
STRIPE_SECRET_KEY: z.string().optional(),
|
||||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||||
@@ -60,7 +64,7 @@ export const env = createEnv({
|
|||||||
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
||||||
S3_REGION: z.string().optional(),
|
S3_REGION: z.string().optional(),
|
||||||
S3_ENDPOINT: z.string().optional(),
|
S3_ENDPOINT: z.string().optional(),
|
||||||
EMAIL_FROM: z.string(),
|
EMAIL_FROM: z.string().optional(),
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,10 +74,18 @@ export const env = createEnv({
|
|||||||
client: {
|
client: {
|
||||||
NEXT_PUBLIC_KAN_ENV: z.string().optional(),
|
NEXT_PUBLIC_KAN_ENV: z.string().optional(),
|
||||||
NEXT_PUBLIC_UMAMI_ID: z.string().optional(),
|
NEXT_PUBLIC_UMAMI_ID: z.string().optional(),
|
||||||
NEXT_PUBLIC_BASE_URL: z.string(),
|
NEXT_PUBLIC_BASE_URL: z.string().url(),
|
||||||
NEXT_PUBLIC_STORAGE_URL: z.string().optional(),
|
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
|
||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
||||||
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
|
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
|
||||||
|
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
|
||||||
|
.string()
|
||||||
|
.refine((s) => s.toLowerCase() === "true" || s.toLowerCase() === "false")
|
||||||
|
.optional(),
|
||||||
|
NEXT_PUBLIC_DISABLE_SIGN_UP: z
|
||||||
|
.string()
|
||||||
|
.refine((s) => s.toLowerCase() === "true" || s.toLowerCase() === "false")
|
||||||
|
.optional(),
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
||||||
@@ -86,6 +98,8 @@ export const env = createEnv({
|
|||||||
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
|
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
|
||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME,
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME,
|
||||||
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
|
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
|
||||||
|
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
|
||||||
|
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
|
||||||
},
|
},
|
||||||
skipValidation:
|
skipValidation:
|
||||||
!!process.env.CI || process.env.npm_lifecycle_event === "lint",
|
!!process.env.CI || process.env.npm_lifecycle_event === "lint",
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import { Auth } from "~/components/AuthForm";
|
import { Auth } from "~/components/AuthForm";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import PatternedBackground from "~/components/PatternedBackground";
|
import PatternedBackground from "~/components/PatternedBackground";
|
||||||
import { authClient } from "@kan/auth/client";
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const isSignUpDisabled = env("NEXT_PUBLIC_DISABLE_SIGN_UP") === "true";
|
||||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
||||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
||||||
|
|
||||||
@@ -48,12 +51,14 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
{!isSignUpDisabled && (
|
||||||
Don't have an account?{" "}
|
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||||
<span className="underline">
|
Don't have an account?{" "}
|
||||||
<Link href="/signup">Sign up</Link>
|
<span className="underline">
|
||||||
</span>
|
<Link href="/signup">Sign up</Link>
|
||||||
</p>
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<PatternedBackground />
|
<PatternedBackground />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,32 +1,31 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
// import { useRouter } from "next/navigation";
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import { Auth } from "~/components/AuthForm";
|
import { Auth } from "~/components/AuthForm";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import PatternedBackground from "~/components/PatternedBackground";
|
import PatternedBackground from "~/components/PatternedBackground";
|
||||||
|
|
||||||
// import { api } from "~/utils/api";
|
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
// const router = useRouter();
|
const router = useRouter();
|
||||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
||||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
||||||
|
const isSignUpDisabled =
|
||||||
|
env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true";
|
||||||
|
|
||||||
const handleMagicLinkSent = (value: boolean, recipient: string) => {
|
const handleMagicLinkSent = (value: boolean, recipient: string) => {
|
||||||
setIsMagicLinkSent(value);
|
setIsMagicLinkSent(value);
|
||||||
setMagicLinkRecipient(recipient);
|
setMagicLinkRecipient(recipient);
|
||||||
};
|
};
|
||||||
|
|
||||||
// const authCookieExists = document.cookie
|
const { data } = authClient.useSession();
|
||||||
// .split("; ")
|
|
||||||
// .some((cookie) => cookie.includes("auth-token"));
|
|
||||||
|
|
||||||
// const { data } = api.user.getUser.useQuery(undefined, {
|
if (data?.user.id) router.push("/boards");
|
||||||
// enabled: authCookieExists ? true : false,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// if (data?.id) router.push("/boards");
|
if (isSignUpDisabled) router.push("/login");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -51,7 +50,7 @@ export default function SignupPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
|
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
|
||||||
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||||
<Auth setIsMagicLinkSent={handleMagicLinkSent} />
|
<Auth setIsMagicLinkSent={handleMagicLinkSent} isSignUp />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ services:
|
|||||||
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
||||||
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
|
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
|
||||||
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
|
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
|
||||||
|
- NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS}
|
||||||
|
- NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP}
|
||||||
networks:
|
networks:
|
||||||
dokploy-network:
|
dokploy-network:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
|
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
|
||||||
import { apiKey } from "better-auth/plugins";
|
import { apiKey } from "better-auth/plugins";
|
||||||
import { magicLink } from "better-auth/plugins/magic-link";
|
import { magicLink } from "better-auth/plugins/magic-link";
|
||||||
|
import { socialProviderList } from "better-auth/social-providers";
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||||
@@ -12,7 +13,6 @@ import * as userRepo from "@kan/db/repository/user.repo";
|
|||||||
import * as schema from "@kan/db/schema";
|
import * as schema from "@kan/db/schema";
|
||||||
import { sendEmail } from "@kan/email";
|
import { sendEmail } from "@kan/email";
|
||||||
import { createStripeClient } from "@kan/stripe";
|
import { createStripeClient } from "@kan/stripe";
|
||||||
import { socialProviderList } from "better-auth/social-providers";
|
|
||||||
|
|
||||||
export const configuredProviders = socialProviderList.reduce<
|
export const configuredProviders = socialProviderList.reduce<
|
||||||
Record<
|
Record<
|
||||||
@@ -83,7 +83,8 @@ export const socialProvidersPlugin = () => ({
|
|||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
},
|
},
|
||||||
async (ctx) => ctx.json(ctx.context.socialProviders.map(p => p.name.toLowerCase())),
|
async (ctx) =>
|
||||||
|
ctx.json(ctx.context.socialProviders.map((p) => p.name.toLowerCase())),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -110,6 +111,17 @@ export const initAuth = (db: dbClient) => {
|
|||||||
user: schema.users,
|
user: schema.users,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true",
|
||||||
|
disableSignUp:
|
||||||
|
env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true",
|
||||||
|
sendResetPassword: async (data) => {
|
||||||
|
await sendEmail(data.user.email, "Reset Password", "RESET_PASSWORD", {
|
||||||
|
resetPasswordUrl: data.url,
|
||||||
|
resetPasswordToken: data.token,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
socialProviders: configuredProviders,
|
socialProviders: configuredProviders,
|
||||||
user: {
|
user: {
|
||||||
deleteUser: {
|
deleteUser: {
|
||||||
@@ -151,8 +163,17 @@ export const initAuth = (db: dbClient) => {
|
|||||||
databaseHooks: {
|
databaseHooks: {
|
||||||
user: {
|
user: {
|
||||||
create: {
|
create: {
|
||||||
async after(user, _context) {
|
before() {
|
||||||
if (user.image && !user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!)) {
|
if (env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true") {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
return Promise.resolve(true);
|
||||||
|
},
|
||||||
|
async after(user) {
|
||||||
|
if (
|
||||||
|
user.image &&
|
||||||
|
!user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!)
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const client = new S3Client({
|
const client = new S3Client({
|
||||||
region: env("S3_REGION") ?? "",
|
region: env("S3_REGION") ?? "",
|
||||||
@@ -165,18 +186,21 @@ export const initAuth = (db: dbClient) => {
|
|||||||
|
|
||||||
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];
|
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];
|
||||||
|
|
||||||
const fileExtension = user.image.split('.').pop()?.split('?')[0] || 'jpg';
|
const fileExtension =
|
||||||
const key = `${user.id}/avatar.${!allowedFileExtensions.includes(fileExtension) ? 'jpg' : fileExtension}`;
|
user.image.split(".").pop()?.split("?")[0] || "jpg";
|
||||||
|
const key = `${user.id}/avatar.${!allowedFileExtensions.includes(fileExtension) ? "jpg" : fileExtension}`;
|
||||||
|
|
||||||
const imageBuffer = await downloadImage(user.image);
|
const imageBuffer = await downloadImage(user.image);
|
||||||
|
|
||||||
await client.send(new PutObjectCommand({
|
await client.send(
|
||||||
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
new PutObjectCommand({
|
||||||
Key: key,
|
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||||
Body: imageBuffer,
|
Key: key,
|
||||||
ContentType: `image/${!allowedFileExtensions.includes(fileExtension) ? 'jpeg' : fileExtension}`,
|
Body: imageBuffer,
|
||||||
ACL: 'public-read',
|
ContentType: `image/${!allowedFileExtensions.includes(fileExtension) ? "jpeg" : fileExtension}`,
|
||||||
}));
|
ACL: "public-read",
|
||||||
|
}),
|
||||||
|
);
|
||||||
await userRepo.update(db, user.id, {
|
await userRepo.update(db, user.id, {
|
||||||
image: key,
|
image: key,
|
||||||
});
|
});
|
||||||
@@ -184,9 +208,9 @@ export const initAuth = (db: dbClient) => {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
after: createAuthMiddleware(async (ctx) => {
|
after: createAuthMiddleware(async (ctx) => {
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import nodemailer from "nodemailer";
|
|||||||
|
|
||||||
import JoinWorkspaceTemplate from "./templates/join-workspace";
|
import JoinWorkspaceTemplate from "./templates/join-workspace";
|
||||||
import MagicLinkTemplate from "./templates/magic-link";
|
import MagicLinkTemplate from "./templates/magic-link";
|
||||||
|
import ResetPasswordTemplate from "./templates/reset-password";
|
||||||
|
|
||||||
type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE";
|
type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE" | "RESET_PASSWORD";
|
||||||
|
|
||||||
const emailTemplates: Record<Templates, React.FC> = {
|
const emailTemplates: Record<Templates, React.FC> = {
|
||||||
MAGIC_LINK: MagicLinkTemplate,
|
MAGIC_LINK: MagicLinkTemplate,
|
||||||
JOIN_WORKSPACE: JoinWorkspaceTemplate,
|
JOIN_WORKSPACE: JoinWorkspaceTemplate,
|
||||||
|
RESET_PASSWORD: ResetPasswordTemplate,
|
||||||
};
|
};
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
|
|||||||
108
packages/email/src/templates/reset-password.tsx
Normal file
108
packages/email/src/templates/reset-password.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { Body } from "@react-email/body";
|
||||||
|
import { Button } from "@react-email/button";
|
||||||
|
import { Container } from "@react-email/container";
|
||||||
|
import { Head } from "@react-email/head";
|
||||||
|
import { Heading } from "@react-email/heading";
|
||||||
|
import { Hr } from "@react-email/hr";
|
||||||
|
import { Html } from "@react-email/html";
|
||||||
|
import { Link } from "@react-email/link";
|
||||||
|
import { Preview } from "@react-email/preview";
|
||||||
|
import { Text } from "@react-email/text";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
|
|
||||||
|
export const ResetPasswordTemplate = ({
|
||||||
|
resetPasswordUrl,
|
||||||
|
resetPasswordToken,
|
||||||
|
}: {
|
||||||
|
resetPasswordUrl?: string;
|
||||||
|
resetPasswordToken?: string;
|
||||||
|
}) => (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>Reset your Kan password</Preview>
|
||||||
|
<Body style={{ backgroundColor: "white" }}>
|
||||||
|
<Container
|
||||||
|
style={{
|
||||||
|
fontFamily:
|
||||||
|
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
|
||||||
|
margin: "auto",
|
||||||
|
paddingLeft: "0.75rem",
|
||||||
|
paddingRight: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Heading
|
||||||
|
style={{
|
||||||
|
marginTop: "2.5rem",
|
||||||
|
marginBottom: "2.5rem",
|
||||||
|
fontSize: "24px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#232323",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
kan.bn
|
||||||
|
</Heading>
|
||||||
|
<Heading
|
||||||
|
style={{ fontSize: "24px", fontWeight: "bold", color: "#232323" }}
|
||||||
|
>
|
||||||
|
Reset your Kan password
|
||||||
|
</Heading>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
marginBottom: "2rem",
|
||||||
|
color: "#232323",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Click the button below to reset your password.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
target="_blank"
|
||||||
|
href={resetPasswordUrl}
|
||||||
|
style={{
|
||||||
|
marginBottom: "2rem",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
backgroundColor: "#282828",
|
||||||
|
paddingLeft: "1.5rem",
|
||||||
|
paddingRight: "1.5rem",
|
||||||
|
paddingTop: "1rem",
|
||||||
|
paddingBottom: "1rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
fontWeight: "500",
|
||||||
|
lineHeight: "1",
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset your password
|
||||||
|
</Button>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginBottom: "1rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
color: "#7e7e7e",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
If you didn't try to reset your password, you can safely ignore this email.
|
||||||
|
</Text>
|
||||||
|
<Hr
|
||||||
|
style={{
|
||||||
|
marginTop: "2.5rem",
|
||||||
|
marginBottom: "2rem",
|
||||||
|
borderWidth: "1px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text style={{ color: "#7e7e7e" }}>
|
||||||
|
<Link
|
||||||
|
href={env("NEXT_PUBLIC_BASE_URL")}
|
||||||
|
target="_blank"
|
||||||
|
style={{ color: "#7e7e7e", textDecoration: "underline" }}
|
||||||
|
>
|
||||||
|
Kan
|
||||||
|
</Link>
|
||||||
|
, the open source Trello alternative.
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ResetPasswordTemplate;
|
||||||
41
turbo.json
41
turbo.json
@@ -3,49 +3,28 @@
|
|||||||
"ui": "tui",
|
"ui": "tui",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"topo": {
|
"topo": {
|
||||||
"dependsOn": [
|
"dependsOn": ["^topo"]
|
||||||
"^topo"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": [
|
"dependsOn": ["^build"],
|
||||||
"^build"
|
"outputs": [".cache/tsbuildinfo.json", "dist/**"]
|
||||||
],
|
|
||||||
"outputs": [
|
|
||||||
".cache/tsbuildinfo.json",
|
|
||||||
"dist/**"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"dev": {
|
"dev": {
|
||||||
"dependsOn": [
|
"dependsOn": ["^dev"],
|
||||||
"^dev"
|
|
||||||
],
|
|
||||||
"cache": false,
|
"cache": false,
|
||||||
"persistent": false
|
"persistent": false
|
||||||
},
|
},
|
||||||
"format": {
|
"format": {
|
||||||
"outputs": [
|
"outputs": [".cache/.prettiercache"],
|
||||||
".cache/.prettiercache"
|
|
||||||
],
|
|
||||||
"outputLogs": "new-only"
|
"outputLogs": "new-only"
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"dependsOn": [
|
"dependsOn": ["^topo", "^build"],
|
||||||
"^topo",
|
"outputs": [".cache/.eslintcache"]
|
||||||
"^build"
|
|
||||||
],
|
|
||||||
"outputs": [
|
|
||||||
".cache/.eslintcache"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"typecheck": {
|
"typecheck": {
|
||||||
"dependsOn": [
|
"dependsOn": ["^topo", "^build"],
|
||||||
"^topo",
|
"outputs": [".cache/tsbuildinfo.json"]
|
||||||
"^build"
|
|
||||||
],
|
|
||||||
"outputs": [
|
|
||||||
".cache/tsbuildinfo.json"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"clean": {
|
"clean": {
|
||||||
"cache": false
|
"cache": false
|
||||||
@@ -117,6 +96,8 @@
|
|||||||
"NEXT_PUBLIC_STORAGE_DOMAIN",
|
"NEXT_PUBLIC_STORAGE_DOMAIN",
|
||||||
"NEXT_PUBLIC_STORAGE_URL",
|
"NEXT_PUBLIC_STORAGE_URL",
|
||||||
"NEXT_PUBLIC_AVATAR_BUCKET_NAME",
|
"NEXT_PUBLIC_AVATAR_BUCKET_NAME",
|
||||||
|
"NEXT_PUBLIC_ALLOW_CREDENTIALS",
|
||||||
|
"NEXT_PUBLIC_DISABLE_SIGN_UP",
|
||||||
"S3_REGION",
|
"S3_REGION",
|
||||||
"S3_ACCESS_KEY_ID",
|
"S3_ACCESS_KEY_ID",
|
||||||
"S3_SECRET_ACCESS_KEY",
|
"S3_SECRET_ACCESS_KEY",
|
||||||
|
|||||||
Reference in New Issue
Block a user