Template
Add customer accounts: login, signup, reset, activate, orders
- Storefront customer operations in graphql/customer.js and a server-safe services/shopify/customer.ts - Session held in an httpOnly, sameSite=lax cookie set by the /api/account handlers; the access token never reaches client JS - Pages: /account/login, /register, /recover, /reset/[id]/[token] and /activate/[id]/[token] for Shopify's emailed links - /account renders order history as master-detail on one screen, since the Storefront API has no standalone order-by-id query for customers - Header user icon: links to sign-in when signed out, otherwise a menu with name, email, order history, and sign out - Login errors are collapsed and password recovery responds identically for known and unknown emails, so neither form enumerates accounts Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
This commit is contained in:
co-authored by
Claude Opus 5
parent
8163f99bd7
commit
8167acb231
@@ -0,0 +1,39 @@
|
||||
import Header from '@/components/shopify/header';
|
||||
import Footer from '@/components/shopify/footer';
|
||||
import AccountForm from '@/components/shopify/account-form';
|
||||
|
||||
export const metadata = { title: 'Activate your account — Shop' };
|
||||
|
||||
// Shopify's emailed activation link is /account/activate/{id}/{token}.
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string; token: string }>;
|
||||
}) {
|
||||
const { id, token } = await params;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header storeName="Shop" logoUrl="" />
|
||||
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
|
||||
<AccountForm
|
||||
title="Activate your account"
|
||||
description="Choose a password to finish setting up your account."
|
||||
fields={[
|
||||
{
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
type: 'password',
|
||||
autoComplete: 'new-password',
|
||||
},
|
||||
]}
|
||||
submitLabel="Activate account"
|
||||
endpoint="/api/account/activate"
|
||||
extraPayload={{ id, activationToken: token }}
|
||||
redirectTo="/account"
|
||||
/>
|
||||
</main>
|
||||
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Header from '@/components/shopify/header';
|
||||
import Footer from '@/components/shopify/footer';
|
||||
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
|
||||
|
||||
export const metadata = { title: 'Sign in — Shop' };
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<Header storeName="Shop" logoUrl="" />
|
||||
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
|
||||
<AccountForm
|
||||
title="Sign in"
|
||||
fields={[
|
||||
{ name: 'email', label: 'Email', type: 'email', autoComplete: 'email' },
|
||||
{
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
type: 'password',
|
||||
autoComplete: 'current-password',
|
||||
},
|
||||
]}
|
||||
submitLabel="Sign in"
|
||||
endpoint="/api/account/login"
|
||||
redirectTo="/account"
|
||||
footer={
|
||||
<>
|
||||
<span>
|
||||
New here?{' '}
|
||||
<AccountFormLink href="/account/register">
|
||||
Create an account
|
||||
</AccountFormLink>
|
||||
</span>
|
||||
<AccountFormLink href="/account/recover">
|
||||
Forgot your password?
|
||||
</AccountFormLink>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</main>
|
||||
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import Header from '@/components/shopify/header';
|
||||
import Footer from '@/components/shopify/footer';
|
||||
import OrderHistory from '@/components/shopify/order-history';
|
||||
import { getSessionToken } from '@/services/shopify/session';
|
||||
import { getCustomer } from '@/services/shopify/customer';
|
||||
|
||||
export const metadata = { title: 'Order history — Shop' };
|
||||
|
||||
export default async function Page() {
|
||||
const token = await getSessionToken();
|
||||
if (!token) redirect('/account/login');
|
||||
|
||||
const customer = await getCustomer(token, 20);
|
||||
// An expired or revoked token reads as signed out.
|
||||
if (!customer) redirect('/account/login');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header storeName="Shop" logoUrl="" />
|
||||
|
||||
<main className="max-w-screen-2xl mx-auto w-full px-8 py-12">
|
||||
<h1 className="text-2xl md:text-3xl font-normal text-foreground">
|
||||
Order history
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{customer.displayName} · {customer.email}
|
||||
</p>
|
||||
|
||||
<div className="mt-10">
|
||||
<OrderHistory customer={customer} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Header from '@/components/shopify/header';
|
||||
import Footer from '@/components/shopify/footer';
|
||||
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
|
||||
|
||||
export const metadata = { title: 'Reset password — Shop' };
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<Header storeName="Shop" logoUrl="" />
|
||||
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
|
||||
<AccountForm
|
||||
title="Reset password"
|
||||
description="Enter your email and we'll send you a link to set a new password."
|
||||
fields={[
|
||||
{ name: 'email', label: 'Email', type: 'email', autoComplete: 'email' },
|
||||
]}
|
||||
submitLabel="Send reset link"
|
||||
endpoint="/api/account/recover"
|
||||
successMessage="If that email has an account, a reset link is on its way."
|
||||
footer={
|
||||
<AccountFormLink href="/account/login">Back to sign in</AccountFormLink>
|
||||
}
|
||||
/>
|
||||
</main>
|
||||
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Header from '@/components/shopify/header';
|
||||
import Footer from '@/components/shopify/footer';
|
||||
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
|
||||
|
||||
export const metadata = { title: 'Create account — Shop' };
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<Header storeName="Shop" logoUrl="" />
|
||||
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
|
||||
<AccountForm
|
||||
title="Create account"
|
||||
fields={[
|
||||
{
|
||||
name: 'firstName',
|
||||
label: 'First name',
|
||||
autoComplete: 'given-name',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'lastName',
|
||||
label: 'Last name',
|
||||
autoComplete: 'family-name',
|
||||
required: false,
|
||||
},
|
||||
{ name: 'email', label: 'Email', type: 'email', autoComplete: 'email' },
|
||||
{
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
type: 'password',
|
||||
autoComplete: 'new-password',
|
||||
},
|
||||
]}
|
||||
submitLabel="Create account"
|
||||
endpoint="/api/account/register"
|
||||
redirectTo="/account"
|
||||
footer={
|
||||
<span>
|
||||
Already have an account?{' '}
|
||||
<AccountFormLink href="/account/login">Sign in</AccountFormLink>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</main>
|
||||
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Header from '@/components/shopify/header';
|
||||
import Footer from '@/components/shopify/footer';
|
||||
import AccountForm from '@/components/shopify/account-form';
|
||||
|
||||
export const metadata = { title: 'Set a new password — Shop' };
|
||||
|
||||
// Shopify's emailed reset link is /account/reset/{id}/{token}.
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string; token: string }>;
|
||||
}) {
|
||||
const { id, token } = await params;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header storeName="Shop" logoUrl="" />
|
||||
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
|
||||
<AccountForm
|
||||
title="Set a new password"
|
||||
fields={[
|
||||
{
|
||||
name: 'password',
|
||||
label: 'New password',
|
||||
type: 'password',
|
||||
autoComplete: 'new-password',
|
||||
},
|
||||
]}
|
||||
submitLabel="Save password"
|
||||
endpoint="/api/account/reset"
|
||||
extraPayload={{ id, resetToken: token }}
|
||||
redirectTo="/account"
|
||||
/>
|
||||
</main>
|
||||
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
activateAccount,
|
||||
toCustomerGid,
|
||||
customerErrorMessage,
|
||||
} from '@/services/shopify/customer';
|
||||
import { setSessionToken } from '@/services/shopify/session';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { id, activationToken, password } = await req.json();
|
||||
|
||||
if (!id || !activationToken || !password) {
|
||||
return Response.json(
|
||||
{ error: 'This activation link is incomplete.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { token, errors } = await activateAccount(
|
||||
toCustomerGid(id),
|
||||
activationToken,
|
||||
password
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
|
||||
}
|
||||
|
||||
await setSessionToken(token.accessToken, token.expiresAt);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { login, customerErrorMessage } from '@/services/shopify/customer';
|
||||
import { setSessionToken } from '@/services/shopify/session';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { email, password } = await req.json();
|
||||
|
||||
if (!email || !password) {
|
||||
return Response.json(
|
||||
{ error: 'Enter your email and password.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { token, errors } = await login(email, password);
|
||||
|
||||
if (!token) {
|
||||
// Shopify distinguishes wrong-password from unknown-email; collapse both so
|
||||
// the form can't be used to enumerate accounts.
|
||||
return Response.json(
|
||||
{
|
||||
error: errors.length
|
||||
? 'Incorrect email or password.'
|
||||
: customerErrorMessage(errors),
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
await setSessionToken(token.accessToken, token.expiresAt);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { logout } from '@/services/shopify/customer';
|
||||
import { getSessionToken, clearSessionToken } from '@/services/shopify/session';
|
||||
|
||||
export async function POST() {
|
||||
const token = await getSessionToken();
|
||||
if (token) await logout(token);
|
||||
|
||||
await clearSessionToken();
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getSessionToken } from '@/services/shopify/session';
|
||||
import { getCustomer } from '@/services/shopify/customer';
|
||||
|
||||
// Minimal session probe for the header menu — never returns the access token.
|
||||
export async function GET() {
|
||||
const token = await getSessionToken();
|
||||
if (!token) return Response.json({ customer: null });
|
||||
|
||||
const customer = await getCustomer(token, 0);
|
||||
if (!customer) return Response.json({ customer: null });
|
||||
|
||||
return Response.json({
|
||||
customer: {
|
||||
displayName: customer.displayName,
|
||||
email: customer.email,
|
||||
firstName: customer.firstName,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { recoverPassword } from '@/services/shopify/customer';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { email } = await req.json();
|
||||
|
||||
if (email) await recoverPassword(email);
|
||||
|
||||
// Always the same response, so the form can't reveal who has an account.
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
createCustomer,
|
||||
login,
|
||||
customerErrorMessage,
|
||||
} from '@/services/shopify/customer';
|
||||
import { setSessionToken } from '@/services/shopify/session';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { email, password, firstName, lastName } = await req.json();
|
||||
|
||||
if (!email || !password) {
|
||||
return Response.json(
|
||||
{ error: 'Enter your email and password.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { errors } = await createCustomer({
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
});
|
||||
|
||||
if (errors.length) {
|
||||
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
|
||||
}
|
||||
|
||||
// Sign the new customer straight in. Accounts needing email confirmation
|
||||
// won't return a token yet, which is not an error.
|
||||
const { token } = await login(email, password);
|
||||
|
||||
if (token) {
|
||||
await setSessionToken(token.accessToken, token.expiresAt);
|
||||
return Response.json({ ok: true, signedIn: true });
|
||||
}
|
||||
|
||||
return Response.json({ ok: true, signedIn: false });
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
resetPassword,
|
||||
toCustomerGid,
|
||||
customerErrorMessage,
|
||||
} from '@/services/shopify/customer';
|
||||
import { setSessionToken } from '@/services/shopify/session';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { id, resetToken, password } = await req.json();
|
||||
|
||||
if (!id || !resetToken || !password) {
|
||||
return Response.json({ error: 'This reset link is incomplete.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { token, errors } = await resetPassword(
|
||||
toCustomerGid(id),
|
||||
resetToken,
|
||||
password
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
|
||||
}
|
||||
|
||||
await setSessionToken(token.accessToken, token.expiresAt);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
Reference in New Issue
Block a user