feat(auth): mobile-only register/login with OTP verification

- Register and login by mobile number; email is now an optional
  contact field only (never used to authenticate)
- After registration, the phone is verified via a 6-digit SMS code
- Login supports both password and one-time-code (OTP) methods
- Phone OTP delivered via Kavenegar (verify/lookup); API key in env
- Account page: edit name/optional email, change password, and
  change mobile number with OTP re-verification
- Codes are hashed, expire in 5m, capped at 5 attempts, rate-limited
- Seed gives the admin a verified phone so mobile login still works

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-16 16:40:08 +03:30
parent ce6813db99
commit 37c103fa20
31 changed files with 1756 additions and 143 deletions
+8
View File
@@ -7,6 +7,14 @@ export enum UserRole {
SALES = 'sales',
}
/** What a one-time SMS code authorises. */
export enum VerificationPurpose {
/** Verify phone ownership for registration completion and passwordless login. */
LOGIN = 'login',
/** Verify a new phone number when changing it from the account page. */
CHANGE_PHONE = 'change_phone',
}
export enum TicketDepartment {
TECHNICAL = 'technical',
SALES = 'sales',
+53
View File
@@ -0,0 +1,53 @@
/**
* Iranian mobile number helpers.
*
* Canonical storage form is E.164: `+989XXXXXXXXX` (13 chars).
* Inputs are accepted liberally (Persian/Arabic digits, spaces, common
* prefixes) and normalised to that single canonical form so the same number
* can never be stored twice under different spellings.
*/
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩';
function toLatinDigits(input: string): string {
return input.replace(/[۰-۹٠-٩]/g, (ch) => {
const p = PERSIAN_DIGITS.indexOf(ch);
if (p > -1) return String(p);
const a = ARABIC_DIGITS.indexOf(ch);
if (a > -1) return String(a);
return ch;
});
}
/**
* Normalise any reasonable Iranian mobile spelling to canonical `+989XXXXXXXXX`.
* Returns `null` when the input is not a valid Iranian mobile number.
*/
export function normalizeIranMobile(raw: string | null | undefined): string | null {
if (!raw) return null;
let s = toLatinDigits(String(raw)).replace(/[\s\-()]/g, '');
// Strip international/access prefixes down to the national significant number.
if (s.startsWith('+98')) s = s.slice(3);
else if (s.startsWith('0098')) s = s.slice(4);
else if (s.startsWith('98') && s.length === 12) s = s.slice(2);
else if (s.startsWith('0')) s = s.slice(1);
// National significant number for Iranian mobiles is `9XXXXXXXXX` (10 digits).
if (!/^9\d{9}$/.test(s)) return null;
return `+98${s}`;
}
export function isValidIranMobile(raw: string | null | undefined): boolean {
return normalizeIranMobile(raw) !== null;
}
/**
* Kavenegar's `receptor` expects the local `09XXXXXXXXX` form rather than E.164.
*/
export function toLocalMobile(e164: string): string {
const normalized = normalizeIranMobile(e164);
if (!normalized) return e164;
return `0${normalized.slice(3)}`;
}