add auth
This commit is contained in:
93
frontend/src/components/AuthProvider/AuthProvider.tsx
Normal file
93
frontend/src/components/AuthProvider/AuthProvider.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import keycloak from '../../services/keycloak';
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const didInit = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Предотвращаем двойную инициализацию в StrictMode
|
||||
if (didInit.current) {
|
||||
return;
|
||||
}
|
||||
didInit.current = true;
|
||||
|
||||
let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const initKeycloak = async () => {
|
||||
try {
|
||||
const authenticated = await keycloak.init({
|
||||
onLoad: 'login-required',
|
||||
checkLoginIframe: false,
|
||||
pkceMethod: 'S256',
|
||||
});
|
||||
|
||||
setIsAuthenticated(authenticated);
|
||||
|
||||
if (authenticated) {
|
||||
// Автоматическое обновление токена
|
||||
refreshInterval = setInterval(() => {
|
||||
keycloak.updateToken(30).catch(() => {
|
||||
console.error('Failed to refresh token');
|
||||
void keycloak.login();
|
||||
});
|
||||
}, 10000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Keycloak init failed:', error);
|
||||
setIsAuthenticated(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void initKeycloak();
|
||||
|
||||
return () => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
<Typography>Авторизация...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<Typography>Ошибка авторизации. Перенаправление...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
1
frontend/src/components/AuthProvider/index.ts
Normal file
1
frontend/src/components/AuthProvider/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { AuthProvider } from './AuthProvider';
|
||||
91
frontend/src/components/IdeasTable/DraggableRow.tsx
Normal file
91
frontend/src/components/IdeasTable/DraggableRow.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { TableRow, TableCell, Box } from '@mui/material';
|
||||
import { DragIndicator } from '@mui/icons-material';
|
||||
import { flexRender } from '@tanstack/react-table';
|
||||
import type { Row } from '@tanstack/react-table';
|
||||
import type { Idea } from '../../types/idea';
|
||||
|
||||
// Контекст для передачи информации о drag handle в ячейку
|
||||
interface DragHandleContextValue {
|
||||
attributes: ReturnType<typeof useSortable>['attributes'];
|
||||
listeners: ReturnType<typeof useSortable>['listeners'];
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
const DragHandleContext = createContext<DragHandleContextValue | null>(null);
|
||||
|
||||
// Компонент drag handle для использования в колонке
|
||||
export function DragHandle() {
|
||||
const context = useContext(DragHandleContext);
|
||||
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { attributes, listeners, isDragging } = context;
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
sx={{
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: 'text.secondary',
|
||||
touchAction: 'none',
|
||||
'&:hover': { color: 'text.primary' },
|
||||
}}
|
||||
>
|
||||
<DragIndicator fontSize="small" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface DraggableRowProps {
|
||||
row: Row<Idea>;
|
||||
}
|
||||
|
||||
export function DraggableRow({ row }: DraggableRowProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: row.original.id });
|
||||
|
||||
// Используем CSS.Translate вместо CSS.Transform для лучшей совместимости с таблицами
|
||||
const style = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
backgroundColor: row.original.color
|
||||
? `${row.original.color}15`
|
||||
: isDragging
|
||||
? 'action.hover'
|
||||
: undefined,
|
||||
position: isDragging ? ('relative' as const) : undefined,
|
||||
zIndex: isDragging ? 1000 : undefined,
|
||||
'&:hover': {
|
||||
backgroundColor: row.original.color
|
||||
? `${row.original.color}25`
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<DragHandleContext.Provider value={{ attributes, listeners, isDragging }}>
|
||||
<TableRow ref={setNodeRef} hover sx={style}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</DragHandleContext.Provider>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,25 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
flexRender,
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragOverlay,
|
||||
} from '@dnd-kit/core';
|
||||
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -19,18 +35,27 @@ import {
|
||||
TablePagination,
|
||||
} from '@mui/material';
|
||||
import { Inbox } from '@mui/icons-material';
|
||||
import { useIdeasQuery, useDeleteIdea } from '../../hooks/useIdeas';
|
||||
import {
|
||||
useIdeasQuery,
|
||||
useDeleteIdea,
|
||||
useReorderIdeas,
|
||||
} from '../../hooks/useIdeas';
|
||||
import { useIdeasStore } from '../../store/ideas';
|
||||
import { createColumns } from './columns';
|
||||
import { DraggableRow } from './DraggableRow';
|
||||
|
||||
const SKELETON_COLUMNS_COUNT = 7;
|
||||
const SKELETON_COLUMNS_COUNT = 8;
|
||||
|
||||
export function IdeasTable() {
|
||||
const { data, isLoading, isError } = useIdeasQuery();
|
||||
const deleteIdea = useDeleteIdea();
|
||||
const reorderIdeas = useReorderIdeas();
|
||||
const { sorting, setSorting, pagination, setPage, setLimit } =
|
||||
useIdeasStore();
|
||||
|
||||
// ID активно перетаскиваемого элемента
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const columns = useMemo(
|
||||
() => createColumns((id) => deleteIdea.mutate(id)),
|
||||
[deleteIdea],
|
||||
@ -43,8 +68,49 @@ export function IdeasTable() {
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualSorting: true,
|
||||
manualPagination: true,
|
||||
getRowId: (row) => row.id,
|
||||
});
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
|
||||
if (over && active.id !== over.id && data?.data) {
|
||||
const oldIndex = data.data.findIndex((item) => item.id === active.id);
|
||||
const newIndex = data.data.findIndex((item) => item.id === over.id);
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
// Создаём новый порядок
|
||||
const items = [...data.data];
|
||||
const [movedItem] = items.splice(oldIndex, 1);
|
||||
items.splice(newIndex, 0, movedItem);
|
||||
|
||||
// Отправляем на сервер новый порядок
|
||||
const reorderItems = items.map((item, index) => ({
|
||||
id: item.id,
|
||||
order: index,
|
||||
}));
|
||||
|
||||
reorderIdeas.mutate(reorderItems);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = (columnId: string) => {
|
||||
setSorting(columnId);
|
||||
};
|
||||
@ -67,101 +133,131 @@ export function IdeasTable() {
|
||||
);
|
||||
}
|
||||
|
||||
const rows = table.getRowModel().rows;
|
||||
const rowIds = rows.map((row) => row.original.id);
|
||||
const activeRow = activeId
|
||||
? rows.find((row) => row.original.id === activeId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Paper sx={{ width: '100%', overflow: 'hidden' }}>
|
||||
<TableContainer>
|
||||
<Table stickyHeader size="small">
|
||||
<TableHead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableCell
|
||||
key={header.id}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
backgroundColor: 'grey.100',
|
||||
width: header.getSize(),
|
||||
}}
|
||||
>
|
||||
{header.column.getCanSort() ? (
|
||||
<TableSortLabel
|
||||
active={sorting.sortBy === header.id}
|
||||
direction={
|
||||
sorting.sortBy === header.id
|
||||
? (sorting.sortOrder.toLowerCase() as
|
||||
| 'asc'
|
||||
| 'desc')
|
||||
: 'asc'
|
||||
}
|
||||
onClick={() => handleSort(header.id)}
|
||||
>
|
||||
{flexRender(
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<TableContainer>
|
||||
<Table stickyHeader size="small">
|
||||
<TableHead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableCell
|
||||
key={header.id}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
backgroundColor: 'grey.100',
|
||||
width: header.getSize(),
|
||||
}}
|
||||
>
|
||||
{header.column.getCanSort() ? (
|
||||
<TableSortLabel
|
||||
active={sorting.sortBy === header.id}
|
||||
direction={
|
||||
sorting.sortBy === header.id
|
||||
? (sorting.sortOrder.toLowerCase() as
|
||||
| 'asc'
|
||||
| 'desc')
|
||||
: 'asc'
|
||||
}
|
||||
onClick={() => handleSort(header.id)}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableSortLabel>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableSortLabel>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 5 }).map((_, index) => (
|
||||
<TableRow key={index}>
|
||||
{Array.from({ length: SKELETON_COLUMNS_COUNT }).map(
|
||||
(_, colIndex) => (
|
||||
<TableCell key={colIndex}>
|
||||
<Skeleton variant="text" />
|
||||
</TableCell>
|
||||
),
|
||||
)}
|
||||
)
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : table.getRowModel().rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={SKELETON_COLUMNS_COUNT}>
|
||||
<Box
|
||||
sx={{
|
||||
py: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
<Inbox sx={{ fontSize: 48, mb: 2, opacity: 0.5 }} />
|
||||
<Typography variant="h6">Идей пока нет</Typography>
|
||||
<Typography variant="body2">
|
||||
Создайте первую идею, чтобы начать
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
hover
|
||||
sx={{
|
||||
backgroundColor: row.original.color
|
||||
? `${row.original.color}15`
|
||||
: undefined,
|
||||
'&:hover': {
|
||||
backgroundColor: row.original.color
|
||||
? `${row.original.color}25`
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
))}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 5 }).map((_, index) => (
|
||||
<TableRow key={index}>
|
||||
{Array.from({ length: SKELETON_COLUMNS_COUNT }).map(
|
||||
(_, colIndex) => (
|
||||
<TableCell key={colIndex}>
|
||||
<Skeleton variant="text" />
|
||||
</TableCell>
|
||||
),
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={SKELETON_COLUMNS_COUNT}>
|
||||
<Box
|
||||
sx={{
|
||||
py: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
<Inbox sx={{ fontSize: 48, mb: 2, opacity: 0.5 }} />
|
||||
<Typography variant="h6">Идей пока нет</Typography>
|
||||
<Typography variant="body2">
|
||||
Создайте первую идею, чтобы начать
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<SortableContext
|
||||
items={rowIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{rows.map((row) => (
|
||||
<DraggableRow key={row.id} row={row} />
|
||||
))}
|
||||
</SortableContext>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeRow ? (
|
||||
<Table
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: 'background.paper',
|
||||
boxShadow: 6,
|
||||
borderRadius: 1,
|
||||
'& td': {
|
||||
backgroundColor: activeRow.original.color
|
||||
? `${activeRow.original.color}30`
|
||||
: 'action.selected',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
{activeRow.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
sx={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
@ -169,11 +265,11 @@ export function IdeasTable() {
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
{data && (
|
||||
<TablePagination
|
||||
component="div"
|
||||
|
||||
@ -4,6 +4,7 @@ import { Delete } from '@mui/icons-material';
|
||||
import type { Idea, IdeaStatus, IdeaPriority } from '../../types/idea';
|
||||
import { EditableCell } from './EditableCell';
|
||||
import { statusOptions, priorityOptions } from './constants';
|
||||
import { DragHandle } from './DraggableRow';
|
||||
|
||||
const columnHelper = createColumnHelper<Idea>();
|
||||
|
||||
@ -29,6 +30,13 @@ const priorityColors: Record<
|
||||
};
|
||||
|
||||
export const createColumns = (onDelete: (id: string) => void) => [
|
||||
columnHelper.display({
|
||||
id: 'drag',
|
||||
header: '',
|
||||
cell: () => <DragHandle />,
|
||||
size: 40,
|
||||
enableSorting: false,
|
||||
}),
|
||||
columnHelper.accessor('title', {
|
||||
header: 'Название',
|
||||
cell: (info) => (
|
||||
|
||||
@ -69,3 +69,57 @@ export function useDeleteIdea() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReorderIdeas() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (items: { id: string; order: number }[]) =>
|
||||
ideasApi.reorder(items),
|
||||
onMutate: async (items) => {
|
||||
// Получаем актуальное состояние store
|
||||
const { filters, sorting, pagination } = useIdeasStore.getState();
|
||||
|
||||
// Отменяем исходящие запросы чтобы не перезаписать оптимистичное обновление
|
||||
await queryClient.cancelQueries({ queryKey: [QUERY_KEY] });
|
||||
|
||||
// Сохраняем предыдущее состояние для отката
|
||||
const queryKey = [QUERY_KEY, { ...filters, ...sorting, ...pagination }];
|
||||
const previousData = queryClient.getQueryData(queryKey);
|
||||
|
||||
// Оптимистично обновляем кэш
|
||||
queryClient.setQueryData(
|
||||
queryKey,
|
||||
(
|
||||
old:
|
||||
| { data: { id: string; order: number }[]; meta: unknown }
|
||||
| undefined,
|
||||
) => {
|
||||
if (!old) return old;
|
||||
|
||||
// Создаём новый порядок на основе items
|
||||
const orderMap = new Map(items.map((item) => [item.id, item.order]));
|
||||
const newData = [...old.data].sort((a, b) => {
|
||||
const orderA = orderMap.get(a.id) ?? a.order;
|
||||
const orderB = orderMap.get(b.id) ?? b.order;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
return { ...old, data: newData };
|
||||
},
|
||||
);
|
||||
|
||||
return { previousData, queryKey };
|
||||
},
|
||||
onError: (_err, _items, context) => {
|
||||
// Откатываем к предыдущему состоянию при ошибке
|
||||
if (context?.previousData) {
|
||||
queryClient.setQueryData(context.queryKey, context.previousData);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
// Инвалидируем для синхронизации с сервером
|
||||
void queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider, CssBaseline } from '@mui/material';
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
import { AuthProvider } from './components/AuthProvider';
|
||||
import App from './App.tsx';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@ -30,7 +31,9 @@ createRoot(rootElement).render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<App />
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import axios, { type AxiosError } from 'axios';
|
||||
import keycloak from './keycloak';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: '/api',
|
||||
@ -6,3 +7,33 @@ export const api = axios.create({
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Interceptor для добавления Authorization header
|
||||
api.interceptors.request.use(
|
||||
async (config) => {
|
||||
if (keycloak.token) {
|
||||
try {
|
||||
await keycloak.updateToken(5);
|
||||
} catch {
|
||||
void keycloak.login();
|
||||
return Promise.reject(new Error('Token refresh failed'));
|
||||
}
|
||||
|
||||
config.headers.Authorization = `Bearer ${keycloak.token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error: unknown) =>
|
||||
Promise.reject(error instanceof Error ? error : new Error('Request error')),
|
||||
);
|
||||
|
||||
// Interceptor для обработки 401 ошибок
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
void keycloak.login();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
@ -61,4 +61,8 @@ export const ideasApi = {
|
||||
const { data } = await api.get<string[]>('/ideas/modules');
|
||||
return data;
|
||||
},
|
||||
|
||||
reorder: async (items: { id: string; order: number }[]): Promise<void> => {
|
||||
await api.patch('/ideas/reorder', { items });
|
||||
},
|
||||
};
|
||||
|
||||
9
frontend/src/services/keycloak.ts
Normal file
9
frontend/src/services/keycloak.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import Keycloak from 'keycloak-js';
|
||||
|
||||
const keycloak = new Keycloak({
|
||||
url: import.meta.env.VITE_KEYCLOAK_URL as string,
|
||||
realm: import.meta.env.VITE_KEYCLOAK_REALM as string,
|
||||
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID as string,
|
||||
});
|
||||
|
||||
export default keycloak;
|
||||
@ -42,7 +42,7 @@ interface IdeasStore {
|
||||
}
|
||||
|
||||
const initialFilters: IdeasFilters = {};
|
||||
const initialSorting: IdeasSorting = { sortBy: 'createdAt', sortOrder: 'DESC' };
|
||||
const initialSorting: IdeasSorting = { sortBy: 'order', sortOrder: 'ASC' };
|
||||
const initialPagination: IdeasPagination = { page: 1, limit: 20 };
|
||||
|
||||
export const useIdeasStore = create<IdeasStore>((set) => ({
|
||||
|
||||
Reference in New Issue
Block a user