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 +83 -0
- FRONTEND_DESIGN_SYSTEM.md +17 -3
- src/middleware/rateLimiter.js +10 -1
- src/routes/auth.routes.js +138 -1
- src/utils/messages.js +8 -0
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 |
-
|
| 517 |
-
|
| 518 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.`,
|