Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion frontend/.env.example
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
VITE_API_URL=http://localhost:8000/api
VITE_API_URL=http://localhost:8000/api
VITE_API_BASE_URL=http://localhost:8000
10 changes: 1 addition & 9 deletions frontend/src/api/axiosInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,11 @@ const axiosInstance = axios.create({
withCredentials: true,
});

axiosInstance.interceptors.request.use((config) => {
const token = sessionStorage.getItem('__auth_token__');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});

axiosInstance.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
if (error.response?.status === 401) {
// Token expired / invalid — caller handles redirect
// Session expired — caller handles redirect
}
return Promise.reject(error);
}
Expand Down
93 changes: 38 additions & 55 deletions frontend/src/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -1,56 +1,39 @@
import {
createContext,
useState,
useEffect,
useCallback,
type ReactNode,
} from 'react';
import type { AuthUser, AuthState, LoginResponseData } from '../types/auth';

interface AuthContextValue extends AuthState {
setAuth: (data: LoginResponseData) => void;
clearAuth: () => void;
}

export const AuthContext = createContext<AuthContextValue | null>(null);

const TOKEN_KEY = '__auth_token__';

export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [token, setToken] = useState<string | null>(
() => sessionStorage.getItem(TOKEN_KEY)
);

useEffect(() => {
if (token) {
sessionStorage.setItem(TOKEN_KEY, token);
} else {
sessionStorage.removeItem(TOKEN_KEY);
}
}, [token]);

const setAuth = useCallback((data: LoginResponseData) => {
setToken(data.token);
setUser(data.user);
}, []);

const clearAuth = useCallback(() => {
setToken(null);
setUser(null);
}, []);

return (
<AuthContext.Provider
value={{
user,
token,
isAuthenticated: !!token,
setAuth,
clearAuth,
}}
>
{children}
</AuthContext.Provider>
);
}
createContext,
useState,
useCallback,
type ReactNode,
} from 'react';
import type { AuthUser, AuthState, LoginResponseData } from '../types/auth';

interface AuthContextValue extends AuthState {
setAuth: (data: LoginResponseData) => void;
clearAuth: () => void;
}

export const AuthContext = createContext<AuthContextValue | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);

const setAuth = useCallback((data: LoginResponseData) => {
setUser(data.user);
}, []);

const clearAuth = useCallback(() => {
setUser(null);
}, []);

return (
<AuthContext.Provider
value={{
user,
isAuthenticated: !!user,
setAuth,
clearAuth,
}}
>
{children}
</AuthContext.Provider>
);
}
15 changes: 12 additions & 3 deletions frontend/src/services/authService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@ import type {
ApiSuccessResponse,
} from '../types/auth';

const CSRF_COOKIE_URL = `${import.meta.env.VITE_API_BASE_URL}/sanctum/csrf-cookie`;

async function initCsrf(): Promise<void> {
await axiosInstance.get(CSRF_COOKIE_URL, { baseURL: '' });
}

export const authService = {
async register(
payload: RegisterPayload
): Promise<ApiSuccessResponse<AuthUser>> {
await initCsrf();
const response = await axiosInstance.post<ApiSuccessResponse<AuthUser>>(
'/auth/register',
payload
Expand All @@ -21,9 +28,11 @@ export const authService = {
async login(
payload: LoginPayload
): Promise<ApiSuccessResponse<LoginResponseData>> {
const response = await axiosInstance.post<
ApiSuccessResponse<LoginResponseData>
>('/auth/login', payload);
await initCsrf();
const response = await axiosInstance.post<ApiSuccessResponse<LoginResponseData>>(
'/auth/login',
payload
);
return response.data;
},

Expand Down
34 changes: 19 additions & 15 deletions frontend/src/tests/pages/LoginPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,14 @@ const mockSetAuth = vi.fn<(data: LoginResponseData) => void>();
const mockClearAuth = vi.fn<() => void>();

const mockAuthContextValue = {
user: null,
token: null,
isAuthenticated: false,
setAuth: mockSetAuth,
clearAuth: mockClearAuth,
} satisfies AuthState & {
setAuth: (data: LoginResponseData) => void;
clearAuth: () => void;
};
user: null,
isAuthenticated: false,
setAuth: mockSetAuth,
clearAuth: mockClearAuth,
} satisfies AuthState & {
setAuth: (data: LoginResponseData) => void;
clearAuth: () => void;
};

function renderLoginPage() {
return render(
Expand All @@ -39,10 +38,8 @@ function renderLoginPage() {
}

const mockLoginResponse: { message: string; data: LoginResponseData } = {
message: 'Login successful.',
message: 'Login berhasil.',
data: {
token: 'test-token-123',
token_type: 'Bearer',
user: {
user_id: 10,
email: 'ucok@example.com',
Expand Down Expand Up @@ -108,7 +105,9 @@ describe('LoginPage', () => {

await waitFor(() => {
expect(mockSetAuth).toHaveBeenCalledWith(mockLoginResponse.data);
expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true });
expect(mockNavigate).toHaveBeenCalledWith('/dashboard', {
replace: true,
});
});
});

Expand Down Expand Up @@ -164,15 +163,20 @@ describe('LoginPage', () => {
fireEvent.click(screen.getByRole('button', { name: 'Login' }));

await waitFor(() => {
expect(screen.getByRole('button', { name: 'Logging in...' })).toBeDisabled();
expect(
screen.getByRole('button', { name: 'Logging in...' })
).toBeDisabled();
});
});

it('renders success message passed via location state', () => {
render(
<MemoryRouter
initialEntries={[
{ pathname: '/login', state: { successMessage: 'Registrasi berhasil. Silakan login.' } },
{
pathname: '/login',
state: { successMessage: 'Registrasi berhasil. Silakan login.' },
},
]}
>
<AuthContext.Provider value={mockAuthContextValue}>
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/tests/pages/RegisterPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ const mockClearAuth = vi.fn<() => void>();

const mockAuthContextValue = {
user: null,
token: null,
isAuthenticated: false,
setAuth: mockSetAuth,
clearAuth: mockClearAuth,
Expand Down Expand Up @@ -128,7 +127,9 @@ describe('RegisterPage', () => {
});

it('displays generic error on network failure', async () => {
vi.mocked(authService.register).mockRejectedValueOnce(new Error('Network Error'));
vi.mocked(authService.register).mockRejectedValueOnce(
new Error('Network Error')
);

renderRegisterPage();
fireEvent.click(screen.getByRole('button', { name: 'Register' }));
Expand All @@ -149,7 +150,9 @@ describe('RegisterPage', () => {
fireEvent.click(screen.getByRole('button', { name: 'Register' }));

await waitFor(() => {
expect(screen.getByRole('button', { name: 'Register...' })).toBeDisabled();
expect(
screen.getByRole('button', { name: 'Register...' })
).toBeDisabled();
});
});
});
102 changes: 49 additions & 53 deletions frontend/src/types/auth.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,50 @@
export interface PatientProfile {
patient_id: number;
name: string;
phone: string;
bpjs_number: string | null;
birth_place: string | null;
birth_date: string | null;
gender: string | null;
}

export type UserRole = 'patient' | 'doctor' | 'nurse' | 'admin';

export interface AuthUser {
user_id: number;
email: string;
role: UserRole;
status: string;
profile: PatientProfile;
}

export interface AuthState {
user: AuthUser | null;
token: string | null;
isAuthenticated: boolean;
}

export interface RegisterPayload {
name: string;
email: string;
password: string;
password_confirmation: string;
// phone: string;
}

export interface LoginPayload {
email: string;
password: string;
}

export interface LoginResponseData {
token: string;
token_type: string;
user: AuthUser;
}

export interface ApiSuccessResponse<T> {
message: string;
data: T;
}

export interface ApiValidationError {
message: string;
errors: Record<string, string[]>;
}
patient_id: number;
name: string;
phone: string;
bpjs_number: string | null;
birth_place: string | null;
birth_date: string | null;
gender: string | null;
}

export type UserRole = 'patient' | 'doctor' | 'nurse' | 'admin';

export interface AuthUser {
user_id: number;
email: string;
role: UserRole;
status: string;
profile: PatientProfile;
}

export interface AuthState {
user: AuthUser | null;
isAuthenticated: boolean;
}

export interface RegisterPayload {
name: string;
email: string;
password: string;
password_confirmation: string;
}

export interface LoginPayload {
email: string;
password: string;
}

export interface LoginResponseData {
user: AuthUser;
}

export interface ApiSuccessResponse<T> {
message: string;
data: T;
}

export interface ApiValidationError {
message: string;
errors: Record<string, string[]>;
}
Loading