From 96f2835bab3f64721206944e69d213969ec2521d Mon Sep 17 00:00:00 2001 From: LovelessCodes Date: Sun, 8 Jun 2025 22:25:37 +0200 Subject: [PATCH] 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 --- .env.example | 2 + README.md | 52 ++--- apps/web/src/components/AuthForm.tsx | 201 +++++++++++++----- apps/web/src/components/Input.tsx | 3 +- apps/web/src/env.ts | 24 ++- apps/web/src/views/auth/login/index.tsx | 21 +- apps/web/src/views/auth/signup/index.tsx | 23 +- docker-compose.yml | 2 + packages/auth/src/auth.ts | 60 ++++-- packages/email/src/sendEmail.tsx | 4 +- .../email/src/templates/reset-password.tsx | 108 ++++++++++ turbo.json | 43 ++-- 12 files changed, 394 insertions(+), 149 deletions(-) create mode 100644 packages/email/src/templates/reset-password.tsx diff --git a/.env.example b/.env.example index 89e2ddec..0bce0644 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,8 @@ SMTP_PASSWORD= NEXT_PUBLIC_BASE_URL= NEXT_PUBLIC_STORAGE_URL= NEXT_PUBLIC_AVATAR_BUCKET_NAME= +NEXT_PUBLIC_ALLOW_CREDENTIALS= +NEXT_PUBLIC_DISABLE_SIGN_UP= S3_REGION= S3_ENDPOINT= diff --git a/README.md b/README.md index 9f6b9bbf..cda25b68 100644 --- a/README.md +++ b/README.md @@ -105,31 +105,33 @@ pnpm dev ## Environment Variables 🔐 -| Variable | Description | Required | Example | -| -------------------------------- | ----------------------------- | ----------------- | --------------------------------------------- | -| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` | -| `EMAIL_FROM` | Sender email address | Yes | `"Kan "` | -| `SMTP_HOST` | SMTP server hostname | Yes | `smtp.resend.com` | -| `SMTP_PORT` | SMTP server port | Yes | `465` | -| `SMTP_USER` | SMTP username/email | Yes | `resend` | -| `SMTP_PASSWORD` | SMTP password/token | Yes | `re_xxxx` | -| `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_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` | -| `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` | -| `DISCORD_CLIENT_ID` | Discord OAuth client ID | 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_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` | -| `S3_REGION` | S3 storage region | For file uploads | `WEUR` | -| `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_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_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` | -| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` | +| Variable | Description | Required | Example | +| -------------------------------- | ----------------------------- | ------------------ | --------------------------------------------- | +| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` | +| `EMAIL_FROM` | Sender email address | For Email | `"Kan "` | +| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` | +| `SMTP_PORT` | SMTP server port | For Email | `465` | +| `SMTP_USER` | SMTP username/email | For Email | `resend` | +| `SMTP_PASSWORD` | SMTP password/token | For Email | `re_xxxx` | +| `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_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` | +| `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` | +| `DISCORD_CLIENT_ID` | Discord OAuth client ID | 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_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` | +| `S3_REGION` | S3 storage region | For file uploads | `WEUR` | +| `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_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_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_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. diff --git a/apps/web/src/components/AuthForm.tsx b/apps/web/src/components/AuthForm.tsx index b3b6006f..047728c0 100644 --- a/apps/web/src/components/AuthForm.tsx +++ b/apps/web/src/components/AuthForm.tsx @@ -1,27 +1,52 @@ +import type { SocialProvider } from "better-auth/social-providers"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; +import { env } from "next-runtime-env"; import { useState } from "react"; 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 { TbBrandKick } from "react-icons/tb"; import { z } from "zod"; -import type { SocialProvider } from "better-auth/social-providers"; import { authClient } from "@kan/auth/client"; import Button from "~/components/Button"; import Input from "~/components/Input"; +import { usePopup } from "~/providers/popup"; interface FormValues { + name?: string; email: string; + password?: string; } interface AuthProps { 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 = { google: { @@ -114,19 +139,22 @@ const availableSocialProviders = { name: "Zoom", icon: SiZoom, }, -} +}; -export function Auth({ setIsMagicLinkSent }: AuthProps) { - const [isLoginWithProviderPending, setIsLoginWithProviderPending] = useState< - null | SocialProvider - >(null); +export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) { + const [isLoginWithProviderPending, setIsLoginWithProviderPending] = + useState(null); + const isCredentialsEnabled = + env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true"; const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false); const [loginError, setLoginError] = useState(null); + const { showPopup } = usePopup(); const { register, handleSubmit, formState: { errors }, + watch, } = useForm({ resolver: zodResolver(EmailSchema), }); @@ -136,27 +164,67 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) { queryFn: () => authClient.getSocialProviders(), }); - const handleLoginWithEmail = async (email: string) => { + const handleLoginWithEmail = async ( + email: string, + password?: string, + name?: string, + ) => { setIsLoginWithEmailPending(true); setLoginError(null); - const { error } = await authClient.signIn.magicLink({ - email, - callbackURL: "/boards", - }); + if (password) { + if (isSignUp && name) { + 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); - - if (error) { - setLoginError( - "Something went wrong, please try again later or contact customer support.", - ); - } else { - setIsMagicLinkSent(true, email); - } }; - const handleLoginWithProvider = async ( - provider: SocialProvider) => { + const handleLoginWithProvider = async (provider: SocialProvider) => { setIsLoginWithProviderPending(provider); setLoginError(null); const { error } = await authClient.signIn.social({ @@ -174,9 +242,11 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) { }; const onSubmit = async (values: FormValues) => { - await handleLoginWithEmail(values.email); + await handleLoginWithEmail(values.email, values.password, values.name); }; + const password = watch("password"); + return (
{socialProviders?.length !== 0 && ( @@ -186,16 +256,17 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) { return null; } return ( - - )})} + + ); + })}
)}
@@ -208,26 +279,60 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
)} - - {errors.email && ( -

- Please enter a valid email address -

- )} - {loginError && ( -

{loginError}

- )} -
+
+ {isSignUp && isCredentialsEnabled && ( +
+ + {errors.name && ( +

+ Please enter a valid name +

+ )} +
+ )} +
+ + {errors.email && ( +

+ Please enter a valid email address +

+ )} +
+ {isCredentialsEnabled && ( +
+ + {errors.password && ( +

+ Please enter a valid password +

+ )} +
+ )} + {loginError && ( +

{loginError}

+ )} +
+
diff --git a/apps/web/src/components/Input.tsx b/apps/web/src/components/Input.tsx index 8a24d212..1770a07d 100644 --- a/apps/web/src/components/Input.tsx +++ b/apps/web/src/components/Input.tsx @@ -67,6 +67,7 @@ const Input = forwardRef( 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", prefix && "rounded-l-none", + type === "password" && "pr-8", className && className, )} onKeyDown={onKeyDown} @@ -75,7 +76,7 @@ const Input = forwardRef( {type === "password" && (
)} -

- Don't have an account?{" "} - - Sign up - -

+ {!isSignUpDisabled && ( +

+ Don't have an account?{" "} + + Sign up + +

+ )} diff --git a/apps/web/src/views/auth/signup/index.tsx b/apps/web/src/views/auth/signup/index.tsx index 98eb032c..06e01f44 100644 --- a/apps/web/src/views/auth/signup/index.tsx +++ b/apps/web/src/views/auth/signup/index.tsx @@ -1,32 +1,31 @@ import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { env } from "next-runtime-env"; import { useState } from "react"; -// import { useRouter } from "next/navigation"; +import { authClient } from "@kan/auth/client"; + import { Auth } from "~/components/AuthForm"; import { PageHead } from "~/components/PageHead"; import PatternedBackground from "~/components/PatternedBackground"; -// import { api } from "~/utils/api"; - export default function SignupPage() { - // const router = useRouter(); + const router = useRouter(); const [isMagicLinkSent, setIsMagicLinkSent] = useState(false); const [magicLinkRecipient, setMagicLinkRecipient] = useState(""); + const isSignUpDisabled = + env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true"; const handleMagicLinkSent = (value: boolean, recipient: string) => { setIsMagicLinkSent(value); setMagicLinkRecipient(recipient); }; - // const authCookieExists = document.cookie - // .split("; ") - // .some((cookie) => cookie.includes("auth-token")); + const { data } = authClient.useSession(); - // const { data } = api.user.getUser.useQuery(undefined, { - // enabled: authCookieExists ? true : false, - // }); + if (data?.user.id) router.push("/boards"); - // if (data?.id) router.push("/boards"); + if (isSignUpDisabled) router.push("/login"); return ( <> @@ -51,7 +50,7 @@ export default function SignupPage() { ) : (
- +
)} diff --git a/docker-compose.yml b/docker-compose.yml index 2ec04b5b..c28979e7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -72,6 +72,8 @@ services: - NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME} - NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN} - 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: dokploy-network: external: true diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index cc0d1ed0..37a39adb 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -1,10 +1,11 @@ +import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api"; import { apiKey } from "better-auth/plugins"; import { magicLink } from "better-auth/plugins/magic-link"; +import { socialProviderList } from "better-auth/social-providers"; import { env } from "next-runtime-env"; -import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import type { dbClient } from "@kan/db/client"; 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 { sendEmail } from "@kan/email"; import { createStripeClient } from "@kan/stripe"; -import { socialProviderList } from "better-auth/social-providers"; export const configuredProviders = socialProviderList.reduce< Record< @@ -83,7 +83,8 @@ export const socialProvidersPlugin = () => ({ { 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, }, }), + 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, user: { deleteUser: { @@ -151,8 +163,17 @@ export const initAuth = (db: dbClient) => { databaseHooks: { user: { create: { - async after(user, _context) { - if (user.image && !user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!)) { + before() { + 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 { const client = new S3Client({ region: env("S3_REGION") ?? "", @@ -165,18 +186,21 @@ export const initAuth = (db: dbClient) => { const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"]; - const fileExtension = user.image.split('.').pop()?.split('?')[0] || 'jpg'; - const key = `${user.id}/avatar.${!allowedFileExtensions.includes(fileExtension) ? 'jpg' : fileExtension}`; + const fileExtension = + user.image.split(".").pop()?.split("?")[0] || "jpg"; + const key = `${user.id}/avatar.${!allowedFileExtensions.includes(fileExtension) ? "jpg" : fileExtension}`; const imageBuffer = await downloadImage(user.image); - - await client.send(new PutObjectCommand({ - Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "", - Key: key, - Body: imageBuffer, - ContentType: `image/${!allowedFileExtensions.includes(fileExtension) ? 'jpeg' : fileExtension}`, - ACL: 'public-read', - })); + + await client.send( + new PutObjectCommand({ + Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "", + Key: key, + Body: imageBuffer, + ContentType: `image/${!allowedFileExtensions.includes(fileExtension) ? "jpeg" : fileExtension}`, + ACL: "public-read", + }), + ); await userRepo.update(db, user.id, { image: key, }); @@ -184,9 +208,9 @@ export const initAuth = (db: dbClient) => { console.error(error); } } - } - } - } + }, + }, + }, }, hooks: { after: createAuthMiddleware(async (ctx) => { diff --git a/packages/email/src/sendEmail.tsx b/packages/email/src/sendEmail.tsx index 56d6c145..815a8ff3 100644 --- a/packages/email/src/sendEmail.tsx +++ b/packages/email/src/sendEmail.tsx @@ -3,12 +3,14 @@ import nodemailer from "nodemailer"; import JoinWorkspaceTemplate from "./templates/join-workspace"; 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 = { MAGIC_LINK: MagicLinkTemplate, JOIN_WORKSPACE: JoinWorkspaceTemplate, + RESET_PASSWORD: ResetPasswordTemplate, }; const transporter = nodemailer.createTransport({ diff --git a/packages/email/src/templates/reset-password.tsx b/packages/email/src/templates/reset-password.tsx new file mode 100644 index 00000000..5c4d80b2 --- /dev/null +++ b/packages/email/src/templates/reset-password.tsx @@ -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; +}) => ( + + + Reset your Kan password + + + + kan.bn + + + Reset your Kan password + + + Click the button below to reset your password. + + + + If you didn't try to reset your password, you can safely ignore this email. + +
+ + + Kan + + , the open source Trello alternative. + +
+ + +); + +export default ResetPasswordTemplate; diff --git a/turbo.json b/turbo.json index 5185e2d6..8e3d11f5 100644 --- a/turbo.json +++ b/turbo.json @@ -3,49 +3,28 @@ "ui": "tui", "tasks": { "topo": { - "dependsOn": [ - "^topo" - ] + "dependsOn": ["^topo"] }, "build": { - "dependsOn": [ - "^build" - ], - "outputs": [ - ".cache/tsbuildinfo.json", - "dist/**" - ] + "dependsOn": ["^build"], + "outputs": [".cache/tsbuildinfo.json", "dist/**"] }, "dev": { - "dependsOn": [ - "^dev" - ], + "dependsOn": ["^dev"], "cache": false, "persistent": false }, "format": { - "outputs": [ - ".cache/.prettiercache" - ], + "outputs": [".cache/.prettiercache"], "outputLogs": "new-only" }, "lint": { - "dependsOn": [ - "^topo", - "^build" - ], - "outputs": [ - ".cache/.eslintcache" - ] + "dependsOn": ["^topo", "^build"], + "outputs": [".cache/.eslintcache"] }, "typecheck": { - "dependsOn": [ - "^topo", - "^build" - ], - "outputs": [ - ".cache/tsbuildinfo.json" - ] + "dependsOn": ["^topo", "^build"], + "outputs": [".cache/tsbuildinfo.json"] }, "clean": { "cache": false @@ -117,6 +96,8 @@ "NEXT_PUBLIC_STORAGE_DOMAIN", "NEXT_PUBLIC_STORAGE_URL", "NEXT_PUBLIC_AVATAR_BUCKET_NAME", + "NEXT_PUBLIC_ALLOW_CREDENTIALS", + "NEXT_PUBLIC_DISABLE_SIGN_UP", "S3_REGION", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY", @@ -134,4 +115,4 @@ "VERCEL_URL", "npm_lifecycle_event" ] -} \ No newline at end of file +}