Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 22 additions & 10 deletions app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { signInAction, type SignInActionData } from "../actions";
import { Eye, EyeOff, Loader2 } from "lucide-react";
import { Donut, Eye, EyeOff, Loader2 } from "lucide-react";
import { useFormStatus, useFormState } from "react-dom";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import SSOButtons from "@/components/sso-buttons";

export default function Login() {
return (
Expand All @@ -18,19 +19,29 @@ export default function Login() {

export function LoginPage({ next }: { next?: string }) {
return (
<main className="max-w-sm p-8 border rounded-md shadow-sm">
<h1 className="text-2xl font-bold mb-4 text-gray-800">Login</h1>
<main className="w-full max-w-md p-8 border rounded-md shadow-sm">
<div className="flex justify-center items-center gap-1.5 mb-4">
<Donut className="w-5 h-5" />
<h1 className="text-lg font-bold text-gray-800">DishCraft</h1>
</div>

<h2 className="text-2xl font-bold mb-4 text-gray-800">Login</h2>
<SignInForm next={next} />

<div className="mt-2 w-full">
<SSOButtons />
</div>

{/* TODO: IMPLEMENT FORGOT PASSWORD FLOW */}
{/* <div className="mt-3">
<span className="cursor-default" title="Unimplemented">
Forgot your password?
</span>
</div> */}
<div className="mt-3">
<span className="text-sm">
<span className="text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link href="/signup" className="underline">
<Link href="/signup" className="underline text-black">
Sign Up
</Link>
</span>
Expand Down Expand Up @@ -67,11 +78,11 @@ function SignInFormFields({ error }: SignInActionData) {
return (
<div className="space-y-3">
<div className="space-y-2">
<label className="block text-gray-700 mb-1" htmlFor="username">
<label className="block text-gray-700 mb-1 text-sm" htmlFor="username">
Username
</label>
<Input
className="w-full text-base"
className="w-full text-sm"
autoFocus
ref={inputRef}
autoCapitalize="off"
Expand All @@ -84,11 +95,11 @@ function SignInFormFields({ error }: SignInActionData) {
/>
</div>
<div className="space-y-2 relative">
<label className="block text-gray-700 mb-1" htmlFor="password">
<label className="block text-gray-700 mb-1 text-sm" htmlFor="password">
Password
</label>
<Input
className="w-full text-base pr-8"
className="w-full text-sm pr-8"
id="password"
type={showPassword ? "text" : "password"}
name="password"
Expand All @@ -111,7 +122,8 @@ function SignInFormFields({ error }: SignInActionData) {
)}
</div>
<div className="flex flex-col gap-3 items-start">
<Button className="p-0 h-8 px-4" disabled={pending}>
{/* <Button className="p-0 h-8 px-4" disabled={pending}> */}
<Button className="w-full" disabled={pending}>
{pending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Login
</Button>
Expand Down
36 changes: 27 additions & 9 deletions app/(auth)/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { signUpAction, type SignUpActionData } from "../actions";
import { Eye, EyeOff, Loader2 } from "lucide-react";
import { Donut, Eye, EyeOff, Loader2 } from "lucide-react";
import { useFormStatus, useFormState } from "react-dom";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import SSOButtons from "@/components/sso-buttons";

export default function SignUp() {
return (
Expand All @@ -18,13 +19,23 @@ export default function SignUp() {

export function SignUpPage({ next }: { next?: string }) {
return (
<main className="max-w-sm p-8 border rounded-md shadow-sm">
<main className="w-full max-w-md p-8 border rounded-md shadow-sm">
<div className="flex justify-center items-center gap-1.5 mb-4">
<Donut className="w-5 h-5" />
<h1 className="text-lg font-bold text-gray-800">DishCraft</h1>
</div>

<h2 className="text-2xl font-bold mb-4 text-gray-800">Create Account</h2>
<SignUpForm next={next} />

<div className="mt-2 w-full">
<SSOButtons />
</div>

<div className="mt-3">
<span className="text-sm">
<span className="text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="underline">
<Link href="/login" className="underline text-black">
Login
</Link>
</span>
Expand Down Expand Up @@ -61,11 +72,14 @@ function SignUpFormFields({ error }: SignUpActionData) {
return (
<div className="space-y-3">
<div className="space-y-2">
<label className="block text-gray-700 mb-1" htmlFor="new-username">
<label
className="block text-gray-700 mb-1 text-sm"
htmlFor="new-username"
>
Username
</label>
<Input
className="w-full text-base"
className="w-full text-sm"
autoFocus
autoCapitalize="off"
id="new-username"
Expand All @@ -85,11 +99,14 @@ function SignUpFormFields({ error }: SignUpActionData) {
) : null}
</div>
<div className="space-y-2 relative">
<label className="block text-gray-700 mb-1" htmlFor="new-password">
<label
className="block text-gray-700 mb-1 text-sm"
htmlFor="new-password"
>
Password
</label>
<Input
className="w-full text-base pr-8"
className="w-full text-sm pr-8"
id="new-password"
name="password"
type={showPassword ? "text" : "password"}
Expand Down Expand Up @@ -119,7 +136,8 @@ function SignUpFormFields({ error }: SignUpActionData) {
) : null}
</div>
<div className="flex flex-col gap-3 items-start">
<Button className="p-0 h-8 px-4" disabled={pending}>
{/* <Button className="p-0 h-8 px-4" disabled={pending}> */}
<Button className="w-full" disabled={pending}>
{pending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Sign Up
</Button>
Expand Down
2 changes: 2 additions & 0 deletions app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { handlers } from "@/app/auth";
export const { GET, POST } = handlers;
16 changes: 11 additions & 5 deletions app/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import NextAuth, { type NextAuthConfig } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import Google from "next-auth/providers/google";
import { db, usersTable } from "./db";
import { compare } from "bcrypt";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
Expand Down Expand Up @@ -27,19 +28,24 @@ const authOptions: NextAuthConfig = {
},
authorize,
}),
Google({
allowDangerousEmailAccountLinking: true,
}),
],
};

async function authorize(
credentials: Partial<Record<"username" | "password", unknown>>,
req: Request,
req: Request
) {
if (!credentials?.username) {
throw new Error('"username" is required in credentials');
// throw new Error('"username" is required in credentials');
return null;
}

if (!credentials?.password || "string" !== typeof credentials.password) {
throw new Error('"password" is required in credentials');
// throw new Error('"password" is required in credentials');
return null;
}

const reqId = req.headers.get("x-vercel-id") ?? nanoid();
Expand All @@ -53,7 +59,7 @@ async function authorize(
)[0];
console.timeEnd(`fetch user for login ${reqId}`);

if (!maybeUser) return null;
if (!maybeUser || !maybeUser.password) return null;

console.time(`bcrypt ${reqId}`);
if (!(await compare(credentials.password, maybeUser.password))) {
Expand All @@ -64,4 +70,4 @@ async function authorize(
return { id: maybeUser.id };
}

export const { auth, signIn, signOut } = NextAuth(authOptions);
export const { handlers, auth, signIn, signOut } = NextAuth(authOptions);
95 changes: 85 additions & 10 deletions app/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ import {
varchar,
timestamp,
unique,
boolean,
primaryKey,
} from "drizzle-orm/pg-core";
import { customAlphabet } from "nanoid";
import { nolookalikes } from "nanoid-dictionary";
import { sql } from "drizzle-orm";
import type { AdapterAccount } from "@auth/core/adapters";

// init nanoid
const nanoid = customAlphabet(nolookalikes, 12);
Expand All @@ -27,22 +30,25 @@ export const db = drizzle(
fetchOptions: {
cache: "no-store",
},
}),
})
);

export const usersTable = pgTable(
"users",
"user",
{
id: varchar("id", { length: 256 }).primaryKey().notNull(),
username: varchar("username", { length: 256 }).notNull().unique(),
email: varchar("email", { length: 256 }),
password: varchar("password", { length: 256 }).notNull(),
username: varchar("username", { length: 256 }).unique(),
name: varchar("name", { length: 256 }),
email: varchar("email", { length: 256 }).unique(),
emailVerified: timestamp("emailVerified", { mode: "date" }),
password: varchar("password", { length: 256 }),
image: varchar("image", { length: 256 }),
created_at: timestamp("created_at").notNull().defaultNow(),
updated_at: timestamp("updated_at").notNull().defaultNow(),
},
(t) => ({
username_idx: index("username_idx").on(t.username),
}),
})
);

export const genUserId = () => {
Expand All @@ -64,7 +70,7 @@ export const recipesTable = pgTable(
likes: integer("likes").notNull().default(0),
submitted_by: varchar("submitted_by", { length: 256 }).references(
() => usersTable.id,
{ onDelete: "set null" },
{ onDelete: "set null" }
),
created_at: timestamp("created_at").notNull().defaultNow(),
updated_at: timestamp("updated_at").notNull().defaultNow(),
Expand All @@ -74,10 +80,10 @@ export const recipesTable = pgTable(
.on(t.title)
.concurrently()
.using(
sql`gin (title gin_trgm_ops, cuisine gin_trgm_ops, category gin_trgm_ops)`,
sql`gin (title gin_trgm_ops, cuisine gin_trgm_ops, category gin_trgm_ops)`
),
created_at_idx: index("created_at_idx").on(t.created_at),
}),
})
);

export const genRecipeId = () => {
Expand All @@ -99,9 +105,78 @@ export const likesTable = pgTable(
},
(t) => ({
unq: unique().on(t.recipe_id, t.user_id),
}),
})
);

export const genLikeId = () => {
return `like_${nanoid(12)}`;
};

export const genAccountId = () => {
return `account_${nanoid(12)}`;
};

export const accounts = pgTable(
"account",
{
userId: text("userId")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
type: text("type").$type<AdapterAccount>().notNull(),
provider: text("provider").notNull(),
providerAccountId: text("providerAccountId").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
},
(account) => ({
compoundKey: unique().on(account.provider, account.providerAccountId),
})
);

// export const sessions = pgTable("session", {
// sessionToken: text("sessionToken").primaryKey(),
// userId: text("userId")
// .notNull()
// .references(() => usersTable.id, { onDelete: "cascade" }),
// expires: timestamp("expires", { mode: "date" }).notNull(),
// });

// export const verificationTokens = pgTable(
// "verificationToken",
// {
// identifier: text("identifier").notNull(),
// token: text("token").notNull(),
// expires: timestamp("expires", { mode: "date" }).notNull(),
// },
// (verificationToken) => ({
// compositePk: primaryKey({
// columns: [verificationToken.identifier, verificationToken.token],
// }),
// })
// );

// export const authenticators = pgTable(
// "authenticator",
// {
// credentialID: text("credentialID").notNull().unique(),
// userId: text("userId")
// .notNull()
// .references(() => usersTable.id, { onDelete: "cascade" }),
// providerAccountId: text("providerAccountId").notNull(),
// credentialPublicKey: text("credentialPublicKey").notNull(),
// counter: integer("counter").notNull(),
// credentialDeviceType: text("credentialDeviceType").notNull(),
// credentialBackedUp: boolean("credentialBackedUp").notNull(),
// transports: text("transports"),
// },
// (authenticator) => ({
// compositePK: primaryKey({
// columns: [authenticator.userId, authenticator.credentialID],
// }),
// })
// );
28 changes: 28 additions & 0 deletions components/sso-buttons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Button } from "./ui/button";
import Image from "next/image";
import { signIn } from "next-auth/react";

export default function SSOButtons() {
const handleGoogleSignIn = () => {
signIn("google", { callbackUrl: "/" });
};

return (
<div className="w-full mt-4">
<div className="flex items-center gap-2 mb-4">
<hr className="flex-1 border-gray-300" />
<span className="text-xs text-gray-500">OR</span>
<hr className="flex-1 border-gray-300" />
</div>

<Button
onClick={handleGoogleSignIn}
variant="outline"
className="w-full flex items-center gap-1.5"
>
<Image src="/google.svg" alt="google-logo" width={18} height={18} />
Google
</Button>
</div>
);
}
Loading