move to webapp

This commit is contained in:
m5r
2021-07-18 23:32:45 +08:00
parent 61c23ec9a7
commit a989125e6e
167 changed files with 26607 additions and 24066 deletions

View File

@ -0,0 +1,330 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`/ landing page snapshot 1`] = `
<DocumentFragment>
<section
class="bg-white"
>
<section
class="bg-primary-700 bg-opacity-5"
>
<header
class="max-w-screen-lg mx-auto px-3 py-6"
>
<div
class="flex flex-wrap justify-between items-center"
>
<a
href="/"
>
<div
class="relative h-8 w-8"
>
<div
style="display: block; overflow: hidden; position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; margin: 0px;"
>
<noscript />
<img
alt="app logo"
decoding="async"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
style="position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; padding: 0px; margin: auto; display: block; width: 0px; height: 0px; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;"
/>
</div>
</div>
</a>
<nav
class="flex items-center justify-end flex-1 w-0"
>
<a
class="whitespace-nowrap text-base font-medium text-gray-600 hover:text-gray-900 transition duration-150 ease-in-out"
href="/auth/sign-in"
>
Sign in
</a>
<a
class="ml-8 whitespace-nowrap inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-base font-medium text-white bg-primary-600 hover:bg-primary-700 transition duration-150 ease-in-out"
href="/auth/sign-up"
>
Sign up
</a>
</nav>
</div>
</header>
<main
class="max-w-screen-lg mx-auto px-3 pt-16 pb-24 text-center"
>
<h2
class="text-5xl tracking-tight font-extrabold text-gray-900"
>
Welcome to your
<br />
<span
class="text-primary-600"
>
serverless
</span>
web app
</h2>
<p
class="mt-3 text-lg text-gray-800 sm:mt-5 sm:max-w-xl sm:mx-auto"
>
Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure qui lorem cupidatat commodo. Elit sunt amet fugiat veniam occaecat fugiat aliqua.
</p>
<div
class="mt-12 space-y-3 sm:space-y-0 sm:space-x-3 sm:flex sm:flex-row-reverse sm:justify-center"
>
<div
class="rounded-md shadow"
>
<a
class="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base leading-6 font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 transition duration-150 ease-in-out md:py-4 md:text-lg"
href="/auth/sign-up"
>
Create an account
</a>
</div>
<div>
<a
class="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base leading-6 font-medium rounded-md text-gray-600 hover:text-gray-900 transition duration-150 ease-in-out md:py-4 md:text-lg"
href="/auth/sign-in"
>
I'm already a user
</a>
</div>
</div>
</main>
</section>
<div
class="py-20"
>
<div
class="max-w-screen-lg mx-auto space-y-32 px-3"
>
<div
class="text-center"
>
<h3
class="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl sm:leading-10"
>
A better way to bootstrap your app
</h3>
<p
class="mt-4 max-w-2xl text-lg leading-7 text-gray-600 lg:mx-auto"
>
Lorem ipsum dolor sit amet consect adipisicing elit. Possimus magnam voluptatum cupiditate veritatis in accusamus quisquam.
</p>
</div>
<div
class="flex flex-col-reverse items-center justify-between md:flex-row"
>
<div
class="flex-1 text-center"
>
<h3
class="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl sm:leading-10"
>
Feature #1
</h3>
<div
class="mt-6 text-lg leading-9 text-gray-800"
>
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste dolor cupiditate blanditiis ratione.
</div>
</div>
<div
class="relative w-96 h-60"
>
<div
style="display: block; overflow: hidden; position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; margin: 0px;"
>
<noscript />
<img
alt="Feature Feature #1 illustration"
decoding="async"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
style="position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; padding: 0px; margin: auto; display: block; width: 0px; height: 0px; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;"
/>
</div>
</div>
</div>
<div
class="flex flex-col-reverse items-center justify-between md:flex-row-reverse"
>
<div
class="flex-1 text-center"
>
<h3
class="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl sm:leading-10"
>
Feature #2
</h3>
<div
class="mt-6 text-lg leading-9 text-gray-800"
>
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste dolor cupiditate blanditiis ratione.
</div>
</div>
<div
class="relative w-96 h-60"
>
<div
style="display: block; overflow: hidden; position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; margin: 0px;"
>
<noscript />
<img
alt="Feature Feature #2 illustration"
decoding="async"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
style="position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; padding: 0px; margin: auto; display: block; width: 0px; height: 0px; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;"
/>
</div>
</div>
</div>
<div
class="flex flex-col-reverse items-center justify-between md:flex-row"
>
<div
class="flex-1 text-center"
>
<h3
class="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl sm:leading-10"
>
Feature #3
</h3>
<div
class="mt-6 text-lg leading-9 text-gray-800"
>
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste dolor cupiditate blanditiis ratione.
</div>
</div>
<div
class="relative w-96 h-60"
>
<div
style="display: block; overflow: hidden; position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; margin: 0px;"
>
<noscript />
<img
alt="Feature Feature #3 illustration"
decoding="async"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
style="position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; padding: 0px; margin: auto; display: block; width: 0px; height: 0px; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;"
/>
</div>
</div>
</div>
</div>
</div>
<div
class="bg-primary-700 bg-opacity-5"
>
<div
class="max-w-screen-lg mx-auto px-3 py-16 xl:flex xl:items-center"
>
<div
class="xl:w-0 xl:flex-1"
>
<h2
class="text-3xl font-extrabold tracking-tight"
>
Want to know when we launch?
</h2>
<p
class="mt-3 max-w-3xl text-lg leading-6 text-gray-600"
>
Lorem ipsum, dolor sit amet.
</p>
</div>
<div
class="mt-8 sm:w-full sm:max-w-md xl:mt-0 xl:ml-8"
>
<form
class="sm:flex"
>
<input
autocomplete=""
class="w-full border-gray-300 px-5 py-3 placeholder-gray-500 rounded-md"
id="email"
name="email"
placeholder="Email address"
required=""
type="email"
/>
<button
class="mt-3 w-full flex items-center justify-center px-5 py-3 border border-transparent shadow text-base font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 transition duration-150 ease-in-out sm:mt-0 sm:ml-3 sm:w-auto sm:flex-shrink-0"
type="submit"
>
💌 Notify me
</button>
</form>
</div>
</div>
</div>
<div
class="max-w-screen-xl mx-auto py-12 px-4 sm:px-6 md:flex md:items-center md:justify-between lg:px-8"
>
<div
class="flex justify-center md:order-2"
>
<a
class="ml-6 text-gray-500 hover:text-gray-600"
href="https://twitter.com/m5r_m"
>
<span
class="sr-only"
>
Twitter
</span>
<svg
class="h-6 w-6"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84"
/>
</svg>
</a>
<a
class="ml-6 text-gray-500 hover:text-gray-600"
href="https://github.com/m5r"
>
<span
class="sr-only"
>
GitHub
</span>
<svg
class="h-6 w-6"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
clip-rule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
fill-rule="evenodd"
/>
</svg>
</a>
</div>
<div
class="mt-8 md:mt-0 md:order-1"
>
<p
class="text-center text-base leading-6 text-gray-600"
>
© 2021
<a
href="https://www.capsulecorp.dev"
rel="noopener noreferrer"
target="_blank"
>
Capsule Corp.
</a>
</p>
</div>
</div>
</section>
</DocumentFragment>
`;

View File

@ -0,0 +1,171 @@
/**
* @jest-environment jsdom
*/
jest.mock("next/router", () => ({
useRouter: jest.fn(),
withRouter: (element: ComponentType) => element,
}));
import type { ComponentType, FunctionComponent } from "react";
import { rest } from "msw";
import { setupServer } from "msw/node";
import { useRouter } from "next/router";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "react-query";
import { render, waitFor, screen } from "../../../../../jest/testing-library";
import type { SerializedSession } from "../../../../../lib/session";
import SettingsPage from "../../../../pages/account/settings";
import { SessionProvider } from "../../../../session-context";
import { SidebarProvider } from "../../../../components/layout/sidebar";
const consoleError = console.error;
describe("/account/settings", () => {
type RequestBody = {
email: string;
password: string;
};
console.error = jest.fn();
const mockedUseRouter = useRouter as jest.Mock<
Partial<ReturnType<typeof useRouter>>
>;
const mockedPush = jest.fn();
mockedUseRouter.mockImplementation(() => ({
push: mockedPush,
pathname: "/account/settings",
}));
const session: SerializedSession = {
user: {
id: "auth0|1234567",
email: "test@fss.dev",
name: "test",
role: "owner",
teamId: "98765",
},
};
const server = setupServer(
rest.get("/api/user/session", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(session));
}),
rest.post<RequestBody>("/api/user/update-user", (req, res, ctx) => {
return res(ctx.status(200));
}),
);
const queryClient = new QueryClient();
const wrapper: FunctionComponent = ({ children }) => {
return (
<QueryClientProvider client={queryClient}>
<SidebarProvider>
<SessionProvider session={session}>
{children}
</SessionProvider>
</SidebarProvider>
</QueryClientProvider>
);
};
beforeEach(() => {
mockedPush.mockClear();
mockedUseRouter.mockClear();
queryClient.clear();
});
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => {
server.close();
console.error = consoleError;
});
test("update email only", async () => {
render(<SettingsPage session={session} />, { wrapper });
userEvent.type(
screen.getByLabelText("Email address"),
"test2@fss.dev{enter}",
);
await waitFor(() => screen.getByText("Your changes have been saved."));
});
test("mismatching passwords", async () => {
render(<SettingsPage session={session} />, { wrapper });
userEvent.type(screen.getByLabelText("New password"), "new password");
userEvent.type(
screen.getByLabelText("Confirm new password"),
"does not match{enter}",
);
await waitFor(() => screen.getByText("New passwords don't match"));
});
test("invalid email format", async () => {
server.use(
rest.post<RequestBody>("/api/user/update-user", (req, res, ctx) => {
return res(
ctx.status(400),
ctx.json({
statusCode: 400,
errorMessage: "Body is malformed",
}),
);
}),
);
render(<SettingsPage session={session} />, { wrapper });
userEvent.type(
screen.getByLabelText("Email address"),
"malformed@email{enter}",
);
await waitFor(() => screen.getByText("Body is malformed"));
});
test("redirect to sign in page on 401 unauthorized", async () => {
server.use(
rest.post<RequestBody>("/api/user/update-user", (req, res, ctx) => {
return res(ctx.status(401));
}),
);
render(<SettingsPage session={session} />, { wrapper });
userEvent.type(
screen.getByLabelText("Email address"),
"unauthorized@fss.dev{enter}",
);
await waitFor(() => expect(mockedPush).toBeCalledTimes(1));
await waitFor(() => expect(mockedPush).toBeCalledWith("/auth/sign-in"));
});
test("redirect to sign in page if user is unauthenticated", async () => {
server.use(
rest.get("/api/user/session", (req, res, ctx) => {
return res(ctx.status(401));
}),
);
const wrapper: FunctionComponent = ({ children }) => {
return (
<QueryClientProvider client={queryClient}>
<SidebarProvider>
<SessionProvider session={null}>
{children}
</SessionProvider>
</SidebarProvider>
</QueryClientProvider>
);
};
render(<SettingsPage session={session} />, { wrapper });
await waitFor(() =>
expect(mockedPush).toBeCalledWith(
"/auth/sign-in?redirectTo=/account/settings",
),
);
});
});

View File

@ -0,0 +1,323 @@
/**
* @jest-environment jsdom
*/
jest.mock("next/router", () => ({
useRouter: jest.fn(),
withRouter: (element: ComponentType) => element,
}));
jest.mock("../../../../database/users", () => ({ findUsersByTeam: jest.fn() }));
jest.mock("../../../../database/teams", () => ({ findTeam: jest.fn() }));
import type { ComponentType, FunctionComponent } from "react";
import { rest } from "msw";
import { setupServer } from "msw/node";
import { useRouter } from "next/router";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "react-query";
import { render, waitFor, screen } from "../../../../../jest/testing-library";
import { act, waitForElementToBeRemoved } from "@testing-library/react";
import type { SerializedSession } from "../../../../../lib/session";
import Session from "../../../../../lib/session";
import type { TeamMembers } from "../../../../pages/api/team/members";
import TeamPage, {
getServerSideProps,
} from "../../../../pages/account/settings/team";
import type { User } from "../../../../database/users";
import { findUsersByTeam } from "../../../../database/users";
import { findTeam } from "../../../../database/teams";
import { sessionCache } from "../../../../../lib/session-helpers";
import { SessionProvider } from "../../../../session-context";
import { SidebarProvider } from "../../../../components/layout/sidebar";
describe("/account/settings/team", () => {
const mockedPush = jest.fn();
const mockedUseRouter = useRouter as jest.Mock<
Partial<ReturnType<typeof useRouter>>
>;
const mockedFindTeam = findTeam as jest.Mock<ReturnType<typeof findTeam>>;
const mockedFindUsersByTeam = findUsersByTeam as jest.Mock<
ReturnType<typeof findUsersByTeam>
>;
window.IntersectionObserver = jest
.fn()
.mockImplementation(() => ({
observe: () => null,
disconnect: () => null,
}));
const session: SerializedSession = {
user: {
id: "auth0|1234567",
email: "test@fss.dev",
name: "test",
role: "owner",
teamId: "98765",
},
};
const createdAt = new Date();
const teamMembers = [
{
...session.user,
createdAt,
updatedAt: createdAt,
},
];
const team = {
id: "98765",
subscriptionId: null,
teamMembersLimit: 2,
createdAt,
updatedAt: createdAt,
};
const teamMembersResponse: TeamMembers = {
teamMembers,
teamMembersLimit: team.teamMembersLimit,
};
const server = setupServer(
rest.get("/api/user/session", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(session));
}),
rest.get("/api/team/members", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(teamMembersResponse));
}),
);
mockedFindUsersByTeam.mockResolvedValue(teamMembers);
mockedFindTeam.mockResolvedValue(team);
const queryClient = new QueryClient();
const wrapper: FunctionComponent = ({ children }) => {
return (
<QueryClientProvider client={queryClient}>
<SidebarProvider>
<SessionProvider session={session}>
{children}
</SessionProvider>
</SidebarProvider>
</QueryClientProvider>
);
};
beforeEach(() => {
mockedPush.mockClear();
mockedUseRouter.mockClear();
mockedFindTeam.mockClear();
mockedFindUsersByTeam.mockClear();
mockedUseRouter.mockImplementation(() => ({
push: mockedPush,
pathname: "/account/settings",
}));
queryClient.clear();
});
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test("list team members and display team limit", async () => {
const teamMembersLimit = team.teamMembersLimit;
render(
<TeamPage
session={session}
teamMembers={teamMembers}
teamMembersLimit={teamMembersLimit}
/>,
{ wrapper },
);
await waitFor(() =>
screen.getByText(
(_, node) =>
node?.textContent ===
"Your team has 1 out of 2 team members.",
),
);
});
test("invite someone", async () => {
const inviteMemberHandler = jest.fn();
server.use(
rest.post("/api/team/invite-member", (req, res, ctx) => {
inviteMemberHandler();
return res(ctx.status(200));
}),
);
const teamMembersLimit = team.teamMembersLimit;
await act(async () => {
render(
<TeamPage
session={session}
teamMembers={teamMembers}
teamMembersLimit={teamMembersLimit}
/>,
{ wrapper },
);
userEvent.click(screen.getByText("Invite member"));
await waitFor(() =>
screen.getByText("Invite a member to your team"),
);
userEvent.type(
screen.getByLabelText("Email address"),
"recipient@fss.dev{enter}",
);
await waitForElementToBeRemoved(
screen.getByLabelText("Email address"),
);
expect(inviteMemberHandler).toBeCalledTimes(1);
});
});
describe("team member management", () => {
const createdAt = new Date();
const invitedUser: User = {
id: "auth0|112233",
email: "recipient@fss.dev",
name: "recipient",
teamId: session.user.teamId,
role: "member",
pendingInvitation: true,
createdAt,
updatedAt: createdAt,
};
const teamMembers = [
{
...session.user,
createdAt,
updatedAt: createdAt,
},
invitedUser,
];
const team = {
id: "98765",
subscriptionId: null,
teamMembersLimit: 2,
createdAt,
updatedAt: createdAt,
};
const teamMembersResponse: TeamMembers = {
teamMembers,
teamMembersLimit: team.teamMembersLimit,
};
const teamMembersLimit = team.teamMembersLimit;
server.use(
rest.get("/api/team/members", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(teamMembersResponse));
}),
);
test("re-send invitation", async () => {
const resendInvitationHandler = jest.fn();
server.use(
rest.post("/api/team/resend-invitation", (req, res, ctx) => {
resendInvitationHandler();
return res.once(ctx.status(200));
}),
);
render(
<TeamPage
session={session}
teamMembers={teamMembers}
teamMembersLimit={teamMembersLimit}
/>,
{ wrapper },
);
userEvent.click(
screen.getByTestId(`manage-team-member-${invitedUser.id}`),
);
userEvent.click(screen.getByText("Re-send invitation"));
await waitFor(() =>
expect(resendInvitationHandler).toBeCalledTimes(1),
);
});
test("cancel invitation", async () => {
const cancelInvitationHandler = jest.fn();
server.use(
rest.post("/api/team/remove-member", (req, res, ctx) => {
cancelInvitationHandler();
return res.once(ctx.status(200));
}),
);
render(
<TeamPage
session={session}
teamMembers={teamMembers}
teamMembersLimit={teamMembersLimit}
/>,
{ wrapper },
);
// await waitFor(() => screen.getByText((_, node) => node?.textContent === "Your team has 2 out of 2 team members."));
userEvent.click(
screen.getByTestId(`manage-team-member-${invitedUser.id}`),
);
userEvent.click(screen.getByText("Cancel invitation"));
userEvent.click(screen.getByText("Remove from my team"));
await waitFor(() =>
expect(cancelInvitationHandler).toBeCalledTimes(1),
);
await waitFor(() =>
screen.getByText(
(_, node) =>
node?.textContent ===
"Your team has 1 out of 2 team members.",
),
);
});
});
describe("getServerSideProps", () => {
const context: any = {
req: {},
res: {},
resolvedUrl: "/account/settings/team",
};
sessionCache.set(context.req, context.req, new Session(session.user));
test("return team members and team limit", async () => {
const serverSideProps = await getServerSideProps(context);
// @ts-ignore
delete serverSideProps.props._superjson;
expect(serverSideProps).toStrictEqual({
props: {
session: {
accessToken: null,
accessTokenExpiresAt: null,
accessTokenScope: null,
idToken: null,
refreshToken: null,
user: {
email: "test@fss.dev",
id: "auth0|1234567",
name: "test",
role: "owner",
teamId: "98765",
},
},
teamMembers: [
{
createdAt: createdAt.toISOString(),
email: "test@fss.dev",
id: "auth0|1234567",
name: "test",
role: "owner",
teamId: "98765",
updatedAt: createdAt.toISOString(),
},
],
teamMembersLimit: 2,
},
});
});
});
});

View File

@ -0,0 +1,221 @@
jest.mock("../../../../pages/api/user/_auth0", () => ({
getAppMetadata: jest.fn(),
setAppMetadata: jest.fn(),
}));
jest.mock("../../../../database/users", () => ({
findUser: jest.fn(),
createUser: jest.fn(),
}));
jest.mock("../../../../pages/api/_send-email", () => ({
sendEmail: jest.fn(),
}));
jest.mock("../../../../database/teams", () => ({ createTeam: jest.fn() }));
import { parse } from "set-cookie-parser";
import { callApiHandler } from "../../../../../jest/helpers";
import signInHandler from "../../../../pages/api/auth/sign-in";
import { sessionName } from "../../../../../lib/cookie-store";
import { sendEmail } from "../../../../pages/api/_send-email";
import { findUser, createUser } from "../../../../database/users";
import { createTeam } from "../../../../database/teams";
import { getAppMetadata } from "../../../../pages/api/user/_auth0";
describe("/api/auth/sign-in", () => {
const mockedSendEmail = sendEmail as jest.Mock<
ReturnType<typeof sendEmail>
>;
const mockedGetAppMetadata = getAppMetadata as jest.Mock<
ReturnType<typeof getAppMetadata>
>;
const mockedFindUser = findUser as jest.Mock<ReturnType<typeof findUser>>;
const mockedCreateUser = createUser as jest.Mock<
ReturnType<typeof createUser>
>;
const mockedCreateTeam = createTeam as jest.Mock<
ReturnType<typeof createTeam>
>;
beforeEach(() => {
mockedFindUser.mockClear();
mockedCreateUser.mockClear();
mockedGetAppMetadata.mockClear();
mockedSendEmail.mockClear();
mockedCreateTeam.mockClear();
});
test("responds 405 to GET", async () => {
const response = await callApiHandler(signInHandler, { method: "GET" });
expect(response.status).toBe(405);
});
test("responds 400 to POST with malformed body", async () => {
const response = await callApiHandler(signInHandler, {
method: "POST",
});
expect(response.status).toBe(400);
});
test("responds 200 to POST with body from email login", async () => {
mockedFindUser.mockResolvedValue({
id: "auth0|1234567",
teamId: "98765",
role: "owner",
email: "test@fss.dev",
name: "Groot",
createdAt: new Date(),
updatedAt: new Date(),
});
mockedGetAppMetadata.mockResolvedValue({ teamId: "98765" });
const body = {
accessToken:
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL21va2h0YXIuZXUuYXV0aDAuY29tLyIsInN1YiI6ImF1dGgwfDEyMzQ1NjciLCJhdWQiOlsiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS9hcGkvdjIvIiwiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS91c2VyaW5mbyJdLCJpYXQiOjE2MTkzMDMyNDUsImV4cCI6MTYxOTM4OTY0NSwiYXpwIjoiZUVWZm5rNkRCN2JDMzNOdUFvd3VjNTRmdXZZQm9OODQiLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIHJlYWQ6Y3VycmVudF91c2VyIHVwZGF0ZTpjdXJyZW50X3VzZXJfbWV0YWRhdGEgZGVsZXRlOmN1cnJlbnRfdXNlcl9tZXRhZGF0YSBjcmVhdGU6Y3VycmVudF91c2VyX21ldGFkYXRhIGNyZWF0ZTpjdXJyZW50X3VzZXJfZGV2aWNlX2NyZWRlbnRpYWxzIGRlbGV0ZTpjdXJyZW50X3VzZXJfZGV2aWNlX2NyZWRlbnRpYWxzIHVwZGF0ZTpjdXJyZW50X3VzZXJfaWRlbnRpdGllcyBvZmZsaW5lX2FjY2VzcyIsImd0eSI6InBhc3N3b3JkIn0",
idToken:
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJuaWNrbmFtZSI6InRlc3QiLCJuYW1lIjoiR3Jvb3QiLCJwaWN0dXJlIjoiaHR0cHM6Ly9zLmdyYXZhdGFyLmNvbS9hdmF0YXIvYTNiNWU5MjkzYWE1MjE1MTUxZTdjOWVhM2FlZjE4MGQ/cz00ODAmcj1wZyZkPWh0dHBzJTNBJTJGJTJGY2RuLmF1dGgwLmNvbSUyRmF2YXRhcnMlMkZnci5wbmciLCJ1cGRhdGVkX2F0IjoiMjAyMS0wNC0yNFQyMjoyNzoyNS43ODlaIiwiZW1haWwiOiJ0ZXN0QGZzcy5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaXNzIjoiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3IiwiYXVkIjoiZUVWZm5rNkRCN2JDMzNOdUFvd3VjNTRmdXZZQm9OODQiLCJpYXQiOjE2MTkzMDMyNDUsImV4cCI6MTYxOTMzOTI0NX0",
scope:
"openid profile email read:current_user update:current_user_metadata delete:current_user_metadata create:current_user_metadata create:current_user_device_credentials delete:current_user_device_credentials update:current_user_identities offline_access",
tokenType: "Bearer",
refreshToken:
"v1.Mb2-7pHz02BMS63hMwHhjFCq5KPy0L29ZENzKIr-KaIFuSxhqDvLTac-ZLwrbQR6KOYRq21d5R5QLvZfeKZMCGM",
expiresIn: 86400,
};
const response = await callApiHandler(signInHandler, {
method: "POST",
body,
});
expect(response.status).toBe(200);
const setCookieHeader = response.headers.get("set-cookie")!;
const parsedCookies = parse(setCookieHeader);
const cookieHasSession = parsedCookies.some((cookie) =>
cookie.name.match(`^${sessionName}(?:\\.\\d)?$`),
);
expect(cookieHasSession).toBe(true);
});
test("responds 200 to POST with body from 3rd party provider login", async () => {
mockedFindUser.mockResolvedValue({
id: "google-oauth2|103423079071922868186",
teamId: "98765",
role: "owner",
email: "fss.user@gmail.com",
name: "FSS User",
createdAt: new Date(),
updatedAt: new Date(),
});
mockedGetAppMetadata.mockResolvedValue({ teamId: "98765" });
const body = {
accessToken:
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL21va2h0YXIuZXUuYXV0aDAuY29tLyIsInN1YiI6Imdvb2dsZS1vYXV0aDJ8MTAzNDIzMDc5MDcxOTIyODY4MTg2IiwiYXVkIjpbImh0dHBzOi8vbW9raHRhci5ldS5hdXRoMC5jb20vYXBpL3YyLyIsImh0dHBzOi8vbW9raHRhci5ldS5hdXRoMC5jb20vdXNlcmluZm8iXSwiaWF0IjoxNjEzMjI4ODY4LCJleHAiOjE2MTMyMzYwNjgsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwgb2ZmbGluZV9hY2Nlc3MifQ",
idToken:
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJnaXZlbl9uYW1lIjoiRlNTIiwiZmFtaWx5X25hbWUiOiJVc2VyIiwibmlja25hbWUiOiJmc3MudXNlciIsIm5hbWUiOiJGU1MgVXNlciIsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vLXgycjhsd0ptUGpNL0FBQUFBQUFBQUFJL0FBQUFBQUFBQUFBL0FNWnV1Y25xLWFocW4tR2VyTHhwakEzODZDbi1kUEtyWkEvczk2LWMvcGhvdG8uanBnIiwibG9jYWxlIjoiZW4iLCJ1cGRhdGVkX2F0IjoiMjAyMS0wMi0xM1QxNTowNzo0OC4zNDlaIiwiZW1haWwiOiJmc3MudXNlckBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaXNzIjoiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS8iLCJzdWIiOiJnb29nbGUtb2F1dGgyfDEwMzQyMzA3OTA3MTkyMjg2ODE4NiIsImF1ZCI6ImF1ZGllbmNlIiwiaWF0IjoxNjEzMjI4ODY4LCJleHAiOjE2MTMyNjQ4NjgsImF0X2hhc2giOiJiQnFSWVlNUWJzUW5od1R5dGR0SmZBIiwibm9uY2UiOiJDeXJuVm1PU3Q0b0pwWkFDaTQwaXU1YUxON3JGM0JrayJ9.G-mNH6NegAJvaX77nijdrBAXJtNbwzyzLSFLvZOuRMojTxHaecwQyPw4oyj98fVx4K7Wvv7XuyTRcP54DsAiyXwaFCyCdU_X0aE058gmXxmD89udd2yWnz24DgjrNmR2EPqcXRZ5eqNH4_XtfhQAtUWhGpvBbmuLfrMphJLfzWn8rMJP185ahTosjrKl8Hun4nRb3IGYQcfOZzDv8JTki8p38tnVIxZA5QBXNDSxNYaoc2u6QsAd8srQ2aScotPuNG82YAECdQ6ySc-ODGtMQsCr3CwqHVhqUD2nyQtuZ1iiMKCcBKHGCVcuMvibKjKrAV-rFAiYccZ3b-AsmB_u6w",
idTokenPayload: {
given_name: "FSS",
family_name: "User",
nickname: "fss.user",
name: "FSS User",
picture:
"https://lh3.googleusercontent.com/-x2r8lwJmPjM/AAAAAAAAAAI/AAAAAAAAAAA/AMZuucnq-ahqn-GerLxpjA386Cn-dPKrZA/s96-c/photo.jpg",
locale: "en",
updated_at: "2021-02-13T15:07:48.349Z",
email: "fss.user@gmail.com",
email_verified: true,
iss: "https://mokhtar.eu.auth0.com/",
sub: "google-oauth2|103423079071922868186",
aud: "audience",
iat: 1613228868,
exp: 1613264868,
at_hash: "bBqRYYMQbsQnhwTytdtJfA",
nonce: "CyrnVmOSt4oJpZACi40iu5aLN7rF3Bkk",
},
appState: null,
refreshToken: null,
state: "xKre8N8V5iq4s4e6GPYvwpRc00WtIn7u",
expiresIn: 7200,
tokenType: "Bearer",
scope: "openid profile email offline_access",
};
const response = await callApiHandler(signInHandler, {
method: "POST",
body,
});
expect(response.status).toBe(200);
const setCookieHeader = response.headers.get("set-cookie")!;
const parsedCookies = parse(setCookieHeader);
const cookieHasSession = parsedCookies.some((cookie) =>
cookie.name.match(`^${sessionName}(?:\\.\\d)?$`),
);
expect(cookieHasSession).toBe(true);
});
test("responds 200 to POST with body from 3rd party provider login for the first time", async () => {
mockedFindUser.mockResolvedValue(undefined);
mockedCreateTeam.mockResolvedValue({
id: "98765",
subscriptionId: null,
teamMembersLimit: 1,
createdAt: new Date(),
updatedAt: new Date(),
});
mockedCreateUser.mockResolvedValue({
id: "google-oauth2|103423079071922868186",
teamId: "98765",
role: "owner",
email: "fss.user@gmail.com",
name: "FSS User",
createdAt: new Date(),
updatedAt: new Date(),
});
const body = {
accessToken:
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL21va2h0YXIuZXUuYXV0aDAuY29tLyIsInN1YiI6Imdvb2dsZS1vYXV0aDJ8MTAzNDIzMDc5MDcxOTIyODY4MTg2IiwiYXVkIjpbImh0dHBzOi8vbW9raHRhci5ldS5hdXRoMC5jb20vYXBpL3YyLyIsImh0dHBzOi8vbW9raHRhci5ldS5hdXRoMC5jb20vdXNlcmluZm8iXSwiaWF0IjoxNjEzMjI4ODY4LCJleHAiOjE2MTMyMzYwNjgsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwgb2ZmbGluZV9hY2Nlc3MifQ",
idToken:
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJnaXZlbl9uYW1lIjoiRlNTIiwiZmFtaWx5X25hbWUiOiJVc2VyIiwibmlja25hbWUiOiJmc3MudXNlciIsIm5hbWUiOiJGU1MgVXNlciIsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vLXgycjhsd0ptUGpNL0FBQUFBQUFBQUFJL0FBQUFBQUFBQUFBL0FNWnV1Y25xLWFocW4tR2VyTHhwakEzODZDbi1kUEtyWkEvczk2LWMvcGhvdG8uanBnIiwibG9jYWxlIjoiZW4iLCJ1cGRhdGVkX2F0IjoiMjAyMS0wMi0xM1QxNTowNzo0OC4zNDlaIiwiZW1haWwiOiJmc3MudXNlckBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaXNzIjoiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS8iLCJzdWIiOiJnb29nbGUtb2F1dGgyfDEwMzQyMzA3OTA3MTkyMjg2ODE4NiIsImF1ZCI6ImF1ZGllbmNlIiwiaWF0IjoxNjEzMjI4ODY4LCJleHAiOjE2MTMyNjQ4NjgsImF0X2hhc2giOiJiQnFSWVlNUWJzUW5od1R5dGR0SmZBIiwibm9uY2UiOiJDeXJuVm1PU3Q0b0pwWkFDaTQwaXU1YUxON3JGM0JrayJ9.G-mNH6NegAJvaX77nijdrBAXJtNbwzyzLSFLvZOuRMojTxHaecwQyPw4oyj98fVx4K7Wvv7XuyTRcP54DsAiyXwaFCyCdU_X0aE058gmXxmD89udd2yWnz24DgjrNmR2EPqcXRZ5eqNH4_XtfhQAtUWhGpvBbmuLfrMphJLfzWn8rMJP185ahTosjrKl8Hun4nRb3IGYQcfOZzDv8JTki8p38tnVIxZA5QBXNDSxNYaoc2u6QsAd8srQ2aScotPuNG82YAECdQ6ySc-ODGtMQsCr3CwqHVhqUD2nyQtuZ1iiMKCcBKHGCVcuMvibKjKrAV-rFAiYccZ3b-AsmB_u6w",
idTokenPayload: {
given_name: "FSS",
family_name: "User",
nickname: "fss.user",
name: "FSS User",
picture:
"https://lh3.googleusercontent.com/-x2r8lwJmPjM/AAAAAAAAAAI/AAAAAAAAAAA/AMZuucnq-ahqn-GerLxpjA386Cn-dPKrZA/s96-c/photo.jpg",
locale: "en",
updated_at: "2021-02-13T15:07:48.349Z",
email: "fss.user@gmail.com",
email_verified: true,
iss: "https://mokhtar.eu.auth0.com/",
sub: "google-oauth2|103423079071922868186",
aud: "audience",
iat: 1613228868,
exp: 1613264868,
at_hash: "bBqRYYMQbsQnhwTytdtJfA",
nonce: "CyrnVmOSt4oJpZACi40iu5aLN7rF3Bkk",
},
appState: null,
refreshToken: null,
state: "xKre8N8V5iq4s4e6GPYvwpRc00WtIn7u",
expiresIn: 7200,
tokenType: "Bearer",
scope: "openid profile email offline_access",
};
const response = await callApiHandler(signInHandler, {
method: "POST",
body,
});
expect(response.status).toBe(200);
expect(mockedSendEmail).toBeCalledTimes(1);
expect(mockedSendEmail.mock.calls[0][0].recipients[0]).toBe(
"fss.user@gmail.com",
);
const setCookieHeader = response.headers.get("set-cookie")!;
const parsedCookies = parse(setCookieHeader);
const cookieHasSession = parsedCookies.some((cookie) =>
cookie.name.match(`^${sessionName}(?:\\.\\d)?$`),
);
expect(cookieHasSession).toBe(true);
});
});

View File

@ -0,0 +1,95 @@
jest.mock("../../../../pages/api/user/_auth0", () => ({
setAppMetadata: jest.fn(),
}));
jest.mock("../../../../pages/api/_send-email", () => ({
sendEmail: jest.fn(),
}));
jest.mock("../../../../database/users", () => ({ createUser: jest.fn() }));
jest.mock("../../../../database/teams", () => ({ createTeam: jest.fn() }));
import { parse } from "set-cookie-parser";
import { callApiHandler } from "../../../../../jest/helpers";
import signUpHandler from "../../../../pages/api/auth/sign-up";
import { sessionName } from "../../../../../lib/cookie-store";
import { sendEmail } from "../../../../pages/api/_send-email";
import { createUser } from "../../../../database/users";
import { createTeam } from "../../../../database/teams";
describe("/api/auth/sign-up", () => {
const mockedSendEmail = sendEmail as jest.Mock<
ReturnType<typeof sendEmail>
>;
const mockedCreateUser = createUser as jest.Mock<
ReturnType<typeof createUser>
>;
const mockedCreateTeam = createTeam as jest.Mock<
ReturnType<typeof createTeam>
>;
beforeEach(() => {
mockedSendEmail.mockClear();
mockedCreateUser.mockClear();
mockedCreateTeam.mockClear();
});
test("responds 405 to GET", async () => {
const response = await callApiHandler(signUpHandler, { method: "GET" });
expect(response.status).toBe(405);
});
test("responds 400 to POST with malformed body", async () => {
const response = await callApiHandler(signUpHandler, {
method: "POST",
});
expect(response.status).toBe(400);
});
test("responds 200 to POST with body from email login", async () => {
mockedCreateUser.mockResolvedValue({
id: "auth0|1234567",
teamId: "98765",
role: "owner",
email: "test@fss.dev",
name: "Groot",
createdAt: new Date(),
updatedAt: new Date(),
});
mockedCreateTeam.mockResolvedValue({
id: "98765",
subscriptionId: null,
teamMembersLimit: 1,
createdAt: new Date(),
updatedAt: new Date(),
});
const body = {
accessToken:
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL21va2h0YXIuZXUuYXV0aDAuY29tLyIsInN1YiI6ImF1dGgwfDEyMzQ1NjciLCJhdWQiOlsiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS9hcGkvdjIvIiwiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS91c2VyaW5mbyJdLCJpYXQiOjE2MTkzMDMyNDUsImV4cCI6MTYxOTM4OTY0NSwiYXpwIjoiZUVWZm5rNkRCN2JDMzNOdUFvd3VjNTRmdXZZQm9OODQiLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIHJlYWQ6Y3VycmVudF91c2VyIHVwZGF0ZTpjdXJyZW50X3VzZXJfbWV0YWRhdGEgZGVsZXRlOmN1cnJlbnRfdXNlcl9tZXRhZGF0YSBjcmVhdGU6Y3VycmVudF91c2VyX21ldGFkYXRhIGNyZWF0ZTpjdXJyZW50X3VzZXJfZGV2aWNlX2NyZWRlbnRpYWxzIGRlbGV0ZTpjdXJyZW50X3VzZXJfZGV2aWNlX2NyZWRlbnRpYWxzIHVwZGF0ZTpjdXJyZW50X3VzZXJfaWRlbnRpdGllcyBvZmZsaW5lX2FjY2VzcyIsImd0eSI6InBhc3N3b3JkIn0",
idToken:
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJuaWNrbmFtZSI6InRlc3QiLCJuYW1lIjoiR3Jvb3QiLCJwaWN0dXJlIjoiaHR0cHM6Ly9zLmdyYXZhdGFyLmNvbS9hdmF0YXIvYTNiNWU5MjkzYWE1MjE1MTUxZTdjOWVhM2FlZjE4MGQ/cz00ODAmcj1wZyZkPWh0dHBzJTNBJTJGJTJGY2RuLmF1dGgwLmNvbSUyRmF2YXRhcnMlMkZnci5wbmciLCJ1cGRhdGVkX2F0IjoiMjAyMS0wNC0yNFQyMjoyNzoyNS43ODlaIiwiZW1haWwiOiJ0ZXN0QGZzcy5kZXYiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaXNzIjoiaHR0cHM6Ly9tb2todGFyLmV1LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3IiwiYXVkIjoiZUVWZm5rNkRCN2JDMzNOdUFvd3VjNTRmdXZZQm9OODQiLCJpYXQiOjE2MTkzMDMyNDUsImV4cCI6MTYxOTMzOTI0NX0",
scope:
"openid profile email read:current_user update:current_user_metadata delete:current_user_metadata create:current_user_metadata create:current_user_device_credentials delete:current_user_device_credentials update:current_user_identities offline_access",
tokenType: "Bearer",
refreshToken:
"v1.Mb2-7pHz02BMS63hMwHhjFCq5KPy0L29ZENzKIr-KaIFuSxhqDvLTac-ZLwrbQR6KOYRq21d5R5QLvZfeKZMCGM",
expiresIn: 86400,
};
const response = await callApiHandler(signUpHandler, {
method: "POST",
body,
});
expect(response.status).toBe(200);
expect(mockedSendEmail).toBeCalledTimes(1);
expect(mockedSendEmail.mock.calls[0][0].recipients[0]).toBe(
"test@fss.dev",
);
const setCookieHeader = response.headers.get("set-cookie")!;
const parsedCookies = parse(setCookieHeader);
const cookieHasSession = parsedCookies.some((cookie) =>
cookie.name.match(`^${sessionName}(?:\\.\\d)?$`),
);
expect(cookieHasSession).toBe(true);
});
});

View File

@ -0,0 +1,164 @@
jest.mock("../../../../database/teams", () => ({
findTeam: jest.fn(),
updateTeam: jest.fn(),
}));
jest.mock("../../../../database/subscriptions", () => ({
...jest.requireActual("../../../../database/subscriptions"),
createSubscription: jest.fn(),
findTeamSubscription: jest.fn(),
updateSubscription: jest.fn(),
}));
jest.mock("../../../../pages/api/_send-email", () => ({
sendEmail: jest.fn(),
}));
jest.mock("../../../../subscription/plans", () => ({
PAID_PLANS: {
"229": { teamMembersLimit: 2 },
},
}));
import { subscriptionCreatedHandler } from "../../../../pages/api/subscription/_subscription-created";
import { callApiHandler } from "../../../../../jest/helpers";
import { findTeam, updateTeam } from "../../../../database/teams";
import {
createSubscription,
findUserSubscription,
updateSubscription,
} from "../../../../database/subscriptions";
import { sendEmail } from "../../../../pages/api/_send-email";
describe("subscription_created webhook event", () => {
const mockedSendEmail = sendEmail as jest.Mock<
ReturnType<typeof sendEmail>
>;
const mockedFindTeam = findTeam as jest.Mock<ReturnType<typeof findTeam>>;
const mockedUpdateTeam = updateTeam as jest.Mock<
ReturnType<typeof updateTeam>
>;
const mockedCreateSubscription = createSubscription as jest.Mock<
ReturnType<typeof createSubscription>
>;
const mockedFindTeamSubscription = findUserSubscription as jest.Mock<
ReturnType<typeof findUserSubscription>
>;
const mockedUpdateSubscription = updateSubscription as jest.Mock<
ReturnType<typeof updateSubscription>
>;
mockedSendEmail.mockResolvedValue();
beforeEach(() => {
mockedSendEmail.mockClear();
mockedFindTeam.mockClear();
mockedUpdateTeam.mockClear();
mockedCreateSubscription.mockClear();
mockedFindTeamSubscription.mockClear();
mockedUpdateSubscription.mockClear();
});
test("responds 400 to malformed event", async () => {
const { status } = await callApiHandler(subscriptionCreatedHandler, {
method: "POST",
body: {},
});
expect(status).toBe(400);
});
test("responds 404 to valid event with unknown team", async () => {
const teamId = "123";
const subscriptionId = "222";
const planId = "229";
const event = {
alert_id: 1789225139,
alert_name: "subscription_created",
cancel_url:
"https://checkout.paddle.com/subscription/cancel?user=4&subscription=9&hash=098bc6b2f641b4f7595fead9f566682f8c512eb0",
checkout_id: "4-d4d49ef5de45892-d6b186adb1",
currency: "GBP",
email: "reichert.arnaldo@example.net",
event_time: "2021-05-07 13:50:58",
linked_subscriptions: "6, 8, 7",
marketing_consent: undefined,
next_bill_date: "2021-06-02",
passthrough: `{"teamId":"${teamId}"}`,
quantity: 16,
source: "Activation",
status: "active",
subscription_id: subscriptionId,
subscription_plan_id: planId,
unit_price: "unit_price",
update_url:
"https://checkout.paddle.com/subscription/update?user=6&subscription=5&hash=018ca7a6b63aaf4c68b7405735084788a3cdd5c6",
user_id: "9",
p_signature:
"Pi/tWLioiCwtTa5HU7N29H1AEDXhfH6+YiBGzu4jxqmXOHZXWVQz0sFMkh4z3Ykp79WgChanGm6kysHk96eGGgM5cg7Y6TCXYFnwHhQdNkkQTPpNrDGbKXdJxj7JJNqa0JxTamMRIXi0o6Azdr2rOgvm+6jQ/FULtZxyqUJSlnm9UrC/QKwPpajtIMUvZy4uSUZnGQl5ynisoyazfFMN3YJ5TMDm0K5Yxx6RC0b+G5AItub900s3jjr41VYhm7svwE/jUCeeNoKT/CIrvBDgWTrqdQYVscTtiSkss9DguDA8yWx2jmzR+fobIxunH3EZ5j7dPFu8WgYtfxeeaaKyChXdl0ubjw2Jwq9PfXjClZnQj6zcEi947329oXN42/lD9FCDbiDkzIiOvOH+RNc3pbPTFfWekcHsc4GEfs2u0ahQ8SbEsLNkki+zF2kaUZrP3qGALnUeHqdSfqivwlEzrb8Qu0Kj6VZfA4zMyAGwgIi2UOFTbXpdck1VJAc0+nafGom9gqTtmqRHwaroKGNKJ7t7AIgjcHZ8I8cgM5Q+OB1i7/JF8aA/WMe4jTdprxeda1XYHCHop+lmwFcSbCc95ZTeD+A0XyGB824eBNU4VTeWfvGhrFNU94qKZXWSq29fl04XaI3hKS1fGbERJ3dz5DUyEU9KpBjSQ+h2MKdbCNw=",
};
mockedFindTeam.mockResolvedValueOnce(undefined);
const { status } = await callApiHandler(subscriptionCreatedHandler, {
method: "POST",
body: event,
});
expect(status).toBe(404);
expect(mockedCreateSubscription).toHaveBeenCalledTimes(0);
});
test("responds 200 to valid event", async () => {
const teamId = "123";
const subscriptionId = "222";
const planId = "229";
const event = {
alert_id: 1789225139,
alert_name: "subscription_created",
cancel_url:
"https://checkout.paddle.com/subscription/cancel?user=4&subscription=9&hash=098bc6b2f641b4f7595fead9f566682f8c512eb0",
checkout_id: "4-d4d49ef5de45892-d6b186adb1",
currency: "GBP",
email: "reichert.arnaldo@example.net",
event_time: "2021-05-07 13:50:58",
linked_subscriptions: "6, 8, 7",
marketing_consent: undefined,
next_bill_date: "2021-06-02",
passthrough: `{"teamId":"${teamId}"}`,
quantity: 16,
source: "Activation",
status: "active",
subscription_id: subscriptionId,
subscription_plan_id: planId,
unit_price: "unit_price",
update_url:
"https://checkout.paddle.com/subscription/update?user=6&subscription=5&hash=018ca7a6b63aaf4c68b7405735084788a3cdd5c6",
user_id: "9",
p_signature:
"Pi/tWLioiCwtTa5HU7N29H1AEDXhfH6+YiBGzu4jxqmXOHZXWVQz0sFMkh4z3Ykp79WgChanGm6kysHk96eGGgM5cg7Y6TCXYFnwHhQdNkkQTPpNrDGbKXdJxj7JJNqa0JxTamMRIXi0o6Azdr2rOgvm+6jQ/FULtZxyqUJSlnm9UrC/QKwPpajtIMUvZy4uSUZnGQl5ynisoyazfFMN3YJ5TMDm0K5Yxx6RC0b+G5AItub900s3jjr41VYhm7svwE/jUCeeNoKT/CIrvBDgWTrqdQYVscTtiSkss9DguDA8yWx2jmzR+fobIxunH3EZ5j7dPFu8WgYtfxeeaaKyChXdl0ubjw2Jwq9PfXjClZnQj6zcEi947329oXN42/lD9FCDbiDkzIiOvOH+RNc3pbPTFfWekcHsc4GEfs2u0ahQ8SbEsLNkki+zF2kaUZrP3qGALnUeHqdSfqivwlEzrb8Qu0Kj6VZfA4zMyAGwgIi2UOFTbXpdck1VJAc0+nafGom9gqTtmqRHwaroKGNKJ7t7AIgjcHZ8I8cgM5Q+OB1i7/JF8aA/WMe4jTdprxeda1XYHCHop+lmwFcSbCc95ZTeD+A0XyGB824eBNU4VTeWfvGhrFNU94qKZXWSq29fl04XaI3hKS1fGbERJ3dz5DUyEU9KpBjSQ+h2MKdbCNw=",
};
mockedFindTeam.mockResolvedValueOnce({
id: teamId,
subscriptionId: null,
teamMembersLimit: 1,
createdAt: new Date(),
updatedAt: new Date(),
});
const { status } = await callApiHandler(subscriptionCreatedHandler, {
method: "POST",
body: event,
});
expect(status).toBe(200);
expect(mockedCreateSubscription).toHaveBeenCalledTimes(1);
expect(mockedUpdateTeam).toHaveBeenCalledWith({
id: teamId,
subscriptionId,
teamMembersLimit: 2,
});
expect(mockedSendEmail.mock.calls[0][0].recipients).toStrictEqual([
event.email,
]);
});
});

View File

@ -0,0 +1,111 @@
jest.mock(
"../../../../pages/api/subscription/_subscription-payment-succeeded",
() => ({
subscriptionPaymentSucceededHandler: jest.fn(),
}),
);
import type { NextApiResponse } from "next";
import { subscriptionPaymentSucceededHandler } from "../../../../pages/api/subscription/_subscription-payment-succeeded";
import { callApiHandler } from "../../../../../jest/helpers";
import webhookHandler from "../../../../pages/api/subscription/webhook";
describe("/api/subscription/webhook", () => {
const mockedSubscriptionPaymentSucceededHandler = subscriptionPaymentSucceededHandler as jest.Mock<
ReturnType<typeof subscriptionPaymentSucceededHandler>
>;
mockedSubscriptionPaymentSucceededHandler.mockImplementation(
async (_, res: NextApiResponse) => res.status(200).end(),
);
beforeEach(() => {
mockedSubscriptionPaymentSucceededHandler.mockClear();
});
test("responds 405 to GET", async () => {
const { status } = await callApiHandler(webhookHandler, {
method: "GET",
});
expect(status).toBe(405);
});
test("responds 500 to POST with invalid webhook event", async () => {
const response = await callApiHandler(webhookHandler, {
method: "POST",
body: {},
});
expect(response.status).toBe(500);
});
test("responds 400 to POST with unsupported webhook event", async () => {
const response = await callApiHandler(webhookHandler, {
method: "POST",
body: payoutPaid,
});
expect(response.status).toBe(400);
});
test("responds 200 to POST with supported and valid webhook event", async () => {
const response = await callApiHandler(webhookHandler, {
method: "POST",
body: subscriptionPaymentSucceeded,
});
expect(response.status).toBe(200);
expect(mockedSubscriptionPaymentSucceededHandler).toHaveBeenCalledTimes(
1,
);
});
});
const payoutPaid = {
alert_id: 833499511,
alert_name: "transfer_paid",
amount: 648.8,
currency: "USD",
event_time: "2021-05-07 00:29:50",
payout_id: 6,
status: "paid",
p_signature:
"p5AwTrjZPgczHkU8CHiUc7VH1mn8FLH+s+JUaNqrlY7xhaD+KG2Aq6njnwH4Q+xGN51pwpFZDpjBI6EZIsYlP/Rs3GWObJU7I2xOpvLXIrvjMDeIgNVL2s+BWeqqzylFYGsH1uKHQIFa5fm/JiUEErHecoNyk3GcwP7j2qeiHra64i+mjhzKsprUd4NUlhxD7nEpfRpM7aMuMii7WE/EGBBW12bxiJCRcrm0yuSrDLTZCbiOnK6ddPqsYrSPjWJjSOFXblQK+erOTuvOZuRaf5eiZodbiOyeGsgZ/AhfqXiWt0bOpbuqgMkofUJSgz5AV3y3HgqxhhsrXCTRgdexr/6Cx7+k1mm2AWMhuTn3DU3+2eDkiNIeP52hPtjx6h/Kxbb7/OoxYB9rfDT42m553nPbWxdSGw6Zz5h2oWOH0goFAFMi9CSXS+HilXpmKWc2KjIFYyu8Yu+3lZ2KAMWPwDEc8liQsWZVSo/R4SXcd3t5p+k3uhFwRkwIoeF7If25MQADEBK1s84p5tZTgo4EPkqEwRYZdRiTBZ+xzrrEOvsAA192hEXcjWRnFlqYeMITY/j2rf/ZTlXXbLw1Bcje1vr27z3Qe64GP4m4Whrh37N0kOkSElMXnCMx8fj3WgyMyHZhKGE96t+sfuA1NJy/dGl968uJIz1XVWh9F+6fcGo=",
};
const subscriptionPaymentSucceeded = {
alert_id: 1667920177,
alert_name: "subscription_payment_succeeded",
balance_currency: "USD",
balance_earnings: 791.71,
balance_fee: 774.49,
balance_gross: 102.03,
balance_tax: 282.55,
checkout_id: "4-599cbbe6fe49dc0-4c628740d6",
country: "DE",
coupon: "Coupon 5",
currency: "USD",
customer_name: "customer_name",
earnings: 253.39,
email: "baron.daugherty@example.org",
event_time: "2021-05-07 00:18:15",
fee: 0.11,
initial_payment: true,
instalments: 6,
marketing_consent: 1,
next_bill_date: "2021-05-23",
next_payment_amount: "next_payment_amount",
order_id: 7,
passthrough: "Example String",
payment_method: "card",
payment_tax: 0.69,
plan_name: "Example String",
quantity: 62,
receipt_url: "https://my.paddle.com/receipt/1/a18b96518813baa-a470ddf641",
sale_gross: 556.08,
status: "trialing",
subscription_id: 3,
subscription_payment_id: 4,
subscription_plan_id: 9,
unit_price: "unit_price",
user_id: 5,
p_signature:
"eucBVrNR/4KySSm+sSGwcBcaCXXZFEyTi4OY0nCxAEeGAc3QaBpGI8r+Ma3J4i7XmKOSYxalDx2nuXB2igqomg9YPQirmcgFOECX8NFDLvZeu3/V7SYuEeGHLmjZFyOSK8htwGVheTzQiFGbGq8ALPD1vgb0CME2iulfLC7kiRGut8enpLWUGSXlzXP0AVvxWkS7MyT0EQEE+b62EDEavyds2YaS7/tWQVoKBuHeWm7JqjdbEg4b+ht7ev9ns2RgyGNxsRs3+w9rpL8uAIzib7m24aWqqfBoB2kMhJvM6csfgqDZ6gF3nOG2PE1VJzD4G2Y0RJsZPC3BboQmE//RIS1UdyxKEwGHi8cDPIJIIzn31xx42uJulyX69w0JihBnTfasuEXy9gZKB96XCsMmks9nBQZAi+ZNteBfT7unToXLMwHn0mPDTUj+NpEWjTdIUCL6JM4Ewk3cDTs9tleo0TAXxikk06YnjJbGxL7mEwofB31rFlUyzmkKtf935TMGGe4cbhBdGcLaImithNyo48mWQvTg8F2yvIa6vZ3rmbGL6oNe3GT8q7r+HBLdatv5uDoomboZqh7dsNEmpv6VwJtmeNEoQs8//VD/MCcLFPaKCZp8QmYBwvYXdVunxSwwCF6rwEm77U8Jo/2Ua7giCQj+ekkgJ7uE4ubo10lB5bE=",
};

View File

@ -0,0 +1,90 @@
jest.mock("../../../../pages/api/_send-email", () => ({
sendEmail: jest.fn(),
}));
jest.mock("../../../../database/users", () => ({
createInvitedUser: jest.fn(),
findUserByEmail: jest.fn(),
}));
jest.mock("../../../../pages/api/user/_auth0", () => ({
createAuth0User: jest.fn(),
}));
import inviteMemberHandler from "../../../../pages/api/team/invite-member";
import { callApiHandler } from "../../../../../jest/helpers";
import { sendEmail } from "../../../../pages/api/_send-email";
import { createInvitedUser, findUserByEmail } from "../../../../database/users";
import { createAuth0User } from "../../../../pages/api/user/_auth0";
describe("/api/team/invite-member", () => {
const mockedSendEmail = sendEmail as jest.Mock<
ReturnType<typeof sendEmail>
>;
const mockedCreateInvitedUser = createInvitedUser as jest.Mock<
ReturnType<typeof createInvitedUser>
>;
const mockedFindUserByEmail = findUserByEmail as jest.Mock<
ReturnType<typeof findUserByEmail>
>;
const mockedCreateAuth0User = createAuth0User as jest.Mock<
ReturnType<typeof createAuth0User>
>;
mockedSendEmail.mockResolvedValue();
beforeEach(() => {
mockedSendEmail.mockClear();
mockedCreateInvitedUser.mockClear();
mockedFindUserByEmail.mockClear();
mockedCreateAuth0User.mockClear();
});
test("responds 405 to GET", async () => {
const { status } = await callApiHandler(inviteMemberHandler, {
method: "GET",
authentication: "auth0",
});
expect(status).toBe(405);
});
test("responds 400 to POST with malformed body", async () => {
const { status } = await callApiHandler(inviteMemberHandler, {
method: "POST",
authentication: "auth0",
body: {},
});
expect(status).toBe(400);
});
test("responds 500 to POST with valid body but already taken email address", async () => {
const inviteeEmail = "test@fss.dev";
mockedFindUserByEmail.mockResolvedValueOnce({
email: inviteeEmail,
} as any);
const body = { inviteeEmail };
const { status } = await callApiHandler(inviteMemberHandler, {
method: "POST",
authentication: "auth0",
body,
});
expect(status).toBe(500);
});
test("responds 200 to POST with valid body", async () => {
const inviteeUserId = "2";
const inviteeEmail = "test@fss.dev";
mockedCreateAuth0User.mockResolvedValueOnce({ user_id: inviteeUserId });
const body = { inviteeEmail };
const { status } = await callApiHandler(inviteMemberHandler, {
method: "POST",
authentication: "auth0",
body,
});
expect(status).toBe(200);
expect(mockedSendEmail.mock.calls[0][0].recipients).toStrictEqual([
inviteeEmail,
]);
expect(mockedCreateInvitedUser).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,21 @@
import { callApiHandler } from "../../../../../jest/helpers";
import sessionHandler from "../../../../pages/api/user/session";
describe("/api/user/session", () => {
test("responds 405 to POST", async () => {
const { status } = await callApiHandler(sessionHandler, {
method: "POST",
authentication: "auth0",
});
expect(status).toBe(405);
});
test("responds 200 with session to GET", async () => {
const response = await callApiHandler(sessionHandler, {
method: "GET",
authentication: "auth0",
});
const session = await response.json();
expect(session.user).toBeDefined();
});
});

View File

@ -0,0 +1,112 @@
jest.mock("auth0", () => ({
ManagementClient: jest.fn(),
}));
jest.mock("openid-client", () => ({
Issuer: {
discover: jest.fn().mockImplementation(() => ({
Client: jest.fn().mockImplementation(() => ({
refresh: jest.fn().mockImplementation(() => ({
claims: jest.fn().mockResolvedValue({}),
})),
})),
})),
},
}));
jest.mock("../../../../database/users", () => ({
findUser: jest.fn(),
updateUser: jest.fn(),
}));
import { ManagementClient } from "auth0";
import { callApiHandler } from "../../../../../jest/helpers";
import updateUserHandler from "../../../../pages/api/user/update-user";
import { findUser, updateUser } from "../../../../database/users";
describe("/api/user/update-user", () => {
const mockedManagementClient = ManagementClient as ReturnType<
typeof jest.fn
>;
const mockedUpdateAuth0User = jest.fn();
mockedManagementClient.mockImplementation(() => ({
updateUser: mockedUpdateAuth0User,
}));
const mockedFindUser = findUser as ReturnType<typeof jest.fn>;
const mockedUpdateUser = updateUser as ReturnType<typeof jest.fn>;
mockedFindUser.mockImplementation(() =>
Promise.resolve({
id: "auth0|1234567",
email: "test@fss.dev",
name: "Groot",
createdAt: new Date(),
updatedAt: new Date(),
}),
);
beforeEach(() => {
mockedUpdateAuth0User.mockClear();
mockedFindUser.mockClear();
mockedUpdateUser.mockClear();
});
test("responds 401 to unauthenticated request", async () => {
const response = await callApiHandler(updateUserHandler, {
method: "POST",
});
expect(response.status).toBe(401);
});
test("responds 405 to authenticated GET", async () => {
const response = await callApiHandler(updateUserHandler, {
method: "GET",
authentication: "auth0",
});
expect(response.status).toBe(405);
});
test("responds 400 to authenticated POST with malformed body", async () => {
const body = { name: "", email: "", password: "" };
const response = await callApiHandler(updateUserHandler, {
method: "POST",
authentication: "auth0",
body,
});
expect(response.status).toBe(400);
});
test("updates user password and responds 200 to authenticated POST", async () => {
const body = { name: "", email: "", password: "dddddd" };
const response = await callApiHandler(updateUserHandler, {
method: "POST",
authentication: "auth0",
body,
});
expect(response.status).toBe(200);
expect(mockedUpdateAuth0User).toBeCalledTimes(1);
});
test("updates both user password & email and responds 200 to authenticated POST", async () => {
const body = { name: "", email: "test@fss.xyz", password: "dddddd" };
const response = await callApiHandler(updateUserHandler, {
method: "POST",
authentication: "auth0",
body,
});
expect(response.status).toBe(200);
expect(mockedUpdateAuth0User).toBeCalledTimes(2);
});
test("responds 403 to authenticated POST when updating email for a 3rd party-authenticated user", async () => {
const body = { name: "", email: "test@fss.xyz", password: "dddddd" };
const response = await callApiHandler(updateUserHandler, {
method: "POST",
authentication: "google-oauth2",
body,
});
expect(response.status).toBe(403);
expect(mockedUpdateAuth0User).toBeCalledTimes(0);
});
});

View File

@ -0,0 +1,51 @@
/**
* @jest-environment jsdom
*/
jest.mock("next/router", () => ({
useRouter: jest.fn().mockImplementation(() => ({ query: {} })),
}));
jest.mock("../../../hooks/use-auth");
import { rest } from "msw";
import { setupServer } from "msw/node";
import userEvent from "@testing-library/user-event";
import { render, screen, waitFor } from "../../../../jest/testing-library";
import useAuth from "../../../hooks/use-auth";
import SignInPage from "../../../pages/auth/sign-in";
describe("/auth/sign-in", () => {
type RequestBody = {
email: string;
password: string;
};
const mockedUseAuth = useAuth as ReturnType<typeof jest.fn>;
const mockedSignIn = jest.fn();
mockedUseAuth.mockImplementation(() => ({
signIn: mockedSignIn,
socialProviders: [],
}));
const server = setupServer(
rest.post<RequestBody>("/api/auth/sign-in", (req, res, ctx) => {
return res(ctx.status(200));
}),
);
beforeEach(() => mockedUseAuth.mockClear());
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test("sign in with email", async () => {
render(<SignInPage />);
userEvent.type(screen.getByLabelText("Email address"), "test@fss.dev");
userEvent.type(screen.getByLabelText(/^Password/)!, "password{enter}");
await waitFor(() => expect(mockedSignIn).toBeCalledTimes(1));
});
});

View File

@ -0,0 +1,46 @@
/**
* @jest-environment jsdom
*/
import { rest } from "msw";
import { setupServer } from "msw/node";
import { render, screen } from "../../../jest/testing-library";
import { waitFor } from "@testing-library/dom";
import userEvent from "@testing-library/user-event";
import Index from "../../pages";
describe("/", () => {
test("landing page snapshot", () => {
const { asFragment } = render(<Index />);
expect(asFragment()).toMatchSnapshot();
});
describe("subscribe to newsletter", () => {
const server = setupServer(
rest.post("/api/newsletter/subscribe", (req, res, ctx) => {
return res(ctx.status(200));
}),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test("should display successful message after subscribing", async () => {
render(<Index />);
userEvent.type(
screen.getByPlaceholderText("Email address"),
"test@fss.dev{enter}",
);
await waitFor(() =>
expect(
screen.getByText(
"Thanks! We'll let you know when we launch",
),
).toBeInTheDocument(),
);
});
});
});

View File

@ -0,0 +1,131 @@
/**
* @jest-environment jsdom
*/
jest.mock("next/router", () => ({
useRouter: jest.fn().mockImplementation(() => ({ query: {} })),
}));
jest.mock("../../../hooks/use-auth");
jest.mock("../../../database/users", () => ({
findTeamOwner: jest.fn(),
findUser: jest.fn(),
}));
import { rest } from "msw";
import { setupServer } from "msw/node";
import userEvent from "@testing-library/user-event";
import { render, screen, waitFor } from "../../../../jest/testing-library";
import InvitationPage, {
getServerSideProps,
} from "../../../pages/team/invitation";
import useAuth from "../../../hooks/use-auth";
import { findTeamOwner, findUser } from "../../../database/users";
import { generateSignInToken } from "../../../pages/api/team/_invite";
describe("/team/invitation", () => {
type RequestBody = {
token: string;
name: string;
email: string;
password: string;
};
const mockedFindTeamOwner = findTeamOwner as jest.Mock<
ReturnType<typeof findTeamOwner>
>;
const mockedFindUser = findUser as jest.Mock<ReturnType<typeof findUser>>;
const mockedUseAuth = useAuth as ReturnType<typeof jest.fn>;
const mockedSignIn = jest.fn();
mockedUseAuth.mockImplementation(() => ({
signIn: mockedSignIn,
socialProviders: [],
}));
const server = setupServer(
rest.post<RequestBody>(
"/api/team/accept-invitation",
(req, res, ctx) => {
return res(ctx.status(200));
},
),
);
beforeEach(() => {
mockedUseAuth.mockClear();
mockedFindTeamOwner.mockClear();
mockedFindUser.mockClear();
});
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
const inviteeEmail = "test@fss.dev";
const teamId = "123";
const teamOwner: any = {
name: "Groot",
};
test("accept invitation", async () => {
render(
<InvitationPage
email={inviteeEmail}
teamId={teamId}
teamOwner={teamOwner}
/>,
);
userEvent.type(screen.getByLabelText("Name"), "John Doe");
userEvent.type(screen.getByLabelText(/^Password/)!, "password{enter}");
await waitFor(() => expect(mockedSignIn).toBeCalledTimes(1));
});
describe("getServerSideProps", () => {
const baseContext: any = {
req: {},
res: {},
resolvedUrl: "/team/invitation",
};
test("decode token and return props", async () => {
const userId = "111";
const invitedUser: any = {
id: userId,
email: inviteeEmail,
teamId,
pendingInvitation: true,
};
mockedFindTeamOwner.mockResolvedValueOnce(teamOwner);
mockedFindUser.mockResolvedValueOnce(invitedUser);
const token = await generateSignInToken({ teamId, userId });
const context = {
...baseContext,
query: { token },
};
const serverSideProps = await getServerSideProps(context);
expect(serverSideProps).toStrictEqual({
props: {
email: inviteeEmail,
teamId,
teamOwner,
},
});
});
test("redirect to sign in page if token is invalid", async () => {
const context = {
...baseContext,
query: { token: "" },
};
const serverSideProps = await getServerSideProps(context);
expect(serverSideProps).toStrictEqual({
redirect: {
permanent: false,
destination: "/auth/sign-in?error=invalid-invitation",
},
});
});
});
});

115
src/components/alert.tsx Normal file
View File

@ -0,0 +1,115 @@
import type { ReactElement } from "react";
type AlertVariant = "error" | "success" | "info" | "warning";
type AlertVariantProps = {
backgroundColor: string;
icon: ReactElement;
titleTextColor: string;
messageTextColor: string;
};
type Props = {
title: string;
message: string;
variant: AlertVariant;
};
const ALERT_VARIANTS: Record<AlertVariant, AlertVariantProps> = {
error: {
backgroundColor: "bg-red-50",
icon: (
<svg
className="h-5 w-5 text-red-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
),
titleTextColor: "text-red-800",
messageTextColor: "text-red-700",
},
success: {
backgroundColor: "bg-green-50",
icon: (
<svg
className="h-5 w-5 text-green-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
),
titleTextColor: "text-green-800",
messageTextColor: "text-green-700",
},
info: {
backgroundColor: "bg-primary-50",
icon: (
<svg
className="h-5 w-5 text-primary-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
),
titleTextColor: "text-primary-800",
messageTextColor: "text-primary-700",
},
warning: {
backgroundColor: "bg-yellow-50",
icon: (
<svg
className="h-5 w-5 text-yellow-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
),
titleTextColor: "text-yellow-800",
messageTextColor: "text-yellow-700",
},
};
export default function Alert({ title, message, variant }: Props) {
const variantProperties = ALERT_VARIANTS[variant];
return (
<div className={`rounded-md p-4 ${variantProperties.backgroundColor}`}>
<div className="flex">
<div className="flex-shrink-0">{variantProperties.icon}</div>
<div className="ml-3">
<h3
className={`text-sm leading-5 font-medium ${variantProperties.titleTextColor}`}
>
{title}
</h3>
<div
className={`mt-2 text-sm leading-5 ${variantProperties.messageTextColor}`}
>
{message}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,224 @@
import type { ReactNode } from "react";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import clsx from "clsx";
import { useForm } from "react-hook-form";
import Alert from "../alert";
import useAuth from "../../hooks/use-auth";
import appLogger from "../../../lib/logger";
import Logo from "../logo";
type Props = {
authType: "signIn" | "signUp";
};
const logger = appLogger.child({ module: "AuthPage" });
type Form = {
name: string;
email: string;
password: string;
};
function AuthPage({ authType }: Props) {
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const auth = useAuth();
const router = useRouter();
const { register, handleSubmit } = useForm<Form>();
const [errorMessage, setErrorMessage] = useState("");
const texts = TEXTS[authType];
let redirectTo: string;
if (Array.isArray(router.query.redirectTo)) {
redirectTo = router.query.redirectTo[0];
} else {
redirectTo = router.query.redirectTo ?? "/messages";
}
const onSubmit = handleSubmit(async ({ email, password, name }) => {
setErrorMessage("");
if (isSubmitting) {
return;
}
setIsSubmitting(true);
const params = { email, password, name, redirectTo };
try {
if (authType === "signIn") {
await auth.signIn(params);
}
if (authType === "signUp") {
await auth.signUp(params);
}
} catch (error) {
logger.error(error);
console.log("error", error);
setErrorMessage(
error.isAxiosError ?
error.response.data.errorMessage :
error.message
);
setIsSubmitting(false);
}
});
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="flex flex-col sm:mx-auto sm:w-full sm:max-w-sm">
<Logo className="mx-auto h-8 w-8" />
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">
{texts.title}
</h2>
<p className="mt-2 text-center text-sm leading-5 text-gray-600">
{texts.subtitle}
</p>
</div>
{errorMessage ? (
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-sm">
<Alert
title="Oops, there was an issue"
message={errorMessage}
variant="error"
/>
</div>
) : null}
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-sm">
<div className="py-8 px-4">
<form onSubmit={onSubmit}>
{authType === "signUp" ? (
<div className="mb-6">
<label
htmlFor="name"
className="block text-sm font-medium leading-5 text-gray-700"
>
Name
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="name"
type="text"
tabIndex={1}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
{...register("name")}
required
/>
</div>
</div>
) : null}
<div className="mb-6">
<label
htmlFor="email"
className="block text-sm font-medium leading-5 text-gray-700"
>
Email address
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="email"
type="email"
tabIndex={1}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
{...register("email")}
required
/>
</div>
</div>
<div>
<label
htmlFor="password"
className="flex justify-between text-sm font-medium leading-5 text-gray-700"
>
<div>Password</div>
{authType === "signIn" ? (
<div>
<Link href="/auth/forgot-password">
<a className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline transition ease-in-out duration-150">
Forgot your password?
</a>
</Link>
</div>
) : null}
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="password"
type="password"
tabIndex={2}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
{...register("password")}
required
/>
</div>
</div>
<div className="mt-6">
<span className="block w-full rounded-md shadow-sm">
<button
type="submit"
className={clsx(
"w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white focus:outline-none focus:border-primary-700 focus:shadow-outline-primary transition duration-150 ease-in-out",
{
"bg-primary-400 cursor-not-allowed": isSubmitting,
"bg-primary-600 hover:bg-primary-700": !isSubmitting,
},
)}
disabled={isSubmitting}
>
{isSubmitting
? "Loading..."
: texts.actionButton}
</button>
</span>
</div>
</form>
</div>
</div>
</div>
);
}
export default AuthPage;
type Texts = {
title: string;
subtitle: ReactNode;
actionButton: string;
};
const TEXTS: Record<Props["authType"], Texts> = {
signUp: {
title: "Create your account",
subtitle: (
<Link href="/auth/sign-in">
<a className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline transition ease-in-out duration-150">
Already have an account?
</a>
</Link>
),
actionButton: "Sign up",
},
signIn: {
title: "Welcome back!",
subtitle: (
<>
Need an account?&nbsp;
<Link href="/auth/sign-up">
<a className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline transition ease-in-out duration-150">
Create yours for free
</a>
</Link>
</>
),
actionButton: "Sign in",
},
};

15
src/components/avatar.tsx Normal file
View File

@ -0,0 +1,15 @@
import type { FunctionComponent } from "react";
type Props = {
name: string;
};
const Avatar: FunctionComponent<Props> = ({ name }) => (
<span className="inline-flex items-center justify-center w-8 h-8 flex-none rounded-full bg-gray-400 group-hover:opacity-75">
<span className="text-sm leading-none text-white uppercase">
{name.substr(0, 2)}
</span>
</span>
);
export default Avatar;

View File

@ -0,0 +1,261 @@
import type { FunctionComponent } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { RadioGroup } from "@headlessui/react";
import clsx from "clsx";
import { useForm } from "react-hook-form";
import Toggle from "../toggle";
import Modal, { ModalTitle } from "../modal";
import useSubscription from "../../hooks/use-subscription";
import useUser from "../../hooks/use-user";
import type {
Plan,
PlanId,
PlanName,
} from "../../subscription/plans";
import { FREE, PLANS } from "../../subscription/plans";
type Props = {
activePlanId?: PlanId;
};
type Form = {
selectedPlanName: PlanName;
};
const BillingPlans: FunctionComponent<Props> = ({ activePlanId = FREE.id }) => {
const { userProfile } = useUser();
const { subscribe, changePlan } = useSubscription();
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(
false,
);
const modalCancelButtonRef = useRef<HTMLButtonElement>(null);
const activePlan = useMemo(() => {
const activePlan = PLANS[activePlanId];
if (!activePlan) {
return FREE;
}
return activePlan;
}, [activePlanId]);
const {
register,
unregister,
handleSubmit,
watch,
setValue,
formState: { isSubmitting },
} = useForm<Form>({
defaultValues: getDefaultValues(activePlan),
});
useEffect(() => {
register("selectedPlanName");
const { selectedPlanName } = getDefaultValues(activePlan);
setValue("selectedPlanName", selectedPlanName);
return () => {
unregister("selectedPlanName");
};
}, [register, unregister, activePlan, setValue]);
const plans = PLANS;
const selectedPlanName = watch("selectedPlanName");
const selectedPlan = useMemo(() => plans[selectedPlanName] ?? FREE, [
plans,
selectedPlanName,
]);
const isActivePlanSelected = activePlan.id === selectedPlan.id;
const isSubmitDisabled = isSubmitting || isActivePlanSelected;
const onSubmit = handleSubmit(() => setIsConfirmationModalOpen(true));
const closeModal = () => setIsConfirmationModalOpen(false);
const onConfirm = async () => {
if (isSubmitDisabled) {
return;
}
const email = userProfile!.email!;
const userId = userProfile!.id;
const selectedPlanId = selectedPlan.id;
const isMovingToPaidPlan =
activePlan.id === "free" && selectedPlanId !== "free";
if (isMovingToPaidPlan) {
await subscribe({ email, userId, planId: selectedPlanId });
} else {
await changePlan({ planId: selectedPlanId });
}
};
return (
<>
<form onSubmit={onSubmit}>
<div className="shadow sm:rounded-md sm:overflow-hidden">
<div className="bg-white py-6 px-4 space-y-6 sm:p-6">
<fieldset>
<RadioGroup
value={selectedPlan.name}
onChange={(planName) => setValue("selectedPlanName", planName)}
className="relative bg-white rounded-md -space-y-px"
>
{Object.entries(plans).map(
([planId, plan], index, plansEntries) => {
const isChecked = selectedPlan.id === planId;
console.log("selectedPlan.name", selectedPlan.name);
return (
<RadioGroup.Option
key={planId}
value={planId}
as="label"
className={clsx(
"relative border p-4 flex flex-col cursor-pointer md:pl-4 md:pr-6 md:grid md:grid-cols-3",
{
"rounded-tl-md rounded-tr-md": index === 0,
"rounded-bl-md rounded-br-md": index === plansEntries.length - 1,
"bg-primary-50 border-primary-200 z-10": isChecked,
"border-gray-200": !isChecked,
},
)}
>
<div className="flex items-center text-sm">
<input
className="h-4 w-4 text-primary-500 border-gray-300"
type="radio"
value={planId}
checked={isChecked}
readOnly
/>
<span className="ml-3 font-medium text-gray-900">
{plan.name}
</span>
</div>
<p className="ml-6 pl-1 text-sm md:ml-0 md:pl-0">
<span
className={clsx(
"font-medium",
{
"text-primary-900": isChecked,
"text-gray-900": !isChecked,
},
)}
>
{plan.price === "free" ? (
"Free "
) : (
<>
${plan.price} /
mo
</>
)}
</span>
</p>
<p
className={clsx(
"ml-6 pl-1 text-sm md:ml-0 md:pl-0 md:text-right",
{
"text-primary-700": isChecked,
"text-gray-500": !isChecked,
},
)}
>
{plan.description}
</p>
</RadioGroup.Option>
);
},
)}
</RadioGroup>
</fieldset>
</div>
<div className="px-4 py-3 bg-gray-50 text-right sm:px-6">
<button
type="submit"
className={clsx(
"transition-colors duration-150 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-900",
{
"bg-primary-400 cursor-not-allowed": isActivePlanSelected,
"bg-primary-600 hover:bg-primary-700": !isActivePlanSelected,
},
)}
disabled={isSubmitDisabled}
>
Save
</button>
</div>
</div>
</form>
<Modal
initialFocus={modalCancelButtonRef}
isOpen={isConfirmationModalOpen}
onClose={closeModal}
>
<div className="md:flex md:items-start">
<div className="mt-3 text-center md:mt-0 md:ml-4 md:text-left">
<ModalTitle>
Move to {selectedPlan.name} plan
</ModalTitle>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to move to{" "}
{selectedPlan.name} plan?{" "}
</p>
{activePlan.name === "Team" &&
selectedPlan.name !== "Team" ? (
<p className="mt-2 text-sm font-medium text-gray-500">
Attention: moving to a smaller plan will
cause to remove extraneous team members to
fit the new plan&apos;s allowance!
</p>
) : null}
</div>
</div>
</div>
<div className="mt-5 md:mt-4 md:flex md:flex-row-reverse">
<button
type="button"
className={clsx(
"transition-colors duration-150 w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:ml-3 md:w-auto md:text-sm",
{
"bg-primary-400 cursor-not-allowed": isSubmitDisabled,
"bg-primary-600 hover:bg-primary-700": !isSubmitDisabled,
},
)}
onClick={onConfirm}
disabled={isSubmitting}
>
Move to {selectedPlan.name} plan
</button>
<button
ref={modalCancelButtonRef}
type="button"
className={clsx(
"transition-colors duration-150 mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto md:text-sm",
{
"bg-gray-50 cursor-not-allowed": isSubmitDisabled,
"hover:bg-gray-50": !isSubmitDisabled,
},
)}
onClick={closeModal}
disabled={isSubmitting}
>
Cancel
</button>
</div>
</Modal>
</>
);
};
const getDefaultValues = (activePlan: Plan) => ({
selectedPlanName: activePlan.name.toLowerCase(),
});
export default BillingPlans;

58
src/components/button.tsx Normal file
View File

@ -0,0 +1,58 @@
import type {
ButtonHTMLAttributes,
FunctionComponent,
MouseEventHandler,
} from "react";
import clsx from "clsx";
type Props = {
variant: Variant;
onClick?: MouseEventHandler;
isDisabled?: boolean;
type: ButtonHTMLAttributes<HTMLButtonElement>["type"];
};
const Button: FunctionComponent<Props> = ({
children,
type,
variant,
onClick,
isDisabled,
}) => {
return (
<button
onClick={onClick}
type={type}
className={clsx(
"inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-white focus:outline-none focus:ring-2 focus:ring-offset-2",
{
[VARIANTS_STYLES[variant].base]: !isDisabled,
[VARIANTS_STYLES[variant].disabled]: isDisabled,
},
)}
disabled={isDisabled}
>
{children}
</button>
);
};
export default Button;
type Variant = "error" | "default";
type VariantStyle = {
base: string;
disabled: string;
};
const VARIANTS_STYLES: Record<Variant, VariantStyle> = {
error: {
base: "bg-red-600 hover:bg-red-700 focus:ring-red-500",
disabled: "bg-red-400 cursor-not-allowed focus:ring-red-500",
},
default: {
base: "bg-primary-600 hover:bg-primary-700 focus:ring-primary-500",
disabled: "bg-primary-400 cursor-not-allowed focus:ring-primary-500",
},
};

View File

@ -0,0 +1,9 @@
export default function Divider() {
return (
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
</div>
);
}

5
src/components/icons.tsx Normal file
View File

@ -0,0 +1,5 @@
import type { FunctionComponent } from "react";
type Props = {
className?: string;
};

View File

@ -0,0 +1,84 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faPhoneAlt as fasPhone,
faTh as fasTh,
faComments as fasComments,
faCog as fasCog,
} from "@fortawesome/pro-solid-svg-icons";
import {
faPhoneAlt as farPhone,
faTh as farTh,
faComments as farComments,
faCog as farCog,
} from "@fortawesome/pro-regular-svg-icons";
export default function Footer() {
return (
<footer
className="grid grid-cols-4"
style={{ flex: "0 0 50px" }}
>
<NavLink
label="Calls"
path="/calls"
icons={{
active: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={fasPhone} />,
inactive: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={farPhone} />,
}}
/>
<NavLink
label="Keypad"
path="/keypad"
icons={{
active: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={fasTh} />,
inactive: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={farTh} />,
}}
/>
<NavLink
label="Messages"
path="/messages"
icons={{
active: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={fasComments} />,
inactive: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={farComments} />,
}}
/>
<NavLink
label="Settings"
path="/settings"
icons={{
active: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={fasCog} />,
inactive: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={farCog} />,
}}
/>
</footer>
);
}
type NavLinkProps = {
path: string;
label: string;
icons: {
active: ReactNode;
inactive: ReactNode;
};
}
function NavLink({ path, label, icons }: NavLinkProps) {
const router = useRouter();
const isActiveRoute = router.pathname.startsWith(path);
const icon = isActiveRoute ? icons.active : icons.inactive;
return (
<div className="flex flex-col items-center justify-around h-full">
<Link href={path}>
<a className="flex flex-col items-center">
{icon}
<span className="text-xs">{label}</span>
</a>
</Link>
</div>
);
}

View File

@ -0,0 +1,90 @@
import type { FunctionComponent } from "react";
import { Menu, Transition } from "@headlessui/react";
import Link from "next/link";
import { MenuIcon } from "@heroicons/react/solid";
import Avatar from "../avatar";
import useUser from "../../hooks/use-user";
export default function Header() {
const { userProfile } = useUser();
return (
<header
style={{ boxShadow: "0 5px 10px -7px rgba(0, 0, 0, 0.0785)" }}
className="z-30 py-4 bg-white"
>
<div className="container flex items-center justify-between h-full px-6 mx-auto text-primary-600">
<button
className="p-1 mr-5 -ml-1 rounded-md lg:hidden focus:outline-none focus:shadow-outline-primary"
onClick={() => void 0}
aria-label="Menu"
>
<MenuIcon className="w-6 h-6" />
</button>
<ul className="flex ml-auto items-center flex-shrink-0 space-x-6">
<li className="relative">
<Menu>
{({ open }) => (
<>
<Menu.Button
className="rounded-full focus:shadow-outline-primary focus:outline-none"
aria-label="Account"
aria-haspopup="true"
>
<Avatar
name={userProfile?.email ?? "FSS"}
/>
</Menu.Button>
<Transition
show={open}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className="absolute outline-none right-0 px-1 py-1 divide-y divide-gray-100 z-30 mt-2 origin-top-right text-gray-600 bg-white border border-gray-100 rounded-md shadow-md min-w-max-content"
static
>
<MenuItem href="/account/settings">
<span>Settings</span>
</MenuItem>
<MenuItem href="/api/auth/sign-out">
<span>Log out</span>
</MenuItem>
</Menu.Items>
</Transition>
</>
)}
</Menu>
</li>
</ul>
</div>
</header>
);
}
type MenuItemProps = {
href: string;
};
const MenuItem: FunctionComponent<MenuItemProps> = ({ children, href }) => (
<Menu.Item>
{() => (
<Link href={href}>
<a
className="inline-flex space-x-2 items-center cursor-pointer w-full px-4 py-2 text-sm font-medium transition-colors duration-150 hover:bg-gray-100 hover:text-gray-800"
role="menuitem"
>
{children}
</a>
</Link>
)}
</Menu.Item>
);

View File

@ -0,0 +1,99 @@
import type { ErrorInfo, FunctionComponent } from "react";
import { Component } from "react";
import Head from "next/head";
import type { WithRouterProps } from "next/dist/client/with-router";
import { withRouter } from "next/router";
import appLogger from "../../../lib/logger";
import Footer from "./footer";
type Props = {
title: string;
pageTitle?: string;
};
const logger = appLogger.child({ module: "Layout" });
const Layout: FunctionComponent<Props> = ({
children,
title,
pageTitle = title,
}) => {
return (
<>
{pageTitle ? (
<Head>
<title>{pageTitle}</title>
</Head>
) : null}
<div className="h-full w-full overflow-hidden fixed bg-gray-50">
<div className="flex flex-col w-full h-full">
<div className="flex flex-col flex-1 w-full overflow-y-auto">
<main className="flex-1 my-0 h-full">
<ErrorBoundary>{children}</ErrorBoundary>
</main>
</div>
<Footer />
</div>
</div>
</>
);
};
type ErrorBoundaryState =
| {
isError: false;
}
| {
isError: true;
errorMessage: string;
};
const ErrorBoundary = withRouter(
class ErrorBoundary extends Component<WithRouterProps, ErrorBoundaryState> {
public readonly state = {
isError: false,
} as const;
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return {
isError: true,
errorMessage: error.message,
};
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
logger.error(error, errorInfo.componentStack);
}
public render() {
if (this.state.isError) {
return (
<>
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">
Oops, something went wrong.
</h2>
<p className="mt-2 text-center text-lg leading-5 text-gray-600">
Would you like to{" "}
<button
className="inline-flex space-x-2 items-center text-left"
onClick={this.props.router.reload}
>
<span className="transition-colors duration-150 border-b border-primary-200 hover:border-primary-500">
reload the page
</span>
</button>{" "}
?
</p>
</>
);
}
return this.props.children;
}
},
);
export default Layout;

View File

@ -0,0 +1,23 @@
export default function Loading({ className = "" }) {
return (
<svg
className={`animate-spin h-5 w-5 text-primary-600 ${className}`}
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
}

15
src/components/logo.tsx Normal file
View File

@ -0,0 +1,15 @@
import type { FunctionComponent } from "react";
import Image from "next/image";
import clsx from "clsx";
type Props = {
className?: string;
};
const Logo: FunctionComponent<Props> = ({ className }) => (
<div className={clsx("relative", className)}>
<Image src="/static/logo.svg" layout="fill" alt="app logo" />
</div>
);
export default Logo;

View File

@ -0,0 +1,15 @@
import { FunctionComponent } from "react";
import usePress from "react-gui/use-press";
const LongPressHandler: FunctionComponent = ({ children }) => {
const onLongPress = (event: any) => console.log("event", event);
const ref = usePress({ onLongPress });
return (
<div ref={ref} onContextMenu={e => e.preventDefault()}>
{children}
</div>
);
};
export default LongPressHandler;

71
src/components/modal.tsx Normal file
View File

@ -0,0 +1,71 @@
import type { FunctionComponent, MutableRefObject, ReactNode } from "react";
import { Fragment } from "react";
import { Transition, Dialog } from "@headlessui/react";
type Props = {
initialFocus?: MutableRefObject<HTMLElement | null> | undefined;
isOpen: boolean;
onClose: () => void;
};
const Modal: FunctionComponent<Props> = ({
children,
initialFocus,
isOpen,
onClose,
}) => {
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog
className="fixed z-30 inset-0 overflow-y-auto"
initialFocus={initialFocus}
onClose={onClose}
open={isOpen}
static
>
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center md:block md:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
{/* This element is to trick the browser into centering the modal contents. */}
<span className="hidden md:inline-block md:align-middle md:h-screen">
&#8203;
</span>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 md:translate-y-0 md:scale-95"
enterTo="opacity-100 translate-y-0 md:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 md:scale-100"
leaveTo="opacity-0 translate-y-4 md:translate-y-0 md:scale-95"
>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all md:my-8 md:align-middle md:max-w-lg md:w-full md:p-6">
{children}
</div>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
);
};
export const ModalTitle: FunctionComponent = ({ children }) => (
<Dialog.Title
as="h3"
className="text-lg leading-6 font-medium text-gray-900"
>
{children}
</Dialog.Title>
);
export default Modal;

View File

@ -0,0 +1,33 @@
import type { ReactNode, RefObject } from "react";
import { useEffect, useRef } from "react";
type Handler = (event: MouseEvent) => void;
type Props = {
children: ReactNode;
handler: Handler;
};
function OutsideAlerter({ children, handler }: Props) {
const wrapperRef = useRef(null);
useOutsideAlerter(wrapperRef, handler);
return <div ref={wrapperRef}>{children}</div>;
}
function useOutsideAlerter(ref: RefObject<HTMLElement>, handler: Handler) {
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
handler(event);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [ref, handler]);
}
export default OutsideAlerter;

View File

@ -0,0 +1,110 @@
import { useRef, useState } from "react";
import clsx from "clsx";
import Button from "../button";
import SettingsSection from "./settings-section";
import Modal, { ModalTitle } from "../modal";
import useUser from "../../hooks/use-user";
export default function DangerZone() {
const user = useUser();
const [isDeletingUser, setIsDeletingUser] = useState(false);
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(
false,
);
const modalCancelButtonRef = useRef<HTMLButtonElement>(null);
const closeModal = () => {
if (isDeletingUser) {
return;
}
setIsConfirmationModalOpen(false);
};
const onConfirm = () => {
setIsDeletingUser(true);
user.deleteUser();
};
return (
<SettingsSection
title="Danger Zone"
description="Highway to the Danger Zone 𝅘𝅥𝅮"
>
<div className="shadow border border-red-300 sm:rounded-md sm:overflow-hidden">
<div className="flex justify-between items-center flex-row px-4 py-5 bg-white sm:p-6">
<p>
Once you delete your account, all of its data will be
permanently deleted.
</p>
<span className="text-base font-medium">
<Button
variant="error"
type="button"
onClick={() => setIsConfirmationModalOpen(true)}
>
Delete my account
</Button>
</span>
</div>
</div>
<Modal
initialFocus={modalCancelButtonRef}
isOpen={isConfirmationModalOpen}
onClose={closeModal}
>
<div className="md:flex md:items-start">
<div className="mt-3 text-center md:mt-0 md:ml-4 md:text-left">
<ModalTitle>Delete my account</ModalTitle>
<div className="mt-2 text-sm text-gray-500">
<p>
Are you sure you want to delete your account?
Your subscription will be cancelled and your
data permanently deleted.
</p>
<p>
You are free to create a new account with the
same email address if you ever wish to come
back.
</p>
</div>
</div>
</div>
<div className="mt-5 md:mt-4 md:flex md:flex-row-reverse">
<button
type="button"
className={clsx(
"transition-colors duration-150 w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 md:ml-3 md:w-auto md:text-sm",
{
"bg-red-400 cursor-not-allowed": isDeletingUser,
"bg-red-600 hover:bg-red-700": !isDeletingUser,
},
)}
onClick={onConfirm}
disabled={isDeletingUser}
>
Delete my account
</button>
<button
ref={modalCancelButtonRef}
type="button"
className={clsx(
"transition-colors duration-150 mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto md:text-sm",
{
"bg-gray-50 cursor-not-allowed": isDeletingUser,
"hover:bg-gray-50": !isDeletingUser,
},
)}
onClick={closeModal}
disabled={isDeletingUser}
>
Cancel
</button>
</div>
</Modal>
</SettingsSection>
);
}

View File

@ -0,0 +1,215 @@
import type { FunctionComponent } from "react";
import { useState } from "react";
import clsx from "clsx";
import { CheckIcon } from "@heroicons/react/outline";
import useUser from "../../hooks/use-user";
import useSubscription from "../../hooks/use-subscription";
import type { Plan, PlanId } from "../../subscription/plans";
import {
FREE,
MONTHLY,
ANNUALLY,
TEAM_MONTHLY,
TEAM_ANNUALLY,
} from "../../subscription/plans";
type Props = {
activePlanId?: PlanId;
};
const PLANS: Record<BillingSchedule, Plan[]> = {
monthly: [FREE, MONTHLY, TEAM_MONTHLY],
yearly: [FREE, ANNUALLY, TEAM_ANNUALLY],
};
const PricingPlans: FunctionComponent<Props> = ({ activePlanId }) => {
const [billingSchedule, setBillingSchedule] = useState<BillingSchedule>(
"monthly",
);
return (
<div className="max-w-7xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
<div className="sm:flex sm:flex-col sm:align-center">
<div className="relative self-center mt-6 bg-gray-100 rounded-lg p-0.5 flex sm:mt-8">
<button
onClick={() => setBillingSchedule("monthly")}
type="button"
className={clsx(
"relative w-1/2 border-gray-200 rounded-md shadow-sm py-2 text-sm font-medium text-gray-700 whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-primary-500 focus:z-10 sm:w-auto sm:px-8",
{
"bg-white": billingSchedule === "monthly",
},
)}
>
Monthly billing
</button>
<button
onClick={() => setBillingSchedule("yearly")}
type="button"
className={clsx(
"ml-0.5 relative w-1/2 border border-transparent rounded-md py-2 text-sm font-medium text-gray-700 whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-primary-500 focus:z-10 sm:w-auto sm:px-8",
{
"bg-white": billingSchedule === "yearly",
},
)}
>
Yearly billing
</button>
</div>
</div>
<div className="mt-6 space-y-4 flex flex-row flex-wrap sm:mt-10 sm:space-y-0 sm:gap-6 lg:max-w-4xl lg:mx-auto">
{PLANS[billingSchedule].map((plan) => (
<PricingPlan
key={`pricing-plan-${plan.name}`}
plan={plan}
billingSchedule={billingSchedule}
activePlanId={activePlanId}
/>
))}
</div>
</div>
);
};
export default PricingPlans;
type BillingSchedule = "yearly" | "monthly";
type PricingPlanProps = {
plan: Plan;
billingSchedule: BillingSchedule;
activePlanId?: PlanId;
};
const PricingPlan: FunctionComponent<PricingPlanProps> = ({
plan,
billingSchedule,
activePlanId,
}) => {
const { subscribe, changePlan } = useSubscription();
const { userProfile } = useUser();
const { name, description, features, price, id } = plan;
const isActivePlan =
(typeof activePlanId !== "undefined" ? activePlanId : "free") === id;
function movePlan() {
const teamId = userProfile!.teamId;
const email = userProfile!.email;
const planId = plan.id;
if (typeof activePlanId === "undefined" && typeof planId === "number") {
return subscribe({ email, teamId, planId });
}
changePlan({ planId });
}
return (
<div
className={clsx(
"bg-white w-full flex-grow border rounded-lg shadow-sm divide-y divide-gray-200 sm:w-auto",
{
"border-gray-200": !isActivePlan,
"border-primary-600": isActivePlan,
},
)}
>
<div className="p-6">
<h2 className="text-lg leading-6 font-medium text-gray-900">
{name}
</h2>
<p className="mt-4 text-sm text-gray-500">{description}</p>
<p className="mt-8">
<PlanPrice
price={price}
billingSchedule={billingSchedule}
/>
</p>
<div className="mt-8">
<PlanButton
name={name}
isActivePlan={isActivePlan}
changePlan={movePlan}
/>
</div>
</div>
<div className="pt-6 pb-8 px-6">
<h3 className="text-xs font-medium text-gray-900 tracking-wide uppercase">
What&apos;s included
</h3>
<ul className="mt-6 space-y-4">
{features.map((feature) => (
<li
key={`pricing-plan-${name}-feature-${feature}`}
className="flex space-x-3"
>
<CheckIcon className="flex-shrink-0 h-5 w-5 text-green-500" />
<span className="text-sm text-gray-500">
{feature}
</span>
</li>
))}
</ul>
</div>
</div>
);
};
type PlanButtonProps = {
name: Plan["name"];
isActivePlan: boolean;
changePlan: () => void;
};
const PlanButton: FunctionComponent<PlanButtonProps> = ({
name,
isActivePlan,
changePlan,
}) => {
return isActivePlan ? (
<div className="block w-full py-2 text-sm font-semibold text-gray-500 text-center">
You&apos;re currently on this plan
</div>
) : (
<button
type="button"
onClick={changePlan}
className="block w-full cursor-pointer bg-primary-600 border border-primary-600 rounded-md py-2 text-sm font-semibold text-white text-center hover:bg-primary-700"
>
Move to <span className="font-bold">{name}</span> plan
</button>
);
};
type PlanPriceProps = {
price: Plan["price"];
billingSchedule: BillingSchedule;
};
const PlanPrice: FunctionComponent<PlanPriceProps> = ({
price,
billingSchedule,
}) => {
if (price === "free") {
return (
<span className="text-4xl font-extrabold text-gray-900">Free</span>
);
}
return (
<>
<span className="text-4xl font-extrabold text-gray-900">
${price}
</span>
<span className="text-base font-medium text-gray-500">/mo</span>
{billingSchedule === "yearly" ? (
<span className="ml-1 text-sm text-gray-500">
billed yearly
</span>
) : null}
</>
);
};

View File

@ -0,0 +1,138 @@
import type { FunctionComponent } from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { useForm } from "react-hook-form";
import Alert from "../alert";
import Button from "../button";
import SettingsSection from "./settings-section";
import useUser from "../../hooks/use-user";
import appLogger from "../../../lib/logger";
type Form = {
name: string;
email: string;
};
const logger = appLogger.child({ module: "profile-settings" });
const ProfileInformations: FunctionComponent = () => {
const user = useUser();
const router = useRouter();
const {
register,
handleSubmit,
setValue,
formState: { isSubmitting, isSubmitSuccessful },
} = useForm<Form>();
const [errorMessage, setErrorMessage] = useState("");
useEffect(() => {
setValue("name", user.userProfile?.name ?? "");
setValue("email", user.userProfile?.email ?? "");
}, [setValue, user.userProfile]);
const onSubmit = handleSubmit(async ({ name, email }) => {
if (isSubmitting) {
return;
}
try {
await user.updateUser({ name, email });
} catch (error) {
logger.error(error.response, "error updating user infos");
if (error.response.status === 401) {
logger.error("session expired, redirecting to sign in page");
return router.push("/auth/sign-in");
}
setErrorMessage(error.response.data.errorMessage);
}
});
return (
<SettingsSection
title="Profile Information"
description="Update your account's profile information and email address."
>
<form onSubmit={onSubmit}>
{errorMessage ? (
<div className="mb-8">
<Alert
title="Oops, there was an issue"
message={errorMessage}
variant="error"
/>
</div>
) : null}
{isSubmitSuccessful ? (
<div className="mb-8">
<Alert
title="Saved successfully"
message="Your changes have been saved."
variant="success"
/>
</div>
) : null}
<div className="shadow sm:rounded-md sm:overflow-hidden">
<div className="px-4 py-5 bg-white space-y-6 sm:p-6">
<div className="col-span-3 sm:col-span-2">
<label
htmlFor="name"
className="block text-sm font-medium leading-5 text-gray-700"
>
Name
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="name"
type="text"
tabIndex={1}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
{...register("name")}
required
/>
</div>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium leading-5 text-gray-700"
>
Email address
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="email"
type="email"
tabIndex={2}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
{...register("email")}
required
/>
</div>
</div>
</div>
<div className="px-4 py-3 bg-gray-50 text-right text-sm font-medium sm:px-6">
<Button
variant="default"
type="submit"
isDisabled={isSubmitting}
>
{isSubmitting ? "Saving..." : "Save"}
</Button>
</div>
</div>
</form>
</SettingsSection>
);
};
export default ProfileInformations;

View File

@ -0,0 +1,52 @@
import type { FunctionComponent } from "react";
import { useRouter } from "next/router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronLeft } from "@fortawesome/pro-regular-svg-icons";
import Layout from "../layout";
import useUser from "../../hooks/use-user";
const pageTitle = "User Settings";
const SettingsLayout: FunctionComponent = ({ children }) => {
const user = useUser();
const router = useRouter();
if (user.isLoading) {
return (
<Layout title={pageTitle}>
<section className="px-4 py-4 sm:px-6 md:px-0">
Loading...
</section>
</Layout>
);
}
if (user.error) {
return (
<Layout title={pageTitle}>
<section className="px-4 py-4 sm:px-6 md:px-0">
<p className="py-2">Oops, something unexpected happened!</p>
<pre>{user.error.message}</pre>
</section>
</Layout>
);
}
return (
<Layout title={pageTitle}>
<header className="px-4 sm:px-6 md:px-0">
<header className="flex">
<span className="flex items-center cursor-pointer" onClick={router.back}>
<FontAwesomeIcon className="h-8 w-8 mr-2" icon={faChevronLeft} /> Back
</span>
</header>
</header>
<main>{children}</main>
</Layout>
);
};
export default SettingsLayout;

View File

@ -0,0 +1,26 @@
import type { FunctionComponent, ReactNode } from "react";
type Props = {
title: string;
description?: ReactNode;
};
const SettingsSection: FunctionComponent<Props> = ({
children,
title,
description,
}) => (
<div className="px-4 sm:px-6 md:px-0 lg:grid lg:grid-cols-4 lg:gap-6">
<div className="lg:col-span-1">
<h3 className="text-lg font-medium leading-6 text-gray-900">
{title}
</h3>
{description ? (
<p className="mt-1 text-sm text-gray-600">{description}</p>
) : null}
</div>
<div className="mt-5 lg:mt-0 lg:col-span-3">{children}</div>
</div>
);
export default SettingsSection;

View File

@ -0,0 +1,141 @@
import type { FunctionComponent } from "react";
import { useRouter } from "next/router";
import { useForm } from "react-hook-form";
import Alert from "../alert";
import Button from "../button";
import SettingsSection from "./settings-section";
import useUser from "../../hooks/use-user";
import appLogger from "../../../lib/logger";
import { useState } from "react";
const logger = appLogger.child({ module: "update-password" });
type Form = {
newPassword: string;
newPasswordConfirmation: string;
};
const UpdatePassword: FunctionComponent = () => {
const user = useUser();
const router = useRouter();
const {
register,
handleSubmit,
formState: { isSubmitting, isSubmitSuccessful },
} = useForm<Form>();
const [errorMessage, setErrorMessage] = useState("");
const onSubmit = handleSubmit(
async ({ newPassword, newPasswordConfirmation }) => {
if (isSubmitting) {
return;
}
if (newPassword !== newPasswordConfirmation) {
setErrorMessage("New passwords don't match");
return;
}
try {
await user.updateUser({ password: newPassword });
} catch (error) {
logger.error(error.response, "error updating user infos");
if (error.response.status === 401) {
logger.error(
"session expired, redirecting to sign in page",
);
return router.push("/auth/sign-in");
}
setErrorMessage(error.response.data.errorMessage);
}
},
);
return (
<SettingsSection
title="Update Password"
description="Make sure you are using a long, random password to stay secure."
>
<form onSubmit={onSubmit}>
{errorMessage ? (
<div className="mb-8">
<Alert
title="Oops, there was an issue"
message={errorMessage}
variant="error"
/>
</div>
) : null}
{isSubmitSuccessful ? (
<div className="mb-8">
<Alert
title="Saved successfully"
message="Your changes have been saved."
variant="success"
/>
</div>
) : null}
<div className="shadow sm:rounded-md sm:overflow-hidden">
<div className="px-4 py-5 bg-white space-y-6 sm:p-6">
<div>
<label
htmlFor="newPassword"
className="flex justify-between text-sm font-medium leading-5 text-gray-700"
>
<div>New password</div>
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="newPassword"
type="password"
tabIndex={3}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
{...register("newPassword")}
required
/>
</div>
</div>
<div>
<label
htmlFor="newPasswordConfirmation"
className="flex justify-between text-sm font-medium leading-5 text-gray-700"
>
<div>Confirm new password</div>
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="newPasswordConfirmation"
type="password"
tabIndex={4}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
{...register("newPasswordConfirmation")}
required
/>
</div>
</div>
</div>
<div className="px-4 py-3 bg-gray-50 text-right text-sm font-medium sm:px-6">
<Button
variant="default"
type="submit"
isDisabled={isSubmitting}
>
{isSubmitting ? "Saving..." : "Save"}
</Button>
</div>
</div>
</form>
</SettingsSection>
);
};
export default UpdatePassword;

41
src/components/toggle.tsx Normal file
View File

@ -0,0 +1,41 @@
import type { FunctionComponent, ReactNode } from "react";
import { Switch } from "@headlessui/react";
import clsx from "clsx";
type Props = {
isChecked: boolean;
label?: ReactNode;
onChange: (checked: boolean) => void;
};
const Toggle: FunctionComponent<Props> = ({ isChecked, label, onChange }) => {
return (
<Switch.Group as="div" className="flex items-center space-x-4">
<Switch
as="button"
checked={isChecked}
onChange={onChange}
className={clsx(
"relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer focus:outline-none ring-0 transition-colors ease-in-out duration-200",
{
"bg-primary-500": isChecked,
"bg-gray-200": !isChecked,
},
)}
>
{({ checked }) => (
<span
className={`${
checked ? "translate-x-5" : "translate-x-0"
} inline-block w-5 h-5 transition duration-200 ease-in-out transform bg-white rounded-full shadow ring-0`}
/>
)}
</Switch>
{label ? (
<Switch.Label className="ml-3">{label}</Switch.Label>
) : null}
</Switch.Group>
);
};
export default Toggle;

View File

@ -0,0 +1,111 @@
import type { FunctionComponent } from "react";
import { CheckIcon } from "@heroicons/react/solid";
import clsx from "clsx";
import Link from "next/link";
type StepLink = {
href: string;
label: string;
}
type Props = {
currentStep: 1 | 2 | 3;
previous?: StepLink;
next?: StepLink;
};
const steps = [
"Welcome",
"Twilio Credentials",
"Pick a plan",
] as const;
const OnboardingLayout: FunctionComponent<Props> = ({
children,
currentStep,
previous,
next,
}) => {
return (
<div className="bg-gray-800 fixed z-10 inset-0 overflow-y-auto">
<div className="min-h-screen text-center block p-0">
{/* This element is to trick the browser into centering the modal contents. */}
<span className="inline-block align-middle h-screen">
&#8203;
</span>
<div className="inline-flex flex-col bg-white rounded-lg text-left overflow-hidden shadow transform transition-all my-8 align-middle max-w-2xl w-[90%] pb-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 px-6 py-5 border-b border-gray-100">{steps[currentStep - 1]}</h3>
<section className="px-6 pt-6 pb-12">{children}</section>
<nav className="grid grid-cols-1 gap-y-3 mx-auto">
{
next ? (
<Link href={next.href}>
<a className="max-w-[240px] mx-auto w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm">
{next.label}
</a>
</Link>
) : null
}
{
previous ? (
<Link href={previous.href}>
<a className="max-w-[240px] mx-auto w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm">
{previous.label}
</a>
</Link>
) : null
}
<ol className="flex items-center">
{steps.map((step, stepIdx) => {
const isComplete = currentStep > stepIdx + 1;
const isCurrent = stepIdx + 1 === currentStep;
return (
<li key={step} className={clsx(stepIdx !== steps.length - 1 ? "pr-20 sm:pr-32" : "", "relative")}>
{isComplete ? (
<>
<div className="absolute inset-0 flex items-center">
<div className="h-0.5 w-full bg-primary-600" />
</div>
<a className="relative w-8 h-8 flex items-center justify-center bg-primary-600 rounded-full">
<CheckIcon className="w-5 h-5 text-white" />
<span className="sr-only">{step}</span>
</a>
</>
) : isCurrent ? (
<>
<div className="absolute inset-0 flex items-center">
<div className="h-0.5 w-full bg-gray-200" />
</div>
<a className="relative w-8 h-8 flex items-center justify-center bg-white border-2 border-primary-600 rounded-full">
<span className="h-2.5 w-2.5 bg-primary-600 rounded-full" />
<span className="sr-only">{step}</span>
</a>
</>
) : (
<>
<div className="absolute inset-0 flex items-center">
<div className="h-0.5 w-full bg-gray-200" />
</div>
<a className="group relative w-8 h-8 flex items-center justify-center bg-white border-2 border-gray-300 rounded-full">
<span className="h-2.5 w-2.5 bg-transparent rounded-full" />
<span className="sr-only">{step}</span>
</a>
</>
)}
</li>
)
})}
</ol>
</nav>
</div>
</div>
</div>
);
};
export default OnboardingLayout;

View File

@ -0,0 +1,33 @@
import crypto from "crypto";
import getConfig from "next/config";
const { serverRuntimeConfig } = getConfig();
const IV_LENGTH = 16;
const ALGORITHM = "aes-256-cbc";
export function encrypt(text: string, encryptionKey: Buffer | string) {
const encryptionKeyAsBuffer = Buffer.isBuffer(encryptionKey) ? encryptionKey : Buffer.from(encryptionKey, "hex");
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, encryptionKeyAsBuffer, iv);
const encrypted = cipher.update(text);
const encryptedBuffer = Buffer.concat([encrypted, cipher.final()]);
return `${iv.toString("hex")}:${encryptedBuffer.toString("hex")}`;
}
export function decrypt(encryptedHexText: string, encryptionKey: Buffer | string) {
const encryptionKeyAsBuffer = Buffer.isBuffer(encryptionKey) ? encryptionKey : Buffer.from(encryptionKey, "hex");
const [hexIv, hexText] = encryptedHexText.split(":");
const iv = Buffer.from(hexIv, "hex");
const encryptedText = Buffer.from(hexText, "hex");
const decipher = crypto.createDecipheriv(ALGORITHM, encryptionKeyAsBuffer, iv);
const decrypted = decipher.update(encryptedText);
const decryptedBuffer = Buffer.concat([decrypted, decipher.final()]);
return decryptedBuffer.toString();
}
export function computeEncryptionKey(userIdentifier: string) {
return crypto.scryptSync(userIdentifier, serverRuntimeConfig.masterEncryptionKey, 32);
}

14
src/database/_types.ts Normal file
View File

@ -0,0 +1,14 @@
export enum SmsType {
SENT = "sent",
RECEIVED = "received",
}
export type Sms = {
id: number;
customerId: string;
content: string;
from: string;
to: string;
type: SmsType;
sentAt: Date;
};

55
src/database/customer.ts Normal file
View File

@ -0,0 +1,55 @@
import appLogger from "../../lib/logger";
import supabase from "../supabase/server";
import { computeEncryptionKey } from "./_encryption";
const logger = appLogger.child({ module: "customer" });
export type Customer = {
id: string;
email: string;
name: string;
paddleCustomerId: string;
paddleSubscriptionId: string;
accountSid: string;
authToken: string;
encryptionKey: string;
};
type CreateCustomerParams = Pick<Customer, "id" | "email" | "name">;
export async function createCustomer({ id, email, name }: CreateCustomerParams): Promise<Customer> {
const encryptionKey = computeEncryptionKey(id).toString("hex");
const { error, data } = await supabase
.from<Customer>("customer")
.insert({
id,
email,
name,
encryptionKey,
});
if (error) throw error;
console.log("data", data);
return data![0];
}
export async function findCustomer(id: Customer["id"]): Promise<Customer> {
const { error, data } = await supabase
.from<Customer>("customer")
.select("*")
.eq("id", id)
.single();
if (error) throw error;
return data!;
}
export async function updateCustomer(id: string, update: Partial<Customer>) {
await supabase.from<Customer>("customer")
.update(update)
.eq("id", id)
.throwOnError();
}

View File

@ -0,0 +1,55 @@
import appLogger from "../../lib/logger";
import supabase from "../supabase/server";
const logger = appLogger.child({ module: "phone-number" });
export type PhoneNumber = {
id: string;
customerId: string;
phoneNumberSid: string;
phoneNumber: string;
};
type CreatePhoneNumberParams = Pick<PhoneNumber, "customerId" | "phoneNumber" | "phoneNumberSid">;
export async function createPhoneNumber({
customerId,
phoneNumber,
phoneNumberSid,
}: CreatePhoneNumberParams): Promise<PhoneNumber> {
const { error, data } = await supabase
.from<PhoneNumber>("phone-number")
.insert({
customerId: customerId,
phoneNumber,
phoneNumberSid,
});
if (error) throw error;
return data![0];
}
export async function findPhoneNumber({ id }: Pick<PhoneNumber, "id">): Promise<PhoneNumber> {
const { error, data } = await supabase
.from<PhoneNumber>("phone-number")
.select("*")
.eq("id", id)
.single();
if (error) throw error;
return data!;
}
export async function findCustomerPhoneNumber(customerId: PhoneNumber["customerId"]): Promise<PhoneNumber> {
const { error, data } = await supabase
.from<PhoneNumber>("phone-number")
.select("*")
.eq("customerId", customerId)
.single();
if (error) throw error;
return data!;
}

46
src/database/sms.ts Normal file
View File

@ -0,0 +1,46 @@
import appLogger from "../../lib/logger";
import supabase from "../supabase/server";
import type { Sms } from "./_types";
const logger = appLogger.child({ module: "sms" });
export async function insertSms(messages: Omit<Sms, "id">): Promise<Sms> {
const { error, data } = await supabase
.from<Sms>("sms")
.insert(messages);
if (error) throw error;
return data![0];
}
export async function insertManySms(messages: Omit<Sms, "id">[]) {
await supabase
.from<Sms>("sms")
.insert(messages)
.throwOnError();
}
export async function findCustomerMessages(customerId: Sms["customerId"]): Promise<Sms[]> {
const { error, data } = await supabase
.from<Sms>("sms")
.select("*")
.eq("customerId", customerId);
if (error) throw error;
return data!;
}
export async function findConversation(customerId: Sms["customerId"], recipient: Sms["to"]): Promise<Sms[]> {
const { error, data } = await supabase
.from<Sms>("sms")
.select("*")
.eq("customerId", customerId)
.or(`to.eq.${recipient},from.eq.${recipient}`);
if (error) throw error;
return data!;
}

View File

@ -0,0 +1,151 @@
import type { PlanId } from "../subscription/plans";
import appLogger from "../../lib/logger";
const logger = appLogger.child({ module: "subscriptions" });
export type SubscriptionStatus =
| "active"
| "trialing"
| "past_due"
| "paused"
| "deleted";
export const SUBSCRIPTION_STATUSES: SubscriptionStatus[] = [
"active",
"trialing",
"past_due",
"paused",
"deleted",
];
export type Subscription = {
userId: string;
planId: PlanId;
paddleCheckoutId: string;
paddleSubscriptionId: string;
nextBillDate: Date;
status: SubscriptionStatus;
lastEventTime: Date;
updateUrl: string;
cancelUrl: string;
createdAt: Date;
updatedAt: Date;
};
type FirestoreSubscription = FirestoreEntry<Subscription>;
const subscriptions = firestoreCollection<FirestoreSubscription>(
"subscriptions",
);
type CreateSubscriptionParams = Pick<
Subscription,
| "userId"
| "planId"
| "paddleCheckoutId"
| "paddleSubscriptionId"
| "nextBillDate"
| "status"
| "updateUrl"
| "cancelUrl"
| "lastEventTime"
>;
export async function createSubscription({
userId,
planId,
paddleCheckoutId,
paddleSubscriptionId,
nextBillDate,
status,
updateUrl,
cancelUrl,
lastEventTime,
}: CreateSubscriptionParams): Promise<Subscription> {
const createdAt = FieldValue.serverTimestamp() as Timestamp;
await subscriptions.doc(paddleSubscriptionId).set({
userId,
planId,
paddleCheckoutId,
paddleSubscriptionId,
nextBillDate,
status,
updateUrl,
cancelUrl,
lastEventTime,
createdAt,
updatedAt: createdAt,
});
const subscription = await findSubscription({ paddleSubscriptionId });
return subscription!;
}
type GetSubscriptionParams = Pick<Subscription, "paddleSubscriptionId">;
export async function findSubscription({
paddleSubscriptionId,
}: GetSubscriptionParams): Promise<Subscription | undefined> {
const subscriptionDocument = await subscriptions
.doc(paddleSubscriptionId)
.get();
if (!subscriptionDocument.exists) {
return;
}
return convertFromFirestore(subscriptionDocument.data()!);
}
type FindUserSubscriptionParams = Pick<Subscription, "userId">;
export async function findUserSubscription({
userId,
}: FindUserSubscriptionParams): Promise<Subscription | null> {
const subscriptionDocumentsSnapshot = await subscriptions
.where("userId", "==", userId)
.where("status", "!=", "deleted")
.get();
if (subscriptionDocumentsSnapshot.docs.length === 0) {
logger.warn(`No subscription found for user ${userId}`);
return null;
}
const subscriptionDocument = subscriptionDocumentsSnapshot.docs[0].data();
return convertFromFirestore(subscriptionDocument);
}
type UpdateSubscriptionParams = Pick<Subscription, "paddleSubscriptionId"> &
Partial<
Pick<
Subscription,
| "planId"
| "paddleCheckoutId"
| "paddleSubscriptionId"
| "nextBillDate"
| "status"
| "updateUrl"
| "cancelUrl"
| "lastEventTime"
>
>;
export async function updateSubscription(
update: UpdateSubscriptionParams,
): Promise<void> {
const paddleSubscriptionId = update.paddleSubscriptionId;
await subscriptions.doc(paddleSubscriptionId).set(
{
...update,
updatedAt: FieldValue.serverTimestamp() as Timestamp,
},
{ merge: true },
);
}
export async function deleteSubscription({
paddleSubscriptionId,
}: Pick<Subscription, "paddleSubscriptionId">): Promise<void> {
await subscriptions.doc(paddleSubscriptionId).delete();
}

17
src/fonts.css Normal file
View File

@ -0,0 +1,17 @@
@font-face {
font-family: "Inter var";
font-weight: 100 900;
font-display: optional;
font-style: normal;
font-named-instance: "Regular";
src: url("/static/fonts/inter/Inter-roman.var.woff2") format("woff2");
}
@font-face {
font-family: "Inter var";
font-weight: 100 900;
font-display: optional;
font-style: italic;
font-named-instance: "Italic";
src: url("/static/fonts/inter/Inter-italic.var.woff2") format("woff2");
}

66
src/hooks/use-auth.ts Normal file
View File

@ -0,0 +1,66 @@
import { useEffect, useRef } from "react";
import { useRouter } from "next/router";
import axios from "axios";
import supabase from "../supabase/client";
type Credentials = {
email: string;
password: string;
};
export default function useAuth() {
const router = useRouter();
const redirectToRef = useRef("/messages");
useEffect(() => {
const { data } = supabase.auth.onAuthStateChange(async (event, session) => {
if (["SIGNED_IN", "SIGNED_OUT"].includes(event)) {
await axios.post("/api/auth/session", { event, session });
if (event === "SIGNED_IN") {
await router.push(redirectToRef.current);
}
}
});
return () => data?.unsubscribe();
}, []);
async function signUp({
email,
password,
name,
}: Credentials & { name: string; redirectTo?: string }) {
await axios.post("/api/auth/sign-up", { email, password, name });
await signIn({ email, password, redirectTo: "/welcome/step-one" });
}
async function signIn({
email,
password,
redirectTo = "/messages",
}: Credentials & { redirectTo?: string }) {
redirectToRef.current = redirectTo;
const { error } = await supabase.auth.signIn({ email, password });
if (error) {
throw error;
}
}
async function signOut() {
const { error } = await supabase.auth.signOut();
if (error) throw error;
}
async function resetPassword(email: string) {
return supabase.auth.api.resetPasswordForEmail(email);
}
return {
signUp,
signIn,
signOut,
resetPassword,
};
}

49
src/hooks/use-paddle.ts Normal file
View File

@ -0,0 +1,49 @@
import { useEffect } from "react";
import { useRouter } from "next/router";
import getConfig from "next/config";
declare global {
interface Window {
Paddle: any;
}
}
const { publicRuntimeConfig } = getConfig();
const vendor = publicRuntimeConfig.paddle.vendorId;
export default function usePaddle({
eventCallback,
}: {
eventCallback: (data: any) => void;
}) {
const router = useRouter();
useEffect(() => {
if (!window.Paddle) {
const script = document.createElement("script");
script.onload = () => {
window.Paddle.Setup({
vendor,
eventCallback(data: any) {
eventCallback(data);
if (data.event === "Checkout.Complete") {
setTimeout(() => router.reload(), 1000);
}
},
});
};
script.src = "https://cdn.paddle.com/paddle/paddle.js";
document.head.appendChild(script);
return;
}
}, []);
if (typeof window === "undefined") {
return { Paddle: null };
}
return { Paddle: window.Paddle };
}

22
src/hooks/use-request.ts Normal file
View File

@ -0,0 +1,22 @@
import type { AxiosError } from "axios";
import axios from "axios";
import type { UseQueryOptions } from "react-query";
import { useQuery } from "react-query";
import type { ApiError } from "../pages/api/_types";
export default function useRequest<
TData = unknown,
TError = AxiosError<ApiError>
>(url: string, options?: UseQueryOptions<TData, TError>) {
const query = createQuery<TData>(url);
return useQuery<TData, TError>(url, query, options);
}
function createQuery<T = any>(url: string) {
return async function query() {
const { data } = await axios.get<T>(url);
return data;
};
}

View File

@ -0,0 +1,92 @@
import { useEffect, useRef } from "react";
import { useRouter } from "next/router";
import axios from "axios";
import type { PlanId } from "../subscription/plans";
import { Plan } from "../subscription/plans";
import usePaddle from "./use-paddle";
export default function useSubscription() {
const router = useRouter();
const resolve = useRef<() => void>();
const promise = useRef<Promise<void>>();
const { Paddle } = usePaddle({
eventCallback(data) {
if (["Checkout.Close", "Checkout.Complete"].includes(data.event)) {
resolve.current!();
promise.current = new Promise((r) => (resolve.current = r));
}
},
});
useEffect(() => {
promise.current = new Promise((r) => (resolve.current = r));
}, []);
type BuyParams = {
email: string;
userId: string;
planId: PlanId;
coupon?: string;
};
async function subscribe(params: BuyParams) {
const { email, userId, planId, coupon } = params;
const checkoutOpenParams = {
email,
product: planId,
allowQuantity: false,
passthrough: JSON.stringify({ userId }),
coupon: "",
};
if (coupon) {
checkoutOpenParams.coupon = coupon;
}
Paddle.Checkout.open(checkoutOpenParams);
return promise.current;
}
async function updatePaymentMethod({ updateUrl }: { updateUrl: string }) {
const checkoutOpenParams = { override: updateUrl };
Paddle.Checkout.open(checkoutOpenParams);
return promise.current;
}
async function cancelSubscription({ cancelUrl }: { cancelUrl: string }) {
const checkoutOpenParams = { override: cancelUrl };
Paddle.Checkout.open(checkoutOpenParams);
return promise.current;
}
type ChangePlanParams = {
planId: Plan["id"];
};
async function changePlan({ planId }: ChangePlanParams) {
try {
await axios.post(
"/api/subscription/update",
{ planId },
{ withCredentials: true },
);
router.reload();
} catch (error) {
console.log("error", error);
}
}
return {
subscribe,
updatePaymentMethod,
cancelSubscription,
changePlan,
};
}

52
src/hooks/use-user.ts Normal file
View File

@ -0,0 +1,52 @@
import { useContext } from "react";
import { useRouter } from "next/router";
import axios from "axios";
import type { User, UserAttributes } from "@supabase/supabase-js";
import { SessionContext } from "../session-context";
import appLogger from "../../lib/logger";
import supabase from "../supabase/client";
const logger = appLogger.child({ module: "useUser" });
type UseUser = {
updateUser: (attributes: UserAttributes) => Promise<void>;
deleteUser: () => Promise<void>;
} & (
| {
isLoading: true;
error: null;
userProfile: null;
}
| {
isLoading: false;
error: Error;
userProfile: User | null;
}
| {
isLoading: false;
error: null;
userProfile: User;
}
);
export default function useUser(): UseUser {
const session = useContext(SessionContext);
const router = useRouter();
return {
isLoading: session.state.user === null,
userProfile: session.state.user,
error: session.state.error,
async deleteUser() {
await axios.post("/api/user/delete-user", null, {
withCredentials: true,
});
router.push("/api/auth/sign-out");
},
async updateUser(attributes: UserAttributes) {
const { error } = await supabase.auth.update(attributes);
if (error) throw error;
}
} as UseUser;
}

39
src/pages/_app.tsx Normal file
View File

@ -0,0 +1,39 @@
import { useRef } from "react";
import type { AppProps } from "next/app";
import Head from "next/head";
import { QueryClient, QueryClientProvider } from "react-query";
import { Hydrate } from "react-query/hydration";
import { pageTitle } from "./_document";
import { SessionProvider } from "../session-context";
import "../fonts.css";
import "../tailwind.css";
const NextApp = (props: AppProps) => {
const queryClientRef = useRef<QueryClient>();
if (!queryClientRef.current) {
queryClientRef.current = new QueryClient();
}
const { Component, pageProps } = props;
return (
<QueryClientProvider client={queryClientRef.current}>
<Hydrate state={pageProps.dehydratedState}>
<SessionProvider user={pageProps.user}>
<Head>
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<title>{pageTitle}</title>
</Head>
<Component {...pageProps} />
</SessionProvider>
</Hydrate>
</QueryClientProvider>
);
};
export default NextApp;

60
src/pages/_document.tsx Normal file
View File

@ -0,0 +1,60 @@
import Document, { Html, Head, Main, NextScript } from "next/document";
export const pageTitle = "My Serverless App";
const defaultDescription = "My app, freshly generated by FSS";
const defaultOGURL = "";
const defaultOGImage = "";
class NextDocument extends Document {
public render() {
return (
<Html lang="en">
<Head>
<meta charSet="UTF-8" />
<meta name="description" content={defaultDescription} />
<link
rel="icon"
sizes="192x192"
href="/static/touch-icon.png"
/>
<link
rel="apple-touch-icon"
href="/static/touch-icon.png"
/>
<link
rel="mask-icon"
href="/static/favicon-mask.svg"
color="#49B882"
/>
<link rel="icon" href="/static/favicon.ico" />
<meta property="og:url" content={defaultOGURL} />
<meta property="og:title" content={pageTitle} />
<meta
property="og:description"
content={defaultDescription}
/>
<meta name="twitter:site" content={defaultOGURL} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={defaultOGImage} />
<meta property="og:image" content={defaultOGImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<link
rel="preload"
as="font"
type="font/woff2"
crossOrigin="anonymous"
href="/static/fonts/inter/Inter-roman.var.woff2"
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}
export default NextDocument;

View File

@ -0,0 +1,6 @@
import type { ServerResponse } from "http";
export function redirect(res: ServerResponse, to: string) {
res.writeHead(302, { Location: encodeURI(to) });
res.end();
}

View File

@ -0,0 +1,45 @@
import type { SendEmailRequest } from "aws-sdk/clients/ses";
import { Credentials, SES } from "aws-sdk";
import getConfig from "next/config";
const { serverRuntimeConfig } = getConfig();
const credentials = new Credentials({
accessKeyId: serverRuntimeConfig.awsSes.accessKeyId,
secretAccessKey: serverRuntimeConfig.awsSes.secretAccessKey,
});
const ses = new SES({
region: serverRuntimeConfig.awsSes.awsRegion,
credentials,
});
type SendEmailParams = {
body: string;
subject: string;
recipients: string[];
};
export async function sendEmail({
body,
subject,
recipients,
}: SendEmailParams) {
const request: SendEmailRequest = {
Destination: { ToAddresses: recipients },
Message: {
Body: {
Text: {
Charset: "UTF-8",
Data: body,
},
},
Subject: {
Charset: "UTF-8",
Data: subject,
},
},
Source: serverRuntimeConfig.awsSes.fromEmail,
};
await ses.sendEmail(request).promise();
}

4
src/pages/api/_types.ts Normal file
View File

@ -0,0 +1,4 @@
export type ApiError = {
statusCode: number;
errorMessage: string;
};

View File

@ -0,0 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next";
import supabase from "../../../supabase/server";
export default async function session(
req: NextApiRequest,
res: NextApiResponse,
) {
return supabase.auth.api.setAuthCookie(req, res);
}

View File

@ -0,0 +1,71 @@
import type { NextApiRequest, NextApiResponse } from "next";
import type { UserCredentials } from "@supabase/supabase-js";
import Joi from "joi";
import type { ApiError } from "../_types";
import appLogger from "../../../../lib/logger";
import supabase from "../../../supabase/server";
type Response = void | ApiError;
type Body = Pick<UserCredentials, "email" | "password">;
const logger = appLogger.child({ route: "/api/auth/sign-in" });
const bodySchema = Joi.object<Body>({
email: Joi.string().required(),
password: Joi.string().required(),
});
export default async function signIn(
req: NextApiRequest,
res: NextApiResponse<Response>,
): Promise<void> {
if (req.method !== "POST") {
const statusCode = 405;
const apiError: ApiError = {
statusCode,
errorMessage: `Method ${req.method} Not Allowed`,
};
logger.error(apiError);
res.setHeader("Allow", ["POST"]);
res.status(statusCode).send(apiError);
return;
}
const validationResult = bodySchema.validate(req.body, { stripUnknown: true });
const validationError = validationResult.error;
if (validationError) {
const statusCode = 400;
const apiError: ApiError = {
statusCode,
errorMessage: "Body is malformed",
};
logger.error(validationError);
res.status(statusCode).send(apiError);
return;
}
const body: Body = validationResult.value;
const { error } = await supabase.auth.signIn({
email: body.email,
password: body.password,
});
if (error) {
const statusCode = 400;
const apiError: ApiError = {
statusCode,
errorMessage: error.message,
};
logger.error(error);
res.status(statusCode).send(apiError);
return;
}
supabase.auth.api.setAuthCookie(req, res);
res.status(200).end();
}

View File

@ -0,0 +1,94 @@
import type { NextApiRequest, NextApiResponse } from "next";
import type { UserCredentials } from "@supabase/supabase-js";
import Joi from "joi";
import type { ApiError } from "../_types";
import appLogger from "../../../../lib/logger";
import { sendEmail } from "../_send-email";
import supabase from "../../../supabase/server";
import { createCustomer } from "../../../database/customer";
type Response = void | ApiError;
type Body = Pick<UserCredentials, "email" | "password"> & {
name: string;
};
const logger = appLogger.child({ route: "/api/auth/sign-up" });
const bodySchema = Joi.object<Body>({
name: Joi.string().required(),
email: Joi.string().required(),
password: Joi.string().required(),
});
export default async function signUp(
req: NextApiRequest,
res: NextApiResponse<Response>,
): Promise<void> {
if (req.method !== "POST") {
const statusCode = 405;
const apiError: ApiError = {
statusCode,
errorMessage: `Method ${req.method} Not Allowed`,
};
logger.error(apiError);
res.setHeader("Allow", ["POST"]);
res.status(statusCode).send(apiError);
return;
}
const validationResult = bodySchema.validate(req.body, { stripUnknown: true });
const validationError = validationResult.error;
if (validationError) {
const statusCode = 400;
const apiError: ApiError = {
statusCode,
errorMessage: "Body is malformed",
};
logger.error(validationError);
res.status(statusCode).send(apiError);
return;
}
const body: Body = validationResult.value;
const { error, user } = await supabase.auth.signUp({ email: body.email, password: body.password });
if (error) {
// @ts-ignore
const statusCode = error.status ?? 400;
const apiError: ApiError = {
statusCode,
errorMessage: error.message,
};
logger.error(error);
res.status(statusCode).send(apiError);
return;
}
await Promise.all([
supabase.auth.update({ data: { name: body.name } }),
createCustomer({
id: user!.id,
email: body.email!,
name: body.name,
}),
]);
console.log("user", user);
const email = user!.email;
if (email && email !== "") {
await sendEmail({
subject: "Welcome to my app",
body: `Hi there,
Thanks for signing up to my app.`,
recipients: [email],
});
}
res.status(200).end();
}

21
src/pages/api/ddd.ts Normal file
View File

@ -0,0 +1,21 @@
import type { NextApiRequest, NextApiResponse } from "next";
import twilio from "twilio";
export default async function ddd(req: NextApiRequest, res: NextApiResponse) {
const accountSid = "ACa886d066be0832990d1cf43fb1d53362";
const authToken = "8696a59a64b94bb4eba3548ed815953b";
// const ddd = await twilio(accountSid, authToken).incomingPhoneNumbers.list();
const phoneNumber = "+33757592025";
const ddd = await twilio(accountSid, authToken)
.messages
.list({
to: phoneNumber,
});
console.log("ddd", ddd);
return res.status(200).send(ddd);
}
// @ts-ignore
function uuid(a,b){for(b=a='';a++<36;b+=a*51&52?(a^15?8^Math.random()*(a^20?16:4):4).toString(16):'-');return b}

View File

@ -0,0 +1,21 @@
import getConfig from "next/config";
import axios from "axios";
const { serverRuntimeConfig } = getConfig();
export async function addSubscriber(email: string) {
const { apiKey, audienceId } = serverRuntimeConfig.mailChimp;
const region = apiKey.split("-")[1];
const url = `https://${region}.api.mailchimp.com/3.0/lists/${audienceId}/members`;
const data = {
email_address: email,
status: "subscribed",
};
const base64ApiKey = Buffer.from(`any:${apiKey}`).toString("base64");
const headers = {
"Content-Type": "application/json",
Authorization: `Basic ${base64ApiKey}`,
};
return axios.post(url, data, { headers });
}

View File

@ -0,0 +1,65 @@
import type { NextApiRequest, NextApiResponse } from "next";
import Joi from "joi";
import type { ApiError } from "../_types";
import appLogger from "../../../../lib/logger";
import { addSubscriber } from "./_mailchimp";
type Body = {
email: string;
};
type Response = {} | ApiError;
const logger = appLogger.child({ route: "/api/newsletter/subscribe" });
const bodySchema = Joi.object<Body>({
email: Joi.string().email().required(),
});
export default async function subscribeToNewsletter(
req: NextApiRequest,
res: NextApiResponse<Response>,
) {
if (req.method !== "POST") {
const statusCode = 405;
const apiError: ApiError = {
statusCode,
errorMessage: `Method ${req.method} Not Allowed`,
};
logger.error(apiError);
res.setHeader("Allow", ["POST"]);
res.status(statusCode).send(apiError);
return;
}
const validationResult = bodySchema.validate(req.body, {
stripUnknown: true,
});
const validationError = validationResult.error;
if (validationError) {
const statusCode = 400;
const apiError: ApiError = {
statusCode,
errorMessage: "Body is malformed",
};
logger.error(validationError);
res.status(statusCode).send(apiError);
return;
}
const { email }: Body = validationResult.value;
try {
await addSubscriber(email);
} catch (error) {
console.log("error", error.response?.data);
if (error.response?.data.title !== "Member Exists") {
return res.status(error.response?.status ?? 400).end();
}
}
res.status(200).end();
}

View File

@ -0,0 +1,36 @@
import { Queue } from "quirrel/next";
import twilio from "twilio";
import { findCustomerPhoneNumber } from "../../../database/phone-number";
import { findCustomer } from "../../../database/customer";
import insertMessagesQueue from "./insert-messages";
type Payload = {
customerId: string;
}
const fetchMessagesQueue = Queue<Payload>(
"api/queue/fetch-messages",
async ({ customerId }) => {
const customer = await findCustomer(customerId);
const phoneNumber = await findCustomerPhoneNumber(customerId);
const messagesSent = await twilio(customer.accountSid, customer.authToken)
.messages
.list({ from: phoneNumber.phoneNumber });
const messagesReceived = await twilio(customer.accountSid, customer.authToken)
.messages
.list({ to: phoneNumber.phoneNumber });
const messages = [
...messagesSent,
...messagesReceived,
].sort((a, b) => a.dateSent.getTime() - b.dateSent.getTime());
await insertMessagesQueue.enqueue({
customerId,
messages,
});
},
);
export default fetchMessagesQueue;

View File

@ -0,0 +1,33 @@
import { Queue } from "quirrel/next";
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message";
import { findCustomer } from "../../../database/customer";
import type { Sms } from "../../../database/_types";
import { SmsType } from "../../../database/_types";
import { insertManySms } from "../../../database/sms";
import { encrypt } from "../../../database/_encryption";
type Payload = {
customerId: string;
messages: MessageInstance[];
}
const insertMessagesQueue = Queue<Payload>(
"api/queue/insert-messages",
async ({ messages, customerId }) => {
const customer = await findCustomer(customerId);
const encryptionKey = customer.encryptionKey;
const sms = messages.map<Omit<Sms, "id">>(message => ({
customerId,
content: encrypt(message.body, encryptionKey),
from: message.from,
to: message.to,
type: ["received", "receiving"].includes(message.status) ? SmsType.RECEIVED : SmsType.SENT,
sentAt: message.dateSent,
}));
await insertManySms(sms);
},
);
export default insertMessagesQueue;

View File

@ -0,0 +1,93 @@
import type { NextApiRequest, NextApiResponse } from "next";
import Joi from "joi";
import type { ApiError } from "../_types";
import type { SubscriptionStatus } from "../../../database/subscriptions";
import {
findSubscription,
SUBSCRIPTION_STATUSES,
updateSubscription,
} from "../../../database/subscriptions";
import { FREE } from "../../../subscription/plans";
import appLogger from "../../../../lib/logger";
const logger = appLogger.child({ module: "subscription-cancelled" });
const bodySchema = Joi.object<Body>({
event_time: Joi.string().required(),
status: Joi.string()
.allow(...SUBSCRIPTION_STATUSES)
.required(),
subscription_id: Joi.string().required(),
});
export async function subscriptionCancelled(
req: NextApiRequest,
res: NextApiResponse,
) {
const validationResult = bodySchema.validate(req.body, {
allowUnknown: true,
});
const validationError = validationResult.error;
if (validationError) {
const statusCode = 400;
const apiError: ApiError = {
statusCode,
errorMessage: "Body is malformed",
};
logger.error(validationError, "/api/subscription/webhook");
res.status(statusCode).send(apiError);
return;
}
const body: Body = validationResult.value;
const paddleSubscriptionId = body.subscription_id;
const subscription = await findSubscription({ paddleSubscriptionId });
if (!subscription) {
const errorMessage = `Subscription with id ${paddleSubscriptionId} not found`;
const statusCode = 404;
const apiError: ApiError = {
statusCode,
errorMessage,
};
logger.error(errorMessage, "/api/subscription/webhook");
res.status(statusCode).send(apiError);
return;
}
const lastEventTime = new Date(body.event_time);
if (subscription.lastEventTime > lastEventTime) {
res.status(200).end();
return;
}
await updateSubscription({
paddleSubscriptionId,
status: body.status,
lastEventTime,
});
return res.status(200).end();
}
type Body = {
alert_id: string;
alert_name: string;
cancellation_effective_date: string;
checkout_id: string;
currency: string;
email: string;
event_time: string;
linked_subscriptions: string;
marketing_consent: string;
passthrough: string;
quantity: string;
status: SubscriptionStatus;
subscription_id: string;
subscription_plan_id: string;
unit_price: string;
user_id: string;
p_signature: string;
};

View File

@ -0,0 +1,131 @@
import type { NextApiRequest, NextApiResponse } from "next";
import Joi from "joi";
import type { SubscriptionStatus } from "../../../database/subscriptions";
import {
createSubscription,
findUserSubscription,
updateSubscription,
SUBSCRIPTION_STATUSES,
} from "../../../database/subscriptions";
import { sendEmail } from "../_send-email";
import type { ApiError } from "../_types";
import appLogger from "../../../../lib/logger";
import { PAID_PLANS } from "../../../subscription/plans";
const logger = appLogger.child({ module: "subscription-created" });
const bodySchema = Joi.object<Body>({
checkout_id: Joi.string().required(),
email: Joi.string().required(),
event_time: Joi.string().required(),
next_bill_date: Joi.string().required(),
passthrough: Joi.string().required(),
status: Joi.string()
.allow(...SUBSCRIPTION_STATUSES)
.required(),
subscription_id: Joi.string().required(),
subscription_plan_id: Joi.string().required(),
});
export async function subscriptionCreatedHandler(
req: NextApiRequest,
res: NextApiResponse,
) {
const validationResult = bodySchema.validate(req.body, {
allowUnknown: true,
});
const validationError = validationResult.error;
if (validationError) {
const statusCode = 400;
const apiError: ApiError = {
statusCode,
errorMessage: "Body is malformed",
};
logger.error(validationError, "/api/subscription/webhook");
res.status(statusCode).send(apiError);
return;
}
const body: Body = validationResult.value;
const paddleCheckoutId = body.checkout_id;
const paddleSubscriptionId = body.subscription_id;
const planId = body.subscription_plan_id;
const { userId } = JSON.parse(body.passthrough);
const email = body.email;
const nextBillDate = new Date(body.next_bill_date);
const status = body.status;
const lastEventTime = new Date(body.event_time);
const updateUrl = body.update_url;
const cancelUrl = body.cancel_url;
const subscription = await findUserSubscription({ userId });
const teamHasSubscription = Boolean(subscription);
if (teamHasSubscription) {
await updateSubscription({
paddleCheckoutId,
paddleSubscriptionId,
planId,
nextBillDate,
status,
lastEventTime,
updateUrl,
cancelUrl,
});
sendEmail({
subject: "Thanks for coming back",
body: "Thanks for coming back",
recipients: [email],
}).catch((error) => {
logger.error(error, "/api/subscription/webhook");
});
} else {
await createSubscription({
paddleCheckoutId,
paddleSubscriptionId,
userId,
planId,
nextBillDate,
status,
lastEventTime,
updateUrl,
cancelUrl,
});
const nextPlan = PAID_PLANS[planId];
sendEmail({
subject: "Thanks for your purchase",
body: `Welcome to ${nextPlan.name} plan`,
recipients: [email],
}).catch((error) => {
logger.error(error, "/api/subscription/webhook");
});
}
return res.status(200).end();
}
type Body = {
alert_id: string;
alert_name: string;
cancel_url: string;
checkout_id: string;
currency: string;
email: string;
event_time: string;
linked_subscriptions: string;
marketing_consent: string;
next_bill_date: string;
passthrough: string;
quantity: string;
source: string;
status: SubscriptionStatus;
subscription_id: string;
subscription_plan_id: string;
unit_price: string;
update_url: string;
user_id: string;
p_signature: string;
};

View File

@ -0,0 +1,90 @@
import type { NextApiRequest, NextApiResponse } from "next";
import type { SubscriptionStatus } from "../../../database/subscriptions";
import {
findSubscription,
updateSubscription,
} from "../../../database/subscriptions";
import type { ApiError } from "../_types";
import appLogger from "../../../../lib/logger";
const logger = appLogger.child({ module: "subscription-payment-succeeded" });
export async function subscriptionPaymentSucceededHandler(
req: NextApiRequest,
res: NextApiResponse,
) {
const body: Body = req.body;
const paddleSubscriptionId = body.subscription_id;
const subscription = await findSubscription({ paddleSubscriptionId });
if (!subscription) {
const errorMessage = `Subscription with id ${paddleSubscriptionId} not found`;
const statusCode = 404;
const apiError: ApiError = {
statusCode,
errorMessage,
};
logger.error(errorMessage, "/api/subscription/webhook");
res.status(statusCode).send(apiError);
return;
}
const lastEventTime = new Date(body.event_time);
if (subscription.lastEventTime > lastEventTime) {
res.status(200).end();
return;
}
const status = body.status;
const nextBillDate = new Date(body.next_bill_date);
await updateSubscription({
paddleSubscriptionId,
status,
lastEventTime,
nextBillDate,
});
return res.status(200).end();
}
type Body = {
alert_id: string;
alert_name: string;
balance_currency: string;
balance_earnings: string;
balance_fee: string;
balance_gross: string;
balance_tax: string;
checkout_id: string;
country: string;
coupon: string;
currency: string;
customer_name: string;
earnings: string;
email: string;
event_time: string;
fee: string;
initial_payment: string;
instalments: string;
marketing_consent: string;
next_bill_date: string;
next_payment_amount: string;
order_id: string;
passthrough: string;
payment_method: string;
payment_tax: string;
plan_name: string;
quantity: string;
receipt_url: string;
sale_gross: string;
status: SubscriptionStatus;
subscription_id: string;
subscription_payment_id: string;
subscription_plan_id: string;
unit_price: string;
user_id: string;
p_signature: string;
};

View File

@ -0,0 +1,122 @@
import type { NextApiRequest, NextApiResponse } from "next";
import Joi from "joi";
import type { ApiError } from "../_types";
import type { SubscriptionStatus } from "../../../database/subscriptions";
import {
findSubscription,
SUBSCRIPTION_STATUSES,
updateSubscription,
} from "../../../database/subscriptions";
import { PAID_PLANS } from "../../../subscription/plans";
import appLogger from "../../../../lib/logger";
import { sendEmail } from "../_send-email";
const logger = appLogger.child({ module: "subscription-updated" });
const bodySchema = Joi.object<Body>({
update_url: Joi.string().required(),
status: Joi.string()
.allow(...SUBSCRIPTION_STATUSES)
.required(),
subscription_id: Joi.string().required(),
event_time: Joi.string().required(),
});
export async function subscriptionUpdated(
req: NextApiRequest,
res: NextApiResponse,
) {
const validationResult = bodySchema.validate(req.body, {
allowUnknown: true,
});
const validationError = validationResult.error;
if (validationError) {
const statusCode = 400;
const apiError: ApiError = {
statusCode,
errorMessage: "Body is malformed",
};
logger.error(validationError, "/api/subscription/webhook");
res.status(statusCode).send(apiError);
return;
}
const body: Body = validationResult.value;
const paddleSubscriptionId = body.subscription_id;
const subscription = await findSubscription({ paddleSubscriptionId });
if (!subscription) {
const errorMessage = `Subscription with id ${paddleSubscriptionId} not found`;
const statusCode = 404;
const apiError: ApiError = {
statusCode,
errorMessage,
};
logger.error(errorMessage, "/api/subscription/webhook");
res.status(statusCode).send(apiError);
return;
}
const lastEventTime = new Date(body.event_time);
if (subscription.lastEventTime > lastEventTime) {
res.status(200).end();
return;
}
const status = body.status;
const updateUrl = body.update_url;
const cancelUrl = body.cancel_url;
const planId = body.subscription_plan_id;
const nextPlan = PAID_PLANS[planId];
await updateSubscription({
paddleSubscriptionId,
planId,
status,
lastEventTime,
updateUrl,
cancelUrl,
});
const user = await findUser({ id: subscription.userId });
sendEmail({
subject: "Thanks for your purchase",
body: `Welcome to ${nextPlan.name} plan`,
recipients: [user.email],
}).catch((error) => {
logger.error(error, "/api/subscription/webhook");
});
return res.status(200).end();
}
type Body = {
alert_id: string;
alert_name: string;
cancel_url: string;
checkout_id: string;
currency: string;
email: string;
event_time: string;
linked_subscriptions: string;
marketing_consent: string;
new_price: string;
new_quantity: string;
new_unit_price: string;
next_bill_date: string;
old_next_bill_date: string;
old_price: string;
old_quantity: string;
old_status: string;
old_subscription_plan_id: string;
old_unit_price: string;
passthrough: string;
status: SubscriptionStatus;
subscription_id: string;
subscription_plan_id: string;
update_url: string;
user_id: string;
p_signature: string;
};

View File

@ -0,0 +1,88 @@
import type { ApiError } from "../_types";
import { withApiAuthRequired } from "../../../../lib/session-helpers";
import appLogger from "../../../../lib/logger";
import { findUserSubscription } from "../../../database/subscriptions";
import Joi from "joi";
import {
cancelPaddleSubscription,
updateSubscriptionPlan,
} from "../../../subscription/_paddle-api";
type Body = {
planId: string;
};
type Response = {} | ApiError;
const logger = appLogger.child({
route: "/api/subscription/update-subscription",
});
const bodySchema = Joi.object<Body>({
planId: Joi.string().required(),
});
export default withApiAuthRequired<Response>(async function updateSubscription(
req,
res,
session,
) {
if (req.method !== "POST") {
const statusCode = 405;
const apiError: ApiError = {
statusCode,
errorMessage: `Method ${req.method} Not Allowed`,
};
logger.error(apiError);
res.setHeader("Allow", ["POST"]);
res.status(statusCode).send(apiError);
return;
}
const validationResult = bodySchema.validate(req.body, {
stripUnknown: true,
});
const validationError = validationResult.error;
if (validationError) {
const statusCode = 400;
const apiError: ApiError = {
statusCode,
errorMessage: "Body is malformed",
};
logger.error(validationError);
res.status(statusCode).send(apiError);
return;
}
const { planId }: Body = validationResult.value;
const subscription = await findUserSubscription({
teamId: session.user.teamId,
});
if (!subscription) {
const statusCode = 500;
const apiError: ApiError = {
statusCode,
errorMessage: "You are not subscribed yet, this should not happen.",
};
logger.error(apiError);
res.status(statusCode).send(apiError);
return;
}
const subscriptionId = subscription.paddleSubscriptionId;
const isMovingToFreePlan = planId === "free";
if (isMovingToFreePlan) {
await cancelPaddleSubscription({ subscriptionId });
res.status(200).end();
return;
}
await updateSubscriptionPlan({
planId,
subscriptionId,
});
res.status(200).end();
});

View File

@ -0,0 +1,78 @@
import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
import getConfig from "next/config";
import { PaddleSdk, stringifyMetadata } from "@devoxa/paddle-sdk";
import { subscriptionCreatedHandler } from "./_subscription-created";
import { subscriptionPaymentSucceededHandler } from "./_subscription-payment-succeeded";
import { subscriptionCancelled } from "./_subscription-cancelled";
import { subscriptionUpdated } from "./_subscription-updated";
import type { ApiError } from "../_types";
import appLogger from "../../../../lib/logger";
type SupportedWebhook =
| "subscription_created"
| "subscription_cancelled"
| "subscription_payment_succeeded"
| "subscription_updated";
const supportedWebhooks: SupportedWebhook[] = [
"subscription_created",
"subscription_cancelled",
"subscription_payment_succeeded",
"subscription_updated",
];
const handlers: Record<SupportedWebhook, NextApiHandler> = {
subscription_created: subscriptionCreatedHandler,
subscription_payment_succeeded: subscriptionPaymentSucceededHandler,
subscription_cancelled: subscriptionCancelled,
subscription_updated: subscriptionUpdated,
};
function isSupportedWebhook(webhook: any): webhook is SupportedWebhook {
return supportedWebhooks.includes(webhook);
}
const logger = appLogger.child({ route: "/api/subscription/webhook" });
const { publicRuntimeConfig, serverRuntimeConfig } = getConfig();
const paddleSdk = new PaddleSdk({
publicKey: serverRuntimeConfig.paddle.publicKey,
vendorId: publicRuntimeConfig.paddle.vendorId,
vendorAuthCode: serverRuntimeConfig.paddle.apiKey,
metadataCodec: stringifyMetadata(),
});
export default async function webhook(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") {
const statusCode = 405;
const apiError: ApiError = {
statusCode,
errorMessage: `Method ${req.method} Not Allowed`,
};
logger.error(apiError);
res.setHeader("Allow", ["POST"]);
res.status(statusCode).send(apiError);
return;
}
if (!paddleSdk.verifyWebhookEvent(req.body)) {
const statusCode = 500;
const apiError: ApiError = {
statusCode,
errorMessage: "Webhook event is invalid",
};
logger.error(apiError);
return res.status(statusCode).send(apiError);
}
const alertName = req.body.alert_name;
if (isSupportedWebhook(alertName)) {
return handlers[alertName](req, res);
}
return res.status(400).end();
}

View File

@ -0,0 +1,52 @@
import Joi from "joi";
import twilio from "twilio";
import type { ApiError } from "../_types";
import { withApiAuthRequired } from "../../../../lib/session-helpers";
import appLogger from "../../../../lib/logger";
import { createPhoneNumber } from "../../../database/phone-number";
import { findCustomer } from "../../../database/customer";
import fetchMessagesQueue from "../queue/fetch-messages";
const logger = appLogger.child({ route: "/api/user/add-phone-number" });
type Body = {
phoneNumberSid: string;
}
export default withApiAuthRequired(async function addPhoneNumberHandler(req, res, user) {
const bodySchema = Joi.object<Body>({
phoneNumberSid: Joi.string().required(),
});
const validationResult = bodySchema.validate(req.body, { stripUnknown: true });
const validationError = validationResult.error;
if (validationError) {
const statusCode = 400;
const apiError: ApiError = {
statusCode,
errorMessage: "Body is malformed",
};
logger.error(validationError);
res.status(statusCode).send(apiError);
return;
}
const customerId = user.id;
const customer = await findCustomer(customerId);
const phoneNumbers = await twilio(customer.accountSid, customer.authToken)
.incomingPhoneNumbers
.list();
const { phoneNumberSid }: Body = validationResult.value;
const phoneNumber = phoneNumbers.find(phoneNumber => phoneNumber.sid === phoneNumberSid)!;
await createPhoneNumber({
customerId,
phoneNumberSid,
phoneNumber: phoneNumber.phoneNumber,
});
await fetchMessagesQueue.enqueue({ customerId });
return res.status(200).end();
});

View File

@ -0,0 +1,71 @@
import type { ApiError } from "../_types";
import { withApiAuthRequired } from "../../../../lib/session-helpers";
import { deleteSubscription } from "../../../database/subscriptions";
import { cancelPaddleSubscription } from "../../../subscription/_paddle-api";
import appLogger from "../../../../lib/logger";
type Response = void | ApiError;
const logger = appLogger.child({ route: "/api/user/delete-user" });
export default withApiAuthRequired<Response>(async function deleteUserHandler(
req,
res,
session,
) {
if (req.method !== "POST") {
const statusCode = 405;
const apiError: ApiError = {
statusCode,
errorMessage: `Method ${req.method} Not Allowed`,
};
logger.error(apiError);
res.setHeader("Allow", ["POST"]);
res.status(statusCode).send(apiError);
return;
}
const { id: userId, role, teamId } = session.user;
const team = await findTeam({ id: teamId });
const subscriptionId = team!.subscriptionId;
try {
let actions: Promise<any>[] = [
deleteAuth0User({ id: userId }),
deleteUser({ id: userId, teamId }),
];
if (role === "owner") {
const teamMembers = await findUsersByTeam({ teamId });
teamMembers.forEach((member) =>
actions.push(deleteUser({ id: member.id, teamId })),
);
actions.push(deleteTeam({ id: teamId }));
if (subscriptionId) {
actions.push(
cancelPaddleSubscription({ subscriptionId }),
deleteSubscription({
paddleSubscriptionId: subscriptionId,
}),
);
}
}
await Promise.all(actions);
res.status(200).end();
} catch (error) {
const statusCode = error.statusCode ?? 500;
const apiError: ApiError = {
statusCode,
errorMessage: error.message,
};
logger.error(apiError);
res.status(statusCode).send(apiError);
}
});

View File

@ -0,0 +1,21 @@
import Joi from "joi";
import twilio from "twilio";
import type { ApiError } from "../_types";
import { withApiAuthRequired } from "../../../../lib/session-helpers";
import appLogger from "../../../../lib/logger";
import { createPhoneNumber } from "../../../database/phone-number";
import { findCustomer } from "../../../database/customer";
const logger = appLogger.child({ route: "/api/user/list-twilio-numbers" });
export default withApiAuthRequired(async function listTwilioNumbersHandler(req, res, user) {
const customer = await findCustomer(user.id);
const phoneNumbers = await twilio(customer.accountSid, customer.authToken)
.incomingPhoneNumbers
.list();
return res.status(200).send({
phoneNumbers: phoneNumbers.map(({ phoneNumber, sid }) => ({ phoneNumber, sid })),
});
});

View File

@ -0,0 +1,30 @@
import type { ApiError } from "../_types";
import type Session from "../../../../lib/session";
import {
sessionCache,
withApiAuthRequired,
} from "../../../../lib/session-helpers";
import appLogger from "../../../../lib/logger";
type Response = Session | ApiError;
const logger = appLogger.child({ route: "/api/user/session" });
export default withApiAuthRequired<Response>(async function session(req, res) {
if (req.method !== "GET") {
const statusCode = 405;
const apiError: ApiError = {
statusCode,
errorMessage: `Method ${req.method} Not Allowed`,
};
logger.error(apiError);
res.setHeader("Allow", ["GET"]);
res.status(statusCode).send(apiError);
return;
}
const session = sessionCache.get(req, res)!;
res.status(200).send(session);
});

View File

@ -0,0 +1,115 @@
import Joi from "joi";
import type { ApiError } from "../_types";
import { withApiAuthRequired } from "../../../../lib/session-helpers";
import appLogger from "../../../../lib/logger";
import supabase from "../../../supabase/server";
import { UserAttributes } from "@supabase/gotrue-js/dist/main/lib/types";
import { Customer, updateCustomer } from "../../../database/customer";
type Response = void | ApiError;
type Body = {
name?: string;
email?: string;
password?: string;
twilioAccountSid?: string;
twilioAuthToken?: string;
};
const logger = appLogger.child({ route: "/api/user/update-user" });
const bodySchema = Joi.object<Body>({
name: Joi.string().allow(""),
email: Joi.string().email().allow(""),
password: Joi.string().allow(""),
twilioAccountSid: Joi.string().allow(""),
twilioAuthToken: Joi.string().allow(""),
});
export default withApiAuthRequired<Response>(async function updateUserHandler(
req,
res,
user,
) {
if (req.method !== "POST") {
const statusCode = 405;
const apiError: ApiError = {
statusCode,
errorMessage: `Method ${req.method} Not Allowed`,
};
logger.error(apiError);
res.setHeader("Allow", ["POST"]);
res.status(statusCode).send(apiError);
return;
}
const validationResult = bodySchema.validate(req.body);
const validationError = validationResult.error;
if (validationError) {
const statusCode = 400;
const apiError: ApiError = {
statusCode,
errorMessage: "Body is malformed",
};
logger.error(validationError);
res.status(statusCode).send(apiError);
return;
}
const body: Body = validationResult.value;
const {
name,
email,
password,
twilioAuthToken,
twilioAccountSid,
} = body;
const shouldUpdateName = name?.length && name !== user.user_metadata.name;
const shouldUpdateEmail = email?.length && email !== user.email;
const shouldUpdatePassword = password?.length;
const shouldUpdateTwilioCredentials = twilioAuthToken?.length && twilioAccountSid?.length;
try {
let updatedSupabaseUser: UserAttributes = {};
let updatedCustomer: Partial<Customer> = {};
if (shouldUpdateName) {
updatedSupabaseUser.data = { name };
updatedCustomer.name = name;
}
if (shouldUpdateEmail) {
updatedSupabaseUser.email = email;
updatedCustomer.email = email;
// TODO: once Paddle allows it, update customer email through their API
}
if (shouldUpdatePassword) {
updatedSupabaseUser.password = password;
}
if (shouldUpdateTwilioCredentials) {
updatedCustomer.accountSid = twilioAccountSid;
updatedCustomer.authToken = twilioAuthToken;
}
await Promise.all([
supabase.auth.update(updatedSupabaseUser),
updateCustomer(user.id, updatedCustomer),
]);
res.status(200).end();
} catch (error) {
const statusCode = error.statusCode ?? 500;
const apiError: ApiError = {
statusCode,
errorMessage: error.message,
};
logger.error(apiError);
res.status(statusCode).send(apiError);
}
});

View File

@ -0,0 +1,127 @@
import type { NextPage } from "next";
import clsx from "clsx";
import { useForm } from "react-hook-form";
import Alert from "../../components/alert";
import useAuth from "../../hooks/use-auth";
import { withPageAuthNotRequired } from "../../../lib/session-helpers";
import appLogger from "../../../lib/logger";
import Logo from "../../components/logo";
type Form = {
email: string;
};
const logger = appLogger.child({ page: "/auth/forgot-password" });
const ForgotPassword: NextPage = () => {
const auth = useAuth();
const {
register,
handleSubmit,
setError,
formState: { isSubmitting, isSubmitSuccessful, errors },
} = useForm<Form>();
const onSubmit = handleSubmit(async ({ email }) => {
if (isSubmitting) {
return;
}
try {
await auth.resetPassword(email);
} catch (error) {
logger.error(error);
setError("email", { message: error.message });
}
});
const errorMessage = errors.email?.message;
return (
<div>
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="flex flex-col sm:mx-auto sm:w-full sm:max-w-sm">
<Logo className="mx-auto h-8 w-8" />
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">
Get a new password
</h2>
<p className="mt-2 px-4 text-center text-sm leading-5 text-gray-600">
Enter your user account&apos;s email address and we will
send you a password reset link.
</p>
</div>
{errorMessage ? (
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-sm">
<Alert
title="Oops, there was an issue"
message={errorMessage}
variant="error"
/>
</div>
) : null}
{isSubmitSuccessful ? (
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-sm">
<Alert
title="Password reset email sent"
message="Check your inbox for a link to reset your password. If it doesn't appear within a few minutes, check your spam folder."
variant="success"
/>
</div>
) : null}
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-sm">
<div className="px-4">
<form onSubmit={onSubmit}>
<div>
<label
htmlFor="email"
className="block text-sm font-medium leading-5 text-gray-700"
>
Email address
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="email"
type="email"
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
{...register("email")}
required
/>
</div>
</div>
<div className="mt-6">
<span className="block w-full rounded-md shadow-sm">
<button
type="submit"
className={clsx(
"w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white focus:outline-none focus:border-primary-700 focus:shadow-outline-primary transition duration-150 ease-in-out",
{
"bg-primary-400 hover:bg-primary-400 active:bg-primary-400": isSubmitting,
"bg-primary-600 hover:bg-primary-500 active:bg-primary-700": !isSubmitting,
},
)}
disabled={isSubmitting}
>
{isSubmitting
? "Loading..."
: "Send password reset email"}
</button>
</span>
</div>
</form>
</div>
</div>
</div>
</div>
);
};
export default ForgotPassword;
export const getServerSideProps = withPageAuthNotRequired();

View File

@ -0,0 +1,12 @@
import type { NextPage } from "next";
import { withPageAuthNotRequired } from "../../../lib/session-helpers";
import AuthPage from "../../components/auth/auth-page";
const SignIn: NextPage = () => {
return <AuthPage authType="signIn" />;
};
export default SignIn;
export const getServerSideProps = withPageAuthNotRequired();

View File

@ -0,0 +1,34 @@
import type { NextPage } from "next";
import { useEffect } from "react";
import Link from "next/link";
import useAuth from "../../hooks/use-auth";
const SignOut: NextPage = () => {
const auth = useAuth();
useEffect(() => void auth.signOut());
return (
<div className="py-12 px-10 my-16 mx-auto w-1/2 leading-5 text-gray-900 bg-white rounded border border-gray-400 border-solid shadow-xs max-w-[400px]">
<div className="block mx-auto w-11/12 text-center max-w-screen-lg">
<h1 className="p-0 text-4xl font-black text-gray-700 normal-case min-h-[1rem]">
See you again soon!
</h1>
<Link href="/auth/sign-in">
<a className="font-bold text-teal-600 no-underline cursor-pointer hover:text-gray-800 hover:no-underline">
Log back in
</a>
</Link>
<br />
<Link href="/">
<a className="font-bold text-teal-600 no-underline cursor-pointer hover:text-gray-800 hover:no-underline">
Back to home
</a>
</Link>
</div>
</div>
);
};
export default SignOut;

View File

@ -0,0 +1,12 @@
import type { NextPage } from "next";
import { withPageAuthNotRequired } from "../../../lib/session-helpers";
import AuthPage from "../../components/auth/auth-page";
const SignUp: NextPage = () => {
return <AuthPage authType="signUp" />;
};
export default SignUp;
export const getServerSideProps = withPageAuthNotRequired();

37
src/pages/calls.tsx Normal file
View File

@ -0,0 +1,37 @@
import type { InferGetServerSidePropsType, NextPage } from "next";
import { withPageOnboardingRequired } from "../../lib/session-helpers";
import Layout from "../components/layout";
import useUser from "../hooks/use-user";
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
const pageTitle = "Calls";
const Calls: NextPage<Props> = (props) => {
const { userProfile } = useUser();
console.log("userProfile", userProfile);
if (!userProfile) {
return <Layout title={pageTitle}>Loading...</Layout>;
}
return (
<Layout title={pageTitle}>
<div className="flex flex-col space-y-6 p-6">
<p>Calls page</p>
</div>
</Layout>
);
};
export const getServerSideProps = withPageOnboardingRequired(
async (context, user) => {
return {
props: { userId: user.id, ddd: 23 as const },
};
},
);
export default Calls;

268
src/pages/index.tsx Normal file
View File

@ -0,0 +1,268 @@
import type { NextPage } from "next";
import Link from "next/link";
import Image from "next/image";
import clsx from "clsx";
import { useForm } from "react-hook-form";
import Logo from "../components/logo";
const Index: NextPage = () => {
return (
<section className="bg-white">
<Hero />
<Features />
<Newsletter />
<Footer />
</section>
);
};
function Hero() {
return (
<section className="bg-primary-700 bg-opacity-5">
<header className="max-w-screen-lg mx-auto px-3 py-6">
<div className="flex flex-wrap justify-between items-center">
<Link href="/">
<a>
<Logo className="h-8 w-8" />
</a>
</Link>
<nav className="flex items-center justify-end flex-1 w-0">
<Link href="/auth/sign-in">
<a className="whitespace-nowrap text-base font-medium text-gray-600 hover:text-gray-900 transition duration-150 ease-in-out">
Sign in
</a>
</Link>
<Link href="/auth/sign-up">
<a className="ml-8 whitespace-nowrap inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-base font-medium text-white bg-primary-600 hover:bg-primary-700 transition duration-150 ease-in-out">
Sign up
</a>
</Link>
</nav>
</div>
</header>
<main className="max-w-screen-lg mx-auto px-3 pt-16 pb-24 text-center">
<h2 className="text-5xl tracking-tight font-extrabold text-gray-900">
Welcome to your
<br />
<span className="text-primary-600">serverless</span> web app
</h2>
<p className="mt-3 text-lg text-gray-800 sm:mt-5 sm:max-w-xl sm:mx-auto">
Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure
qui lorem cupidatat commodo. Elit sunt amet fugiat veniam
occaecat fugiat aliqua.
</p>
<div className="mt-12 space-y-3 sm:space-y-0 sm:space-x-3 sm:flex sm:flex-row-reverse sm:justify-center">
<div className="rounded-md shadow">
<Link href="/auth/sign-up">
<a className="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base leading-6 font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 transition duration-150 ease-in-out md:py-4 md:text-lg">
Create an account
</a>
</Link>
</div>
<div>
<Link href="/auth/sign-in">
<a className="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base leading-6 font-medium rounded-md text-gray-600 hover:text-gray-900 transition duration-150 ease-in-out md:py-4 md:text-lg">
I&apos;m already a user
</a>
</Link>
</div>
</div>
</main>
</section>
);
}
function Features() {
return (
<div className="py-20">
<div className="max-w-screen-lg mx-auto space-y-32 px-3">
<div className="text-center">
<h3 className="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl sm:leading-10">
A better way to bootstrap your app
</h3>
<p className="mt-4 max-w-2xl text-lg leading-7 text-gray-600 lg:mx-auto">
Lorem ipsum dolor sit amet consect adipisicing elit.
Possimus magnam voluptatum cupiditate veritatis in
accusamus quisquam.
</p>
</div>
<Feature
title="Feature #1"
description="Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste dolor cupiditate blanditiis ratione."
illustration="/static/illustrations/support-team.svg"
/>
<Feature
title="Feature #2"
description="Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste dolor cupiditate blanditiis ratione."
illustration="/static/illustrations/data-analytics.svg"
isReversed
/>
<Feature
title="Feature #3"
description="Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste dolor cupiditate blanditiis ratione."
illustration="/static/illustrations/learn-coding.svg"
/>
</div>
</div>
);
}
type FeatureProps = {
title: string;
description: string;
illustration: string;
isReversed?: true;
};
function Feature({
title,
description,
illustration,
isReversed,
}: FeatureProps) {
return (
<div
className={clsx(
"flex flex-col-reverse items-center justify-between",
isReversed && "md:flex-row-reverse",
!isReversed && "md:flex-row",
)}
>
<div className="flex-1 text-center">
<h3 className="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl sm:leading-10">
{title}
</h3>
<div className="mt-6 text-lg leading-9 text-gray-800">
{description}
</div>
</div>
<div className="relative w-96 h-60">
<Image
src={illustration}
layout="fill"
alt={`Feature ${title} illustration`}
/>
</div>
</div>
);
}
function Newsletter() {
const {
register,
handleSubmit,
setValue,
formState: { isSubmitted, isSubmitting },
} = useForm<{ email: string }>();
const onSubmit = handleSubmit(async ({ email }) => {
try {
const { default: axios } = await import("axios");
await axios.post("/api/newsletter/subscribe", { email });
setValue("email", "");
} catch (error) {
console.error(error);
}
});
return (
<div className="bg-primary-700 bg-opacity-5">
<div className="max-w-screen-lg mx-auto px-3 py-16 xl:flex xl:items-center">
<div className="xl:w-0 xl:flex-1">
<h2 className="text-3xl font-extrabold tracking-tight">
Want to know when we launch?
</h2>
<p className="mt-3 max-w-3xl text-lg leading-6 text-gray-600">
Lorem ipsum, dolor sit amet.
</p>
</div>
<div className="mt-8 sm:w-full sm:max-w-md xl:mt-0 xl:ml-8">
{isSubmitting || isSubmitted ? (
<span className="text-green-600">
Thanks! We&apos;ll let you know when we launch
</span>
) : (
<form onSubmit={onSubmit} className="sm:flex">
<input
id="email"
type="email"
autoComplete=""
className="w-full border-gray-300 px-5 py-3 placeholder-gray-500 rounded-md"
placeholder="Email address"
{...register("email")}
required
/>
<button
type="submit"
className="mt-3 w-full flex items-center justify-center px-5 py-3 border border-transparent shadow text-base font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 transition duration-150 ease-in-out sm:mt-0 sm:ml-3 sm:w-auto sm:flex-shrink-0"
>
💌 Notify me
</button>
</form>
)}
</div>
</div>
</div>
);
}
function Footer() {
return (
<div className="max-w-screen-xl mx-auto py-12 px-4 sm:px-6 md:flex md:items-center md:justify-between lg:px-8">
<div className="flex justify-center md:order-2">
<a
href="https://twitter.com/m5r_m"
className="ml-6 text-gray-500 hover:text-gray-600"
>
<span className="sr-only">Twitter</span>
<svg
className="h-6 w-6"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84" />
</svg>
</a>
<a
href="https://github.com/m5r"
className="ml-6 text-gray-500 hover:text-gray-600"
>
<span className="sr-only">GitHub</span>
<svg
className="h-6 w-6"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fillRule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clipRule="evenodd"
/>
</svg>
</a>
</div>
<div className="mt-8 md:mt-0 md:order-1">
<p className="text-center text-base leading-6 text-gray-600">
&copy; 2021{" "}
<a
href="https://www.capsulecorp.dev"
target="_blank"
rel="noopener noreferrer"
>
Capsule Corp.
</a>
</p>
</div>
</div>
);
}
export default Index;

129
src/pages/keypad.tsx Normal file
View File

@ -0,0 +1,129 @@
import type { InferGetServerSidePropsType, NextPage } from "next";
import type { FunctionComponent } from "react";
import { atom, useAtom } from "jotai";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBackspace, faPhoneAlt as faPhone } from "@fortawesome/pro-solid-svg-icons";
import { withPageOnboardingRequired } from "../../lib/session-helpers";
import Layout from "../components/layout";
import useUser from "../hooks/use-user";
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
const pageTitle = "Keypad";
const Keypad: NextPage<Props> = () => {
const { userProfile } = useUser();
const phoneNumber = useAtom(phoneNumberAtom)[0];
const pressBackspace = useAtom(pressBackspaceAtom)[1];
if (!userProfile) {
return <Layout title={pageTitle}>Loading...</Layout>;
}
return (
<Layout title={pageTitle}>
<div className="w-96 h-full flex flex-col justify-around py-5 mx-auto text-center text-black bg-white">
<div className="h-16 text-3xl text-gray-700">
<span>{phoneNumber}</span>
</div>
<section>
<Row>
<Digit digit="1" />
<Digit digit="2"><DigitLetters>ABC</DigitLetters></Digit>
<Digit digit="3"><DigitLetters>DEF</DigitLetters></Digit>
</Row>
<Row>
<Digit digit="4"><DigitLetters>GHI</DigitLetters></Digit>
<Digit digit="5"><DigitLetters>JKL</DigitLetters></Digit>
<Digit digit="6"><DigitLetters>MNO</DigitLetters></Digit>
</Row>
<Row>
<Digit digit="7"><DigitLetters>PQRS</DigitLetters></Digit>
<Digit digit="8"><DigitLetters>TUV</DigitLetters></Digit>
<Digit digit="9"><DigitLetters>WXYZ</DigitLetters></Digit>
</Row>
<Row>
<Digit digit="*" />
<ZeroDigit />
<Digit digit="#" />
</Row>
<Row>
<div
className="col-start-2 h-12 w-12 flex justify-center items-center mx-auto bg-green-800 rounded-full">
<FontAwesomeIcon icon={faPhone} color="white" size="lg" />
</div>
<div className="my-auto" onClick={pressBackspace}>
<FontAwesomeIcon icon={faBackspace} size="lg" />
</div>
</Row>
</section>
</div>
</Layout>
);
};
const ZeroDigit: FunctionComponent = () => {
return (
<div className="text-3xl cursor-pointer">
0 <DigitLetters>+</DigitLetters>
</div>
);
};
const Row: FunctionComponent = ({ children }) => (
<div className="grid grid-cols-3 p-4 my-0 mx-auto text-black">
{children}
</div>
);
const Digit: FunctionComponent<{ digit: string }> = ({ children, digit }) => {
const pressDigit = useAtom(pressDigitAtom)[1];
const onClick = () => pressDigit(digit);
return (
<div onClick={onClick} className="text-3xl cursor-pointer">
{digit}
{children}
</div>
);
};
const DigitLetters: FunctionComponent = ({ children }) => (
<div className="text-xs text-gray-600">
{children}
</div>
);
const phoneNumberAtom = atom("");
const pressDigitAtom = atom(
null,
(get, set, digit) => {
if (get(phoneNumberAtom).length > 17) {
return;
}
set(phoneNumberAtom, prevState => prevState + digit);
},
);
const pressBackspaceAtom = atom(
null,
(get, set) => {
if (get(phoneNumberAtom).length === 0) {
return;
}
set(phoneNumberAtom, prevState => prevState.slice(0, -1));
},
);
export const getServerSideProps = withPageOnboardingRequired(
async (context, user) => {
return {
props: { userId: user.id, ddd: 23 as const },
};
},
);
export default Keypad;

83
src/pages/messages.tsx Normal file
View File

@ -0,0 +1,83 @@
import type { InferGetServerSidePropsType, NextPage } from "next";
import Link from "next/link";
import { withPageOnboardingRequired } from "../../lib/session-helpers";
import Layout from "../components/layout";
import useUser from "../hooks/use-user";
import type { Sms } from "../database/_types";
import { SmsType } from "../database/_types";
import { findCustomerMessages } from "../database/sms";
import { findCustomer } from "../database/customer";
import { decrypt } from "../database/_encryption";
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
const pageTitle = "Messages";
const Messages: NextPage<Props> = ({ conversations }) => {
const { userProfile } = useUser();
if (!userProfile) {
return <Layout title={pageTitle}>Loading...</Layout>;
}
console.log("conversations", conversations);
return (
<Layout title={pageTitle}>
<div className="flex flex-col space-y-6 p-6">
<p>Messages page</p>
<ul>
{Object.entries(conversations).map(([recipient, conversation]) => {
const lastMessage = conversation[conversation.length - 1];
return (
<li key={recipient}>
<Link href={`/messages/${recipient}`}>
<a>
<div>{recipient}</div>
<div>{lastMessage.content}</div>
</a>
</Link>
</li>
)
})}
</ul>
</div>
</Layout>
);
};
type Recipient = string;
export type Conversation = Record<Recipient, Sms[]>;
export const getServerSideProps = withPageOnboardingRequired(
async (context, user) => {
const customer = await findCustomer(user.id);
const messages = await findCustomerMessages(user.id);
const conversations = messages.reduce<Conversation>((acc, message) => {
let recipient: string;
if (message.type === SmsType.SENT) {
recipient = message.to;
} else {
recipient = message.from;
}
if (!acc[recipient]) {
acc[recipient] = [];
}
acc[recipient].push({
...message,
content: decrypt(message.content, customer.encryptionKey), // TODO: should probably decrypt on the phone
});
return acc;
}, {});
return {
props: { conversations },
};
},
);
export default Messages;

View File

@ -0,0 +1,83 @@
import type { NextPage } from "next";
import { useRouter } from "next/router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronLeft } from "@fortawesome/pro-regular-svg-icons";
import clsx from "clsx";
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
import Layout from "../../components/layout";
import useUser from "../../hooks/use-user";
import { findConversation } from "../../database/sms";
import { decrypt } from "../../database/_encryption";
import { findCustomer } from "../../database/customer";
import type { Sms } from "../../database/_types";
import { SmsType } from "../../database/_types";
type Props = {
recipient: string;
conversation: Sms[];
};
const Messages: NextPage<Props> = ({ conversation }) => {
const { userProfile } = useUser();
const router = useRouter();
const pageTitle = `Messages with ${router.query.recipient}`;
console.log("userProfile", userProfile);
if (!userProfile) {
return <Layout title={pageTitle}>Loading...</Layout>;
}
return (
<Layout title={pageTitle}>
<header className="flex">
<span className="flex items-center cursor-pointer" onClick={router.back}>
<FontAwesomeIcon className="h-8 w-8" icon={faChevronLeft} /> Back
</span>
</header>
<div className="flex flex-col space-y-6 p-6">
<ul>
{conversation.map(message => {
return (
<li key={message.id} className={clsx(message.type === SmsType.SENT ? "text-right" : "text-left")}>
{message.content}
</li>
)
})}
</ul>
</div>
</Layout>
);
};
export const getServerSideProps = withPageOnboardingRequired<Props>(
async (context, user) => {
const recipient = context.params?.recipient;
if (!recipient || Array.isArray(recipient)) {
return {
redirect: {
destination: "/messages",
permanent: false,
},
};
}
const customer = await findCustomer(user.id);
const conversation = await findConversation(user.id, recipient);
console.log("conversation", conversation);
console.log("recipient", recipient);
return {
props: {
recipient,
conversation: conversation.map(message => ({
...message,
content: decrypt(message.content, customer.encryptionKey),
})),
},
};
},
);
export default Messages;

View File

@ -0,0 +1,57 @@
import type { NextPage } from "next";
import useUser from "../../hooks/use-user";
import SettingsLayout from "../../components/settings/settings-layout";
import Alert from "../../components/alert";
import ProfileInformations from "../../components/settings/profile-informations";
import Divider from "../../components/divider";
import UpdatePassword from "../../components/settings/update-password";
import DangerZone from "../../components/settings/danger-zone";
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
const Account: NextPage = () => {
const user = useUser();
if (user.isLoading) {
return <SettingsLayout>Loading...</SettingsLayout>;
}
if (user.error !== null) {
return (
<SettingsLayout>
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<Alert
title="Oops, there was an issue"
message={user.error.message}
variant="error"
/>
</div>
</SettingsLayout>
);
}
return (
<SettingsLayout>
<div className="flex flex-col space-y-6 p-6">
<ProfileInformations />
<div className="hidden lg:block">
<Divider />
</div>
<UpdatePassword />
<div className="hidden lg:block">
<Divider />
</div>
<DangerZone />
</div>
</SettingsLayout>
);
};
export const getServerSideProps = withPageOnboardingRequired();
export default Account;

View File

@ -0,0 +1,106 @@
import type { FunctionComponent, MouseEventHandler } from "react";
import type { NextPage } from "next";
import { ExternalLinkIcon } from "@heroicons/react/outline";
import SettingsLayout from "../../components/settings/settings-layout";
import SettingsSection from "../../components/settings/settings-section";
import BillingPlans from "../../components/billing/billing-plans";
import Divider from "../../components/divider";
import useSubscription from "../../hooks/use-subscription";
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
import type { Subscription } from "../../database/subscriptions";
import { findUserSubscription } from "../../database/subscriptions";
import appLogger from "../../../lib/logger";
const logger = appLogger.child({ page: "/account/settings/billing" });
type Props = {
subscription: Subscription | null;
};
const Billing: NextPage<Props> = ({ subscription }) => {
/*
TODO: I want to be able to
- renew subscription (after pause/cancel for example) (message like "your subscription expired, would you like to renew ?")
- know when is the last time I paid and for how much
- know when is the next time I will pay and for how much
*/
const { cancelSubscription, updatePaymentMethod } = useSubscription();
return (
<SettingsLayout>
<div className="flex flex-col space-y-6 p-6">
{subscription ? (
<>
<SettingsSection title="Payment method">
<PaddleLink
onClick={() =>
updatePaymentMethod({
updateUrl: subscription.updateUrl,
})
}
text="Update payment method on Paddle"
/>
</SettingsSection>
<div className="hidden lg:block">
<Divider />
</div>
<SettingsSection title="Plan">
<BillingPlans activePlanId={subscription?.planId} />
</SettingsSection>
<div className="hidden lg:block">
<Divider />
</div>
<SettingsSection title="Cancel subscription">
<PaddleLink
onClick={() =>
cancelSubscription({
cancelUrl: subscription.cancelUrl,
})
}
text="Cancel subscription on Paddle"
/>
</SettingsSection>
</>
) : (
<SettingsSection title="Plan">
<BillingPlans />
</SettingsSection>
)}
</div>
</SettingsLayout>
);
};
export default Billing;
type PaddleLinkProps = {
onClick: MouseEventHandler<HTMLButtonElement>;
text: string;
};
const PaddleLink: FunctionComponent<PaddleLinkProps> = ({ onClick, text }) => (
<button className="flex space-x-2 items-center text-left" onClick={onClick}>
<ExternalLinkIcon className="w-6 h-6 flex-shrink-0" />
<span className="transition-colors duration-150 border-b border-transparent hover:border-primary-500">
{text}
</span>
</button>
);
export const getServerSideProps = withPageOnboardingRequired<Props>(
async (context, user) => {
// const subscription = await findUserSubscription({ userId: user.id });
return {
props: { subscription: null },
};
},
);

View File

@ -0,0 +1,52 @@
import type { InferGetServerSidePropsType, NextPage } from "next";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCreditCard, faUserCircle } from "@fortawesome/pro-regular-svg-icons";
import Layout from "../../components/layout";
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
import appLogger from "../../../lib/logger";
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
const logger = appLogger.child({ page: "/account/settings" });
const navigation = [
{
name: "Account",
href: "/settings/account",
icon: ({className = "w-8 h-8"}) => <FontAwesomeIcon size="lg" className={className} icon={faUserCircle} />
},
{
name: "Billing",
href: "/settings/billing",
icon: ({className = "w-8 h-8"}) => <FontAwesomeIcon size="lg" className={className} icon={faCreditCard} />
},
];
const Settings: NextPage<Props> = (props) => {
return (
<Layout title="Settings">
<div className="flex flex-col space-y-6 p-6">
<aside className="py-6 lg:col-span-3">
<nav className="space-y-1">
{navigation.map((item) => (
<a
key={item.name}
href={item.href}
className='border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900 group border-l-4 px-3 py-2 flex items-center text-sm font-medium'
>
<item.icon className='text-gray-400 group-hover:text-gray-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6' />
<span className="truncate">{item.name}</span>
</a>
))}
</nav>
</aside>
</div>
</Layout>
);
};
export const getServerSideProps = withPageOnboardingRequired();
export default Settings;

View File

@ -0,0 +1,22 @@
import type { NextPage } from "next";
import { withPageAuthRequired } from "../../../lib/session-helpers";
import OnboardingLayout from "../../components/welcome/onboarding-layout";
const StepOne: NextPage = () => {
return (
<OnboardingLayout
currentStep={1}
next={{ href: "/welcome/step-two", label: "Set up your phone number" }}
>
<div className="flex flex-col space-y-4 items-center">
<span>Welcome, lets set up your virtual phone!</span>
</div>
</OnboardingLayout>
);
};
export const getServerSideProps = withPageAuthRequired();
export default StepOne;

View File

@ -0,0 +1,107 @@
import type { InferGetServerSidePropsType, NextPage } from "next";
import { useEffect } from "react";
import { useRouter } from "next/router";
import twilio from "twilio";
import { useForm } from "react-hook-form";
import axios from "axios";
import { withPageAuthRequired } from "../../../lib/session-helpers";
import OnboardingLayout from "../../components/welcome/onboarding-layout";
import { findCustomer } from "../../database/customer";
import clsx from "clsx";
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
type Form = {
phoneNumberSid: string;
}
const StepThree: NextPage<Props> = ({ hasTwilioCredentials, availablePhoneNumbers }) => {
const {
register,
handleSubmit,
setValue,
formState: { isSubmitting },
} = useForm<Form>();
const router = useRouter();
useEffect(() => {
setValue("phoneNumberSid", availablePhoneNumbers[0].sid);
});
const onSubmit = handleSubmit(async ({ phoneNumberSid }) => {
if (isSubmitting) {
return;
}
await axios.post("/api/user/add-phone-number", { phoneNumberSid }, { withCredentials: true });
await router.push("/messages");
});
if (!hasTwilioCredentials) {
return (
<OnboardingLayout
currentStep={3}
previous={{ href: "/welcome/step-two", label: "Back" }}
>
<div className="flex flex-col space-y-4 items-center">
<span>You don't have any phone number, fill your Twilio credentials first</span>
</div>
</OnboardingLayout>
)
}
return (
<OnboardingLayout
currentStep={3}
previous={{ href: "/welcome/step-two", label: "Back" }}
>
<div className="flex flex-col space-y-4 items-center">
<form onSubmit={onSubmit}>
<label htmlFor="phoneNumberSid" className="block text-sm font-medium text-gray-700">
Phone number
</label>
<select
id="phoneNumberSid"
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md"
{...register("phoneNumberSid")}
>
{availablePhoneNumbers.map(({ sid, phoneNumber }) => (
<option value={sid} key={sid}>{phoneNumber}</option>
))}
</select>
<button
type="submit"
className={clsx(
"max-w-[240px] mt-6 mx-auto w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm",
!isSubmitting && "bg-primary-600 hover:bg-primary-700",
isSubmitting && "bg-primary-400 cursor-not-allowed",
)}
>
Save
</button>
</form>
</div>
</OnboardingLayout>
);
};
export const getServerSideProps = withPageAuthRequired(async (context, user) => {
const customer = await findCustomer(user.id);
const hasTwilioCredentials = customer.accountSid.length > 0 && customer.authToken.length > 0;
const incomingPhoneNumbers = await twilio(customer.accountSid, customer.authToken)
.incomingPhoneNumbers
.list();
const phoneNumbers = incomingPhoneNumbers.map(({ phoneNumber, sid }) => ({ phoneNumber, sid }));
return {
props: {
hasTwilioCredentials,
availablePhoneNumbers: phoneNumbers,
},
};
});
export default StepThree;

View File

@ -0,0 +1,104 @@
import type { InferGetServerSidePropsType, NextPage } from "next";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import axios from "axios";
import { withPageAuthRequired } from "../../../lib/session-helpers";
import OnboardingLayout from "../../components/welcome/onboarding-layout";
import clsx from "clsx";
import { findCustomer } from "../../database/customer";
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
type Form = {
twilioAccountSid: string;
twilioAuthToken: string;
}
const StepTwo: NextPage<Props> = ({ accountSid, authToken }) => {
const {
register,
handleSubmit,
setValue,
formState: { isSubmitting },
} = useForm<Form>();
const router = useRouter();
useEffect(() => {
setValue("twilioAuthToken", authToken);
setValue("twilioAccountSid", accountSid);
});
const onSubmit = handleSubmit(async ({ twilioAccountSid, twilioAuthToken }) => {
if (isSubmitting) {
return;
}
await axios.post("/api/user/update-user", {
twilioAccountSid,
twilioAuthToken,
}, { withCredentials: true });
await router.push("/welcome/step-three");
});
const hasTwilioCredentials = accountSid.length > 0 && authToken.length > 0;
return (
<OnboardingLayout
currentStep={2}
next={hasTwilioCredentials ? { href: "/welcome/step-three", label: "Next" } : undefined}
previous={{ href: "/welcome/step-one", label: "Back" }}
>
<div className="flex flex-col space-y-4 items-center">
<form onSubmit={onSubmit} className="flex flex-col gap-6">
<div className="w-full">
<label htmlFor="twilioAccountSid" className="block text-sm font-medium text-gray-700">
Twilio Account SID
</label>
<input
type="text"
id="twilioAccountSid"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
{...register("twilioAccountSid", { required: true })}
/>
</div>
<div className="w-full">
<label htmlFor="twilioAuthToken" className="block text-sm font-medium text-gray-700">
Twilio Auth Token
</label>
<input
type="text"
id="twilioAuthToken"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
{...register("twilioAuthToken", { required: true })}
/>
</div>
<button
type="submit"
className={clsx(
"max-w-[240px] mx-auto w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm",
!isSubmitting && "bg-primary-600 hover:bg-primary-700",
isSubmitting && "bg-primary-400 cursor-not-allowed",
)}
>
Save
</button>
</form>
</div>
</OnboardingLayout>
);
};
export const getServerSideProps = withPageAuthRequired(async (context, user) => {
const customer = await findCustomer(user.id);
return {
props: {
accountSid: customer.accountSid ?? "",
authToken: customer.authToken ?? "",
},
};
});
export default StepTwo;

105
src/session-context.tsx Normal file
View File

@ -0,0 +1,105 @@
import type { Dispatch, ReactNode, Reducer, ReducerAction } from "react";
import { createContext, useEffect, useReducer } from "react";
import type { User } from "@supabase/supabase-js";
import supabase from "./supabase/client";
type Context = {
state: SessionState;
dispatch: Dispatch<ReducerAction<typeof sessionReducer>>;
};
export const SessionContext = createContext<Context>(null as any);
type ProviderProps = {
children: ReactNode;
user?: User | null;
};
function getInitialState(initialUser: User | null | undefined): SessionState {
if (!initialUser) {
return {
state: "LOADING",
user: null,
error: null,
};
}
return {
state: "SUCCESS",
user: initialUser,
error: null,
};
}
export function SessionProvider({ children, user }: ProviderProps) {
const [state, dispatch] = useReducer(
sessionReducer,
getInitialState(user),
);
useEffect(() => {
supabase.auth.onAuthStateChange((event, session) => {
console.log("event", event);
if (["SIGNED_IN", "USER_UPDATED"].includes(event)) {
dispatch({
type: "SET_SESSION",
user: session!.user!,
});
}
});
if (state.user === null) {
dispatch({
type: "SET_SESSION",
user: supabase.auth.user()!,
});
}
}, []);
return (
<SessionContext.Provider value={{ state, dispatch }}>
{children}
</SessionContext.Provider>
);
}
type SessionState =
| {
state: "LOADING";
user: null;
error: null;
}
| {
state: "SUCCESS";
user: User;
error: null;
}
| {
state: "ERROR";
user: User | null;
error: Error;
};
type Action =
| { type: "SET_SESSION"; user: User }
| { type: "THROW_ERROR"; error: Error };
const sessionReducer: Reducer<SessionState, Action> = (state, action) => {
switch (action.type) {
case "SET_SESSION":
return {
...state,
state: "SUCCESS",
user: action.user,
error: null,
};
case "THROW_ERROR":
return {
...state,
state: "ERROR",
error: action.error,
};
default:
throw new Error("unreachable");
}
};

View File

@ -0,0 +1,51 @@
import axios from "axios";
import getConfig from "next/config";
const { publicRuntimeConfig, serverRuntimeConfig } = getConfig();
const vendor_id = publicRuntimeConfig.paddle.vendorId;
const vendor_auth_code = serverRuntimeConfig.paddle.apiKey;
const client = axios.create({
baseURL: "https://vendors.paddle.com/api/2.0",
});
async function request<T>(path: string, data: any) {
return client.post<T>(path, {
...data,
vendor_id,
vendor_auth_code,
});
}
type UpdateSubscriptionPlanParams = {
subscriptionId: string;
planId: string;
prorate?: boolean;
};
export async function updateSubscriptionPlan({
subscriptionId,
planId,
prorate = true,
}: UpdateSubscriptionPlanParams) {
const { data } = await request("/subscription/users/update", {
subscription_id: subscriptionId,
plan_id: planId,
prorate,
});
return data;
}
export async function cancelPaddleSubscription({
subscriptionId,
}: {
subscriptionId: string;
}) {
const { data } = await request("/subscription/users_cancel", {
subscription_id: subscriptionId,
});
return data;
}

61
src/subscription/plans.ts Normal file
View File

@ -0,0 +1,61 @@
export type PlanId = string;
export type PaidPlan = {
id: PlanId;
name: string;
description: string;
price: number;
features: readonly string[];
};
export type FreePlan = Omit<PaidPlan, "id" | "billingCycle" | "price"> & {
id: "free";
billingCycle: null;
price: "free";
};
export type Plan = FreePlan | PaidPlan;
export const FREE: FreePlan = {
id: "free",
billingCycle: null,
name: "Free",
description: "Try out our software",
price: "free",
features: [
"Potenti felis, in cras at at ligula nunc.",
"Orci neque eget pellentesque.",
],
};
export const MONTHLY: PaidPlan = {
id: "647654",
name: "Monthly",
description: "All the basics for starting a new business",
price: 21,
features: [
"Potenti felis, in cras at at ligula nunc.",
"Orci neque eget pellentesque.",
"Donec mauris sit in eu tincidunt etiam.",
],
};
export const ANNUALLY: PaidPlan = {
id: "647656",
name: "Annually",
description: "All the basics for starting a new business",
price: 19,
features: [
"Potenti felis, in cras at at ligula nunc.",
"Orci neque eget pellentesque.",
"Donec mauris sit in eu tincidunt etiam.",
],
};
export const PLANS = {
[FREE.id]: FREE,
[MONTHLY.id]: MONTHLY,
[ANNUALLY.id]: ANNUALLY,
};
export type PlanName = Lowercase<Plan["name"]>;

10
src/supabase/client.ts Normal file
View File

@ -0,0 +1,10 @@
import getConfig from "next/config";
import { createClient } from "@supabase/supabase-js";
const { publicRuntimeConfig } = getConfig();
const { supabase: { url, anonKey } } = publicRuntimeConfig;
const supabase = createClient(url, anonKey);
export default supabase;

11
src/supabase/server.ts Normal file
View File

@ -0,0 +1,11 @@
import getConfig from "next/config";
import { createClient } from "@supabase/supabase-js";
const { publicRuntimeConfig, serverRuntimeConfig } = getConfig();
const { supabase: { url } } = publicRuntimeConfig;
const { supabase: { roleKey } } = serverRuntimeConfig;
const supabase = createClient(url, roleKey);
export default supabase;

3
src/tailwind.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;