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
2 changes: 2 additions & 0 deletions .storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import { mswLoader, initialize } from "msw-storybook-addon";

import type { Preview } from "@storybook/nextjs-vite";

sb.mock(import("../auth.ts"));
sb.mock(import("../data/card-dto.ts"));
sb.mock(import("../data/revenue-dto.ts"));
sb.mock(import("../data/invoices-dto.ts"));
sb.mock(import("../features/user/action"), { spy: true });

initialize({
onUnhandledRequest: "bypass",
Expand Down
27 changes: 27 additions & 0 deletions __mocks__/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export const nextAuthResult = {
handlers: {
GET: async () => {},
POST: async () => {},
},
auth: {},
signIn: async () => {},
signOut: async () => {},
};

type NextAuthResultKey = keyof typeof nextAuthResult;

export function __setNextAuthResult<T extends NextAuthResultKey>(
key: T,
callback: (prev: (typeof nextAuthResult)[T]) => (typeof nextAuthResult)[T],
) {
console.log("executed");
nextAuthResult[key] = callback(nextAuthResult[key]);
}

export class AuthError {
type: string;

constructor(type: string) {
this.type = type;
}
}
4 changes: 4 additions & 0 deletions app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { nextAuthResult } from "@/auth";
export const {
handlers: { GET, POST },
} = nextAuthResult;
1 change: 1 addition & 0 deletions app/dashboard/invoices/[id]/edit/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* v8 ignore next */
import Link from "next/link";

import { Frown } from "lucide-react";
Expand Down
5 changes: 5 additions & 0 deletions app/signin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import SignIn from "@/components/pages/sign-in";

export default function SigninPage() {
return <SignIn />;
}
70 changes: 70 additions & 0 deletions auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import bcrypt from "bcryptjs";
import Google from "next-auth/providers/google";
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";

import { NextResponse } from "next/server";
import { SignInSchema } from "./features/user/schema";
import { getUserByEmail } from "./features/user/dal/query";

export const nextAuthResult = NextAuth({
providers: [
Google,
Credentials({
credentials: {
email: {},
password: {},
},
authorize: async (credentials) => {
const parsedCredentials = SignInSchema.safeParse(credentials);

if (parsedCredentials.success) {
const { email, password } = parsedCredentials.data;
const user = await getUserByEmail(email);

if (user) {
const isPasswordsMatch = await bcrypt.compare(
password,
user.password,
);

if (isPasswordsMatch) {
return user;
}
}
}

return null;
},
}),
],
callbacks: {
authorized: async ({ auth, request }) => {
if (request.nextUrl.pathname.startsWith("/dashboard")) {
return !!auth;
}
if (request.nextUrl.pathname === "/signin" && !!auth) {
return NextResponse.redirect(new URL("/", request.nextUrl.origin));
}
return true;
},
},
pages: {
signIn: "/signin",
},
});

export { AuthError } from "next-auth";

// For testing-only
type NextAuthResultKey = keyof typeof nextAuthResult;

export function __setNextAuthResult<T extends NextAuthResultKey>(
key: T,
callback: (prev: (typeof nextAuthResult)[T]) => (typeof nextAuthResult)[T],
) {
throw new Error(
"This function is for testing only and should not be invoked outside tests.",
);
callback(nextAuthResult[key]);
}
1 change: 1 addition & 0 deletions components/dashboard/invoices/breadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* v8 ignore next */
import Link from "next/link";

import { clsx } from "clsx";
Expand Down
4 changes: 3 additions & 1 deletion components/dashboard/invoices/buttons.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/* v8 ignore next */
import Link from "next/link";

import { deleteInvoice } from "@/lib/actions";
import { Pencil, Plus, Trash } from "lucide-react";
import Link from "next/link";

export function CreateInvoice() {
return (
Expand Down
1 change: 1 addition & 0 deletions components/dashboard/invoices/create-form.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

/* v8 ignore next */
import Link from "next/link";

import { Button } from "@/components/button";
Expand Down
3 changes: 2 additions & 1 deletion components/dashboard/invoices/edit-form.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
"use client";

/* v8 ignore next */
import Link from "next/link";

import { Button } from "@/components/button";
import { useActionState } from "react";
import { State, updateInvoice } from "@/lib/actions";
import { CustomerField, InvoiceForm } from "@/types/global";
import { Check, Clock, CircleDollarSign, UserCircle } from "lucide-react";
import { useActionState } from "react";

export default function EditInvoiceForm({
invoice,
Expand Down
6 changes: 4 additions & 2 deletions components/dashboard/invoices/pagination.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"use client";

import { ArrowLeft, ArrowRight } from "lucide-react";
import clsx from "clsx";
/* v8 ignore next */
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";

import { generatePagination } from "@/lib/utils";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { usePathname, useSearchParams } from "next/navigation";

export default function Pagination({ totalPages }: { totalPages: number }) {
const searchParams = useSearchParams();
Expand Down
4 changes: 3 additions & 1 deletion components/dashboard/nav-links.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"use client";

import clsx from "clsx";
import { Users, House, Files } from "lucide-react";
/* v8 ignore next */
import Link from "next/link";

import { usePathname } from "next/navigation";
import { Users, House, Files } from "lucide-react";

// Map of links to display in the side navigation.
// Depending on the size of the application, this would be stored in a database.
Expand Down
11 changes: 10 additions & 1 deletion components/dashboard/sidenav.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
/* v8 ignore next */
import Link from "next/link";
import NavLinks from "@/components/dashboard/nav-links";
import AcmeLogo from "@/components/acme-logo";

import { Power } from "lucide-react";
import { nextAuthResult } from "@/auth";

export default function SideNav() {
return (
Expand All @@ -17,7 +20,13 @@ export default function SideNav() {
<div className="flex grow flex-row justify-between space-x-2 md:flex-col md:space-x-0 md:space-y-2">
<NavLinks />
<div className="hidden h-auto w-full grow rounded-md bg-gray-50 md:block"></div>
<form>
<form
action={async () => {
"use server";
const { signOut } = nextAuthResult;
await signOut({ redirectTo: "/" });
}}
>
<button className="flex h-[48px] w-full grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3">
<Power className="w-6" />
<div className="hidden md:block">Sign Out</div>
Expand Down
7 changes: 6 additions & 1 deletion components/dashboard/stories/sidenav.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import SideNav from "../sidenav";

import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { DashboardActive } from "./nav-link.stories";

const meta = {
Expand All @@ -17,4 +18,8 @@ export const Default: Story = {
parameters: {
nextjs: DashboardActive.parameters?.nextjs,
},
play: async ({ canvas, userEvent }) => {
const signOutButton = canvas.getByRole("button", { name: /Sign Out/i });
await userEvent.click(signOutButton);
},
};
1 change: 1 addition & 0 deletions components/pages/home.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* v8 ignore next */
import Link from "next/link";
import Image from "next/image";
import AcmeLogo from "@/components/acme-logo";
Expand Down
21 changes: 21 additions & 0 deletions components/pages/sign-in.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import AcmeLogo from "@/components/acme-logo";
import LoginForm from "@/components/user/login-form";

import { Suspense } from "react";

export default function SignIn() {
return (
<main className="flex items-center justify-center md:h-screen">
<div className="relative mx-auto flex w-full max-w-[400px] flex-col space-y-2.5 p-4 md:-mt-32">
<div className="flex h-20 w-full items-end rounded-lg bg-blue-500 p-3 md:h-36">
<div className="w-32 text-white md:w-36">
<AcmeLogo />
</div>
</div>
<Suspense>
<LoginForm />
</Suspense>
</div>
</main>
);
}
3 changes: 2 additions & 1 deletion components/pages/stories/home.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import Home from "../home";

import { Meta, StoryObj } from "@storybook/nextjs-vite";

const meta = {
title: "Pages/Home",
component: Home,
Expand Down
17 changes: 17 additions & 0 deletions components/pages/stories/sign-in.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import SignIn from "../sign-in";

import { Meta, StoryObj } from "@storybook/nextjs-vite";

const meta = {
title: "Pages/Signin",
component: SignIn,
parameters: {
layout: "fullscreen",
},
} satisfies Meta<typeof SignIn>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {};
21 changes: 21 additions & 0 deletions components/stories/button.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Button } from "../button";
import { Meta, StoryObj } from "@storybook/nextjs-vite";

const meta = {
title: "Utilities/Button",
component: Button,
tags: ["autodocs"],
parameters: {
layout: "centered",
},
} satisfies Meta<typeof Button>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {
children: "Button",
},
};
78 changes: 78 additions & 0 deletions components/user/login-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"use client";

import { Button } from "../button";
import { robotoSlab } from "@/components/fonts";
import { authenticate } from "@/features/user/action";
import { useActionState } from "react";
import { useSearchParams } from "next/navigation";
import { AtSign, Key, AlertCircle, ArrowRight } from "lucide-react";

export default function LoginForm() {
const searchParams = useSearchParams();

const callbackUrl = searchParams.get("callbackUrl") || "/dashboard";
const [errorMessage, formAction] = useActionState(authenticate, undefined);

return (
<form action={formAction} className="space-y-3">
<div className="flex-1 rounded-lg bg-gray-50 px-6 pb-4 pt-8">
<h1 className={`${robotoSlab.className} mb-3 text-2xl`}>
Please log in to continue.
</h1>
<div className="w-full">
<div>
<label
className="mb-3 mt-5 block text-xs font-medium text-gray-900"
htmlFor="email"
>
Email
</label>
<div className="relative">
<input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
id="email"
type="email"
name="email"
placeholder="Enter your email address"
required
/>
<AtSign className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
</div>
<div className="mt-4">
<label
className="mb-3 mt-5 block text-xs font-medium text-gray-900"
htmlFor="password"
>
Password
</label>
<div className="relative">
<input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
id="password"
type="password"
name="password"
placeholder="Enter password"
required
minLength={6}
/>
<Key className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
</div>
</div>
<input type="hidden" name="redirectTo" value={callbackUrl} />
<Button className="mt-4 w-full">
Log in <ArrowRight className="ml-auto h-5 w-5 text-gray-50" />
</Button>
<div className="flex h-8 items-end space-x-1">
{errorMessage && (
<>
<AlertCircle className="h-5 w-5 text-red-500" />
<p className="text-sm text-red-500">{errorMessage}</p>
</>
)}
</div>
</div>
</form>
);
}
Loading