Project Init

This commit is contained in:
Muluhabt
2026-05-29 15:23:46 +03:00
commit 2fbc557aac
67387 changed files with 6063341 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
import { AppProviders } from './providers/AppProviders';
import { AppRouter } from './router';
export function App() {
return (
<AppProviders>
<AppRouter />
</AppProviders>
);
}

View File

@@ -0,0 +1,17 @@
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { loginSuccess, logout as logoutAction, hydrateAuth } from '../store/auth.slice';
import type { LoginPayload } from '../types/auth.types';
export function useAuth() {
const dispatch = useAppDispatch();
const { user, token, isAuthenticated } = useAppSelector((s) => s.auth);
return {
user,
token,
isAuthenticated,
login: (p: LoginPayload) => dispatch(loginSuccess(p)),
logout: () => dispatch(logoutAction()),
hydrate: () => dispatch(hydrateAuth()),
};
}

View File

@@ -0,0 +1,89 @@
import { useState } from 'react';
import {
Paper,
TextInput,
PasswordInput,
Button,
Stack,
Title,
Center,
} from '@mantine/core';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import { notify } from '@ema-platform/ui';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000';
const schema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(6, 'Password must be at least 6 characters'),
});
type FormValues = z.infer<typeof schema>;
export function LoginPage() {
const navigate = useNavigate();
const { login } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
});
const onSubmit = async (values: FormValues) => {
setIsLoading(true);
try {
const res = await fetch(`${BASE_API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
if (!res.ok) throw new Error('Login failed');
const data = (await res.json()) as Parameters<typeof login>[0];
login(data);
navigate('/dashboard');
} catch {
notify.error('Invalid email or password');
} finally {
setIsLoading(false);
}
};
return (
<Center h="100vh">
<Paper p="xl" shadow="md" radius="md" w={400}>
<Stack gap="md">
<Title order={3}>Sign in to Portal</Title>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="sm">
<TextInput
label="Email"
placeholder="you@example.com"
error={errors.email?.message}
{...register('email')}
/>
<PasswordInput
label="Password"
placeholder="Your password"
error={errors.password?.message}
{...register('password')}
/>
<Button type="submit" loading={isLoading} fullWidth mt="sm">
Sign in
</Button>
</Stack>
</form>
</Stack>
</Paper>
</Center>
);
}

View File

@@ -0,0 +1,42 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { AuthState, LoginPayload } from '../types/auth.types';
import { authStorage } from '../utils/auth-storage';
const initialState: AuthState = {
user: null,
token: null,
isAuthenticated: false,
};
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
loginSuccess(state, action: PayloadAction<LoginPayload>) {
state.user = action.payload.user;
state.token = action.payload.token;
state.isAuthenticated = true;
authStorage.setToken(action.payload.token);
authStorage.setRefreshToken(action.payload.refreshToken);
authStorage.setUser(action.payload.user);
},
logout(state) {
state.user = null;
state.token = null;
state.isAuthenticated = false;
authStorage.clear();
},
hydrateAuth(state) {
const token = authStorage.getToken();
const user = authStorage.getUser();
if (token && user) {
state.token = token;
state.user = user;
state.isAuthenticated = true;
}
},
},
});
export const { loginSuccess, logout, hydrateAuth } = authSlice.actions;
export const authReducer = authSlice.reducer;

View File

@@ -0,0 +1,19 @@
export interface AuthUser {
id: string;
email: string;
username: string;
roles: string[];
permissions: string[];
}
export interface AuthState {
user: AuthUser | null;
token: string | null;
isAuthenticated: boolean;
}
export interface LoginPayload {
user: AuthUser;
token: string;
refreshToken: string;
}

View File

@@ -0,0 +1,23 @@
import type { AuthUser } from '../types/auth.types';
const KEYS = {
token: 'ema-portal-auth-token',
refreshToken: 'ema-portal-refresh-token',
user: 'ema-portal-auth-user',
} as const;
export const authStorage = {
getToken: () => localStorage.getItem(KEYS.token) ?? undefined,
setToken: (token: string) => localStorage.setItem(KEYS.token, token),
getRefreshToken: () => localStorage.getItem(KEYS.refreshToken) ?? undefined,
setRefreshToken: (t: string) => localStorage.setItem(KEYS.refreshToken, t),
getUser: (): AuthUser | null => {
try {
return JSON.parse(localStorage.getItem(KEYS.user) ?? 'null') as AuthUser | null;
} catch {
return null;
}
},
setUser: (u: AuthUser) => localStorage.setItem(KEYS.user, JSON.stringify(u)),
clear: () => Object.values(KEYS).forEach((k) => localStorage.removeItem(k)),
};

View File

@@ -0,0 +1,14 @@
import { Stack, Title, Text, Paper } from '@mantine/core';
export function DashboardPage() {
return (
<Stack gap="lg">
<Title order={2}>My Dashboard</Title>
<Paper p="md" shadow="sm" radius="md" withBorder>
<Text c="dimmed">
Welcome to the EMA Portal. Your content will appear here.
</Text>
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,36 @@
import { AppShell, Group, Text, Button } from '@mantine/core';
import { Outlet, useNavigate } from 'react-router-dom';
export function PortalLayout() {
const navigate = useNavigate();
const isLoggedIn = !!localStorage.getItem('ema-portal-auth-token');
return (
<AppShell header={{ height: 56 }} padding="md">
<AppShell.Header>
<Group h="100%" px="md" justify="space-between">
<Text fw={700}>EMA Portal</Text>
{isLoggedIn ? (
<Button
variant="subtle"
size="sm"
onClick={() => {
localStorage.removeItem('ema-portal-auth-token');
navigate('/login');
}}
>
Logout
</Button>
) : (
<Button variant="subtle" size="sm" onClick={() => navigate('/login')}>
Login
</Button>
)}
</Group>
</AppShell.Header>
<AppShell.Main>
<Outlet />
</AppShell.Main>
</AppShell>
);
}

View File

@@ -0,0 +1,19 @@
import { Provider } from 'react-redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { store } from '../store';
import { MantineThemeProvider } from './MantineThemeProvider';
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, staleTime: 1000 * 60 * 5 } },
});
export function AppProviders({ children }: { children: ReactNode }) {
return (
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<MantineThemeProvider>{children}</MantineThemeProvider>
</QueryClientProvider>
</Provider>
);
}

View File

@@ -0,0 +1,13 @@
import { MantineProvider } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import { emaTheme } from '@ema-platform/shared';
import type { ReactNode } from 'react';
export function MantineThemeProvider({ children }: { children: ReactNode }) {
return (
<MantineProvider theme={emaTheme}>
<Notifications position="top-right" />
{children}
</MantineProvider>
);
}

View File

@@ -0,0 +1,37 @@
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
import type { ReactNode } from 'react';
import { PortalLayout } from './layouts/PortalLayout';
import { LoginPage } from './features/auth/pages/LoginPage';
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
function getToken(): string | null {
return localStorage.getItem('ema-portal-auth-token');
}
function ProtectedRoute({ children }: { children: ReactNode }) {
if (!getToken()) return <Navigate to="/login" replace />;
return <>{children}</>;
}
const router = createBrowserRouter([
{ path: '/login', element: <LoginPage /> },
{
element: <PortalLayout />,
children: [
{ path: '/', element: <Navigate to="/dashboard" replace /> },
{
path: '/dashboard',
element: (
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
),
},
],
},
{ path: '*', element: <Navigate to="/" replace /> },
]);
export function AppRouter() {
return <RouterProvider router={router} />;
}

View File

@@ -0,0 +1,6 @@
import { useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './index';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector = <T>(selector: (state: RootState) => T) =>
useSelector(selector);

View File

@@ -0,0 +1,15 @@
import { configureStore } from '@reduxjs/toolkit';
import { baseApi } from '@ema-platform/api';
import { authReducer } from '../features/auth/store/auth.slice';
export const store = configureStore({
reducer: {
auth: authReducer,
[baseApi.reducerPath]: baseApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(baseApi.middleware),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;