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:
LovelessCodes
2025-06-08 22:25:37 +02:00
committed by GitHub
parent 53426bf155
commit 96f2835bab
12 changed files with 394 additions and 149 deletions

View File

@@ -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=

View File

@@ -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.

View File

@@ -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>

View File

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

View File

@@ -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",

View File

@@ -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>

View File

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

View File

@@ -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

View File

@@ -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) => {

View File

@@ -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({

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

View File

@@ -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",