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:
@@ -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) => {
|
||||
|
||||
@@ -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<Templates, React.FC> = {
|
||||
MAGIC_LINK: MagicLinkTemplate,
|
||||
JOIN_WORKSPACE: JoinWorkspaceTemplate,
|
||||
RESET_PASSWORD: ResetPasswordTemplate,
|
||||
};
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user