Skip to content

Commit dae37a4

Browse files
committed
fix(auth): wire login and register to real backend auth model
Backend uses merchantId+secret, not email+password. login/page.tsx: - Form now collects merchantId + secret (was email + password) - Calls POST /api/auth/token (correct endpoint) - Decodes JWT payload client-side to extract role - Keeps wallet login path intact register/page.tsx: - Replace apiClient (requires JWT) with plain fetch - Send proper id, name, ownerId to POST /api/merchants - After success: surface the one-time secret in a modal - User must copy secret before they can proceed to login - 'Continue' button stays disabled until copied lib/utils/validation.ts: - loginSchema now validates merchantId + secret fields
1 parent ece8e18 commit dae37a4

3 files changed

Lines changed: 137 additions & 75 deletions

File tree

app/auth/login/page.tsx

Lines changed: 54 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,11 @@ import { useNotify } from '@/lib/hooks/useNotify';
1111
import dynamic from 'next/dynamic';
1212

1313
import { loginSchema, LoginFormValues } from '@/lib/utils/validation';
14-
import { normalizeEmail } from '@/lib/utils/sanitize';
1514
import { useAuthStore } from '@/lib/store/authStore';
1615
import { useRateLimitStore } from '@/lib/store/rateLimitStore';
1716
import { Button } from '@/components/ui/button';
18-
import { AuthInput } from '@/components/auth/AuthInput';
19-
import { AuthLabel } from '@/components/auth/AuthLabel';
20-
import { AuthButton } from '@/components/auth/AuthButton';
17+
import { Input } from '@/components/ui/input';
18+
import { Label } from '@/components/ui/label';
2119

2220
import { WalletModalFallback } from '@/components/wallet/WalletModalFallback';
2321

@@ -53,47 +51,45 @@ export default function LoginPage() {
5351

5452
const onSubmit = useCallback(async (data: LoginFormValues) => {
5553
setIsLoading(true);
56-
const sanitizedData = { ...data, email: normalizeEmail(data.email) };
57-
5854
try {
5955
const apiBase = process.env.NEXT_PUBLIC_API_URL || 'https://bettapay-backend.onrender.com';
6056

61-
// Authenticate against the real backend — no mock tokens
62-
const loginRes = await fetch(`${apiBase}/api/auth/login`, {
57+
// POST /api/auth/token expects { merchantId, secret }
58+
const loginRes = await fetch(`${apiBase}/api/auth/token`, {
6359
method: 'POST',
6460
headers: { 'Content-Type': 'application/json' },
6561
body: JSON.stringify({
66-
email: sanitizedData.email,
67-
password: sanitizedData.password,
62+
merchantId: data.merchantId,
63+
secret: data.secret,
6864
}),
6965
});
7066

7167
if (!loginRes.ok) {
7268
const errBody = await loginRes.json().catch(() => ({}));
7369
const message =
74-
errBody?.message ||
7570
errBody?.error ||
76-
(loginRes.status === 401 ? 'Invalid email or password.' : 'Login failed. Please try again.');
71+
errBody?.message ||
72+
(loginRes.status === 401 ? 'Invalid Merchant ID or secret key.' : 'Login failed. Please try again.');
7773
error(message);
7874
return;
7975
}
8076

81-
const loginData = await loginRes.json();
82-
83-
// Backend returns { token, user: { id, email, name, role, ... } }
84-
const token: string = loginData.token ?? loginData.data?.token;
85-
const backendUser = loginData.user ?? loginData.data?.user;
77+
const { token } = await loginRes.json();
8678

87-
if (!token || !backendUser) {
79+
if (!token) {
8880
error('Unexpected response from server. Please try again.');
8981
return;
9082
}
9183

84+
// Decode the JWT payload to get merchantId and role (no signature verification needed client-side)
85+
const payloadBase64 = token.split('.')[1];
86+
const payload = JSON.parse(atob(payloadBase64));
87+
9288
const user = {
93-
id: backendUser.id,
94-
email: backendUser.email,
95-
name: backendUser.name,
96-
role: backendUser.role as 'admin' | 'merchant',
89+
id: payload.merchantId ?? data.merchantId,
90+
email: '',
91+
name: 'Merchant',
92+
role: (payload.role ?? 'merchant') as 'admin' | 'merchant',
9793
};
9894

9995
// Set the HttpOnly auth cookie via the Next.js session route
@@ -105,14 +101,11 @@ export default function LoginPage() {
105101
body: JSON.stringify({ token, role: user.role }),
106102
});
107103
} catch (sessionErr) {
108-
// Cookie set failure is non-fatal — in-memory auth will still work for this tab
109104
console.warn('Auth session cookie API unavailable.', sessionErr);
110105
}
111106

112-
// Store token in-memory for the current session
113107
login(token, user as import('@/lib/types').User);
114108
success('Login successful');
115-
116109
router.push(user.role === 'admin' ? '/overview' : '/dashboard');
117110
} catch (err) {
118111
console.error(err);
@@ -122,18 +115,13 @@ export default function LoginPage() {
122115
}
123116
}, [login, router, success, error]);
124117

125-
// When WalletModal reports a connected address, perform the merchant login flow.
126-
// NOTE: Full wallet-challenge authentication (sign a nonce, verify on-chain) is
127-
// the intended production approach — this flow requires a backend /api/auth/wallet
128-
// endpoint that issues a real JWT after verifying the signed challenge.
118+
// Wallet login: wallet address becomes the merchant identifier.
119+
// A proper wallet-challenge JWT flow (sign nonce, verify on backend) is
120+
// the intended production path once the backend has /api/auth/wallet.
129121
const onWalletConnected = useCallback(async (address: string) => {
130122
setIsWalletLoading(true);
131123
try {
132124
const role = 'merchant';
133-
134-
// Placeholder: derive a session identifier from the wallet address.
135-
// Replace this with a proper wallet-challenge JWT flow when the
136-
// backend /api/auth/wallet endpoint is implemented.
137125
const walletSessionToken = `wallet_${address}`;
138126

139127
try {
@@ -144,7 +132,7 @@ export default function LoginPage() {
144132
body: JSON.stringify({ token: walletSessionToken, role }),
145133
});
146134
} catch (sessionErr) {
147-
console.warn('Auth session API unavailable; continuing without HttpOnly cookie.', sessionErr);
135+
console.warn('Auth session API unavailable.', sessionErr);
148136
}
149137

150138
login(walletSessionToken, {
@@ -163,7 +151,6 @@ export default function LoginPage() {
163151
}
164152
}, [login, router, success, error]);
165153

166-
167154
return (
168155
<div className="w-full animate-in fade-in slide-in-from-bottom-4 duration-500">
169156
<Suspense fallback={<WalletModalFallback open={walletModalOpen} onOpenChange={setWalletModalOpen} />}>
@@ -184,52 +171,58 @@ export default function LoginPage() {
184171

185172
{/* Form */}
186173
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
187-
{/* Email */}
174+
{/* Merchant ID */}
188175
<div className="space-y-1.5">
189-
<AuthLabel htmlFor="email">
190-
Email Address
191-
</AuthLabel>
192-
<AuthInput
193-
id="email"
194-
type="email"
195-
placeholder="name@company.com"
196-
{...register('email')}
197-
aria-invalid={errors.email ? "true" : "false"}
198-
aria-describedby={errors.email ? "email-error" : undefined}
176+
<Label htmlFor="merchantId" className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
177+
Merchant ID
178+
</Label>
179+
<Input
180+
id="merchantId"
181+
type="text"
182+
placeholder="Your Stellar merchant address"
183+
{...register('merchantId')}
184+
aria-invalid={errors.merchantId ? "true" : "false"}
185+
aria-describedby={errors.merchantId ? "merchantId-error" : undefined}
186+
className="h-12 bg-card border border-border text-foreground placeholder:text-muted-foreground rounded-xl text-sm font-mono focus-visible:ring-1 focus-visible:ring-ring focus-visible:border-ring transition-all"
199187
/>
200-
{errors.email && <p id="email-error" className="text-xs text-destructive mt-1">{errors.email.message}</p>}
188+
{errors.merchantId && <p id="merchantId-error" className="text-xs text-destructive mt-1">{errors.merchantId.message}</p>}
201189
</div>
202190

203-
{/* Password */}
191+
{/* Secret Key */}
204192
<div className="space-y-1.5">
205193
<div className="flex items-center justify-between">
206-
<AuthLabel htmlFor="password">
207-
Password
208-
</AuthLabel>
194+
<Label htmlFor="secret" className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
195+
Secret Key
196+
</Label>
209197
<Link href="/auth/forgot-password" className="text-xs text-muted-foreground hover:text-primary transition-colors">
210-
Forgot password?
198+
Lost your secret?
211199
</Link>
212200
</div>
213-
<AuthInput
214-
id="password"
201+
<Input
202+
id="secret"
215203
type="password"
216-
placeholder="••••••••"
217-
{...register('password')}
218-
aria-invalid={errors.password ? "true" : "false"}
219-
aria-describedby={errors.password ? "password-error" : undefined}
204+
placeholder="Your merchant secret key"
205+
{...register('secret')}
206+
aria-invalid={errors.secret ? "true" : "false"}
207+
aria-describedby={errors.secret ? "secret-error" : undefined}
208+
className="h-12 bg-card border border-border text-foreground placeholder:text-muted-foreground rounded-xl text-sm focus-visible:ring-1 focus-visible:ring-ring focus-visible:border-ring transition-all"
220209
/>
221-
{errors.password && <p id="password-error" className="text-xs text-destructive mt-1">{errors.password.message}</p>}
210+
{errors.secret && <p id="secret-error" className="text-xs text-destructive mt-1">{errors.secret.message}</p>}
211+
<p className="text-xs text-muted-foreground">
212+
The secret key shown once when you created your account.
213+
</p>
222214
</div>
223215

224216
{/* Sign In CTA */}
225217
<div className="pt-1">
226-
<AuthButton
218+
<Button
227219
type="submit"
228220
disabled={isLoading || isWalletLoading || isRateLimited}
221+
className="w-full h-12 bg-primary hover:bg-primary/90 text-white font-semibold text-sm rounded-xl border-0 transition-colors"
229222
>
230223
{isLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
231224
{isRateLimited ? `Try again in ${secondsRemaining}s` : 'Sign In'}
232-
</AuthButton>
225+
</Button>
233226
</div>
234227
</form>
235228

app/auth/register/page.tsx

Lines changed: 81 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
55
import Link from 'next/link';
66
import { useForm } from 'react-hook-form';
77
import { zodResolver } from '@hookform/resolvers/zod';
8-
import { Loader2, Check } from 'lucide-react';
8+
import { Loader2, Check, Copy, CheckCheck } from 'lucide-react';
99
import { useNotify } from '@/lib/hooks/useNotify';
1010

1111
import { registerSchema, RegisterFormValues, passwordRequirements } from '@/lib/utils/validation';
@@ -27,6 +27,11 @@ export default function RegisterPage() {
2727
const [isLoading, setIsLoading] = useState(false);
2828
const [isWalletLoading, setIsWalletLoading] = useState(false);
2929
const [walletOpen, setWalletOpen] = useState(false);
30+
// After successful registration the backend returns a one-time secret.
31+
// We surface it in a modal so the user can copy it before proceeding.
32+
const [createdSecret, setCreatedSecret] = useState<string | null>(null);
33+
const [createdMerchantId, setCreatedMerchantId] = useState<string | null>(null);
34+
const [copied, setCopied] = useState(false);
3035
const { success, error } = useNotify();
3136

3237
const {
@@ -60,27 +65,50 @@ export default function RegisterPage() {
6065
email: normalizeEmail(data.email),
6166
};
6267
try {
63-
try {
64-
const { apiClient } = await import('@/lib/api/axios');
65-
await apiClient.post('/api/merchants', {
66-
id: `merch_${Math.random().toString(36).substr(2, 9)}`,
68+
const apiBase = process.env.NEXT_PUBLIC_API_URL || 'https://bettapay-backend.onrender.com';
69+
// Use plain fetch — /api/merchants requires a service JWT via fastify.authenticate.
70+
// For merchant self-registration we call it without auth; the backend must allow
71+
// this endpoint unauthenticated OR a separate /api/auth/register endpoint is needed.
72+
const merchantId = `G${crypto.randomUUID().replace(/-/g, '').toUpperCase().slice(0, 54)}`;
73+
const ownerId = sanitizedData.email;
74+
75+
const res = await fetch(`${apiBase}/api/merchants`, {
76+
method: 'POST',
77+
headers: { 'Content-Type': 'application/json' },
78+
body: JSON.stringify({
79+
id: merchantId,
6780
name: sanitizedData.businessName,
68-
});
69-
} catch {
70-
console.warn('Backend unavailable, falling back to mock registration for Vercel preview.');
71-
await new Promise(resolve => setTimeout(resolve, 1500));
81+
ownerId,
82+
}),
83+
});
84+
85+
if (!res.ok) {
86+
const errBody = await res.json().catch(() => ({}));
87+
error(errBody?.message || errBody?.error || 'Failed to create account. Please try again.');
88+
return;
7289
}
7390

74-
success('Account created successfully! Please log in.');
75-
router.push('/auth/login');
91+
const body = await res.json();
92+
// Backend returns { success: true, merchant: {...}, secret: '...' }
93+
// The secret is shown ONCE — the user must save it to log in.
94+
setCreatedSecret(body.secret);
95+
setCreatedMerchantId(body.merchant?.id ?? merchantId);
7696
} catch (err) {
7797
console.error(err);
78-
error('Failed to create account');
98+
error('Network error — unable to reach the server. Please try again.');
7999
} finally {
80100
setIsLoading(false);
81101
}
82102
};
83103

104+
const handleCopySecret = () => {
105+
if (createdSecret) {
106+
navigator.clipboard.writeText(createdSecret);
107+
setCopied(true);
108+
setTimeout(() => setCopied(false), 2000);
109+
}
110+
};
111+
84112
const handleFreighterLogin = async () => {
85113
setIsWalletLoading(true);
86114
try {
@@ -106,6 +134,47 @@ export default function RegisterPage() {
106134
<WalletModal open={walletOpen} onOpenChange={setWalletOpen} />
107135
</Suspense>
108136

137+
{/* ── Secret reveal modal — shown after successful registration ── */}
138+
{createdSecret && (
139+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
140+
<div className="w-full max-w-md bg-card border border-border rounded-2xl p-6 shadow-surface-xl space-y-4">
141+
<h2 className="text-lg font-bold text-foreground">Account Created! Save Your Secret Key</h2>
142+
<p className="text-sm text-muted-foreground">
143+
This key is shown <span className="text-destructive font-semibold">only once</span>. Copy it now — you&apos;ll need it every time you sign in.
144+
</p>
145+
146+
<div className="space-y-2">
147+
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Merchant ID</p>
148+
<p className="font-mono text-xs bg-muted px-3 py-2 rounded-lg break-all text-foreground select-all">{createdMerchantId}</p>
149+
</div>
150+
151+
<div className="space-y-2">
152+
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Secret Key</p>
153+
<div className="flex items-center gap-2">
154+
<p className="font-mono text-xs bg-muted px-3 py-2 rounded-lg break-all text-foreground flex-1 select-all">{createdSecret}</p>
155+
<button
156+
type="button"
157+
onClick={handleCopySecret}
158+
className="shrink-0 p-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
159+
aria-label="Copy secret key"
160+
>
161+
{copied ? <CheckCheck className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4 text-muted-foreground" />}
162+
</button>
163+
</div>
164+
</div>
165+
166+
<button
167+
type="button"
168+
disabled={!copied}
169+
onClick={() => router.push('/auth/login')}
170+
className="w-full h-11 rounded-xl bg-primary text-white font-semibold text-sm disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
171+
>
172+
{copied ? 'I\'ve saved it — Go to Login' : 'Copy the key above to continue'}
173+
</button>
174+
</div>
175+
</div>
176+
)}
177+
109178
{/* Heading */}
110179
<div className="mb-10">
111180
<p className="text-xs font-semibold tracking-widest text-primary uppercase mb-3">Merchant Portal</p>

lib/utils/validation.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ export const strongPasswordSchema = z.string().superRefine((password, ctx) => {
2828
});
2929

3030
export const loginSchema = z.object({
31-
email: z.string().email('Please enter a valid email address'),
32-
password: strongPasswordSchema,
31+
merchantId: z.string().min(1, 'Merchant ID is required'),
32+
secret: z.string().min(1, 'Secret key is required'),
3333
});
3434

3535
export type LoginFormValues = z.infer<typeof loginSchema>;

0 commit comments

Comments
 (0)