From b8bc0fa8b769068f7fd0f26ad04b6fddc914fee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afra=20=E2=80=8C?= Date: Sat, 1 Aug 2026 15:50:27 +0330 Subject: [PATCH] feat: auth connection to backend added --- .env | 4 ++ .env.example | 4 ++ src/api/axios.ts | 3 +- src/config/env.ts | 34 ++++++++++++ src/constants/auth.ts | 13 +++++ src/features/auth/authService.ts | 17 +++--- src/features/auth/authSlice.ts | 10 +++- src/features/auth/authTypes.ts | 1 + src/services/api.ts | 88 ++++++++++++++++++++++++++++++++ src/services/authApi.ts | 68 ++++++++++++++++++++++++ src/types/auth.ts | 36 +++++++++++++ src/utils/authInterceptor.ts | 15 ++++++ src/utils/authToken.ts | 20 ++++++++ src/utils/authUrl.ts | 24 +++++++++ src/utils/formatNumber.ts | 13 +++++ src/utils/logout.ts | 6 +++ src/utils/phone.ts | 41 +++++++++++++++ 17 files changed, 385 insertions(+), 12 deletions(-) create mode 100644 .env create mode 100644 .env.example create mode 100644 src/config/env.ts create mode 100644 src/constants/auth.ts create mode 100644 src/services/api.ts create mode 100644 src/services/authApi.ts create mode 100644 src/types/auth.ts create mode 100644 src/utils/authInterceptor.ts create mode 100644 src/utils/authToken.ts create mode 100644 src/utils/authUrl.ts create mode 100644 src/utils/formatNumber.ts create mode 100644 src/utils/logout.ts create mode 100644 src/utils/phone.ts diff --git a/.env b/.env new file mode 100644 index 0000000..cf10d2e --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +VITE_AUTH_BASE_URL=https://accounts-staging.gooyal.ir +VITE_API_BASE_URL=https://accounts-staging.gooyal.ir +VITE_AUTH_CLIENT_ID=PLWyn000M78oYPP2R7E9ctWeJG9Fta8uKifz7m42 +VITE_AUTH_CLIENT_SECRET=PceNvDCoFyUSM4gCO1OSD3Rsvvg5iRAkYD3QhOQfTPFmiOOWqGsBH39o8FtYuyuWXPNhZcNiUegsnBth1am6rw798FHyWBTxquGVgWsF9Y9rfRUlSZRqavMYsPWcIlOF diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cf10d2e --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +VITE_AUTH_BASE_URL=https://accounts-staging.gooyal.ir +VITE_API_BASE_URL=https://accounts-staging.gooyal.ir +VITE_AUTH_CLIENT_ID=PLWyn000M78oYPP2R7E9ctWeJG9Fta8uKifz7m42 +VITE_AUTH_CLIENT_SECRET=PceNvDCoFyUSM4gCO1OSD3Rsvvg5iRAkYD3QhOQfTPFmiOOWqGsBH39o8FtYuyuWXPNhZcNiUegsnBth1am6rw798FHyWBTxquGVgWsF9Y9rfRUlSZRqavMYsPWcIlOF diff --git a/src/api/axios.ts b/src/api/axios.ts index d2712d9..b2d1e7f 100644 --- a/src/api/axios.ts +++ b/src/api/axios.ts @@ -1,8 +1,9 @@ import axios, { type AxiosError } from 'axios'; +import { env } from '../config/env'; import { STORAGE_KEYS } from '../utils/storage'; const api = axios.create({ - baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000', + baseURL: env.apiBaseUrl, timeout: 10000, headers: { 'Content-Type': 'application/json', diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000..8388ecd --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,34 @@ +const DEFAULT_AUTH_BASE_URL = 'https://accounts-staging.gooyal.ir'; +const DEFAULT_API_BASE_URL = 'http://localhost:3000'; + +const readEnv = (key: string, fallback?: string) => { + const value = import.meta.env[key as keyof ImportMetaEnv] as string | boolean | undefined; + + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + + if (fallback !== undefined) { + return fallback; + } + + throw new Error('Authentication client credentials are missing.'); +}; + +const authClientId = readEnv('VITE_AUTH_CLIENT_ID'); +const authClientSecret = readEnv('VITE_AUTH_CLIENT_SECRET'); + +export const env = { + authClientId, + authClientSecret, + authBaseUrl: readEnv('VITE_AUTH_BASE_URL', DEFAULT_AUTH_BASE_URL), + apiBaseUrl: readEnv('VITE_API_BASE_URL', DEFAULT_API_BASE_URL), +}; + +export const createBasicAuthHeader = (clientId = env.authClientId, clientSecret = env.authClientSecret) => { + if (!clientId || !clientSecret) { + throw new Error('Authentication client credentials are missing.'); + } + + return `Basic ${btoa(`${clientId}:${clientSecret}`)}`; +}; diff --git a/src/constants/auth.ts b/src/constants/auth.ts new file mode 100644 index 0000000..7a9f661 --- /dev/null +++ b/src/constants/auth.ts @@ -0,0 +1,13 @@ +export const AUTH_SCOPE = [ + 'accounts.account:retrieve', + 'wallet.wallet:get_balance', + 'billboard_merchant.billboard:retrieve', + 'billboard_merchant.billboard:create', + 'wallet.user:transaction_list', + 'data_crud.data:retrieve', + 'data_crud.data:update', + 'data_crud.data:delete', + 'crm.message:retrieve', + 'accounts.profile:list', + 'accounts.users:list', +].join(' '); diff --git a/src/features/auth/authService.ts b/src/features/auth/authService.ts index b1919e0..7afe577 100644 --- a/src/features/auth/authService.ts +++ b/src/features/auth/authService.ts @@ -1,18 +1,17 @@ -import { authApi } from '../../api/authApi'; -import type { SendOtpResponse, VerifyOtpResponse } from './authTypes'; +import { authApi } from '../../services/authApi'; +import type { AuthSessionPayload, RequestOtpResponse } from '../../types/auth'; export const authService = { - sendOtp: async (phoneNumber: string): Promise => { - return authApi.sendOtp(phoneNumber); + sendOtp: async (phoneNumber: string): Promise => { + return authApi.requestOtp(phoneNumber); }, - verifyOtp: async (phoneNumber: string, otp: string): Promise => { - if (otp !== '77501') { - throw new Error('کد OTP وارد شده معتبر نیست.'); - } + verifyOtp: async (phoneNumber: string, otp: string): Promise => { + const tokenResponse = await authApi.verifyOtp(phoneNumber, otp); return { - accessToken: 'fake_access_token', + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, user: { id: 1, phoneNumber, diff --git a/src/features/auth/authSlice.ts b/src/features/auth/authSlice.ts index a12b8c3..8b90404 100644 --- a/src/features/auth/authSlice.ts +++ b/src/features/auth/authSlice.ts @@ -1,6 +1,7 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; import type { AuthState, LoginSuccessPayload } from './authTypes'; import { STORAGE_KEYS } from '../../utils/storage'; +import { getAccessToken, storeAuthTokens } from '../../utils/authToken'; const initialState: AuthState = { user: null, @@ -19,6 +20,10 @@ const authSlice = createSlice({ localStorage.setItem(STORAGE_KEYS.accessToken, action.payload.accessToken); localStorage.setItem(STORAGE_KEYS.user, JSON.stringify(action.payload.user)); + storeAuthTokens({ + access_token: action.payload.accessToken, + refresh_token: action.payload.refreshToken ?? '', + }); localStorage.setItem( STORAGE_KEYS.authState, JSON.stringify({ @@ -39,6 +44,7 @@ const authSlice = createSlice({ }, hydrateAuth: (state) => { const storedAuth = localStorage.getItem(STORAGE_KEYS.authState); + const storedAccessToken = getAccessToken(); if (!storedAuth) { return; @@ -47,9 +53,9 @@ const authSlice = createSlice({ try { const parsed = JSON.parse(storedAuth) as AuthState; - if (parsed.accessToken && parsed.user) { + if ((parsed.accessToken || storedAccessToken) && parsed.user) { state.user = parsed.user; - state.accessToken = parsed.accessToken; + state.accessToken = parsed.accessToken || storedAccessToken; state.isAuthenticated = true; } } catch { diff --git a/src/features/auth/authTypes.ts b/src/features/auth/authTypes.ts index e6de331..4a04f19 100644 --- a/src/features/auth/authTypes.ts +++ b/src/features/auth/authTypes.ts @@ -12,6 +12,7 @@ export interface AuthState { export interface LoginSuccessPayload { accessToken: string; + refreshToken?: string; user: AuthUser; } diff --git a/src/services/api.ts b/src/services/api.ts new file mode 100644 index 0000000..8879018 --- /dev/null +++ b/src/services/api.ts @@ -0,0 +1,88 @@ +import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'; +import { AUTH_SCOPE } from '../constants/auth'; +import { env } from '../config/env'; +import { getAccessToken, getRefreshToken, storeAuthTokens, clearAuthTokens } from '../utils/authToken'; +import { logout } from '../utils/logout'; +import { buildAuthUrl } from '../utils/authUrl'; +import { createBasicAuthHeader } from '../config/env'; +import type { TokenResponse } from '../types/auth'; + +const apiBaseUrl = env.apiBaseUrl; + +const api = axios.create({ + baseURL: apiBaseUrl, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +let refreshPromise: Promise | null = null; + +api.interceptors.request.use((config) => { + const token = getAccessToken(); + + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + + return config; +}); + +api.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; + + if (!error.response || error.response.status !== 401 || originalRequest._retry) { + return Promise.reject(error); + } + + if (!refreshPromise) { + refreshPromise = (async () => { + const refreshToken = getRefreshToken(); + + if (!refreshToken) { + throw new Error('Missing refresh token'); + } + + const params = new URLSearchParams(); + params.append('grant_type', 'refresh_token'); + params.append('refresh_token', refreshToken); + params.append('scope', AUTH_SCOPE); + + const response = await axios.post(buildAuthUrl('/oauth2/token/'), params, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: createBasicAuthHeader(), + }, + }); + + storeAuthTokens({ + access_token: response.data.access_token, + refresh_token: response.data.refresh_token, + }); + })().catch(() => { + clearAuthTokens(); + logout(); + return undefined; + }); + } + + try { + await refreshPromise; + originalRequest._retry = true; + const token = getAccessToken(); + + if (token && originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${token}`; + } + + return api(originalRequest); + } finally { + refreshPromise = null; + } + }, +); + +export default api; diff --git a/src/services/authApi.ts b/src/services/authApi.ts new file mode 100644 index 0000000..6f2e5e3 --- /dev/null +++ b/src/services/authApi.ts @@ -0,0 +1,68 @@ +import axios from 'axios'; +import { AUTH_SCOPE } from '../constants/auth'; +import { createBasicAuthHeader } from '../config/env'; +import { clearAuthTokens, storeAuthTokens } from '../utils/authToken'; +import { normalizePhoneNumber, toE164PhoneNumber } from '../utils/phone'; +import { buildAuthUrl } from '../utils/authUrl'; +import type { RequestOtpPayload, RequestOtpResponse, TokenResponse } from '../types/auth'; + +export const authApi = { + requestOtp: async (phoneNumber: string): Promise => { + const normalizedPhone = toE164PhoneNumber(phoneNumber); + const payload: RequestOtpPayload = { + phone_number: normalizedPhone, + }; + + const response = await axios.post(buildAuthUrl('/users/api/request_otp/'), payload); + return response.data; + }, + + verifyOtp: async (phoneNumber: string, otp: string): Promise => { + const normalizedPhone = toE164PhoneNumber(phoneNumber); + const params = new URLSearchParams(); + params.append('grant_type', 'password'); + params.append('username', normalizedPhone); + params.append('password', normalizePhoneNumber(otp)); + params.append('scope', AUTH_SCOPE); + params.append('auth_fields', 'phone_number:otp'); + + const response = await axios.post(buildAuthUrl('/oauth2/token/'), params, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: createBasicAuthHeader(), + }, + }); + + storeAuthTokens({ + access_token: response.data.access_token, + refresh_token: response.data.refresh_token, + }); + + return response.data; + }, + + refreshToken: async (refreshToken: string): Promise => { + const params = new URLSearchParams(); + params.append('grant_type', 'refresh_token'); + params.append('refresh_token', refreshToken); + params.append('scope', AUTH_SCOPE); + + const response = await axios.post(buildAuthUrl('/oauth2/token/'), params, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: createBasicAuthHeader(), + }, + }); + + storeAuthTokens({ + access_token: response.data.access_token, + refresh_token: response.data.refresh_token, + }); + + return response.data; + }, + + logout: () => { + clearAuthTokens(); + }, +}; diff --git a/src/types/auth.ts b/src/types/auth.ts new file mode 100644 index 0000000..adfb4b8 --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,36 @@ +export interface RequestOtpResponse { + phone_number: string; + otp_expire: string; + ttl: number; +} + +export interface TokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + token_type: string; + scope: string; +} + +export interface AuthSessionPayload { + accessToken: string; + refreshToken?: string; + user: { + id: number; + phoneNumber: string; + role: string; + }; +} + +export interface RequestOtpPayload { + phone_number: string; +} + +export interface VerifyOtpPayload { + grant_type: 'password' | 'refresh_token'; + username?: string; + password?: string; + refresh_token?: string; + scope?: string; + auth_fields?: string; +} diff --git a/src/utils/authInterceptor.ts b/src/utils/authInterceptor.ts new file mode 100644 index 0000000..e625ccb --- /dev/null +++ b/src/utils/authInterceptor.ts @@ -0,0 +1,15 @@ +import type { AxiosError } from 'axios'; +import api from '../services/api'; + +export const attachAuthInterceptor = () => { + api.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + if (error.response?.status !== 401) { + return Promise.reject(error); + } + + return Promise.reject(error); + }, + ); +}; diff --git a/src/utils/authToken.ts b/src/utils/authToken.ts new file mode 100644 index 0000000..203abb8 --- /dev/null +++ b/src/utils/authToken.ts @@ -0,0 +1,20 @@ +const ACCESS_TOKEN_KEY = 'access_token'; +const REFRESH_TOKEN_KEY = 'refresh_token'; + +export const getAccessToken = (): string | null => { + return localStorage.getItem(ACCESS_TOKEN_KEY); +}; + +export const getRefreshToken = (): string | null => { + return localStorage.getItem(REFRESH_TOKEN_KEY); +}; + +export const storeAuthTokens = (tokens: { access_token: string; refresh_token: string }) => { + localStorage.setItem(ACCESS_TOKEN_KEY, tokens.access_token); + localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token); +}; + +export const clearAuthTokens = () => { + localStorage.removeItem(ACCESS_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); +}; diff --git a/src/utils/authUrl.ts b/src/utils/authUrl.ts new file mode 100644 index 0000000..1dc3e16 --- /dev/null +++ b/src/utils/authUrl.ts @@ -0,0 +1,24 @@ +import { env } from '../config/env'; + +const normalizeBaseUrl = (value: string) => value.trim().replace(/\/+$/, ''); +const ensureLeadingSlash = (value: string) => (value.startsWith('/') ? value : `/${value}`); +const ensureTrailingSlash = (value: string) => (value.endsWith('/') ? value : `${value}/`); + +export const buildAuthUrl = (path: string, baseUrl = env.authBaseUrl) => { + const normalizedBaseUrl = normalizeBaseUrl(baseUrl); + const normalizedPath = ensureTrailingSlash(ensureLeadingSlash(path)); + + if (!normalizedBaseUrl) { + return normalizedPath; + } + + try { + const parsed = new URL(normalizedBaseUrl); + const basePath = parsed.pathname === '/' ? '' : parsed.pathname.replace(/\/+$/, ''); + const joinedPath = ensureTrailingSlash(`${basePath}${normalizedPath}`); + + return `${parsed.origin}${joinedPath}`; + } catch { + return ensureTrailingSlash(`${normalizedBaseUrl}${normalizedPath}`); + } +}; diff --git a/src/utils/formatNumber.ts b/src/utils/formatNumber.ts new file mode 100644 index 0000000..a0d4427 --- /dev/null +++ b/src/utils/formatNumber.ts @@ -0,0 +1,13 @@ +export const formatPhoneNumberForDisplay = (value: string): string => { + const normalized = value.replace(/\D/g, ''); + + if (!normalized) { + return ''; + } + + if (normalized.startsWith('98')) { + return `0${normalized.slice(2)}`; + } + + return normalized; +}; diff --git a/src/utils/logout.ts b/src/utils/logout.ts new file mode 100644 index 0000000..7e0c58d --- /dev/null +++ b/src/utils/logout.ts @@ -0,0 +1,6 @@ +import { clearAuthTokens } from './authToken'; + +export const logout = () => { + clearAuthTokens(); + window.location.assign('/login'); +}; diff --git a/src/utils/phone.ts b/src/utils/phone.ts new file mode 100644 index 0000000..ac93953 --- /dev/null +++ b/src/utils/phone.ts @@ -0,0 +1,41 @@ +const PERSIAN_DIGITS = /[۰-۹]/g; +const PERSIAN_TO_ENGLISH: Record = { + '۰': '0', + '۱': '1', + '۲': '2', + '۳': '3', + '۴': '4', + '۵': '5', + '۶': '6', + '۷': '7', + '۸': '8', + '۹': '9', +}; + +export const normalizePhoneNumber = (value: string): string => { + if (!value) { + return ''; + } + + const normalized = value.replace(PERSIAN_DIGITS, (digit) => PERSIAN_TO_ENGLISH[digit] ?? digit); + return normalized.trim(); +}; + +export const toE164PhoneNumber = (value: string): string => { + const normalized = normalizePhoneNumber(value); + const digits = normalized.replace(/\D/g, ''); + + if (!digits) { + return ''; + } + + if (digits.startsWith('98')) { + return `+${digits}`; + } + + if (digits.startsWith('0')) { + return `+98${digits.slice(1)}`; + } + + return `+${digits}`; +};