feat: auth connection to backend added

This commit is contained in:
Afra ‌ 2026-08-01 15:50:27 +03:30
parent 7e582f1a40
commit b8bc0fa8b7
17 changed files with 385 additions and 12 deletions

4
.env Normal file
View file

@ -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

4
.env.example Normal file
View file

@ -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

View file

@ -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',

34
src/config/env.ts Normal file
View file

@ -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}`)}`;
};

13
src/constants/auth.ts Normal file
View file

@ -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(' ');

View file

@ -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<SendOtpResponse> => {
return authApi.sendOtp(phoneNumber);
sendOtp: async (phoneNumber: string): Promise<RequestOtpResponse> => {
return authApi.requestOtp(phoneNumber);
},
verifyOtp: async (phoneNumber: string, otp: string): Promise<VerifyOtpResponse> => {
if (otp !== '77501') {
throw new Error('کد OTP وارد شده معتبر نیست.');
}
verifyOtp: async (phoneNumber: string, otp: string): Promise<AuthSessionPayload> => {
const tokenResponse = await authApi.verifyOtp(phoneNumber, otp);
return {
accessToken: 'fake_access_token',
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
user: {
id: 1,
phoneNumber,

View file

@ -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 {

View file

@ -12,6 +12,7 @@ export interface AuthState {
export interface LoginSuccessPayload {
accessToken: string;
refreshToken?: string;
user: AuthUser;
}

88
src/services/api.ts Normal file
View file

@ -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<void> | 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<TokenResponse>(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;

68
src/services/authApi.ts Normal file
View file

@ -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<RequestOtpResponse> => {
const normalizedPhone = toE164PhoneNumber(phoneNumber);
const payload: RequestOtpPayload = {
phone_number: normalizedPhone,
};
const response = await axios.post<RequestOtpResponse>(buildAuthUrl('/users/api/request_otp/'), payload);
return response.data;
},
verifyOtp: async (phoneNumber: string, otp: string): Promise<TokenResponse> => {
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<TokenResponse>(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<TokenResponse> => {
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<TokenResponse>(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();
},
};

36
src/types/auth.ts Normal file
View file

@ -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;
}

View file

@ -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);
},
);
};

20
src/utils/authToken.ts Normal file
View file

@ -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);
};

24
src/utils/authUrl.ts Normal file
View file

@ -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}`);
}
};

13
src/utils/formatNumber.ts Normal file
View file

@ -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;
};

6
src/utils/logout.ts Normal file
View file

@ -0,0 +1,6 @@
import { clearAuthTokens } from './authToken';
export const logout = () => {
clearAuthTokens();
window.location.assign('/login');
};

41
src/utils/phone.ts Normal file
View file

@ -0,0 +1,41 @@
const PERSIAN_DIGITS = /[۰-۹]/g;
const PERSIAN_TO_ENGLISH: Record<string, string> = {
'۰': '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}`;
};