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

@@ -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 | SocialProvider>(null);
const isCredentialsEnabled =
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const { showPopup } = usePopup();
const {
register,
handleSubmit,
formState: { errors },
watch,
} = useForm<FormValues>({
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 (
<div className="space-y-6">
{socialProviders?.length !== 0 && (
@@ -186,16 +256,17 @@ export function Auth({ setIsMagicLinkSent }: AuthProps) {
return null;
}
return (
<Button
onClick={() => handleLoginWithProvider(key as SocialProvider)}
isLoading={isLoginWithProviderPending === key}
iconLeft={<provider.icon />}
fullWidth
size="lg"
>
Continue with {provider.name}
</Button>
)})}
<Button
onClick={() => handleLoginWithProvider(key as SocialProvider)}
isLoading={isLoginWithProviderPending === key}
iconLeft={<provider.icon />}
fullWidth
size="lg"
>
Continue with {provider.name}
</Button>
);
})}
</div>
)}
<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>
)}
<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>
)}
{loginError && (
<p className="mt-2 text-xs text-red-400">{loginError}</p>
)}
<div className="mt-[1.5rem]">
<div className="space-y-2">
{isSignUp && isCredentialsEnabled && (
<div>
<Input
{...register("name", { required: true })}
placeholder="Enter your name"
/>
{errors.name && (
<p className="mt-2 text-xs text-red-400">
Please enter a valid name
</p>
)}
</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
isLoading={isLoginWithEmailPending}
fullWidth
size="lg"
variant="secondary"
>
Continue with email
{isSignUp ? "Sign up with " : "Continue with "}
{!isCredentialsEnabled || (password && password.length !== 0)
? "email"
: "magic link"}
</Button>
</div>
</form>

View File

@@ -67,6 +67,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
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<HTMLInputElement, InputProps>(
{type === "password" && (
<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}
onClick={() => setShowPassword((v) => !v)}
>

View File

@@ -15,8 +15,12 @@ export const env = createEnv({
*/
server: {
BETTER_AUTH_SECRET: z.string(),
BETTER_AUTH_URL: z.string(),
BETTER_AUTH_TRUSTED_ORIGINS: z.string(),
BETTER_AUTH_URL: z.string().url(),
BETTER_AUTH_TRUSTED_ORIGINS: z
.string()
.refine((s) =>
s.split(",").every((l) => z.string().url().safeParse(l).success),
),
POSTGRES_URL: z.string().url(),
STRIPE_SECRET_KEY: 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_REGION: 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: {
NEXT_PUBLIC_KAN_ENV: z.string().optional(),
NEXT_PUBLIC_UMAMI_ID: z.string().optional(),
NEXT_PUBLIC_BASE_URL: z.string(),
NEXT_PUBLIC_STORAGE_URL: z.string().optional(),
NEXT_PUBLIC_BASE_URL: z.string().url(),
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
NEXT_PUBLIC_AVATAR_BUCKET_NAME: 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.
@@ -86,6 +98,8 @@ export const env = createEnv({
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
NEXT_PUBLIC_AVATAR_BUCKET_NAME: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME,
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:
!!process.env.CI || process.env.npm_lifecycle_event === "lint",

View File

@@ -1,14 +1,17 @@
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 { authClient } from "@kan/auth/client";
export default function LoginPage() {
const router = useRouter();
const isSignUpDisabled = env("NEXT_PUBLIC_DISABLE_SIGN_UP") === "true";
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
@@ -48,12 +51,14 @@ export default function LoginPage() {
</div>
</div>
)}
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
Don't have an account?{" "}
<span className="underline">
<Link href="/signup">Sign up</Link>
</span>
</p>
{!isSignUpDisabled && (
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
Don't have an account?{" "}
<span className="underline">
<Link href="/signup">Sign up</Link>
</span>
</p>
)}
</div>
<PatternedBackground />
</div>

View File

@@ -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<boolean>(false);
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
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() {
) : (
<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">
<Auth setIsMagicLinkSent={handleMagicLinkSent} />
<Auth setIsMagicLinkSent={handleMagicLinkSent} isSignUp />
</div>
</div>
)}