Mbonea Claude Sonnet 4.6 commited on
Commit
a4ca9d1
·
1 Parent(s): 0731ea0

Add tenant profile customization endpoints

Browse files

- PATCH /api/auth/profile — update name, business name, email
- POST /api/auth/change-password — in-app password change
- POST /api/auth/phone/request — send OTP to new phone
- POST /api/auth/phone/confirm — verify OTP, update phone, notify old number
- messages.js: phoneChangeOtp, phoneChangeNotice templates
- rateLimiter.js: phoneOtpLimiter (3/30min)
- Docs updated in API_DOCUMENTATION.md and FRONTEND_DESIGN_SYSTEM.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

API_DOCUMENTATION.md CHANGED
@@ -164,6 +164,89 @@ Get the authenticated tenant's profile.
164
 
165
  ---
166
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  ## 2. Tenant — Devices
168
 
169
  A **device** represents one WiFi access point (AP) registered to a tenant. Each device maps to one Omada site and has its own captive portal, WiFi plans, billing, and guest sessions.
 
164
 
165
  ---
166
 
167
+ ### PATCH `/api/auth/profile`
168
+ Update contact name, business name, or email. Send only the fields you want to change.
169
+
170
+ **Auth:** Required
171
+
172
+ **Request body:**
173
+ ```json
174
+ {
175
+ "contact_name": "Jane Doe",
176
+ "business_name": "Sunset Hotel",
177
+ "email": "jane@sunset.co.tz"
178
+ }
179
+ ```
180
+
181
+ **Response `200`:** Updated client object (same shape as `GET /api/auth/me`).
182
+ **Error `409`:** Email already in use by another account.
183
+
184
+ ---
185
+
186
+ ### POST `/api/auth/change-password`
187
+ Change password while logged in. Requires the current password as proof.
188
+
189
+ **Auth:** Required
190
+
191
+ **Request body:**
192
+ ```json
193
+ {
194
+ "current_password": "OldPass123",
195
+ "new_password": "NewPass456"
196
+ }
197
+ ```
198
+
199
+ **Response `200`:**
200
+ ```json
201
+ { "message": "Password changed successfully" }
202
+ ```
203
+
204
+ **Error `401`:** Current password is incorrect.
205
+
206
+ ---
207
+
208
+ ### POST `/api/auth/phone/request`
209
+ Send a 6-digit OTP to a new phone number to verify the tenant owns it.
210
+ Rate limited: 3 requests per 30 minutes.
211
+
212
+ **Auth:** Required
213
+
214
+ **Request body:**
215
+ ```json
216
+ { "new_phone": "0754000111" }
217
+ ```
218
+
219
+ **Response `200`:**
220
+ ```json
221
+ { "message": "Verification code sent to new phone number" }
222
+ ```
223
+
224
+ **Error `409`:** Phone already in use by another account.
225
+
226
+ ---
227
+
228
+ ### POST `/api/auth/phone/confirm`
229
+ Verify the OTP and update the phone number. Sends a security notice SMS to the old number.
230
+
231
+ **Auth:** Required
232
+
233
+ **Request body:**
234
+ ```json
235
+ {
236
+ "new_phone": "0754000111",
237
+ "otp": "847291"
238
+ }
239
+ ```
240
+
241
+ **Response `200`:**
242
+ ```json
243
+ { "message": "Phone number updated successfully" }
244
+ ```
245
+
246
+ **Error `400`:** Invalid or expired OTP.
247
+
248
+ ---
249
+
250
  ## 2. Tenant — Devices
251
 
252
  A **device** represents one WiFi access point (AP) registered to a tenant. Each device maps to one Omada site and has its own captive portal, WiFi plans, billing, and guest sessions.
FRONTEND_DESIGN_SYSTEM.md CHANGED
@@ -513,9 +513,23 @@ Mark all resolved button.
513
 
514
  ### Profile / Settings
515
 
516
- Fields: Contact name, Email, Phone (read-only — used for SMS/M-Pesa).
517
-
518
- Change password form: current password, new password, confirm.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
519
 
520
  ---
521
 
 
513
 
514
  ### Profile / Settings
515
 
516
+ Three cards stacked vertically:
517
+
518
+ **Profile card** `PATCH /api/auth/profile`
519
+ - Fields: Contact name, Business name, Email
520
+ - Save button — updates on submit, shows success toast
521
+
522
+ **Change password card** — `POST /api/auth/change-password`
523
+ - Fields: Current password, New password, Confirm new password
524
+ - Client-side validation: new passwords match, min 8 chars
525
+ - On `401`: show inline error "Current password is incorrect"
526
+
527
+ **Phone number card** — two-step flow
528
+ - Shows current phone (read-only display)
529
+ - "Change phone number" button → expands a form:
530
+ - Step 1: Enter new phone → `POST /api/auth/phone/request` → shows OTP input
531
+ - Step 2: Enter OTP → `POST /api/auth/phone/confirm` → updates display, collapses form
532
+ - Note beneath: "Your phone is used for M-Pesa payments and SMS alerts"
533
 
534
  ---
535
 
src/middleware/rateLimiter.js CHANGED
@@ -46,4 +46,13 @@ const portalAuthLimiter = rateLimit({
46
  message: { error: 'Too many authorization attempts. Try again in 15 minutes.' },
47
  });
48
 
49
- module.exports = { loginLimiter, registerLimiter, forgotPasswordLimiter, purchaseLimiter, portalAuthLimiter };
 
 
 
 
 
 
 
 
 
 
46
  message: { error: 'Too many authorization attempts. Try again in 15 minutes.' },
47
  });
48
 
49
+ // Phone change OTP 3 requests per 30 min per IP (prevents SMS bombing new numbers)
50
+ const phoneOtpLimiter = rateLimit({
51
+ windowMs: 30 * 60 * 1000,
52
+ max: 3,
53
+ standardHeaders: true,
54
+ legacyHeaders: false,
55
+ message: { error: 'Too many verification requests. Try again in 30 minutes.' },
56
+ });
57
+
58
+ module.exports = { loginLimiter, registerLimiter, forgotPasswordLimiter, purchaseLimiter, portalAuthLimiter, phoneOtpLimiter };
src/routes/auth.routes.js CHANGED
@@ -4,7 +4,7 @@ const db = require('../config/db');
4
  const sms = require('../services/sms');
5
  const messages = require('../utils/messages');
6
  const { requireAuth, signToken } = require('../middleware/auth');
7
- const { loginLimiter, registerLimiter, forgotPasswordLimiter } = require('../middleware/rateLimiter');
8
 
9
  // POST /api/auth/register
10
  router.post('/register', registerLimiter, async (req, res) => {
@@ -143,6 +143,143 @@ router.post('/reset-password', async (req, res) => {
143
  }
144
  });
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  // GET /api/auth/me
147
  router.get('/me', requireAuth, async (req, res) => {
148
  try {
 
4
  const sms = require('../services/sms');
5
  const messages = require('../utils/messages');
6
  const { requireAuth, signToken } = require('../middleware/auth');
7
+ const { loginLimiter, registerLimiter, forgotPasswordLimiter, phoneOtpLimiter } = require('../middleware/rateLimiter');
8
 
9
  // POST /api/auth/register
10
  router.post('/register', registerLimiter, async (req, res) => {
 
143
  }
144
  });
145
 
146
+ // PATCH /api/auth/profile — update name, business name, email
147
+ router.patch('/profile', requireAuth, async (req, res) => {
148
+ const { contact_name, business_name, email } = req.body;
149
+
150
+ if (!contact_name && !business_name && !email) {
151
+ return res.status(400).json({ error: 'Nothing to update' });
152
+ }
153
+
154
+ try {
155
+ if (email) {
156
+ const taken = await db.queryOne(
157
+ 'SELECT id FROM clients WHERE email = ? AND id != ? LIMIT 1',
158
+ [email.toLowerCase(), req.client.id]
159
+ );
160
+ if (taken) return res.status(409).json({ error: 'Email already in use' });
161
+ }
162
+
163
+ await db.query(
164
+ `UPDATE clients SET
165
+ contact_name = COALESCE(?, contact_name),
166
+ business_name = COALESCE(?, business_name),
167
+ email = COALESCE(?, email)
168
+ WHERE id = ?`,
169
+ [contact_name ?? null, business_name ?? null, email ? email.toLowerCase() : null, req.client.id]
170
+ );
171
+
172
+ const updated = await db.queryOne(
173
+ 'SELECT id, business_name, contact_name, email, phone, balance, total_earned, total_withdrawn, created_at FROM clients WHERE id = ?',
174
+ [req.client.id]
175
+ );
176
+ res.json(updated);
177
+ } catch (err) {
178
+ console.error('[auth/profile]', err.message);
179
+ res.status(500).json({ error: 'Failed to update profile' });
180
+ }
181
+ });
182
+
183
+ // POST /api/auth/change-password — in-app password change (requires current password)
184
+ router.post('/change-password', requireAuth, async (req, res) => {
185
+ const { current_password, new_password } = req.body;
186
+
187
+ if (!current_password || !new_password) {
188
+ return res.status(400).json({ error: 'current_password and new_password required' });
189
+ }
190
+ if (new_password.length < 8) {
191
+ return res.status(400).json({ error: 'Password must be at least 8 characters' });
192
+ }
193
+
194
+ try {
195
+ const client = await db.queryOne(
196
+ 'SELECT password_hash FROM clients WHERE id = ?',
197
+ [req.client.id]
198
+ );
199
+
200
+ if (!(await bcrypt.compare(current_password, client.password_hash))) {
201
+ return res.status(401).json({ error: 'Current password is incorrect' });
202
+ }
203
+
204
+ const hash = await bcrypt.hash(new_password, 10);
205
+ await db.query('UPDATE clients SET password_hash = ? WHERE id = ?', [hash, req.client.id]);
206
+
207
+ res.json({ message: 'Password changed successfully' });
208
+ } catch (err) {
209
+ console.error('[auth/change-password]', err.message);
210
+ res.status(500).json({ error: 'Failed to change password' });
211
+ }
212
+ });
213
+
214
+ // POST /api/auth/phone/request — send OTP to new phone number to verify ownership
215
+ router.post('/phone/request', requireAuth, phoneOtpLimiter, async (req, res) => {
216
+ const { new_phone } = req.body;
217
+ if (!new_phone) return res.status(400).json({ error: 'new_phone required' });
218
+
219
+ try {
220
+ const taken = await db.queryOne(
221
+ 'SELECT id FROM clients WHERE phone = ? AND id != ? LIMIT 1',
222
+ [new_phone, req.client.id]
223
+ );
224
+ if (taken) return res.status(409).json({ error: 'Phone number already in use' });
225
+
226
+ const otp = String(Math.floor(100000 + Math.random() * 900000));
227
+ const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
228
+
229
+ // Invalidate previous pending phone-change OTPs for this client
230
+ await db.query(
231
+ `UPDATE password_resets SET used = 1 WHERE phone = ? AND used = 0`,
232
+ [`PHONECHANGE:${req.client.id}`]
233
+ );
234
+
235
+ await db.query(
236
+ `INSERT INTO password_resets (phone, otp, expires_at) VALUES (?, ?, ?)`,
237
+ [`PHONECHANGE:${req.client.id}`, otp, expiresAt]
238
+ );
239
+
240
+ sms.sendSMS(new_phone, messages.phoneChangeOtp(otp)).catch(() => {});
241
+
242
+ res.json({ message: 'Verification code sent to new phone number' });
243
+ } catch (err) {
244
+ console.error('[auth/phone/request]', err.message);
245
+ res.status(500).json({ error: 'Failed to send verification code' });
246
+ }
247
+ });
248
+
249
+ // POST /api/auth/phone/confirm — verify OTP and update phone number
250
+ router.post('/phone/confirm', requireAuth, async (req, res) => {
251
+ const { new_phone, otp } = req.body;
252
+ if (!new_phone || !otp) return res.status(400).json({ error: 'new_phone and otp required' });
253
+
254
+ try {
255
+ const reset = await db.queryOne(
256
+ `SELECT id FROM password_resets
257
+ WHERE phone = ? AND otp = ? AND used = 0 AND expires_at > NOW()
258
+ ORDER BY created_at DESC LIMIT 1`,
259
+ [`PHONECHANGE:${req.client.id}`, otp]
260
+ );
261
+
262
+ if (!reset) return res.status(400).json({ error: 'Invalid or expired code' });
263
+
264
+ // Get old phone for security notice
265
+ const { phone: oldPhone } = await db.queryOne(
266
+ 'SELECT phone FROM clients WHERE id = ?',
267
+ [req.client.id]
268
+ );
269
+
270
+ await db.query('UPDATE clients SET phone = ? WHERE id = ?', [new_phone, req.client.id]);
271
+ await db.query('UPDATE password_resets SET used = 1 WHERE id = ?', [reset.id]);
272
+
273
+ // Notify old number
274
+ sms.sendSMS(oldPhone, messages.phoneChangeNotice(new_phone)).catch(() => {});
275
+
276
+ res.json({ message: 'Phone number updated successfully' });
277
+ } catch (err) {
278
+ console.error('[auth/phone/confirm]', err.message);
279
+ res.status(500).json({ error: 'Failed to update phone number' });
280
+ }
281
+ });
282
+
283
  // GET /api/auth/me
284
  router.get('/me', requireAuth, async (req, res) => {
285
  try {
src/utils/messages.js CHANGED
@@ -39,6 +39,14 @@ module.exports = {
39
  // Tenant receives this when requesting a password reset
40
  passwordResetOtp: (otp) => `Token is: ${otp}\n.`,
41
 
 
 
 
 
 
 
 
 
42
  // Tenant receives this when a payout completes
43
  payoutCompleted: (amount) =>
44
  `Payout of ${parseInt(amount).toLocaleString()} TZS sent to your M-Pesa.`,
 
39
  // Tenant receives this when requesting a password reset
40
  passwordResetOtp: (otp) => `Token is: ${otp}\n.`,
41
 
42
+ // Sent to the NEW phone number to verify ownership during a phone change
43
+ phoneChangeOtp: (otp) =>
44
+ `Your WifiBiz verification code is: ${otp}\nEnter this to confirm your new phone number. Valid for 10 minutes.`,
45
+
46
+ // Sent to the OLD phone number as a security notice after phone change completes
47
+ phoneChangeNotice: (newPhone) =>
48
+ `Your WifiBiz account phone number has been changed to ${newPhone}. If this wasn't you, contact support immediately.`,
49
+
50
  // Tenant receives this when a payout completes
51
  payoutCompleted: (amount) =>
52
  `Payout of ${parseInt(amount).toLocaleString()} TZS sent to your M-Pesa.`,