feat(auth): add Google authentication endpoints and hooks (#51)

- Implemented `getGoogleAuthUrl` and `postGoogleCallback` in auth API.
- Added corresponding hooks `useGoogleAuth` and `useGoogleCallback` for handling Google authentication.

feat(api): enhance API structure with mentors and upload services

- Created `mentors` and `upload` API modules with respective services for managing mentor details and file uploads.
- Added session management utilities to handle authentication tokens in cookies.

feat(users): expand user service with detailed user management

- Enhanced user service to include detailed user information and update capabilities.
- Added hooks for fetching and updating user data.

feat(hooks): introduce hooks for mentors and upload functionalities

- Added hooks for fetching mentor details and updating mentor information.
- Implemented hooks for file upload, avatar upload, and CV upload with validation.

refactor(types): organize and extend type definitions

- Introduced new types for mentors and enhanced existing user types.
- Re-exported user-related types for better accessibility.

chore: update index files to include new services and hooks

- Updated index files to export new services and hooks for mentors and uploads.
This commit is contained in:
Asep Haryana Saputra
2025-08-18 12:50:32 +07:00
committed by GitHub
parent 65a6bb686c
commit 3c301ec2c4
65 changed files with 7215 additions and 26 deletions
+14
View File
@@ -5,6 +5,7 @@ import {
TRegisterRequest,
TSendOTPRequest,
TVerifyEmailRequest,
TGoogleCallbackResponse,
} from '../../types/auth';
import { TResponseMessage } from '../../types/common';
@@ -51,3 +52,16 @@ export const postSendOtp = async (
});
return data;
};
export const getGoogleAuthUrl = async (): Promise<string> => {
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080';
return `${baseUrl}/auth/google/login`;
};
export const postGoogleCallback = async (code: string, state: string): Promise<TGoogleCallbackResponse> => {
const { data } = await api({
method: 'GET',
url: `/auth/google/callback?code=${code}&state=${state}`,
});
return data;
};
+131
View File
@@ -3,9 +3,140 @@ import axios, { AxiosRequestConfig } from 'axios';
export * from './auth';
export * from './gacha';
export * from './users';
export * from './mentors';
export * from './upload';
// Common API response wrapper interface
export interface ApiResponse<T> {
data: T;
version: string;
}
const TOKEN_KEY = 'token';
// Helper functions for session management (avoiding circular dependency)
const getSessionTokenFromCookies = () => {
if (typeof document === 'undefined') return null;
const cookies = document.cookie.split(';');
const tokenCookie = cookies.find(cookie => cookie.trim().startsWith(`${TOKEN_KEY}=`));
if (!tokenCookie) return null;
try {
const tokenValue = tokenCookie.split('=')[1];
return JSON.parse(decodeURIComponent(tokenValue));
} catch {
return null;
}
};
const setSessionTokenToCookies = (tokenData: { token: { access_token: string; refresh_token: string } }) => {
if (typeof document === 'undefined') return;
const expires = new Date();
expires.setDate(expires.getDate() + 7);
document.cookie = `${TOKEN_KEY}=${encodeURIComponent(JSON.stringify(tokenData))}; expires=${expires.toUTCString()}; path=/; secure; samesite=strict`;
};
const removeSessionTokenFromCookies = () => {
if (typeof document === 'undefined') return;
document.cookie = `${TOKEN_KEY}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
};
const config: AxiosRequestConfig = {
baseURL: import.meta.env.VITE_API_URL,
};
export const api = axios.create(config);
// Add request interceptor to include authentication token
api.interceptors.request.use(
(config) => {
const sessionData = getSessionTokenFromCookies();
const token = sessionData?.token?.access_token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(new Error(error.message || 'Request failed'));
}
);
// Add response interceptor to handle token refresh
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
return handleTokenRefresh(originalRequest);
}
// If backend sends a message, use it
const backendMsg = error?.response?.data?.message;
if (backendMsg && typeof backendMsg === 'string') {
return Promise.reject(new Error(backendMsg));
}
return Promise.reject(new Error(error.message || 'Request failed'));
}
);
// Helper function to handle token refresh
async function handleTokenRefresh(originalRequest: AxiosRequestConfig) {
const sessionData = getSessionTokenFromCookies();
const refreshToken = sessionData?.token?.refresh_token;
if (!refreshToken) {
clearSessionAndRedirect();
throw new Error('No refresh token available');
}
try {
const response = await refreshAccessToken(refreshToken);
if (response.data?.access_token) {
// Update session in cookies
setSessionTokenToCookies({
token: {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token || refreshToken,
},
});
// Retry original request with new token
originalRequest.headers ??= {};
originalRequest.headers.Authorization = `Bearer ${response.data.access_token}`;
return api(originalRequest);
}
throw new Error('Invalid refresh response');
} catch (refreshError) {
console.error('Token refresh failed:', refreshError);
clearSessionAndRedirect();
throw new Error('Token refresh failed');
}
}
// Helper function to refresh access token
async function refreshAccessToken(refreshToken: string) {
return axios.post(`${import.meta.env.VITE_API_URL}/auth/refresh`, {
refresh_token: refreshToken,
});
}
// Helper function to clear session and redirect
function clearSessionAndRedirect() {
removeSessionTokenFromCookies();
if (typeof window !== 'undefined') {
window.location.href = '/auth/login';
}
}
+34
View File
@@ -0,0 +1,34 @@
import { api, ApiResponse } from '../index';
import type {
MentorDetailResponseDto,
MentorUpdateRequestDto
} from '../../types/mentors';
export interface MentorService {
getMentorMe(): Promise<MentorDetailResponseDto>;
getMentorById(id: string): Promise<MentorDetailResponseDto>;
updateMentorMe(data: MentorUpdateRequestDto): Promise<MentorDetailResponseDto>;
updateMentorById(id: string, data: MentorUpdateRequestDto): Promise<MentorDetailResponseDto>;
}
export const mentorService: MentorService = {
async getMentorMe() {
const response = await api.get<ApiResponse<MentorDetailResponseDto>>('/mentors/me');
return response.data.data;
},
async getMentorById(id: string) {
const response = await api.get<ApiResponse<MentorDetailResponseDto>>(`/mentors/detail/${id}`);
return response.data.data;
},
async updateMentorMe(data: MentorUpdateRequestDto) {
const response = await api.put<ApiResponse<MentorDetailResponseDto>>('/mentors/update/me', data);
return response.data.data;
},
async updateMentorById(id: string, data: MentorUpdateRequestDto) {
const response = await api.put<ApiResponse<MentorDetailResponseDto>>(`/mentors/update/${id}`, data);
return response.data.data;
},
};
+63
View File
@@ -0,0 +1,63 @@
import { api, ApiResponse } from '../index';
export interface UploadResponse {
filename: string;
original_filename: string;
uploaded_path: string;
url: string;
size: number;
content_type: string;
file_type: string;
user_id: string;
email: string;
}
export interface UploadService {
uploadFile(file: File): Promise<UploadResponse>;
uploadAvatar(file: File): Promise<UploadResponse>;
uploadCV(file: File): Promise<UploadResponse>;
}
export const uploadService: UploadService = {
async uploadFile(file: File) {
const formData = new FormData();
formData.append('file', file);
const response = await api.post<ApiResponse<UploadResponse>>('/users/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data.data;
},
async uploadAvatar(file: File) {
// Validate file type
if (!file.type.startsWith('image/')) {
throw new Error('File harus berupa gambar');
}
// Validate file size (max 5MB for images)
const maxSize = 5 * 1024 * 1024; // 5MB
if (file.size > maxSize) {
throw new Error('Ukuran file maksimal 5MB');
}
return this.uploadFile(file);
},
async uploadCV(file: File) {
// Validate file type
if (file.type !== 'application/pdf') {
throw new Error('CV harus berupa file PDF');
}
// Validate file size (max 10MB for PDFs)
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
throw new Error('Ukuran file maksimal 10MB');
}
return this.uploadFile(file);
},
};
+89 -1
View File
@@ -1 +1,89 @@
export {};
import { api, ApiResponse } from '../index';
import { TUserItem } from '../../types/users';
export interface UserDetailResponseDto extends TUserItem {
bio?: string;
location?: string;
website_url?: string;
linkedin_url?: string;
github_url?: string;
twitter_url?: string;
skills?: string[];
career_status?: string;
experience?: Array<{
id: string;
company: string;
position: string;
duration: string;
period: string;
}>;
education?: Array<{
id: string;
institution: string;
degree: string;
field: string;
period: string;
}>;
}
export interface UserUpdateRequestDto {
fullname?: string;
bio?: string;
location?: string;
website_url?: string;
linkedin_url?: string;
github_url?: string;
twitter_url?: string;
skills?: string[];
phone_number?: string;
birthdate?: string;
gender?: string;
career_status?: string;
avatar?: string;
cv_url?: string;
phone_for_verification?: string;
domicile?: string;
experience?: Array<{
id: string;
company: string;
position: string;
duration: string;
period: string;
}>;
education?: Array<{
id: string;
institution: string;
degree: string;
field: string;
period: string;
}>;
}
export interface UserService {
getUserMe(): Promise<UserDetailResponseDto>;
getUserById(id: string): Promise<UserDetailResponseDto>;
updateUserMe(data: UserUpdateRequestDto): Promise<UserDetailResponseDto>;
updateUserById(id: string, data: UserUpdateRequestDto): Promise<UserDetailResponseDto>;
}
export const userService: UserService = {
async getUserMe() {
const response = await api.get<ApiResponse<UserDetailResponseDto>>('/users/me');
return response.data.data;
},
async getUserById(id: string) {
const response = await api.get<ApiResponse<UserDetailResponseDto>>(`/users/detail/${id}`);
return response.data.data;
},
async updateUserMe(data: UserUpdateRequestDto) {
const response = await api.put<ApiResponse<UserDetailResponseDto>>('/users/update/me', data);
return response.data.data;
},
async updateUserById(id: string, data: UserUpdateRequestDto) {
const response = await api.put<ApiResponse<UserDetailResponseDto>>(`/users/${id}`, data);
return response.data.data;
},
};
+25 -2
View File
@@ -1,11 +1,12 @@
import { useMutation, UseMutationResult } from '@tanstack/react-query';
import { postLogin, postRegister, postSendOtp, postVerifyEmail } from '../../api/auth';
import { postLogin, postRegister, postSendOtp, postVerifyEmail, getGoogleAuthUrl, postGoogleCallback } from '../../api/auth';
import {
TLoginRequest,
TLoginResponse,
TRegisterRequest,
TSendOTPRequest,
TVerifyEmailRequest,
TGoogleCallbackResponse,
} from '../../types/auth';
import { TResponseError, TResponseMessage } from '../../types/common';
@@ -21,7 +22,7 @@ export const usePostLogin = (): UseMutationResult<
mutationFn: async (payload) => await postLogin(payload),
});
};
export const usePostRegister = (): UseMutationResult<
TResponseMessage,
TResponseError,
@@ -58,3 +59,25 @@ export const usePostSendOTP = (): UseMutationResult<
});
};
export const useGoogleAuth = () => {
const redirectToGoogle = async (): Promise<string> => {
return await getGoogleAuthUrl();
};
return {
redirectToGoogle,
};
};
export const useGoogleCallback = (): UseMutationResult<
TGoogleCallbackResponse,
TResponseError,
{ code: string; state: string },
unknown
> => {
return useMutation({
mutationKey: ['google-callback'],
mutationFn: async ({ code, state }) => await postGoogleCallback(code, state),
});
};
+2
View File
@@ -1,3 +1,5 @@
export * from './auth';
export * from './gacha';
export * from './users';
export * from './mentors';
export * from './upload';
+45
View File
@@ -0,0 +1,45 @@
import { useQuery, useMutation, UseQueryResult, UseMutationResult, UseQueryOptions } from '@tanstack/react-query';
import { mentorService } from '../../api/mentors';
import { MentorDetailResponseDto, MentorUpdateRequestDto } from '../../types/mentors';
import { TResponseError } from '../../types/common';
export const useMentorMe = (options?: UseQueryOptions<MentorDetailResponseDto, TResponseError>): UseQueryResult<MentorDetailResponseDto, TResponseError> => {
return useQuery({
queryKey: ['mentor-me'],
queryFn: () => mentorService.getMentorMe(),
...options,
});
};
export const useMentorById = (id: string, options?: UseQueryOptions<MentorDetailResponseDto, TResponseError>): UseQueryResult<MentorDetailResponseDto, TResponseError> => {
return useQuery({
queryKey: ['mentor-by-id', id],
queryFn: () => mentorService.getMentorById(id),
enabled: !!id,
...options,
});
};
export const useUpdateMentorMe = (): UseMutationResult<
MentorDetailResponseDto,
TResponseError,
MentorUpdateRequestDto,
unknown
> => {
return useMutation({
mutationKey: ['update-mentor-me'],
mutationFn: (data) => mentorService.updateMentorMe(data),
});
};
export const useUpdateMentorById = (): UseMutationResult<
MentorDetailResponseDto,
TResponseError,
{ id: string; data: MentorUpdateRequestDto },
unknown
> => {
return useMutation({
mutationKey: ['update-mentor-by-id'],
mutationFn: ({ id, data }) => mentorService.updateMentorById(id, data),
});
};
+39
View File
@@ -0,0 +1,39 @@
import { useMutation, UseMutationResult } from '@tanstack/react-query';
import { uploadService, UploadResponse } from '../../api/upload';
import { TResponseError } from '../../types/common';
export const useUploadFile = (): UseMutationResult<
UploadResponse,
TResponseError,
File,
unknown
> => {
return useMutation({
mutationKey: ['upload-file'],
mutationFn: (file) => uploadService.uploadFile(file),
});
};
export const useUploadAvatar = (): UseMutationResult<
UploadResponse,
TResponseError,
File,
unknown
> => {
return useMutation({
mutationKey: ['upload-avatar'],
mutationFn: (file) => uploadService.uploadAvatar(file),
});
};
export const useUploadCV = (): UseMutationResult<
UploadResponse,
TResponseError,
File,
unknown
> => {
return useMutation({
mutationKey: ['upload-cv'],
mutationFn: (file) => uploadService.uploadCV(file),
});
};
+44 -1
View File
@@ -1 +1,44 @@
export {};
import { useQuery, useMutation, UseQueryResult, UseMutationResult, UseQueryOptions } from '@tanstack/react-query';
import { userService, UserDetailResponseDto, UserUpdateRequestDto } from '../../api/users';
import { TResponseError } from '../../types/common';
export const useUserMe = (options?: UseQueryOptions<UserDetailResponseDto, TResponseError>): UseQueryResult<UserDetailResponseDto, TResponseError> => {
return useQuery({
queryKey: ['user-me'],
queryFn: () => userService.getUserMe(),
...options,
});
};
export const useUserById = (id: string, options?: UseQueryOptions<UserDetailResponseDto, TResponseError>): UseQueryResult<UserDetailResponseDto, TResponseError> => {
return useQuery({
queryKey: ['user-by-id', id],
queryFn: () => userService.getUserById(id),
enabled: !!id,
...options,
});
};
export const useUpdateUserMe = (): UseMutationResult<
UserDetailResponseDto,
TResponseError,
UserUpdateRequestDto,
unknown
> => {
return useMutation({
mutationKey: ['update-user-me'],
mutationFn: (data) => userService.updateUserMe(data),
});
};
export const useUpdateUserById = (): UseMutationResult<
UserDetailResponseDto,
TResponseError,
{ id: string; data: UserUpdateRequestDto },
unknown
> => {
return useMutation({
mutationKey: ['update-user-by-id'],
mutationFn: ({ id, data }) => userService.updateUserById(id, data),
});
};
+6 -1
View File
@@ -30,4 +30,9 @@ export type TVerifyEmailRequest = {
export type TSendOTPRequest = {
email: string
};
};
export type TGoogleCallbackResponse = {
token: TTokenItem;
user: TUserItem;
};
+1
View File
@@ -3,3 +3,4 @@ export * from './gacha';
export * from './users';
export * from './roles';
export * from './permissions';
export * from './mentors';
+89
View File
@@ -0,0 +1,89 @@
export interface MentoringRate {
amount: number;
currency: string;
}
export interface MentorDetailResponseDto {
availability_commitment: string;
bio?: string | null;
created_at: string;
current_company: string;
current_role: string;
cv_url?: string | null;
domicile?: string | null;
email?: string | null;
expertise: string[];
fullname?: string | null;
gender?: string | null;
github_url?: string | null;
id: string;
industries: string[];
languages: string[];
last_education?: string | null;
legal_name?: string | null;
linkedin_url?: string | null;
mentoring_rate: MentoringRate;
phone_for_verification?: string | null;
portfolio_url?: string | null;
preferred_mentee_level: string[];
preferred_mentoring_formats: string[];
status: string;
topics_of_interest: string[];
updated_at: string;
user_id: string;
years_of_experience: number;
mentoring_sessions?: number;
rating?: number;
experience?: Array<{
id: string;
company: string;
position: string;
duration: string;
period: string;
}>;
education?: Array<{
id: string;
institution: string;
degree: string;
field: string;
period: string;
}>;
}
export interface MentorUpdateRequestDto {
availability_commitment?: string | null;
bio?: string | null;
current_company?: string | null;
current_role?: string | null;
cv_url?: string | null;
domicile?: string | null;
expertise?: string[] | null;
gender?: string | null;
github_url?: string | null;
industries?: string[] | null;
languages?: string[] | null;
last_education?: string | null;
legal_name?: string | null;
linkedin_url?: string | null;
mentoring_rate_amount?: number | null;
phone_for_verification?: string | null;
portfolio_url?: string | null;
preferred_mentee_level?: string[] | null;
preferred_mentoring_formats?: string[] | null;
topics_of_interest?: string[] | null;
years_of_experience?: number | null;
experience?: Array<{
id: string;
company: string;
position: string;
duration: string;
period: string;
}>;
education?: Array<{
id: string;
institution: string;
degree: string;
field: string;
period: string;
}>;
}
+3
View File
@@ -11,3 +11,6 @@ export type TUserItem = {
phone_number: string;
role: TRoleDetailItem;
};
// Re-export types from API for convenience
export type { UserDetailResponseDto, UserUpdateRequestDto } from '../../api/users';