+
+
+
+
+
+
+
+
Sign up as a...
+
-
+
);
}
diff --git a/packages/trpc/src/procedures/email-verification.ts b/packages/trpc/src/procedures/email-verification.ts
index e496dc9..42c6081 100644
--- a/packages/trpc/src/procedures/email-verification.ts
+++ b/packages/trpc/src/procedures/email-verification.ts
@@ -23,12 +23,28 @@ export const sendEmailVerificationProcedure = notAuthenticatedProcedureBuilder
)
.mutation(async ({ ctx, input }) => {
// Check if there is already an email verification code for the given email
- const existingEmailVerificationCode =
- await ctx.prisma.emailVerificationCode.findUnique({
+ const [existingUser, existingEmailVerificationCode] = await Promise.all([
+ ctx.prisma.user.findUnique({
+ where: {
+ email: input.email,
+ },
+ }),
+ ctx.prisma.emailVerificationCode.findUnique({
where: {
email: input.email,
},
+ }),
+ ]);
+
+ if (existingUser) {
+ // TODO
+ // we don't want to leak details that the email already exists for a user,
+ // but we should still fail
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: `Email confirmation to ${input.email} failed to send.`,
});
+ }
// If email already verified, throw error
if (existingEmailVerificationCode?.emailConfirmed) {
@@ -55,7 +71,7 @@ export const sendEmailVerificationProcedure = notAuthenticatedProcedureBuilder
}
}
// Create/update the email verification code in the database
- await ctx.prisma.emailVerificationCode.upsert({
+ const result = await ctx.prisma.emailVerificationCode.upsert({
where: {
email: input.email,
},
@@ -71,9 +87,9 @@ export const sendEmailVerificationProcedure = notAuthenticatedProcedureBuilder
});
return {
- email: input.email,
+ email: result.email,
status: "EMAIL_SENT" as const,
- message: `Email verification code sent to ${input.email}`,
+ message: `Email verification code sent to ${result.email}`,
};
});
@@ -102,17 +118,18 @@ export const confirmEmailProcedure = notAuthenticatedProcedureBuilder
// If user already exists
if (existingUser) {
throw new TRPCError({
- code: "CONFLICT",
- message: `User already exists with email ${input.email}`,
+ code: "UNAUTHORIZED",
+ message: `${input.email} is not verified.`,
});
}
// If email already verified
if (existingEmailVerificationCode?.emailConfirmed) {
- throw new TRPCError({
- code: "CONFLICT",
- message: `Email already verified.`,
- });
+ return {
+ status: "SUCCESS" as const,
+ email: existingEmailVerificationCode.email,
+ message: `Email was successfully verified. Email: ${input.email}.`,
+ };
}
// If email verification not found or given code is wrong, throw UNAUTHORIZED error
@@ -144,7 +161,7 @@ export const confirmEmailProcedure = notAuthenticatedProcedureBuilder
}
}
// Create/update the email verification code in the database
- await ctx.prisma.emailVerificationCode.update({
+ const result = await ctx.prisma.emailVerificationCode.update({
where: {
email: input.email,
},
@@ -156,12 +173,13 @@ export const confirmEmailProcedure = notAuthenticatedProcedureBuilder
return {
status: "RESENT" as const,
+ email: result.email,
message: `Code is expired. A new code was sent to ${input.email}.`,
};
}
// If here, the given code was valid, so we can update the emailVerificationCode.
- await ctx.prisma.emailVerificationCode.update({
+ const result = await ctx.prisma.emailVerificationCode.update({
where: {
email: input.email,
},
@@ -173,6 +191,7 @@ export const confirmEmailProcedure = notAuthenticatedProcedureBuilder
return {
status: "SUCCESS" as const,
+ email: result.email,
message: `Email was successfully verified. Email: ${input.email}.`,
};
});
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 920809e..703fa24 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -34,15 +34,18 @@
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-avatar": "^1.1.0",
+ "@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
+ "@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
+ "input-otp": "^1.4.1",
"react-day-picker": "8.10.1",
"tailwind-merge": "^2.5.2"
},
diff --git a/packages/ui/shad/checkbox.tsx b/packages/ui/shad/checkbox.tsx
new file mode 100644
index 0000000..9dad2ab
--- /dev/null
+++ b/packages/ui/shad/checkbox.tsx
@@ -0,0 +1,28 @@
+import React from "react";
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
+import { CheckIcon } from "@radix-ui/react-icons";
+
+import { cn } from "@good-dog/ui";
+
+const Checkbox = React.forwardRef<
+ React.ElementRef
,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+));
+Checkbox.displayName = CheckboxPrimitive.Root.displayName;
+
+export { Checkbox };
diff --git a/packages/ui/shad/input-otp.tsx b/packages/ui/shad/input-otp.tsx
new file mode 100644
index 0000000..2b60a10
--- /dev/null
+++ b/packages/ui/shad/input-otp.tsx
@@ -0,0 +1,74 @@
+import React from "react";
+import { MinusIcon } from "@radix-ui/react-icons";
+import { OTPInput, OTPInputContext } from "input-otp";
+
+import { cn } from "@good-dog/ui";
+
+const InputOTP = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, containerClassName, ...props }, ref) => (
+
+));
+InputOTP.displayName = "InputOTP";
+
+const InputOTPGroup = React.forwardRef<
+ React.ElementRef<"div">,
+ React.ComponentPropsWithoutRef<"div">
+>(({ className, ...props }, ref) => (
+
+));
+InputOTPGroup.displayName = "InputOTPGroup";
+
+const InputOTPSlot = React.forwardRef<
+ React.ElementRef<"div">,
+ React.ComponentPropsWithoutRef<"div"> & { index: number }
+>(({ index, className, ...props }, ref) => {
+ const inputOTPContext = React.useContext(OTPInputContext);
+
+ const slot = inputOTPContext.slots[index];
+ if (!slot) {
+ return null;
+ }
+ const { char, hasFakeCaret, isActive } = slot;
+
+ return (
+
+ {char}
+ {hasFakeCaret && (
+
+ )}
+
+ );
+});
+InputOTPSlot.displayName = "InputOTPSlot";
+
+const InputOTPSeparator = React.forwardRef<
+ React.ElementRef<"div">,
+ React.ComponentPropsWithoutRef<"div">
+>(({ ...props }, ref) => (
+
+
+
+));
+InputOTPSeparator.displayName = "InputOTPSeparator";
+
+export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
diff --git a/packages/ui/shad/select.tsx b/packages/ui/shad/select.tsx
new file mode 100644
index 0000000..47a236e
--- /dev/null
+++ b/packages/ui/shad/select.tsx
@@ -0,0 +1,161 @@
+import React from "react";
+import {
+ CheckIcon,
+ ChevronDownIcon,
+ ChevronUpIcon,
+} from "@radix-ui/react-icons";
+import * as SelectPrimitive from "@radix-ui/react-select";
+
+import { cn } from "@good-dog/ui";
+
+const Select = SelectPrimitive.Root;
+
+const SelectGroup = SelectPrimitive.Group;
+
+const SelectValue = SelectPrimitive.Value;
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName;
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectLabel.displayName = SelectPrimitive.Label.displayName;
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+};
diff --git a/tests/api/email-verification.test.ts b/tests/api/email-verification.test.ts
index 024bc5b..e6dddc4 100644
--- a/tests/api/email-verification.test.ts
+++ b/tests/api/email-verification.test.ts
@@ -155,14 +155,12 @@ describe("email-verification", () => {
test("Email already verified", async () => {
await createEmailVerificationCode(true);
- expect(
- $trpcCaller.confirmEmail({
- email: "damian@gmail.com",
- code: "019821",
- }),
- ).rejects.toThrow("Email already verified.");
+ const response = await $trpcCaller.confirmEmail({
+ email: "damian@gmail.com",
+ code: "019821",
+ });
- await cleanupEmailVerificationCode();
+ expect(response.status).toBe("SUCCESS");
});
test("No email verification code entry", async () => {
diff --git a/tests/frontend/signin.test.tsx b/tests/frontend/signin.test.tsx
index 299c5e1..dfcd60f 100644
--- a/tests/frontend/signin.test.tsx
+++ b/tests/frontend/signin.test.tsx
@@ -1,6 +1,6 @@
import React from "react";
import { fireEvent, screen } from "@testing-library/react";
-import { afterEach, beforeAll, expect, test } from "bun:test";
+import { afterEach, beforeAll, describe, expect, test } from "bun:test";
import { SignInForm } from "@good-dog/components/registration";
@@ -9,35 +9,37 @@ import { renderWithShell } from "./util";
const mockNavigation = new MockNextNavigation();
-beforeAll(async () => {
- await mockNavigation.apply();
-});
+describe("SignInForm", () => {
+ beforeAll(async () => {
+ await mockNavigation.apply();
+ });
-afterEach(() => {
- mockNavigation.clear();
-});
+ afterEach(() => {
+ mockNavigation.clear();
+ });
-test("Renders the sign in form with email and password fields", () => {
- renderWithShell();
+ test("Renders the sign in form with email and password fields", () => {
+ renderWithShell();
- const signInForm = screen.getByTestId("sign-in-form");
- expect(signInForm).toBeInTheDocument();
+ const signInForm = screen.getByRole("form");
+ expect(signInForm).toBeInTheDocument();
- const emailField = screen.getByPlaceholderText(/email/i);
- expect(emailField).toBeInTheDocument();
+ const emailField = screen.getByLabelText(/email/i);
+ expect(emailField).toBeInTheDocument();
- const passwordField = screen.getByPlaceholderText(/password/i);
- expect(passwordField).toBeInTheDocument();
+ const passwordField = screen.getByLabelText(/password/i);
+ expect(passwordField).toBeInTheDocument();
- const submitButton = screen.getByRole("button", { name: /sign in/i });
+ const submitButton = screen.getByRole("button", { name: /continue/i });
- fireEvent.change(emailField, { target: { value: "test@example.com" } });
- fireEvent.change(passwordField, { target: { value: "password123" } });
+ fireEvent.change(emailField, { target: { value: "test@example.com" } });
+ fireEvent.change(passwordField, { target: { value: "password123" } });
- expect(emailField).toHaveValue("test@example.com");
- expect(passwordField).toHaveValue("password123");
+ expect(emailField).toHaveValue("test@example.com");
+ expect(passwordField).toHaveValue("password123");
- fireEvent.click(submitButton);
+ fireEvent.click(submitButton);
- // TODO: we need to build out an app shell that allows us to test trpc mutations/queries
+ // TODO: we need to build out an app shell that allows us to test trpc mutations/queries
+ });
});
diff --git a/tests/mocks/MockNextNavigation.ts b/tests/mocks/MockNextNavigation.ts
index 44cc6ce..52159de 100644
--- a/tests/mocks/MockNextNavigation.ts
+++ b/tests/mocks/MockNextNavigation.ts
@@ -10,11 +10,21 @@ export class MockNextNavigation {
this.useParams.mockClear();
this.usePathname.mockClear();
this.useSearchParams.mockClear();
- this.useRouter.mockClear();
+ this.mockRouter.clear();
}
+ readonly mockRouter = new MockRouter();
+
readonly useSearchParams = mock();
readonly usePathname = mock();
- readonly useRouter = mock();
+ readonly useRouter = () => this.mockRouter;
readonly useParams = mock();
}
+
+class MockRouter {
+ clear() {
+ this.push.mockClear();
+ }
+
+ readonly push = mock();
+}
diff --git a/tests/package.json b/tests/package.json
index 535efda..56d89cb 100644
--- a/tests/package.json
+++ b/tests/package.json
@@ -24,6 +24,7 @@
"@testing-library/react": "^16.0.1",
"@types/bun": "^1.1.10",
"eslint": "9.10.0",
+ "next": "14.2.18",
"prettier": "3.2.5",
"typescript": "5.4.5",
"zod": "3.23.8"
diff --git a/tooling/tailwind/web.ts b/tooling/tailwind/web.ts
index 1c073ec..1db0e46 100644
--- a/tooling/tailwind/web.ts
+++ b/tooling/tailwind/web.ts
@@ -33,6 +33,7 @@ export default {
"good-dog-celadon": "#ACDD92",
"good-dog-orange": "#EF946C",
"good-dog-purple": "#574AE2",
+ "good-dog-error": "#800000",
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
diff --git a/turbo.json b/turbo.json
index 98a1f1e..aabd571 100644
--- a/turbo.json
+++ b/turbo.json
@@ -43,7 +43,7 @@
},
"push": {
"cache": false,
- "interactive": false
+ "interactive": true
},
"generate": {
"cache": false,