Skip to content

Commit 7170ea4

Browse files
committed
feat(auth): implement authentication modal with sign in, sign up, and forgot password functionality
- Added `AuthModal` component to manage user authentication flows, including sign in, sign up, and password recovery. - Introduced `AccountIcon` for triggering the authentication modal. - Created forms for sign in, sign up, and forgot password, utilizing Zod for validation. - Implemented tab navigation between sign in and sign up views. - Integrated Google OAuth sign-in functionality. - Established context and hooks for managing modal state and feature flags. - Added comprehensive tests for modal behavior and form validation.
1 parent b992652 commit 7170ea4

File tree

18 files changed

+1796
-1
lines changed

18 files changed

+1796
-1
lines changed
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import {
2+
emailSchema,
3+
forgotPasswordSchema,
4+
nameSchema,
5+
passwordSchema,
6+
signInSchema,
7+
signUpSchema,
8+
} from "./auth.schemas";
9+
10+
describe("auth.schemas", () => {
11+
describe("emailSchema", () => {
12+
it("validates a correct email", () => {
13+
const result = emailSchema.safeParse("test@example.com");
14+
expect(result.success).toBe(true);
15+
if (result.success) {
16+
expect(result.data).toBe("test@example.com");
17+
}
18+
});
19+
20+
it("transforms email to lowercase", () => {
21+
const result = emailSchema.safeParse("Test@EXAMPLE.com");
22+
expect(result.success).toBe(true);
23+
if (result.success) {
24+
expect(result.data).toBe("test@example.com");
25+
}
26+
});
27+
28+
it("trims whitespace", () => {
29+
const result = emailSchema.safeParse(" test@example.com ");
30+
expect(result.success).toBe(true);
31+
if (result.success) {
32+
expect(result.data).toBe("test@example.com");
33+
}
34+
});
35+
36+
it("rejects empty string", () => {
37+
const result = emailSchema.safeParse("");
38+
expect(result.success).toBe(false);
39+
if (!result.success) {
40+
expect(result.error.errors[0].message).toBe("Email is required");
41+
}
42+
});
43+
44+
it("rejects invalid email format", () => {
45+
const result = emailSchema.safeParse("not-an-email");
46+
expect(result.success).toBe(false);
47+
if (!result.success) {
48+
expect(result.error.errors[0].message).toBe(
49+
"Please enter a valid email address",
50+
);
51+
}
52+
});
53+
54+
it("rejects email without domain", () => {
55+
const result = emailSchema.safeParse("test@");
56+
expect(result.success).toBe(false);
57+
});
58+
});
59+
60+
describe("passwordSchema", () => {
61+
it("validates password with 8+ characters", () => {
62+
const result = passwordSchema.safeParse("password123");
63+
expect(result.success).toBe(true);
64+
});
65+
66+
it("validates password with exactly 8 characters", () => {
67+
const result = passwordSchema.safeParse("12345678");
68+
expect(result.success).toBe(true);
69+
});
70+
71+
it("rejects empty password", () => {
72+
const result = passwordSchema.safeParse("");
73+
expect(result.success).toBe(false);
74+
if (!result.success) {
75+
expect(result.error.errors[0].message).toBe("Password is required");
76+
}
77+
});
78+
79+
it("rejects password shorter than 8 characters", () => {
80+
const result = passwordSchema.safeParse("1234567");
81+
expect(result.success).toBe(false);
82+
if (!result.success) {
83+
expect(result.error.errors[0].message).toBe(
84+
"Password must be at least 8 characters",
85+
);
86+
}
87+
});
88+
});
89+
90+
describe("nameSchema", () => {
91+
it("validates a non-empty name", () => {
92+
const result = nameSchema.safeParse("John Doe");
93+
expect(result.success).toBe(true);
94+
if (result.success) {
95+
expect(result.data).toBe("John Doe");
96+
}
97+
});
98+
99+
it("trims whitespace", () => {
100+
const result = nameSchema.safeParse(" John Doe ");
101+
expect(result.success).toBe(true);
102+
if (result.success) {
103+
expect(result.data).toBe("John Doe");
104+
}
105+
});
106+
107+
it("rejects empty string", () => {
108+
const result = nameSchema.safeParse("");
109+
expect(result.success).toBe(false);
110+
if (!result.success) {
111+
expect(result.error.errors[0].message).toBe("Name is required");
112+
}
113+
});
114+
115+
it("rejects whitespace-only string", () => {
116+
const result = nameSchema.safeParse(" ");
117+
expect(result.success).toBe(false);
118+
});
119+
});
120+
121+
describe("signUpSchema", () => {
122+
it("validates complete sign up data", () => {
123+
const result = signUpSchema.safeParse({
124+
name: "John Doe",
125+
email: "john@example.com",
126+
password: "password123",
127+
});
128+
expect(result.success).toBe(true);
129+
if (result.success) {
130+
expect(result.data).toEqual({
131+
name: "John Doe",
132+
email: "john@example.com",
133+
password: "password123",
134+
});
135+
}
136+
});
137+
138+
it("transforms email and trims name", () => {
139+
const result = signUpSchema.safeParse({
140+
name: " John Doe ",
141+
email: "JOHN@EXAMPLE.COM",
142+
password: "password123",
143+
});
144+
expect(result.success).toBe(true);
145+
if (result.success) {
146+
expect(result.data.name).toBe("John Doe");
147+
expect(result.data.email).toBe("john@example.com");
148+
}
149+
});
150+
151+
it("rejects missing name", () => {
152+
const result = signUpSchema.safeParse({
153+
email: "john@example.com",
154+
password: "password123",
155+
});
156+
expect(result.success).toBe(false);
157+
});
158+
159+
it("rejects invalid email", () => {
160+
const result = signUpSchema.safeParse({
161+
name: "John",
162+
email: "invalid",
163+
password: "password123",
164+
});
165+
expect(result.success).toBe(false);
166+
});
167+
168+
it("rejects short password", () => {
169+
const result = signUpSchema.safeParse({
170+
name: "John",
171+
email: "john@example.com",
172+
password: "short",
173+
});
174+
expect(result.success).toBe(false);
175+
});
176+
});
177+
178+
describe("signInSchema", () => {
179+
it("validates complete sign in data", () => {
180+
const result = signInSchema.safeParse({
181+
email: "john@example.com",
182+
password: "anypassword",
183+
});
184+
expect(result.success).toBe(true);
185+
});
186+
187+
it("accepts any non-empty password (no min length)", () => {
188+
const result = signInSchema.safeParse({
189+
email: "john@example.com",
190+
password: "a",
191+
});
192+
expect(result.success).toBe(true);
193+
});
194+
195+
it("rejects empty password", () => {
196+
const result = signInSchema.safeParse({
197+
email: "john@example.com",
198+
password: "",
199+
});
200+
expect(result.success).toBe(false);
201+
});
202+
203+
it("transforms email to lowercase", () => {
204+
const result = signInSchema.safeParse({
205+
email: "JOHN@EXAMPLE.COM",
206+
password: "password",
207+
});
208+
expect(result.success).toBe(true);
209+
if (result.success) {
210+
expect(result.data.email).toBe("john@example.com");
211+
}
212+
});
213+
});
214+
215+
describe("forgotPasswordSchema", () => {
216+
it("validates a correct email", () => {
217+
const result = forgotPasswordSchema.safeParse({
218+
email: "john@example.com",
219+
});
220+
expect(result.success).toBe(true);
221+
});
222+
223+
it("rejects invalid email", () => {
224+
const result = forgotPasswordSchema.safeParse({
225+
email: "not-an-email",
226+
});
227+
expect(result.success).toBe(false);
228+
});
229+
230+
it("transforms email to lowercase", () => {
231+
const result = forgotPasswordSchema.safeParse({
232+
email: "JOHN@EXAMPLE.COM",
233+
});
234+
expect(result.success).toBe(true);
235+
if (result.success) {
236+
expect(result.data.email).toBe("john@example.com");
237+
}
238+
});
239+
});
240+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { z } from "zod";
2+
3+
/**
4+
* Email validation schema
5+
* - Required with meaningful error
6+
* - Validates email format
7+
* - Transforms to lowercase and trims whitespace
8+
*
9+
* Note: We use preprocess to trim/lowercase BEFORE validation so that
10+
* " test@example.com " validates correctly after trimming.
11+
*/
12+
export const emailSchema = z.preprocess(
13+
(val) => (typeof val === "string" ? val.trim().toLowerCase() : val),
14+
z
15+
.string()
16+
.min(1, "Email is required")
17+
.email("Please enter a valid email address"),
18+
);
19+
20+
/**
21+
* Password validation schema
22+
* - Required with meaningful error
23+
* - Minimum 8 characters for security
24+
*/
25+
export const passwordSchema = z
26+
.string()
27+
.min(1, "Password is required")
28+
.min(8, "Password must be at least 8 characters");
29+
30+
/**
31+
* Name validation schema
32+
* - Required with meaningful error
33+
* - Trims whitespace
34+
*
35+
* Note: We use preprocess to trim BEFORE validation, then refine to check
36+
* that the trimmed result is non-empty (rejects whitespace-only strings).
37+
*/
38+
export const nameSchema = z.preprocess(
39+
(val) => (typeof val === "string" ? val.trim() : val),
40+
z.string().min(1, "Name is required"),
41+
);
42+
43+
/**
44+
* Sign up form schema
45+
* Combines name, email, and password validation
46+
*/
47+
export const signUpSchema = z.object({
48+
name: nameSchema,
49+
email: emailSchema,
50+
password: passwordSchema,
51+
});
52+
53+
/**
54+
* Sign in form schema
55+
* Email validation + password presence check (no min length on sign in)
56+
*/
57+
export const signInSchema = z.object({
58+
email: emailSchema,
59+
password: z.string().min(1, "Password is required"),
60+
});
61+
62+
/**
63+
* Forgot password form schema
64+
* Only requires valid email
65+
*/
66+
export const forgotPasswordSchema = z.object({
67+
email: emailSchema,
68+
});
69+
70+
// Type exports for form data
71+
export type SignUpFormData = z.infer<typeof signUpSchema>;
72+
export type SignInFormData = z.infer<typeof signInSchema>;
73+
export type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { FC } from "react";
2+
import { User } from "@phosphor-icons/react";
3+
import { useSession } from "@web/auth/hooks/session/useSession";
4+
import { TooltipWrapper } from "@web/components/Tooltip/TooltipWrapper";
5+
import { useAuthFeatureFlag } from "./hooks/useAuthFeatureFlag";
6+
import { useAuthModal } from "./hooks/useAuthModal";
7+
8+
/**
9+
* Account icon button for triggering the auth modal
10+
*
11+
* Only renders when:
12+
* - User is not authenticated
13+
* - Feature flag ?enableAuth=true is present in URL
14+
*
15+
* Clicking opens the auth modal with sign-in view
16+
*/
17+
export const AccountIcon: FC = () => {
18+
const { authenticated } = useSession();
19+
const isEnabled = useAuthFeatureFlag();
20+
const { openModal } = useAuthModal();
21+
22+
// Don't show if user is already authenticated or feature is disabled
23+
if (authenticated || !isEnabled) {
24+
return null;
25+
}
26+
27+
const handleClick = () => {
28+
openModal("signIn");
29+
};
30+
31+
return (
32+
<TooltipWrapper description="Sign in" onClick={handleClick}>
33+
<User
34+
size={24}
35+
className="cursor-pointer text-white/70 transition-colors hover:text-white"
36+
aria-label="Sign in"
37+
/>
38+
</TooltipWrapper>
39+
);
40+
};

0 commit comments

Comments
 (0)