winofy-front/src/lib/auth/gooyal-client.ts
Afra ‌ c290575be7 feat: real design system tokens + C01/C02/C03 login-OTP flow per winofyfrontenddoc.md
Replaces placeholder colors/type-scale with the documented Figma design
system (§1), and rebuilds login as three screens matching the doc exactly:
role selection, phone entry, and 6-box OTP verification with countdown
resend. Fixes a real bug along the way: normalizeIranianPhone stripped
Persian-digit keyboard input entirely instead of converting it (\D matches
non-ASCII digits too) -- added toLatinDigits/toPersianDigits and verified
the round-trip in isolation.
2026-08-17 14:10:27 +03:30

98 lines
3.3 KiB
TypeScript

"use client";
/**
* Browser-side calls straight to Gooyal accounts (CORS is open there).
* The client_id/secret are intentionally public here — see .env.local comment.
*/
import { toLatinDigits } from "@/lib/format/persian-digits";
const ACCOUNTS_BASE_URL = process.env.NEXT_PUBLIC_GOOYAL_ACCOUNTS_BASE_URL!;
const CLIENT_ID = process.env.NEXT_PUBLIC_GOOYAL_CLIENT_ID!;
const CLIENT_SECRET = process.env.NEXT_PUBLIC_GOOYAL_CLIENT_SECRET!;
const SCOPE = process.env.NEXT_PUBLIC_GOOYAL_OAUTH_SCOPE!;
function basicAuthHeader() {
return "Basic " + btoa(`${CLIENT_ID}:${CLIENT_SECRET}`);
}
const ERROR_MESSAGES_FA: Record<string, string> = {
invalid_grant: "کد تایید نامعتبر یا منقضی شده است.",
invalid_client: "خطا در پیکربندی سرویس احراز هویت. لطفاً بعداً تلاش کنید.",
invalid_request: "درخواست نامعتبر است.",
};
export class GooyalClientError extends Error {
constructor(
public status: number,
public code: string,
description?: string,
) {
super(ERROR_MESSAGES_FA[code] ?? description ?? code);
}
}
/**
* Accepts local Iranian input (09..., 9..., 0098...) — including Persian-digit
* keyboard input — and normalizes to Gooyal's required +989999999999.
*/
export function normalizeIranianPhone(input: string): string {
const digits = toLatinDigits(input).replace(/\D/g, "");
const local = digits.replace(/^0098/, "").replace(/^98/, "").replace(/^0/, "");
return `+98${local}`;
}
function extractWrappedErrorMessage(data: unknown): string | undefined {
const details = (data as { details?: { message?: string | Record<string, string[]> } })?.details;
if (!details) return undefined;
if (typeof details.message === "string") return details.message;
if (details.message && typeof details.message === "object") {
const first = Object.values(details.message)[0];
return Array.isArray(first) ? first[0] : undefined;
}
return undefined;
}
export async function requestOtp(phoneNumber: string) {
const res = await fetch(`${ACCOUNTS_BASE_URL}/users/api/request_otp/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone_number: phoneNumber }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new GooyalClientError(res.status, "request_otp_failed", extractWrappedErrorMessage(data));
}
return data as { phone_number: string; otp_expire: string; ttl: number };
}
export interface GooyalTokenResponse {
access_token: string;
refresh_token?: string;
token_type: string;
expires_in: number;
scope: string;
}
export async function exchangeOtpForToken(phoneNumber: string, otp: string) {
const body = new URLSearchParams({
grant_type: "password",
username: phoneNumber,
password: otp,
scope: SCOPE,
auth_fields: "phone_number:otp",
});
const res = await fetch(`${ACCOUNTS_BASE_URL}/oauth2/token/`, {
method: "POST",
headers: {
Authorization: basicAuthHeader(),
"Content-Type": "application/x-www-form-urlencoded",
},
body: body.toString(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new GooyalClientError(res.status, data.error ?? "token_exchange_failed", data.error_description);
}
return data as GooyalTokenResponse;
}