Jack commited on
Commit
f3845d0
·
1 Parent(s): 240bc36

account page fixes

Browse files
README.md CHANGED
@@ -61,4 +61,4 @@ docker compose run --rm db-reset
61
  - No third-party auth dashboard is required for local development.
62
  - No hosted payment provider is required for checkout in the local demo.
63
  - Seeded grocery catalog data and images are local once the vendoring step has run.
64
- - Product cards still have local fallback imagery for newly created items without image URLs.
 
61
  - No third-party auth dashboard is required for local development.
62
  - No hosted payment provider is required for checkout in the local demo.
63
  - Seeded grocery catalog data and images are local once the vendoring step has run.
64
+ - Product cards still have local fallback imagery for newly created items without image URLs.
app/layout.tsx CHANGED
@@ -1,5 +1,6 @@
1
  // app/layout.tsx
2
  import type { Metadata } from "next";
 
3
  import "./globals.css";
4
  import "../styles/globals.css";
5
 
@@ -15,7 +16,10 @@ export default function RootLayout({
15
  }) {
16
  return (
17
  <html lang="en">
18
- <body>{children}</body>
 
 
 
19
  </html>
20
  );
21
  }
 
1
  // app/layout.tsx
2
  import type { Metadata } from "next";
3
+ import { Toaster } from "@/components/ui/Toaster";
4
  import "./globals.css";
5
  import "../styles/globals.css";
6
 
 
16
  }) {
17
  return (
18
  <html lang="en">
19
+ <body>
20
+ {children}
21
+ <Toaster />
22
+ </body>
23
  </html>
24
  );
25
  }
components/auth/auth-card.tsx CHANGED
@@ -8,8 +8,7 @@ import { routes } from "@/lib/routes";
8
  import { signIn, signUp } from "@/server-actions/auth";
9
  import { Loader2 } from "lucide-react";
10
  import Link from "next/link";
11
- import { useRouter } from "next/navigation";
12
- import { useState } from "react";
13
 
14
  const demoAccounts = [
15
  "buyer@shopsmart.local / demo1234",
@@ -19,9 +18,14 @@ const demoAccounts = [
19
  ];
20
 
21
  export function AuthCard(props: { mode: "signIn" | "signUp" }) {
22
- const router = useRouter();
23
  const { toast } = useToast();
24
  const [isLoading, setIsLoading] = useState(false);
 
 
 
 
 
 
25
  const [formValues, setFormValues] = useState({
26
  name: "",
27
  email: "",
@@ -30,6 +34,10 @@ export function AuthCard(props: { mode: "signIn" | "signUp" }) {
30
 
31
  const isSignIn = props.mode === "signIn";
32
 
 
 
 
 
33
  return (
34
  <div className="grid w-full max-w-5xl gap-8 rounded-3xl border border-border bg-white p-4 shadow-sm md:grid-cols-[1.15fr_0.85fr] md:p-8">
35
  <div className="overflow-hidden rounded-[1.5rem] bg-slate-950 p-8 text-white">
@@ -58,6 +66,7 @@ export function AuthCard(props: { mode: "signIn" | "signUp" }) {
58
  onSubmit={(event) => {
59
  event.preventDefault();
60
  setIsLoading(true);
 
61
 
62
  const action = isSignIn
63
  ? signIn({
@@ -68,16 +77,28 @@ export function AuthCard(props: { mode: "signIn" | "signUp" }) {
68
 
69
  action
70
  .then((result) => {
 
 
 
 
 
 
71
  toast({
72
  title: result.message,
73
  description: result.action,
74
  });
75
 
76
  if (!result.error) {
77
- router.push(routes.account);
78
- router.refresh();
79
  }
80
  })
 
 
 
 
 
 
 
81
  .finally(() => setIsLoading(false));
82
  }}
83
  >
@@ -88,6 +109,12 @@ export function AuthCard(props: { mode: "signIn" | "signUp" }) {
88
  <h2 className="mt-2 text-2xl font-semibold">
89
  {isSignIn ? "Sign in to your account" : "Create your account"}
90
  </h2>
 
 
 
 
 
 
91
  </div>
92
 
93
  {!isSignIn && (
@@ -147,6 +174,31 @@ export function AuthCard(props: { mode: "signIn" | "signUp" }) {
147
  {isSignIn ? "Sign In" : "Create Account"}
148
  </Button>
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  <p className="text-sm text-muted-foreground">
151
  {isSignIn ? "Need an account?" : "Already have an account?"}{" "}
152
  <Link
 
8
  import { signIn, signUp } from "@/server-actions/auth";
9
  import { Loader2 } from "lucide-react";
10
  import Link from "next/link";
11
+ import { useEffect, useState } from "react";
 
12
 
13
  const demoAccounts = [
14
  "buyer@shopsmart.local / demo1234",
 
18
  ];
19
 
20
  export function AuthCard(props: { mode: "signIn" | "signUp" }) {
 
21
  const { toast } = useToast();
22
  const [isLoading, setIsLoading] = useState(false);
23
+ const [isEmbedded, setIsEmbedded] = useState(false);
24
+ const [status, setStatus] = useState<{
25
+ tone: "error" | "success";
26
+ title: string;
27
+ description: string;
28
+ } | null>(null);
29
  const [formValues, setFormValues] = useState({
30
  name: "",
31
  email: "",
 
34
 
35
  const isSignIn = props.mode === "signIn";
36
 
37
+ useEffect(() => {
38
+ setIsEmbedded(window.self !== window.top);
39
+ }, []);
40
+
41
  return (
42
  <div className="grid w-full max-w-5xl gap-8 rounded-3xl border border-border bg-white p-4 shadow-sm md:grid-cols-[1.15fr_0.85fr] md:p-8">
43
  <div className="overflow-hidden rounded-[1.5rem] bg-slate-950 p-8 text-white">
 
66
  onSubmit={(event) => {
67
  event.preventDefault();
68
  setIsLoading(true);
69
+ setStatus(null);
70
 
71
  const action = isSignIn
72
  ? signIn({
 
77
 
78
  action
79
  .then((result) => {
80
+ setStatus({
81
+ tone: result.error ? "error" : "success",
82
+ title: result.message,
83
+ description: result.action,
84
+ });
85
+
86
  toast({
87
  title: result.message,
88
  description: result.action,
89
  });
90
 
91
  if (!result.error) {
92
+ window.location.assign(routes.account);
 
93
  }
94
  })
95
+ .catch(() => {
96
+ setStatus({
97
+ tone: "error",
98
+ title: isSignIn ? "Sign in failed" : "Sign up failed",
99
+ description: "The request did not complete. Please try again.",
100
+ });
101
+ })
102
  .finally(() => setIsLoading(false));
103
  }}
104
  >
 
109
  <h2 className="mt-2 text-2xl font-semibold">
110
  {isSignIn ? "Sign in to your account" : "Create your account"}
111
  </h2>
112
+ {isEmbedded ? (
113
+ <p className="mt-3 text-sm text-muted-foreground">
114
+ If this Space is embedded on Hugging Face, open it in a new tab
115
+ before signing in so the browser can keep the session cookie.
116
+ </p>
117
+ ) : null}
118
  </div>
119
 
120
  {!isSignIn && (
 
174
  {isSignIn ? "Sign In" : "Create Account"}
175
  </Button>
176
 
177
+ {status ? (
178
+ <div
179
+ className={
180
+ status.tone === "error"
181
+ ? "rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
182
+ : "rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700"
183
+ }
184
+ >
185
+ <p className="font-medium">{status.title}</p>
186
+ <p className="mt-1">{status.description}</p>
187
+ </div>
188
+ ) : null}
189
+
190
+ {isEmbedded ? (
191
+ <Button asChild variant="outline">
192
+ <a
193
+ href={isSignIn ? routes.signIn : routes.signUp}
194
+ target="_blank"
195
+ rel="noreferrer"
196
+ >
197
+ Open This Page In A New Tab
198
+ </a>
199
+ </Button>
200
+ ) : null}
201
+
202
  <p className="text-sm text-muted-foreground">
203
  {isSignIn ? "Need an account?" : "Already have an account?"}{" "}
204
  <Link
lib/auth.ts CHANGED
@@ -10,6 +10,20 @@ import { cache } from "react";
10
  const SESSION_COOKIE_NAME = "shopsmart_session";
11
  const SESSION_DURATION_SECONDS = 60 * 60 * 24 * 30;
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  export function hashPassword(password: string) {
14
  const salt = randomBytes(16).toString("hex");
15
  const hash = scryptSync(password, salt, 64).toString("hex");
@@ -37,25 +51,30 @@ export const getCurrentUser = cache(async () => {
37
  return null;
38
  }
39
 
40
- const [record] = await db
41
- .select({
42
- id: users.id,
43
- name: users.name,
44
- email: users.email,
45
- passwordHash: users.passwordHash,
46
- storeId: users.storeId,
47
- createdAt: users.createdAt,
48
- })
49
- .from(sessions)
50
- .innerJoin(users, eq(sessions.userId, users.id))
51
- .where(
52
- and(
53
- eq(sessions.sessionToken, sessionToken),
54
- gt(sessions.expiresAt, Math.floor(Date.now() / 1000))
55
- )
56
- );
57
-
58
- return record ?? null;
 
 
 
 
 
59
  });
60
 
61
  export async function requireUser() {
@@ -79,13 +98,11 @@ export async function createSession(userId: number) {
79
  expiresAt,
80
  });
81
 
82
- cookies().set(SESSION_COOKIE_NAME, sessionToken, {
83
- httpOnly: true,
84
- sameSite: "lax",
85
- secure: process.env.NODE_ENV === "production",
86
- expires: new Date(expiresAt * 1000),
87
- path: "/",
88
- });
89
  }
90
 
91
  export async function destroySession() {
@@ -95,11 +112,9 @@ export async function destroySession() {
95
  await db.delete(sessions).where(eq(sessions.sessionToken, sessionToken));
96
  }
97
 
98
- cookies().set(SESSION_COOKIE_NAME, "", {
99
- httpOnly: true,
100
- sameSite: "lax",
101
- secure: process.env.NODE_ENV === "production",
102
- expires: new Date(0),
103
- path: "/",
104
- });
105
  }
 
10
  const SESSION_COOKIE_NAME = "shopsmart_session";
11
  const SESSION_DURATION_SECONDS = 60 * 60 * 24 * 30;
12
 
13
+ function getSessionCookieOptions(expires: Date) {
14
+ const isProduction = process.env.NODE_ENV === "production";
15
+ const isHuggingFaceSpace = Boolean(process.env.SPACE_HOST);
16
+ const useCrossSiteCookie = isProduction && isHuggingFaceSpace;
17
+
18
+ return {
19
+ httpOnly: true,
20
+ sameSite: useCrossSiteCookie ? "none" : "lax",
21
+ secure: isProduction,
22
+ expires,
23
+ path: "/",
24
+ } as const;
25
+ }
26
+
27
  export function hashPassword(password: string) {
28
  const salt = randomBytes(16).toString("hex");
29
  const hash = scryptSync(password, salt, 64).toString("hex");
 
51
  return null;
52
  }
53
 
54
+ try {
55
+ const [record] = await db
56
+ .select({
57
+ id: users.id,
58
+ name: users.name,
59
+ email: users.email,
60
+ passwordHash: users.passwordHash,
61
+ storeId: users.storeId,
62
+ createdAt: users.createdAt,
63
+ })
64
+ .from(sessions)
65
+ .innerJoin(users, eq(sessions.userId, users.id))
66
+ .where(
67
+ and(
68
+ eq(sessions.sessionToken, sessionToken),
69
+ gt(sessions.expiresAt, Math.floor(Date.now() / 1000))
70
+ )
71
+ );
72
+
73
+ return record ?? null;
74
+ } catch (error) {
75
+ console.error("Failed to load the current user from the session store.", error);
76
+ return null;
77
+ }
78
  });
79
 
80
  export async function requireUser() {
 
98
  expiresAt,
99
  });
100
 
101
+ cookies().set(
102
+ SESSION_COOKIE_NAME,
103
+ sessionToken,
104
+ getSessionCookieOptions(new Date(expiresAt * 1000))
105
+ );
 
 
106
  }
107
 
108
  export async function destroySession() {
 
112
  await db.delete(sessions).where(eq(sessions.sessionToken, sessionToken));
113
  }
114
 
115
+ cookies().set(
116
+ SESSION_COOKIE_NAME,
117
+ "",
118
+ getSessionCookieOptions(new Date(0))
119
+ );
 
 
120
  }
lib/demo-auth-seed.ts ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "server-only";
2
+
3
+ import { db } from "@/db/db";
4
+ import { stores, users } from "@/db/schema";
5
+ import { eq } from "drizzle-orm";
6
+
7
+ const buyerPasswordHash =
8
+ "cb8e410fc0d517520d05c38b0bc25243:091a527d3595b8bd2c93c5129b83363fa786417d9eb40f0f42693e10b5f307a655063a8fbfa5697d825ef4edb6ca1e184af3dd304bcdaa2ab21ab30eb3dd4e94";
9
+
10
+ const sellerPasswordHash =
11
+ "456a6918668c20e44ccd0129385afdd8:6afaf4c3a3b13023157ac0bab318e77ef05c4cfa8cd5751b10f095a000288de4e77cb82ba5b8e64db2202a527b41d1bd03fd8918268c25a66a6e9ec911a8309c";
12
+
13
+ const demoBuyer = {
14
+ name: "Demo Buyer",
15
+ email: "buyer@shopsmart.local",
16
+ passwordHash: buyerPasswordHash,
17
+ storeId: null as number | null,
18
+ };
19
+
20
+ const demoStores = [
21
+ {
22
+ name: "Orchard Market",
23
+ slug: "orchard-market",
24
+ email: "orchard@shopsmart.local",
25
+ industry: "Produce Market",
26
+ description:
27
+ "Peak-season fruit, greens, herbs, and produce-box essentials for the week ahead.",
28
+ },
29
+ {
30
+ name: "Pantry Lane",
31
+ slug: "pantry-lane",
32
+ email: "pantry@shopsmart.local",
33
+ industry: "Pantry & Drinks",
34
+ description:
35
+ "Shelf staples, breakfast basics, drinks, and everyday pantry refills in one stop.",
36
+ },
37
+ {
38
+ name: "FreshMart",
39
+ slug: "freshmart",
40
+ email: "freshmart@shopsmart.local",
41
+ industry: "Neighborhood Grocery",
42
+ description:
43
+ "Everyday grocery basics with fruit, dairy, eggs, and bakery staples for fast weekly orders.",
44
+ },
45
+ {
46
+ name: "GreenBasket",
47
+ slug: "greenbasket",
48
+ email: "greenbasket@shopsmart.local",
49
+ industry: "Organic Grocer",
50
+ description:
51
+ "Organic produce and pantry staples curated for lighter cooking, meal prep, and low-waste baskets.",
52
+ },
53
+ {
54
+ name: "Family Fare",
55
+ slug: "family-fare",
56
+ email: "family@shopsmart.local",
57
+ industry: "Family Grocer",
58
+ description:
59
+ "Protein picks, freezer favorites, bulk produce, and family-size grocery staples.",
60
+ },
61
+ {
62
+ name: "Ready Table",
63
+ slug: "ready-table",
64
+ email: "readytable@shopsmart.local",
65
+ industry: "Prepared Meals",
66
+ description:
67
+ "Heat-and-eat dinners, pasta bowls, curries, and quick mains for busy nights.",
68
+ },
69
+ {
70
+ name: "Green Spoon",
71
+ slug: "green-spoon",
72
+ email: "greenspoon@shopsmart.local",
73
+ industry: "Fresh Deli",
74
+ description:
75
+ "Fresh salads, grain bowls, wraps, and lighter prepared foods for everyday lunches.",
76
+ },
77
+ {
78
+ name: "Oven & Crumb",
79
+ slug: "oven-and-crumb",
80
+ email: "oven@shopsmart.local",
81
+ industry: "Bakery & Cafe",
82
+ description:
83
+ "Bakery favorites, cafe-style sides, desserts, and grab-and-go comfort food.",
84
+ },
85
+ ] as const;
86
+
87
+ const demoUsers = [
88
+ demoBuyer,
89
+ ...demoStores.map((store) => ({
90
+ name: `${store.name} Seller`,
91
+ email: store.email,
92
+ passwordHash: sellerPasswordHash,
93
+ storeSlug: store.slug,
94
+ })),
95
+ ] as const;
96
+
97
+ const demoUserByEmail = new Map(
98
+ demoUsers.map((user) => [user.email.toLowerCase(), user])
99
+ );
100
+
101
+ export function isDemoAccountEmail(email: string) {
102
+ return demoUserByEmail.has(email.trim().toLowerCase());
103
+ }
104
+
105
+ async function ensureDemoStore(store: (typeof demoStores)[number]) {
106
+ const [existingStore] = await db
107
+ .select({ id: stores.id })
108
+ .from(stores)
109
+ .where(eq(stores.slug, store.slug));
110
+
111
+ if (existingStore) {
112
+ await db
113
+ .update(stores)
114
+ .set({
115
+ name: store.name,
116
+ industry: store.industry,
117
+ description: store.description,
118
+ })
119
+ .where(eq(stores.id, existingStore.id));
120
+
121
+ return existingStore.id;
122
+ }
123
+
124
+ const [{ insertId }] = await db.insert(stores).values({
125
+ name: store.name,
126
+ slug: store.slug,
127
+ industry: store.industry,
128
+ description: store.description,
129
+ });
130
+
131
+ return insertId;
132
+ }
133
+
134
+ export async function ensureDemoAccountSeed(email: string) {
135
+ const normalizedEmail = email.trim().toLowerCase();
136
+ const demoUser = demoUserByEmail.get(normalizedEmail);
137
+
138
+ if (!demoUser) {
139
+ return;
140
+ }
141
+
142
+ let storeId: number | null = null;
143
+
144
+ if ("storeSlug" in demoUser) {
145
+ const store = demoStores.find(
146
+ (candidate) => candidate.slug === demoUser.storeSlug
147
+ );
148
+
149
+ if (!store) {
150
+ throw new Error(`Missing demo store config for ${demoUser.email}`);
151
+ }
152
+
153
+ storeId = await ensureDemoStore(store);
154
+ }
155
+
156
+ const [existingUser] = await db
157
+ .select({ id: users.id })
158
+ .from(users)
159
+ .where(eq(users.email, demoUser.email));
160
+
161
+ const nextValues = {
162
+ name: demoUser.name,
163
+ email: demoUser.email,
164
+ passwordHash: demoUser.passwordHash,
165
+ storeId,
166
+ createdAt: Math.floor(Date.now() / 1000),
167
+ };
168
+
169
+ if (existingUser) {
170
+ await db.update(users).set(nextValues).where(eq(users.id, existingUser.id));
171
+ return;
172
+ }
173
+
174
+ await db.insert(users).values(nextValues);
175
+ }
server-actions/auth.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
  hashPassword,
10
  verifyPassword,
11
  } from "@/lib/auth";
 
12
  import { eq } from "drizzle-orm";
13
  import { revalidatePath } from "next/cache";
14
  import { z } from "zod";
@@ -26,7 +27,11 @@ const signUpSchema = z.object({
26
 
27
  export async function signIn(values: { email: string; password: string }) {
28
  try {
29
- const { email, password } = signInSchema.parse(values);
 
 
 
 
30
 
31
  const [user] = await db.select().from(users).where(eq(users.email, email));
32
 
@@ -47,7 +52,7 @@ export async function signIn(values: { email: string; password: string }) {
47
  action: "Welcome back to ShopSmart.",
48
  };
49
  } catch (error) {
50
- console.log(error);
51
 
52
  return {
53
  error: true,
@@ -64,10 +69,11 @@ export async function signUp(values: {
64
  }) {
65
  try {
66
  const input = signUpSchema.parse(values);
 
67
  const [existingUser] = await db
68
  .select({ id: users.id })
69
  .from(users)
70
- .where(eq(users.email, input.email));
71
 
72
  if (existingUser) {
73
  return {
@@ -79,7 +85,7 @@ export async function signUp(values: {
79
 
80
  const result = await db.insert(users).values({
81
  name: input.name,
82
- email: input.email,
83
  passwordHash: hashPassword(input.password),
84
  createdAt: Math.floor(Date.now() / 1000),
85
  });
@@ -93,7 +99,7 @@ export async function signUp(values: {
93
  action: "You're ready to start shopping.",
94
  };
95
  } catch (error) {
96
- console.log(error);
97
 
98
  return {
99
  error: true,
 
9
  hashPassword,
10
  verifyPassword,
11
  } from "@/lib/auth";
12
+ import { ensureDemoAccountSeed } from "@/lib/demo-auth-seed";
13
  import { eq } from "drizzle-orm";
14
  import { revalidatePath } from "next/cache";
15
  import { z } from "zod";
 
27
 
28
  export async function signIn(values: { email: string; password: string }) {
29
  try {
30
+ const parsed = signInSchema.parse(values);
31
+ const email = parsed.email.toLowerCase();
32
+ const { password } = parsed;
33
+
34
+ await ensureDemoAccountSeed(email);
35
 
36
  const [user] = await db.select().from(users).where(eq(users.email, email));
37
 
 
52
  action: "Welcome back to ShopSmart.",
53
  };
54
  } catch (error) {
55
+ console.error("Sign in failed.", error);
56
 
57
  return {
58
  error: true,
 
69
  }) {
70
  try {
71
  const input = signUpSchema.parse(values);
72
+ const email = input.email.toLowerCase();
73
  const [existingUser] = await db
74
  .select({ id: users.id })
75
  .from(users)
76
+ .where(eq(users.email, email));
77
 
78
  if (existingUser) {
79
  return {
 
85
 
86
  const result = await db.insert(users).values({
87
  name: input.name,
88
+ email,
89
  passwordHash: hashPassword(input.password),
90
  createdAt: Math.floor(Date.now() / 1000),
91
  });
 
99
  action: "You're ready to start shopping.",
100
  };
101
  } catch (error) {
102
+ console.error("Sign up failed.", error);
103
 
104
  return {
105
  error: true,