[data-slot=field-group]]:gap-4",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+const fieldVariants = cva(
+ "group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
+ {
+ variants: {
+ orientation: {
+ vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
+ horizontal: [
+ "flex-row items-center",
+ "[&>[data-slot=field-label]]:flex-auto",
+ "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ ],
+ responsive: [
+ "flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
+ "@md/field-group:[&>[data-slot=field-label]]:flex-auto",
+ "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ ],
+ },
+ },
+ defaultVariants: {
+ orientation: "vertical",
+ },
+ }
+)
+
+function Field({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps
) {
+ return (
+
+ )
+}
+
+function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function FieldLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+ [data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
+ "has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ children?: React.ReactNode
+}) {
+ return (
+
+
+ {children && (
+
+ {children}
+
+ )}
+
+ )
+}
+
+function FieldError({
+ className,
+ children,
+ errors,
+ ...props
+}: React.ComponentProps<"div"> & {
+ errors?: Array<{ message?: string } | undefined>
+}) {
+ const content = useMemo(() => {
+ if (children) {
+ return children
+ }
+
+ if (!errors?.length) {
+ return null
+ }
+
+ const uniqueErrors = [
+ ...new Map(errors.map((error) => [error?.message, error])).values(),
+ ]
+
+ if (uniqueErrors?.length == 1) {
+ return uniqueErrors[0]?.message
+ }
+
+ return (
+
+ {uniqueErrors.map(
+ (error, index) =>
+ error?.message && {error.message}
+ )}
+
+ )
+ }, [children, errors])
+
+ if (!content) {
+ return null
+ }
+
+ return (
+
+ {content}
+
+ )
+}
+
+export {
+ Field,
+ FieldLabel,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLegend,
+ FieldSeparator,
+ FieldSet,
+ FieldContent,
+ FieldTitle,
+}
diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx
new file mode 100644
index 0000000..f371fea
--- /dev/null
+++ b/src/components/ui/form.tsx
@@ -0,0 +1,167 @@
+"use client"
+
+import * as React from "react"
+import type { Label as LabelPrimitive } from "radix-ui"
+import { Slot } from "radix-ui"
+import {
+ Controller,
+ FormProvider,
+ useFormContext,
+ useFormState,
+ type ControllerProps,
+ type FieldPath,
+ type FieldValues,
+} from "react-hook-form"
+
+import { cn } from "@/lib/utils"
+import { Label } from "@/components/ui/label"
+
+const Form = FormProvider
+
+type FormFieldContextValue<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+> = {
+ name: TName
+}
+
+const FormFieldContext = React.createContext(
+ {} as FormFieldContextValue
+)
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+>({
+ ...props
+}: ControllerProps) => {
+ return (
+
+
+
+ )
+}
+
+const useFormField = () => {
+ const fieldContext = React.useContext(FormFieldContext)
+ const itemContext = React.useContext(FormItemContext)
+ const { getFieldState } = useFormContext()
+ const formState = useFormState({ name: fieldContext.name })
+ const fieldState = getFieldState(fieldContext.name, formState)
+
+ if (!fieldContext) {
+ throw new Error("useFormField should be used within ")
+ }
+
+ const { id } = itemContext
+
+ return {
+ id,
+ name: fieldContext.name,
+ formItemId: `${id}-form-item`,
+ formDescriptionId: `${id}-form-item-description`,
+ formMessageId: `${id}-form-item-message`,
+ ...fieldState,
+ }
+}
+
+type FormItemContextValue = {
+ id: string
+}
+
+const FormItemContext = React.createContext(
+ {} as FormItemContextValue
+)
+
+function FormItem({ className, ...props }: React.ComponentProps<"div">) {
+ const id = React.useId()
+
+ return (
+
+
+
+ )
+}
+
+function FormLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ const { error, formItemId } = useFormField()
+
+ return (
+
+ )
+}
+
+function FormControl({ ...props }: React.ComponentProps) {
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
+
+ return (
+
+ )
+}
+
+function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
+ const { formDescriptionId } = useFormField()
+
+ return (
+
+ )
+}
+
+function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
+ const { error, formMessageId } = useFormField()
+ const body = error ? String(error?.message ?? "") : props.children
+
+ if (!body) {
+ return null
+ }
+
+ return (
+
+ {body}
+
+ )
+}
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+}
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 0000000..f1124ae
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,21 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx
new file mode 100644
index 0000000..1ac80f7
--- /dev/null
+++ b/src/components/ui/label.tsx
@@ -0,0 +1,24 @@
+"use client"
+
+import * as React from "react"
+import { Label as LabelPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Label({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
new file mode 100644
index 0000000..cd873e3
--- /dev/null
+++ b/src/components/ui/separator.tsx
@@ -0,0 +1,28 @@
+"use client"
+
+import * as React from "react"
+import { Separator as SeparatorPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx
new file mode 100644
index 0000000..9b20afe
--- /dev/null
+++ b/src/components/ui/sonner.tsx
@@ -0,0 +1,40 @@
+"use client"
+
+import {
+ CircleCheckIcon,
+ InfoIcon,
+ Loader2Icon,
+ OctagonXIcon,
+ TriangleAlertIcon,
+} from "lucide-react"
+import { useTheme } from "next-themes"
+import { Toaster as Sonner, type ToasterProps } from "sonner"
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme()
+
+ return (
+ ,
+ info: ,
+ warning: ,
+ error: ,
+ loading: ,
+ }}
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+ } as React.CSSProperties
+ }
+ {...props}
+ />
+ )
+}
+
+export { Toaster }
diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts
new file mode 100644
index 0000000..c741d8c
--- /dev/null
+++ b/src/lib/auth-client.ts
@@ -0,0 +1,10 @@
+import { twoFactorClient, usernameClient } from "better-auth/client/plugins";
+import { createAuthClient } from "better-auth/react";
+
+export const authClient = createAuthClient({
+ fetchOptions: {},
+ plugins: [
+ usernameClient(),
+ twoFactorClient(),
+ ]
+})
\ No newline at end of file
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 1db6d25..6fd22e4 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -1,13 +1,40 @@
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import { betterAuth } from "better-auth";
import { createAuthMiddleware } from "better-auth/api";
+import { bearer, haveIBeenPwned, testUtils, twoFactor, username } from "better-auth/plugins";
import db from "./db";
+import * as schema from "./db/schema";
+import EmailService from "./mail";
+
+const isTest = process.env.NODE_ENV === "test";
+const emailService: EmailService | undefined = isTest ? undefined : new EmailService();
export const auth = betterAuth({
+ secret: process.env.BETTER_AUTH_SECRET!,
+ baseURL: process.env.BETTER_AUTH_URL ?? (process.env.NODE_ENV === "test" ? "http://localhost:3000" : undefined),
experimental: {
joins: true
},
- database: drizzleAdapter(db, { provider: "pg" }),
+ emailAndPassword: {
+ enabled: true,
+ },
+ emailVerification: {
+ sendOnSignUp: true,
+ sendVerificationEmail: async ({ user, url, token }) => {
+ try {
+ if (isTest) return;
+ await emailService!.sendRegisterEmail(user.email, token);
+ console.log("Email sent to", user.email);
+ } catch (error) {
+ console.error("Error sending email", error);
+ throw error;
+ }
+ }
+ },
+ database: drizzleAdapter(db, {
+ provider: "pg",
+ schema
+ }),
hooks: {
after: createAuthMiddleware(async (context) => {
if (!context.path) return;
@@ -21,5 +48,17 @@ export const auth = betterAuth({
break;
}
})
+ },
+ plugins: [
+ username(),
+ twoFactor(),
+ bearer(),
+ haveIBeenPwned(),
+ testUtils() // TODO: Add a conditional plugin for test utils in development
+ ],
+ // This is disabled by default, but I'll keep this here for ease of mind.
+ // You never know when companies will change their minds and decide to start tracking you.
+ telemetry: {
+ enabled: false
}
});
\ No newline at end of file
diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts
index e0db39a..479360d 100644
--- a/src/lib/db/index.ts
+++ b/src/lib/db/index.ts
@@ -1,11 +1,12 @@
import 'dotenv/config';
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
+import * as schema from "./schema";
const pool = new Pool({
connectionString: process.env.DATABASE_URL!,
});
-const db = drizzle({ client: pool });
+const db = drizzle({ client: pool, schema });
export default db;
\ No newline at end of file
diff --git a/src/lib/db/schema/index.ts b/src/lib/db/schema/index.ts
index 413a524..bea36f7 100644
--- a/src/lib/db/schema/index.ts
+++ b/src/lib/db/schema/index.ts
@@ -12,6 +12,9 @@ export const user = pgTable("user", {
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
+ username: text("username").unique(),
+ displayUsername: text("display_username"),
+ twoFactorEnabled: boolean("two_factor_enabled").default(false),
});
export const session = pgTable(
@@ -73,9 +76,26 @@ export const verification = pgTable(
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
+export const twoFactor = pgTable(
+ "two_factor",
+ {
+ id: text("id").primaryKey(),
+ secret: text("secret").notNull(),
+ backupCodes: text("backup_codes").notNull(),
+ userId: text("user_id")
+ .notNull()
+ .references(() => user.id, { onDelete: "cascade" }),
+ },
+ (table) => [
+ index("twoFactor_secret_idx").on(table.secret),
+ index("twoFactor_userId_idx").on(table.userId),
+ ],
+);
+
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
+ twoFactors: many(twoFactor),
}));
export const sessionRelations = relations(session, ({ one }) => ({
@@ -91,3 +111,10 @@ export const accountRelations = relations(account, ({ one }) => ({
references: [user.id],
}),
}));
+
+export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
+ user: one(user, {
+ fields: [twoFactor.userId],
+ references: [user.id],
+ }),
+}));
diff --git a/src/lib/db/schema/user/index.ts b/src/lib/db/schema/user/index.ts
new file mode 100644
index 0000000..f748f06
--- /dev/null
+++ b/src/lib/db/schema/user/index.ts
@@ -0,0 +1,120 @@
+import { relations } from "drizzle-orm";
+import { boolean, index, pgTable, text, timestamp } from "drizzle-orm/pg-core";
+
+export const user = pgTable("user", {
+ id: text("id").primaryKey(),
+ name: text("name").notNull(),
+ email: text("email").notNull().unique(),
+ emailVerified: boolean("email_verified").default(false).notNull(),
+ image: text("image"),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at")
+ .defaultNow()
+ .$onUpdate(() => /* @__PURE__ */ new Date())
+ .notNull(),
+ username: text("username").unique(),
+ displayUsername: text("display_username"),
+ twoFactorEnabled: boolean("two_factor_enabled").default(false),
+});
+
+export const session = pgTable(
+ "session",
+ {
+ id: text("id").primaryKey(),
+ expiresAt: timestamp("expires_at").notNull(),
+ token: text("token").notNull().unique(),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at")
+ .$onUpdate(() => /* @__PURE__ */ new Date())
+ .notNull(),
+ ipAddress: text("ip_address"),
+ userAgent: text("user_agent"),
+ userId: text("user_id")
+ .notNull()
+ .references(() => user.id, { onDelete: "cascade" }),
+ },
+ (table) => [index("session_userId_idx").on(table.userId)],
+);
+
+export const account = pgTable(
+ "account",
+ {
+ id: text("id").primaryKey(),
+ accountId: text("account_id").notNull(),
+ providerId: text("provider_id").notNull(),
+ userId: text("user_id")
+ .notNull()
+ .references(() => user.id, { onDelete: "cascade" }),
+ accessToken: text("access_token"),
+ refreshToken: text("refresh_token"),
+ idToken: text("id_token"),
+ accessTokenExpiresAt: timestamp("access_token_expires_at"),
+ refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
+ scope: text("scope"),
+ password: text("password"),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at")
+ .$onUpdate(() => /* @__PURE__ */ new Date())
+ .notNull(),
+ },
+ (table) => [index("account_userId_idx").on(table.userId)],
+);
+
+export const verification = pgTable(
+ "verification",
+ {
+ id: text("id").primaryKey(),
+ identifier: text("identifier").notNull(),
+ value: text("value").notNull(),
+ expiresAt: timestamp("expires_at").notNull(),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at")
+ .defaultNow()
+ .$onUpdate(() => /* @__PURE__ */ new Date())
+ .notNull(),
+ },
+ (table) => [index("verification_identifier_idx").on(table.identifier)],
+);
+
+export const twoFactor = pgTable(
+ "two_factor",
+ {
+ id: text("id").primaryKey(),
+ secret: text("secret").notNull(),
+ backupCodes: text("backup_codes").notNull(),
+ userId: text("user_id")
+ .notNull()
+ .references(() => user.id, { onDelete: "cascade" }),
+ },
+ (table) => [
+ index("twoFactor_secret_idx").on(table.secret),
+ index("twoFactor_userId_idx").on(table.userId),
+ ],
+);
+
+export const userRelations = relations(user, ({ many }) => ({
+ sessions: many(session),
+ accounts: many(account),
+ twoFactors: many(twoFactor),
+}));
+
+export const sessionRelations = relations(session, ({ one }) => ({
+ user: one(user, {
+ fields: [session.userId],
+ references: [user.id],
+ }),
+}));
+
+export const accountRelations = relations(account, ({ one }) => ({
+ user: one(user, {
+ fields: [account.userId],
+ references: [user.id],
+ }),
+}));
+
+export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
+ user: one(user, {
+ fields: [twoFactor.userId],
+ references: [user.id],
+ }),
+}));
diff --git a/src/lib/mail/email-tailwind.config.ts b/src/lib/mail/email-tailwind.config.ts
new file mode 100644
index 0000000..95282de
--- /dev/null
+++ b/src/lib/mail/email-tailwind.config.ts
@@ -0,0 +1,46 @@
+import { pixelBasedPreset } from "@react-email/tailwind";
+import type { TailwindConfig } from "@react-email/tailwind";
+
+/**
+ * React Email Tailwind config matching globals.css design tokens.
+ * Uses literal hex values since email clients don't support CSS variables.
+ */
+export const emailTailwindConfig: TailwindConfig = {
+ presets: [pixelBasedPreset],
+ theme: {
+ extend: {
+ colors: {
+ background: "#fafafa",
+ foreground: "#0a0a0a",
+ card: "#ffffff",
+ "card-foreground": "#0a0a0a",
+ popover: "#ffffff",
+ "popover-foreground": "#0a0a0a",
+ primary: "#0a0a0a",
+ "primary-foreground": "#fafafa",
+ secondary: "#0d9b6b",
+ "secondary-foreground": "#fafafa",
+ muted: "#f5f5f5",
+ "muted-foreground": "#737373",
+ accent: "#5ee7c4",
+ "accent-foreground": "#032d22",
+ destructive: "#e85a5a",
+ "destructive-foreground": "#fafafa",
+ border: "#e5e5e5",
+ input: "#e5e5e5",
+ ring: "#0d9b6b",
+ },
+ fontFamily: {
+ sans: ["Inter", "sans-serif"],
+ serif: ["Playfair Display", "serif"],
+ mono: ["JetBrains Mono", "monospace"],
+ },
+ borderRadius: {
+ sm: "2px",
+ md: "3px",
+ lg: "5px",
+ xl: "9px",
+ },
+ },
+ },
+};
diff --git a/src/lib/mail/index.ts b/src/lib/mail/index.ts
new file mode 100644
index 0000000..125edd3
--- /dev/null
+++ b/src/lib/mail/index.ts
@@ -0,0 +1,86 @@
+import { render } from "@react-email/render";
+import { createTransport, SendMailOptions } from "nodemailer";
+import React from "react";
+import { z } from "zod";
+import RegisterEmail from "./templates/register";
+
+export default class EmailService {
+ private readonly config: {
+ host: string;
+ port: number;
+ secure: boolean;
+ auth: {
+ user: string;
+ pass: string;
+ };
+ } | null = null;
+
+ private readonly transporter: ReturnType | null = null;
+
+ constructor() {
+ const configSchema = z.object({
+ host: z.string("EMAIL_HOST is required").min(1, "EMAIL_HOST cannot be empty"),
+ port: z.string("EMAIL_PORT is required")
+ .min(1, "EMAIL_PORT cannot be empty")
+ .transform((val, ctx) => {
+ const n = parseInt(val, 10);
+ if (Number.isNaN(n) || n < 1 || n > 65535) {
+ ctx.addIssue({ code: "custom", message: "EMAIL_PORT must be a valid port number (1-65535)" });
+ return z.NEVER;
+ }
+ return n;
+ }),
+ secure: z.union([
+ z.string().transform(val => val === "true" || val === "1"),
+ z.boolean()
+ ], { error: "EMAIL_SECURE must be a boolean or string 'true'/'false'" }),
+ auth: z.object({
+ user: z.string("EMAIL_USER is required").min(1, "EMAIL_USER cannot be empty"),
+ pass: z.string("EMAIL_PASSWORD is required").min(1, "EMAIL_PASSWORD cannot be empty"),
+ }, { error: "Email auth credentials (EMAIL_USER, EMAIL_PASSWORD) are required" }),
+ })
+
+ const fromEnv = {
+ host: process.env.EMAIL_HOST,
+ port: process.env.EMAIL_PORT,
+ secure: Boolean(process.env.EMAIL_SECURE ?? false),
+ auth: {
+ user: process.env.EMAIL_USER,
+ pass: process.env.EMAIL_PASSWORD,
+ },
+ }
+
+
+ const validatedConfig = configSchema.safeParse(fromEnv);
+ if (!validatedConfig.success) {
+ const details = validatedConfig.error.issues
+ .map((issue) =>
+ ` • ${issue.path.length ? String(issue.path.join(".")) : "config"}: ${issue.message}`
+ )
+ .join("\n");
+ throw new Error(`Invalid email configuration:\n${details}`);
+ }
+
+ this.config = validatedConfig.data;
+ this.transporter = createTransport(this.config);
+ }
+
+ private async sendEmail(to: string, subject: string, content: string, options?: { html?: boolean }) {
+ if (!this.transporter || !this.config) { throw new Error("Email transporter not initialized"); }
+ console.log("Sending email to", to, "with subject", subject);
+ const mailOptions: SendMailOptions = {
+ from: `${this.config.auth.user} <${this.config.auth.user}>`,
+ to,
+ subject,
+ ...(options?.html ? { html: content } : { text: content }),
+ };
+
+ const result = await this.transporter.sendMail(mailOptions);
+ return result.messageId;
+ }
+
+ public async sendRegisterEmail(to: string, verificationCode: string) {
+ const template = await render(React.createElement(RegisterEmail, { verificationCode }));
+ return this.sendEmail(to, "Verify your email", template, { html: true });
+ }
+}
\ No newline at end of file
diff --git a/src/lib/mail/templates/register.tsx b/src/lib/mail/templates/register.tsx
new file mode 100644
index 0000000..1416c0f
--- /dev/null
+++ b/src/lib/mail/templates/register.tsx
@@ -0,0 +1,115 @@
+import {
+ Body,
+ Container,
+ Font,
+ Head,
+ Heading,
+ Hr,
+ Html,
+ Img,
+ Link,
+ Preview,
+ Section,
+ Tailwind,
+ Text,
+} from '@react-email/components';
+import { emailTailwindConfig } from '../email-tailwind.config';
+
+interface RegisterEmailProps {
+ verificationCode?: string;
+}
+
+const baseUrl = process.env.VERCEL_URL
+ ? `https://${process.env.VERCEL_URL}`
+ : '';
+
+export default function RegisterEmail({
+ verificationCode,
+}: RegisterEmailProps) {
+ return (
+
+
+
+
+
+ Sipher Email Verification
+
+
+
+
+
+
+
+ Verify your email address
+
+
+ Hope this message finds you well.
+ Please enter the following verification code when prompted. If you don't want to
+ create an account, you can ignore this message and the account will be deleted after 10 minutes.
+
+
+
+ Verification code
+
+
+
+ {verificationCode}
+
+
+ (This code is valid for 10 minutes and can be used only once)
+
+
+
+
+
+
+ Sipher will never email you and ask you for your personal information.
+ We also will never send you any promotion emails or spam emails.
+
+ If you receive any email asking for your personal information or any other data, please ignore it and report it to us immediately at support@sipher.com.
+
+
+
+
+
+ This message was produced and distributed by Sipher,
+ Sipher is a federated social media platform. View our{' '}
+
+ Terms of Service
+
+ {' '}and our{' '}
+
+ Privacy Policy
+
+ .
+
+
+
+
+
+ );
+}
+
+RegisterEmail.PreviewProps = {
+ verificationCode: '596853',
+} satisfies RegisterEmailProps;
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..bd0c391
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/src/server.ts b/src/server.ts
index e69de29..7a35249 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -0,0 +1,20 @@
+import { config } from 'dotenv'
+import { createServer } from 'http'
+import next from 'next'
+
+config({ path: '.env.local' })
+const port = parseInt(process.env.PORT || '3000', 10)
+const dev = process.env.NODE_ENV !== 'production'
+const app = next({ dev })
+const handle = app.getRequestHandler()
+
+app.prepare().then(() => {
+ createServer((req, res) => {
+ handle(req, res)
+ }).listen(port)
+
+ console.log(
+ `> Server listening at http://localhost:${port} as ${dev ? 'development' : process.env.NODE_ENV
+ }`
+ )
+})
\ No newline at end of file
diff --git a/tests/auth.test.ts b/tests/auth.test.ts
new file mode 100644
index 0000000..ee68d46
--- /dev/null
+++ b/tests/auth.test.ts
@@ -0,0 +1,45 @@
+import { auth } from "@/lib/auth"
+import { expect, test } from "@playwright/test"
+
+// NOTICE: Does not work, will fix it later
+
+test("create and login user", async ({ context, page }) => {
+ const ctx = await auth.$context
+ const testUtils = ctx.test
+
+ // Go to home page
+ await page.goto("/")
+ // Check if we are redirected to the auth page
+ await expect(page).toHaveURL("/auth")
+
+ // Create and save user
+ const user = testUtils.createUser({
+ email: "e2e@example.com",
+ name: "E2E User"
+ })
+ await testUtils.saveUser(user)
+
+ // Get cookies and inject into browser
+ const cookies = await testUtils.getCookies({
+ userId: user.id,
+ domain: "localhost"
+ })
+ await context.addCookies(cookies)
+
+ // Login
+ await testUtils.login({ userId: user.id })
+ // Check if we got redirected to the home page
+ await expect(page).toHaveURL("/")
+
+ // Check if we are logged in
+ const headers = await testUtils.getAuthHeaders({ userId: user.id })
+ expect(headers).toBeDefined()
+ expect(headers.get("Authorization")).toBeDefined()
+
+ // Delete user
+ await testUtils.deleteUser(user.id)
+
+ // Check if user is deleted
+ const deletedUser = await ctx.internalAdapter.findUserById(user.id)
+ expect(deletedUser).toBeNull()
+})
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
index cf9c65d..a19c151 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,34 +1,45 @@
{
- "compilerOptions": {
- "target": "ES2017",
- "lib": ["dom", "dom.iterable", "esnext"],
- "allowJs": true,
- "skipLibCheck": true,
- "strict": true,
- "noEmit": true,
- "esModuleInterop": true,
- "module": "esnext",
- "moduleResolution": "bundler",
- "resolveJsonModule": true,
- "isolatedModules": true,
- "jsx": "react-jsx",
- "incremental": true,
- "plugins": [
- {
- "name": "next"
- }
- ],
- "paths": {
- "@/*": ["./src/*"]
- }
- },
- "include": [
- "next-env.d.ts",
- "**/*.ts",
- "**/*.tsx",
- ".next/types/**/*.ts",
- ".next/dev/types/**/*.ts",
- "**/*.mts"
- ],
- "exclude": ["node_modules"]
-}
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": [
+ "./src/*"
+ ],
+ "@app": [
+ "./src/app"
+ ]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts",
+ "**/*.mts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
+}
\ No newline at end of file