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:
@@ -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',
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user