From 25da0cd6872c53c990c065fb51e5b9ab26ad62e1 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 25 May 2026 15:20:01 -0400 Subject: [PATCH] feat: add auth pages (login, signup, password reset, onboarding) - Create stub auth API (lib/auth.ts) with simulated delay - Add PasswordInput component with visibility toggle - Add SocialAuthButtons component (Google/Apple placeholders) - Add AuthLayout with split-panel layout and rotating testimonial - Implement login page with email/password validation and remember me - Implement signup page with password strength indicator and ToS checkbox - Implement forgot-password page with email submission and success state - Implement reset-password page with token validation from query params - Implement 4-step onboarding flow (plan selection, watchlist, invites, success) - Add ToastProvider to root app - Write 28 tests for all auth components and form validation --- web/src/app.tsx | 3 + web/src/components/auth/AuthLayout.tsx | 86 ++++ web/src/components/auth/PasswordInput.tsx | 91 ++++ web/src/components/auth/SocialAuthButtons.tsx | 29 ++ web/src/components/auth/auth.test.tsx | 149 +++++++ web/src/components/auth/index.ts | 3 + web/src/lib/auth.ts | 59 +++ web/src/routes/(auth)/auth-pages.test.tsx | 255 +++++++++++ web/src/routes/(auth)/forgot-password.tsx | 127 ++++++ web/src/routes/(auth)/login.tsx | 139 ++++++ web/src/routes/(auth)/onboarding.tsx | 418 ++++++++++++++++++ web/src/routes/(auth)/reset-password.tsx | 146 ++++++ web/src/routes/(auth)/signup.tsx | 218 +++++++++ 13 files changed, 1723 insertions(+) create mode 100644 web/src/components/auth/AuthLayout.tsx create mode 100644 web/src/components/auth/PasswordInput.tsx create mode 100644 web/src/components/auth/SocialAuthButtons.tsx create mode 100644 web/src/components/auth/auth.test.tsx create mode 100644 web/src/components/auth/index.ts create mode 100644 web/src/lib/auth.ts create mode 100644 web/src/routes/(auth)/auth-pages.test.tsx create mode 100644 web/src/routes/(auth)/forgot-password.tsx create mode 100644 web/src/routes/(auth)/login.tsx create mode 100644 web/src/routes/(auth)/onboarding.tsx create mode 100644 web/src/routes/(auth)/reset-password.tsx create mode 100644 web/src/routes/(auth)/signup.tsx diff --git a/web/src/app.tsx b/web/src/app.tsx index 59e0782..dec13a5 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -4,11 +4,13 @@ import { FileRoutes } from "@solidjs/start/router"; import { Suspense } from "solid-js"; import { ThemeProvider } from "./lib/theme"; import { AppShell } from "./components/layout"; +import { ToastProvider } from "./components/ui"; import "./app.css"; export default function App() { return ( + ( @@ -18,6 +20,7 @@ export default function App() { > + ); } diff --git a/web/src/components/auth/AuthLayout.tsx b/web/src/components/auth/AuthLayout.tsx new file mode 100644 index 0000000..c6b83bf --- /dev/null +++ b/web/src/components/auth/AuthLayout.tsx @@ -0,0 +1,86 @@ +import { createSignal, onMount, onCleanup } from "solid-js"; +import type { JSX } from "solid-js"; +import { PageContainer } from "~/components/layout"; + +interface Testimonial { + quote: string; + author: string; + role: string; +} + +const testimonials: Testimonial[] = [ + { + quote: + "ShieldAI caught a credential leak before it became a disaster. Essential tool for anyone concerned about their digital identity.", + author: "Sarah Chen", + role: "Security Engineer", + }, + { + quote: + "I sleep better knowing ShieldAI is monitoring my personal information 24/7.", + author: "Marcus Johnson", + role: "Freelance Developer", + }, + { + quote: + "The family protection plan means I can protect not just myself but my whole family.", + author: "Emily Rodriguez", + role: "Mother of three", + }, +]; + +interface AuthLayoutProps { + children: JSX.Element; +} + +export default function AuthLayout(props: AuthLayoutProps) { + const [index, setIndex] = createSignal(0); + + onMount(() => { + const interval = setInterval(() => { + setIndex((i) => (i + 1) % testimonials.length); + }, 6000); + onCleanup(() => clearInterval(interval)); + }); + + const t = () => testimonials[index()]; + + return ( +
+ +
+ +
+
+ {props.children} +
+
+
+
+
+ ); +} diff --git a/web/src/components/auth/PasswordInput.tsx b/web/src/components/auth/PasswordInput.tsx new file mode 100644 index 0000000..7a5c4b7 --- /dev/null +++ b/web/src/components/auth/PasswordInput.tsx @@ -0,0 +1,91 @@ +import { createSignal, Show } from "solid-js"; +import { cn } from "~/lib/utils"; +import type { JSX } from "solid-js"; + +interface PasswordInputProps { + label?: string; + value?: string; + onInput?: (e: InputEvent & { currentTarget: HTMLInputElement }) => void; + error?: string; + helperText?: string; + placeholder?: string; + class?: string; + id?: string; + name?: string; + required?: boolean; + disabled?: boolean; +} + +export default function PasswordInput(props: PasswordInputProps) { + const [visible, setVisible] = createSignal(false); + const id = () => + props.id ?? + props.name ?? + globalThis.crypto?.randomUUID?.() ?? + Math.random().toString(36).slice(2, 10); + + return ( +
+ {props.label && ( + + )} +
+ + +
+ {props.error && ( +

{props.error}

+ )} + {props.helperText && !props.error && ( +

+ {props.helperText} +

+ )} +
+ ); +} diff --git a/web/src/components/auth/SocialAuthButtons.tsx b/web/src/components/auth/SocialAuthButtons.tsx new file mode 100644 index 0000000..dcbd7ee --- /dev/null +++ b/web/src/components/auth/SocialAuthButtons.tsx @@ -0,0 +1,29 @@ +export default function SocialAuthButtons() { + return ( +
+ + +
+ ); +} diff --git a/web/src/components/auth/auth.test.tsx b/web/src/components/auth/auth.test.tsx new file mode 100644 index 0000000..87e6eec --- /dev/null +++ b/web/src/components/auth/auth.test.tsx @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render } from "solid-js/web"; +import type { JSX } from "solid-js"; + +import PasswordInput from "./PasswordInput"; +import SocialAuthButtons from "./SocialAuthButtons"; +import AuthLayout from "./AuthLayout"; +import { Input, Button } from "~/components/ui"; + +function mount(comp: () => JSX.Element): HTMLDivElement { + const container = document.createElement("div"); + document.body.appendChild(container); + render(() => comp(), container); + return container; +} + +beforeEach(() => { + document.body.innerHTML = ""; + if (!globalThis.crypto) { + Object.defineProperty(globalThis, "crypto", { value: {} }); + } + (globalThis.crypto as unknown as Record).randomUUID = vi.fn( + () => "test-uuid-1234", + ); +}); + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("PasswordInput", () => { + it("renders with label", () => { + mount(() => ); + expect(document.body.textContent).toContain("Password"); + expect(document.querySelector("label")).toBeTruthy(); + }); + + it("renders password type by default", () => { + mount(() => ); + const input = document.querySelector("input")!; + expect(input.getAttribute("type")).toBe("password"); + }); + + it("toggles visibility when eye icon is clicked", () => { + mount(() => ); + const input = document.querySelector("input")!; + const toggle = document.querySelector("button[aria-label]")!; + + expect(input.getAttribute("type")).toBe("password"); + expect(toggle.getAttribute("aria-label")).toBe("Show password"); + + toggle.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(input.getAttribute("type")).toBe("text"); + expect(toggle.getAttribute("aria-label")).toBe("Hide password"); + + toggle.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(input.getAttribute("type")).toBe("password"); + }); + + it("shows error message", () => { + mount(() => ( + + )); + expect(document.body.textContent).toContain("Password is required"); + }); + + it("shows helper text when no error", () => { + mount(() => ( + + )); + expect(document.body.textContent).toContain("At least 8 characters"); + }); + + it("hides helper text when error is present", () => { + mount(() => ( + + )); + expect(document.body.textContent).toContain("Required"); + expect(document.body.textContent).not.toContain("Helper text"); + }); + + it("forwards onInput handler", () => { + const onInput = vi.fn(); + mount(() => ); + const input = document.querySelector("input")!; + input.dispatchEvent(new InputEvent("input", { bubbles: true })); + expect(onInput).toHaveBeenCalled(); + }); +}); + +describe("SocialAuthButtons", () => { + it("renders Google and Apple buttons", () => { + mount(() => ); + const buttons = document.querySelectorAll("button"); + expect(buttons.length).toBe(2); + expect(buttons[0].textContent).toContain("Google"); + expect(buttons[1].textContent).toContain("Apple"); + }); + + it("renders SVG icons in each button", () => { + mount(() => ); + const buttons = document.querySelectorAll("button"); + expect(buttons[0].querySelector("svg")).toBeTruthy(); + expect(buttons[1].querySelector("svg")).toBeTruthy(); + }); +}); + +describe("AuthLayout", () => { + it("renders children inside the form card", () => { + mount(() => ( + +

Form content

+
+ )); + expect(document.body.textContent).toContain("Form content"); + }); + + it("renders ShieldAI branding", () => { + mount(() => ( + +

Content

+
+ )); + expect(document.body.textContent).toContain("ShieldAI"); + }); + + it("renders gradient-card wrapper", () => { + mount(() => ( + +

Content

+
+ )); + expect(document.querySelector(".gradient-card")).toBeTruthy(); + }); + + it("renders testimonial text", () => { + mount(() => ( + +

Content

+
+ )); + expect(document.body.textContent).toContain("ShieldAI"); + expect(document.body.textContent).toContain("AI-Powered Identity Protection"); + }); +}); diff --git a/web/src/components/auth/index.ts b/web/src/components/auth/index.ts new file mode 100644 index 0000000..070631d --- /dev/null +++ b/web/src/components/auth/index.ts @@ -0,0 +1,3 @@ +export { default as AuthLayout } from "./AuthLayout"; +export { default as PasswordInput } from "./PasswordInput"; +export { default as SocialAuthButtons } from "./SocialAuthButtons"; diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts new file mode 100644 index 0000000..26f5b35 --- /dev/null +++ b/web/src/lib/auth.ts @@ -0,0 +1,59 @@ +export interface AuthUser { + name: string; + email: string; +} + +export interface AuthResponse { + user: AuthUser; + token: string; +} + +export interface OnboardingData { + plan: string; + watchlistItems: string[]; + familyInvites: string[]; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function login( + email: string, + password: string, + _rememberMe: boolean, +): Promise { + await delay(800); + if (!email || !password) throw new Error("Invalid credentials"); + return { user: { name: "Test User", email }, token: "stub-token" }; +} + +export async function signup( + name: string, + email: string, + password: string, +): Promise { + await delay(800); + if (!name || !email || !password) + throw new Error("All fields are required"); + return { user: { name, email }, token: "stub-token" }; +} + +export async function forgotPassword(email: string): Promise { + await delay(800); + if (!email) throw new Error("Email is required"); +} + +export async function resetPassword( + token: string, + password: string, +): Promise { + await delay(800); + if (!token || !password) throw new Error("Invalid request"); +} + +export async function submitOnboarding( + _data: OnboardingData, +): Promise { + await delay(800); +} diff --git a/web/src/routes/(auth)/auth-pages.test.tsx b/web/src/routes/(auth)/auth-pages.test.tsx new file mode 100644 index 0000000..e6352e3 --- /dev/null +++ b/web/src/routes/(auth)/auth-pages.test.tsx @@ -0,0 +1,255 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render } from "solid-js/web"; +import { Router, Route } from "@solidjs/router"; +import { MetaProvider } from "@solidjs/meta"; +import type { JSX } from "solid-js"; + +import LoginPage from "./login"; +import SignupPage from "./signup"; +import ForgotPasswordPage from "./forgot-password"; +import OnboardingPage from "./onboarding"; + +import * as auth from "~/lib/auth"; + +function mount(comp: () => JSX.Element): HTMLDivElement { + const container = document.createElement("div"); + document.body.appendChild(container); + render(() => comp(), container); + return container; +} + +beforeEach(() => { + document.body.innerHTML = ""; + if (!globalThis.crypto) { + Object.defineProperty(globalThis, "crypto", { value: {} }); + } + (globalThis.crypto as unknown as Record).randomUUID = vi.fn( + () => "test-uuid-1234", + ); +}); + +afterEach(() => { + document.body.innerHTML = ""; + vi.restoreAllMocks(); +}); + +function setInputValue(input: HTMLInputElement, value: string) { + input.value = value; + input.dispatchEvent( + new InputEvent("input", { bubbles: true, composed: true }), + ); +} + +describe("LoginPage", () => { + function WrappedLogin() { + return ( + + + + + + ); + } + + it("renders sign in form with email, password, and submit button", () => { + mount(() => ); + expect(document.body.textContent).toContain("Welcome back"); + expect(document.body.textContent).toContain("Email"); + expect(document.querySelector("input[type='email']")).toBeTruthy(); + expect(document.body.textContent).toContain("Sign In"); + }); + + it("renders Remember me and Forgot password links", () => { + mount(() => ); + expect(document.body.textContent).toContain("Remember me"); + expect(document.body.textContent).toContain("Forgot password?"); + expect(document.body.textContent).toContain("Sign up"); + expect(document.body.textContent).toContain("Or continue with"); + }); + + it("shows validation errors for empty fields on submit", () => { + mount(() => ); + const form = document.querySelector("form")!; + form.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + expect(document.body.textContent).toContain("Email is required"); + expect(document.body.textContent).toContain("Password is required"); + }); + + it("shows email format error for invalid email", () => { + mount(() => ); + const emailInput = + document.querySelector("input[type='email']")!; + setInputValue(emailInput, "not-an-email"); + const form = document.querySelector("form")!; + form.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + expect(document.body.textContent).toContain("Invalid email format"); + }); + + it("shows server error on failed login", async () => { + vi.spyOn(auth, "login").mockRejectedValue(new Error("Invalid credentials")); + mount(() => ); + const emailInput = + document.querySelector("input[type='email']")!; + setInputValue(emailInput, "test@example.com"); + const passwordInput = + document.querySelector( + "input[type='password']", + )!; + setInputValue(passwordInput, "password123"); + const form = document.querySelector("form")!; + form.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + await vi.waitFor(() => { + expect(document.body.textContent).toContain("Invalid email or password"); + }); + }); +}); + +describe("SignupPage", () => { + function WrappedSignup() { + return ( + + + + + + ); + } + + it("renders sign up form with all fields", () => { + mount(() => ); + expect(document.body.textContent).toContain("Create your account"); + expect(document.body.textContent).toContain("Full name"); + expect(document.body.textContent).toContain("Email"); + expect(document.body.textContent).toContain("Password"); + expect(document.body.textContent).toContain("Confirm password"); + expect(document.body.textContent).toContain("Terms of Service"); + expect(document.body.textContent).toContain("Create Account"); + }); + + it("shows validation errors for empty fields on submit", () => { + mount(() => ); + const form = document.querySelector("form")!; + form.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + expect(document.body.textContent).toContain("Name is required"); + expect(document.body.textContent).toContain("Email is required"); + expect(document.body.textContent).toContain("Password is required"); + }); + + it("shows password mismatch error", () => { + mount(() => ); + const inputs = document.querySelectorAll("input"); + const passwordInput = inputs[2]; + const confirmInput = inputs[3]; + setInputValue(passwordInput, "password123"); + setInputValue(confirmInput, "different"); + const form = document.querySelector("form")!; + form.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + expect(document.body.textContent).toContain("Passwords do not match"); + }); + + it("shows ToS error when checkbox is unchecked", () => { + mount(() => ); + const nameInput = + document.querySelector("input[name='name']")!; + const emailInput = + document.querySelector("input[name='email']")!; + setInputValue(nameInput, "Test User"); + setInputValue(emailInput, "test@example.com"); + const form = document.querySelector("form")!; + form.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + expect(document.body.textContent).toContain("You must agree to the Terms"); + }); + + it("renders Log in link and social buttons", () => { + mount(() => ); + expect(document.body.textContent).toContain("Log in"); + expect(document.body.textContent).toContain("Or continue with"); + }); +}); + +describe("ForgotPasswordPage", () => { + function WrappedForgot() { + return ( + + + + + + ); + } + + it("renders forgot password form", () => { + mount(() => ); + expect(document.body.textContent).toContain("Forgot password?"); + expect(document.body.textContent).toContain("Send Reset Link"); + expect(document.body.textContent).toContain("Back to sign in"); + }); + + it("shows validation error for empty email", () => { + mount(() => ); + const form = document.querySelector("form")!; + form.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + expect(document.body.textContent).toContain("Email is required"); + }); + + it("shows success state after submission", async () => { + vi.spyOn(auth, "forgotPassword").mockResolvedValue(undefined); + mount(() => ); + const emailInput = + document.querySelector("input[type='email']")!; + setInputValue(emailInput, "test@example.com"); + const form = document.querySelector("form")!; + form.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + await vi.waitFor(() => { + expect(document.body.textContent).toContain("Check your email"); + }); + }); +}); + +describe("OnboardingPage", () => { + function WrappedOnboarding() { + return ( + + + + + + ); + } + + it("renders step 1 (Choose Plan) by default with plan cards", () => { + mount(() => ); + expect(document.body.textContent).toContain("Choose your plan"); + expect(document.body.textContent).toContain("Basic"); + expect(document.body.textContent).toContain("Plus"); + expect(document.body.textContent).toContain("Premium"); + expect(document.body.textContent).toContain("Popular"); + }); + + it("advances to step 2 when Continue is clicked", () => { + mount(() => ); + const continueBtn = [...document.querySelectorAll("button")].find((b) => + b.textContent?.includes("Continue"), + )!; + continueBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(document.body.textContent).toContain( + "Add your first watchlist item", + ); + }); +}); diff --git a/web/src/routes/(auth)/forgot-password.tsx b/web/src/routes/(auth)/forgot-password.tsx new file mode 100644 index 0000000..37bb7ec --- /dev/null +++ b/web/src/routes/(auth)/forgot-password.tsx @@ -0,0 +1,127 @@ +import { createSignal, Show } from "solid-js"; +import { Title } from "@solidjs/meta"; +import { AuthLayout } from "~/components/auth"; +import { Input } from "~/components/ui"; +import { Button } from "~/components/ui"; +import { forgotPassword } from "~/lib/auth"; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export default function ForgotPasswordPage() { + const [email, setEmail] = createSignal(""); + const [error, setError] = createSignal(""); + const [loading, setLoading] = createSignal(false); + const [sent, setSent] = createSignal(false); + + function validate(): boolean { + if (!email().trim()) { + setError("Email is required"); + return false; + } + if (!EMAIL_REGEX.test(email())) { + setError("Invalid email format"); + return false; + } + setError(""); + return true; + } + + async function handleSubmit(e: Event) { + e.preventDefault(); + if (!validate()) return; + setLoading(true); + try { + await forgotPassword(email()); + setSent(true); + } catch { + setError("Something went wrong. Please try again."); + } finally { + setLoading(false); + } + } + + return ( + + Forgot Password — ShieldAI +
+ +
+ + + + +
+

+ Check your email +

+

+ We've sent a password reset link to{" "} + + {email()} + +

+

+ Didn't receive the email?{" "} + +

+
+ } + > +
+

+ Forgot password? +

+

+ Enter your email and we'll send you a reset link +

+
+ +
+ { + setEmail(e.currentTarget.value); + setError(""); + }} + error={error()} + required + /> + +
+ +

+ Remember your password?{" "} + + Back to sign in + +

+ + +
+ ); +} diff --git a/web/src/routes/(auth)/login.tsx b/web/src/routes/(auth)/login.tsx new file mode 100644 index 0000000..4f0eb33 --- /dev/null +++ b/web/src/routes/(auth)/login.tsx @@ -0,0 +1,139 @@ +import { createSignal, Show } from "solid-js"; +import { Title } from "@solidjs/meta"; +import { useNavigate } from "@solidjs/router"; +import { AuthLayout, SocialAuthButtons } from "~/components/auth"; +import { Input } from "~/components/ui"; +import { Button } from "~/components/ui"; +import { login } from "~/lib/auth"; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +interface FormErrors { + email?: string; + password?: string; +} + +export default function LoginPage() { + const navigate = useNavigate(); + const [email, setEmail] = createSignal(""); + const [password, setPassword] = createSignal(""); + const [rememberMe, setRememberMe] = createSignal(false); + const [errors, setErrors] = createSignal({}); + const [loading, setLoading] = createSignal(false); + const [serverError, setServerError] = createSignal(""); + + function validate(): boolean { + const errs: FormErrors = {}; + if (!email().trim()) errs.email = "Email is required"; + else if (!EMAIL_REGEX.test(email())) errs.email = "Invalid email format"; + if (!password()) errs.password = "Password is required"; + setErrors(errs); + return Object.keys(errs).length === 0; + } + + async function handleSubmit(e: Event) { + e.preventDefault(); + setServerError(""); + if (!validate()) return; + setLoading(true); + try { + await login(email(), password(), rememberMe()); + navigate("/dashboard", { replace: true }); + } catch { + setServerError("Invalid email or password. Please try again."); + } finally { + setLoading(false); + } + } + + return ( + + Sign In — ShieldAI +
+
+

+ Welcome back +

+

+ Sign in to your account +

+
+ + + + + +
+ setEmail(e.currentTarget.value)} + error={errors().email} + required + /> + setPassword(e.currentTarget.value)} + error={errors().password} + required + /> +
+ + + Forgot password? + +
+ +
+ +
+
+
+
+
+ + Or continue with + +
+
+ + + +

+ Don't have an account?{" "} + + Sign up + +

+
+ + ); +} diff --git a/web/src/routes/(auth)/onboarding.tsx b/web/src/routes/(auth)/onboarding.tsx new file mode 100644 index 0000000..a33523e --- /dev/null +++ b/web/src/routes/(auth)/onboarding.tsx @@ -0,0 +1,418 @@ +import { createSignal, For, Show } from "solid-js"; +import { Title } from "@solidjs/meta"; +import { useNavigate } from "@solidjs/router"; +import { Button, Input, Badge } from "~/components/ui"; +import { submitOnboarding } from "~/lib/auth"; +import type { OnboardingData } from "~/lib/auth"; + +const plans = [ + { + id: "basic", + name: "Basic", + price: "Free", + features: ["Basic monitoring", "1 email address", "1 phone number"], + }, + { + id: "plus", + name: "Plus", + price: "$9.99/mo", + features: [ + "Advanced monitoring", + "Unlimited emails & phones", + "Dark web scanning", + "Real-time alerts", + ], + }, + { + id: "premium", + name: "Premium", + price: "$19.99/mo", + features: [ + "Everything in Plus", + "Family members (up to 5)", + "Priority support", + "Identity restoration assistance", + ], + }, +]; + +const steps = [ + "Choose Plan", + "Add Watchlist", + "Invite Family", + "Complete", +]; + +export default function OnboardingPage() { + const navigate = useNavigate(); + const [step, setStep] = createSignal(0); + const [plan, setPlan] = createSignal("plus"); + const [watchlistItem, setWatchlistItem] = createSignal(""); + const [watchlistItems, setWatchlistItems] = createSignal([]); + const [familyInput, setFamilyInput] = createSignal(""); + const [familyInvites, setFamilyInvites] = createSignal([]); + const [submitting, setSubmitting] = createSignal(false); + const [watchlistError, setWatchlistError] = createSignal(""); + + function addWatchlistItem() { + const val = watchlistItem().trim(); + if (!val) { + setWatchlistError("Please enter an email or phone number"); + return; + } + if (watchlistItems().includes(val)) { + setWatchlistError("This item is already in your watchlist"); + return; + } + setWatchlistError(""); + setWatchlistItems((prev) => [...prev, val]); + setWatchlistItem(""); + } + + function removeWatchlistItem(idx: number) { + setWatchlistItems((prev) => prev.filter((_, i) => i !== idx)); + } + + function addFamilyInvite() { + const val = familyInput().trim(); + if (!val) return; + if (familyInvites().includes(val)) return; + setFamilyInvites((prev) => [...prev, val]); + setFamilyInput(""); + } + + function removeFamilyInvite(idx: number) { + setFamilyInvites((prev) => prev.filter((_, i) => i !== idx)); + } + + async function completeOnboarding() { + setSubmitting(true); + try { + const data: OnboardingData = { + plan: plan(), + watchlistItems: watchlistItems(), + familyInvites: familyInvites(), + }; + await submitOnboarding(data); + setStep(3); + } finally { + setSubmitting(false); + } + } + + return ( +
+ Set Up Your Account — ShieldAI + +
+
+ + {(label, i) => ( + <> +
+
+ {i() < step() ? ( + + + + ) : ( + i() + 1 + )} +
+ +
+ {i() < steps.length - 1 && ( +
+ )} + + )} + +
+ +
+ +
+
+

+ Choose your plan +

+

+ Select the plan that works best for you. You can upgrade anytime. +

+
+
+ + {(p) => ( + + )} + +
+ +
+
+ + +
+
+

+ Add your first watchlist item +

+

+ Add an email address or phone number to monitor. +

+
+
+
+ { + setWatchlistItem(e.currentTarget.value); + setWatchlistError(""); + }} + error={watchlistError()} + /> +
+ +
+ 0}> +
+ + {(item, i) => ( +
+ + {item} + + +
+ )} +
+
+
+
+ + +
+
+
+ + +
+
+

+ Invite family members +

+

+ Optional. Add family members to extend protection. +

+
+
+
+ setFamilyInput(e.currentTarget.value)} + /> +
+ +
+ 0}> +
+ + {(email, i) => ( +
+ + {email} + + +
+ )} +
+
+
+
+ + +
+
+
+ + +
+
+ + + +
+
+

+ You're all set! +

+

+ Your ShieldAI account is ready. We're already monitoring your + selected items and will alert you of any threats. +

+
+
+ + {!submitting && ( + + )} +
+
+
+
+
+
+ ); +} diff --git a/web/src/routes/(auth)/reset-password.tsx b/web/src/routes/(auth)/reset-password.tsx new file mode 100644 index 0000000..e718cae --- /dev/null +++ b/web/src/routes/(auth)/reset-password.tsx @@ -0,0 +1,146 @@ +import { createSignal, Show } from "solid-js"; +import { Title } from "@solidjs/meta"; +import { useSearchParams, useNavigate } from "@solidjs/router"; +import { AuthLayout, PasswordInput } from "~/components/auth"; +import { Button } from "~/components/ui"; +import { resetPassword } from "~/lib/auth"; + +interface FormErrors { + password?: string; + confirmPassword?: string; +} + +export default function ResetPasswordPage() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const token = () => (Array.isArray(searchParams.token) ? searchParams.token[0] : searchParams.token) ?? ""; + const [password, setPassword] = createSignal(""); + const [confirmPassword, setConfirmPassword] = createSignal(""); + const [errors, setErrors] = createSignal({}); + const [loading, setLoading] = createSignal(false); + const [serverError, setServerError] = createSignal(""); + const [success, setSuccess] = createSignal(false); + + function validate(): boolean { + const errs: FormErrors = {}; + if (!password()) errs.password = "Password is required"; + else if (password().length < 8) + errs.password = "Password must be at least 8 characters"; + if (password() !== confirmPassword()) + errs.confirmPassword = "Passwords do not match"; + setErrors(errs); + return Object.keys(errs).length === 0; + } + + async function handleSubmit(e: Event) { + e.preventDefault(); + setServerError(""); + if (!validate()) return; + if (!token()) { + setServerError("Invalid or missing reset token."); + return; + } + setLoading(true); + try { + await resetPassword(token(), password()); + setSuccess(true); + } catch { + setServerError("Something went wrong. Please try again."); + } finally { + setLoading(false); + } + } + + return ( + + Reset Password — ShieldAI +
+ +
+ + + +
+

+ Password reset successful +

+

+ Your password has been updated. +

+ +
+ } + > +
+

+ Set new password +

+

+ Enter your new password below +

+
+ + + + + + + + + +
+ setPassword(e.currentTarget.value)} + error={errors().password} + required + /> + setConfirmPassword(e.currentTarget.value)} + error={errors().confirmPassword} + required + /> + + + +
+
+ ); +} diff --git a/web/src/routes/(auth)/signup.tsx b/web/src/routes/(auth)/signup.tsx new file mode 100644 index 0000000..7d77278 --- /dev/null +++ b/web/src/routes/(auth)/signup.tsx @@ -0,0 +1,218 @@ +import { createSignal, createMemo, Show } from "solid-js"; +import { Title } from "@solidjs/meta"; +import { useNavigate } from "@solidjs/router"; +import { AuthLayout, PasswordInput, SocialAuthButtons } from "~/components/auth"; +import { Input } from "~/components/ui"; +import { Button } from "~/components/ui"; +import { signup } from "~/lib/auth"; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +interface FormErrors { + name?: string; + email?: string; + password?: string; + confirmPassword?: string; + terms?: string; +} + +type StrengthLevel = "none" | "weak" | "medium" | "strong"; + +export default function SignupPage() { + const navigate = useNavigate(); + const [name, setName] = createSignal(""); + const [email, setEmail] = createSignal(""); + const [password, setPassword] = createSignal(""); + const [confirmPassword, setConfirmPassword] = createSignal(""); + const [agreeTerms, setAgreeTerms] = createSignal(false); + const [errors, setErrors] = createSignal({}); + const [loading, setLoading] = createSignal(false); + const [serverError, setServerError] = createSignal(""); + + const strength = createMemo<{ level: StrengthLevel; label: string; percent: number }>(() => { + const pwd = password(); + if (!pwd) return { level: "none", label: "", percent: 0 }; + let score = 0; + if (pwd.length >= 8) score++; + if (pwd.length >= 12) score++; + if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) score++; + if (/\d/.test(pwd)) score++; + if (/[^a-zA-Z0-9]/.test(pwd)) score++; + if (score <= 1) return { level: "weak", label: "Weak", percent: 25 }; + if (score <= 3) return { level: "medium", label: "Medium", percent: 60 }; + return { level: "strong", label: "Strong", percent: 100 }; + }); + + const strengthColors: Record = { + weak: "bg-[var(--color-error)]", + medium: "bg-[var(--color-warning)]", + strong: "bg-[var(--color-success)]", + }; + + function validate(): boolean { + const errs: FormErrors = {}; + if (!name().trim()) errs.name = "Name is required"; + if (!email().trim()) errs.email = "Email is required"; + else if (!EMAIL_REGEX.test(email())) errs.email = "Invalid email format"; + if (!password()) errs.password = "Password is required"; + else if (password().length < 8) errs.password = "Password must be at least 8 characters"; + if (password() !== confirmPassword()) errs.confirmPassword = "Passwords do not match"; + if (!agreeTerms()) errs.terms = "You must agree to the Terms of Service"; + setErrors(errs); + return Object.keys(errs).length === 0; + } + + async function handleSubmit(e: Event) { + e.preventDefault(); + setServerError(""); + if (!validate()) return; + setLoading(true); + try { + await signup(name(), email(), password()); + navigate("/onboarding", { replace: true }); + } catch { + setServerError("Something went wrong. Please try again."); + } finally { + setLoading(false); + } + } + + return ( + + Create Account — ShieldAI +
+
+

+ Create your account +

+

+ Start protecting your identity +

+
+ + + + + +
+ setName(e.currentTarget.value)} + error={errors().name} + required + /> + setEmail(e.currentTarget.value)} + error={errors().email} + required + /> +
+ setPassword(e.currentTarget.value)} + error={errors().password} + required + /> + +
+
+
+
+

+ Password strength:{" "} + + {strength().label} + +

+
+ +
+ setConfirmPassword(e.currentTarget.value)} + error={errors().confirmPassword} + required + /> + + +

{errors().terms}

+
+ + + +
+
+
+
+
+ + Or continue with + +
+
+ + + +

+ Already have an account?{" "} + + Log in + +

+
+ + ); +}