feat: auth connection to backend added
This commit is contained in:
parent
7e582f1a40
commit
b8bc0fa8b7
17 changed files with 385 additions and 12 deletions
4
.env
Normal file
4
.env
Normal 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
4
.env.example
Normal 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
|
||||
|
|
@ -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
34
src/config/env.ts
Normal 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
13
src/constants/auth.ts
Normal 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(' ');
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export interface AuthState {
|
|||
|
||||
export interface LoginSuccessPayload {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
|
|
|
|||
88
src/services/api.ts
Normal file
88
src/services/api.ts
Normal 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
68
src/services/authApi.ts
Normal 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
36
src/types/auth.ts
Normal 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;
|
||||
}
|
||||
15
src/utils/authInterceptor.ts
Normal file
15
src/utils/authInterceptor.ts
Normal 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
20
src/utils/authToken.ts
Normal 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
24
src/utils/authUrl.ts
Normal 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
13
src/utils/formatNumber.ts
Normal 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
6
src/utils/logout.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clearAuthTokens } from './authToken';
|
||||
|
||||
export const logout = () => {
|
||||
clearAuthTokens();
|
||||
window.location.assign('/login');
|
||||
};
|
||||
41
src/utils/phone.ts
Normal file
41
src/utils/phone.ts
Normal 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}`;
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue