[FEAT] review the loggin model to be satble and efficient with user interactions

Signed-off-by: Edouard DUPIN <yui.heero@gmail.com>
This commit is contained in:
Edouard DUPIN 2025-03-23 14:11:30 +01:00
parent 83f8ec0e9b
commit 4caec9a54a
14 changed files with 357 additions and 219 deletions

View File

@ -35,6 +35,7 @@ public class WebLauncherLocal extends WebLauncher {
ConfigBaseVariable.dbPort = "3906"; ConfigBaseVariable.dbPort = "3906";
ConfigBaseVariable.testMode = "true"; ConfigBaseVariable.testMode = "true";
} }
// Test fail of SSO: ConfigBaseVariable.ssoAdress = null;
try { try {
super.migrateDB(); super.migrateDB();
} catch (final Exception e) { } catch (final Exception e) {

View File

@ -1,6 +1,13 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Box, Flex, IconButton, SliderTrack, Text } from '@chakra-ui/react'; import {
Box,
Flex,
IconButton,
SliderTrack,
Text,
chakra,
} from '@chakra-ui/react';
import { import {
MdFastForward, MdFastForward,
MdFastRewind, MdFastRewind,
@ -223,6 +230,7 @@ export const AudioPlayer = ({}: AudioPlayerProps) => {
paddingX="10px" paddingX="10px"
marginX="15px" marginX="15px"
bottom={0} bottom={0}
//top="calc(100% - 150px)"
left={0} left={0}
right={0} right={0}
zIndex={1000} zIndex={1000}
@ -318,7 +326,9 @@ export const AudioPlayer = ({}: AudioPlayerProps) => {
marginLeft="auto" marginLeft="auto"
variant="ghost" variant="ghost"
> >
<MdNavigateBefore style={{ width: '100%', height: '100%' }} />{' '} <MdNavigateBefore
style={{ width: '100%', height: '100%' }}
/>{' '}
</IconButton> </IconButton>
<IconButton <IconButton
{...configButton} {...configButton}
@ -357,7 +367,7 @@ export const AudioPlayer = ({}: AudioPlayerProps) => {
</Flex> </Flex>
)} )}
<audio <chakra.audio
src={mediaSource} src={mediaSource}
ref={audioRef} ref={audioRef}
//preload={true} //preload={true}

View File

@ -29,6 +29,7 @@ import {
MdMore, MdMore,
MdOutlinePlaylistPlay, MdOutlinePlaylistPlay,
MdOutlineUploadFile, MdOutlineUploadFile,
MdRestartAlt,
MdSupervisedUserCircle, MdSupervisedUserCircle,
} from 'react-icons/md'; } from 'react-icons/md';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@ -47,7 +48,6 @@ import {
MenuTrigger, MenuTrigger,
} from '@/components/ui/menu'; } from '@/components/ui/menu';
import { useServiceContext } from '@/service/ServiceContext'; import { useServiceContext } from '@/service/ServiceContext';
import { SessionState } from '@/service/SessionState';
import { useSessionService } from '@/service/session'; import { useSessionService } from '@/service/session';
import { colors } from '@/theme/colors'; import { colors } from '@/theme/colors';
import { import {
@ -120,11 +120,16 @@ export const TopBar = ({
const navigate = useNavigate(); const navigate = useNavigate();
const { colorMode, toggleColorMode } = useColorMode(); const { colorMode, toggleColorMode } = useColorMode();
const { session } = useServiceContext(); const { session } = useServiceContext();
const { clearToken } = useSessionService(); const { clearToken, isConnected } = useSessionService();
const backColor = useColorModeValue('back.100', 'back.800'); const backColor = useColorModeValue('back.100', 'back.800');
const drawerDisclose = useDisclosure(); const drawerDisclose = useDisclosure();
const onChangeTheme = () => { const isVisible = useBreakpointValue({ base: false, md: true });
drawerDisclose.onOpen(); const onOpenLeftMenu = () => {
if (!isConnected) {
onForceReload();
} else {
drawerDisclose.onOpen();
}
}; };
const onSignIn = (): void => { const onSignIn = (): void => {
clearToken(); clearToken();
@ -141,7 +146,10 @@ export const TopBar = ({
const onKarso = (): void => { const onKarso = (): void => {
requestOpenSite(); requestOpenSite();
}; };
const isVisible = useBreakpointValue({ base: false, md: true }); const onForceReload = (): void => {
// @ts-expect-error
window.location.reload(true);
};
return ( return (
<Flex <Flex
minWidth="320px" minWidth="320px"
@ -158,7 +166,7 @@ export const TopBar = ({
boxShadow={'0px 2px 4px ' + colors.back[900]} boxShadow={'0px 2px 4px ' + colors.back[900]}
zIndex={200} zIndex={200}
> >
<Button {...BUTTON_TOP_BAR_PROPERTY} onClick={onChangeTheme}> <Button {...BUTTON_TOP_BAR_PROPERTY} onClick={onOpenLeftMenu}>
<HStack> <HStack>
<LuAlignJustify /> <LuAlignJustify />
{isVisible && ( {isVisible && (
@ -169,7 +177,7 @@ export const TopBar = ({
</HStack> </HStack>
</Button> </Button>
{title && ( {title && (
<Text <Flex
truncate truncate
fontSize="20px" fontSize="20px"
fontWeight="bold" fontWeight="bold"
@ -183,11 +191,11 @@ export const TopBar = ({
{titleIcon} {titleIcon}
{title} {title}
</Flex> </Flex>
</Text> </Flex>
)} )}
{children} {children}
<Flex right="0"> <Flex right="0">
{session?.state !== SessionState.CONNECTED && ( {!session?.isConnected && (
<> <>
<Button {...BUTTON_TOP_BAR_PROPERTY} onClick={onSignIn}> <Button {...BUTTON_TOP_BAR_PROPERTY} onClick={onSignIn}>
<LuLogIn /> <LuLogIn />
@ -207,7 +215,7 @@ export const TopBar = ({
</Button> </Button>
</> </>
)} )}
{session?.state === SessionState.CONNECTED && ( {session?.isConnected && (
<MenuRoot> <MenuRoot>
<MenuTrigger asChild> <MenuTrigger asChild>
<IconButton {...BUTTON_TOP_BAR_PROPERTY} width={TOP_BAR_HEIGHT}> <IconButton {...BUTTON_TOP_BAR_PROPERTY} width={TOP_BAR_HEIGHT}>
@ -248,6 +256,13 @@ export const TopBar = ({
<MenuItem value="karso" valueText="Karso" onClick={onKarso}> <MenuItem value="karso" valueText="Karso" onClick={onKarso}>
<LuKeySquare /> Karso (SSO) <LuKeySquare /> Karso (SSO)
</MenuItem> </MenuItem>
<MenuItem
value="force_reload"
valueText="Karso"
onClick={onForceReload}
>
<MdRestartAlt /> force reload
</MenuItem>
{colorMode === 'light' ? ( {colorMode === 'light' ? (
<MenuItem <MenuItem
value="set-dark" value="set-dark"
@ -269,50 +284,52 @@ export const TopBar = ({
</MenuRoot> </MenuRoot>
)} )}
</Flex> </Flex>
<DrawerRoot {session?.isConnected && (
placement="start" <DrawerRoot
onOpenChange={drawerDisclose.onClose} placement="start"
open={drawerDisclose.open} onOpenChange={drawerDisclose.onClose}
data-testid="top-bar_drawer-root" open={drawerDisclose.open}
> data-testid="top-bar_drawer-root"
<DrawerContent data-testid="top-bar_drawer-content"> >
<DrawerHeader <DrawerContent data-testid="top-bar_drawer-content">
paddingY="auto" <DrawerHeader
as="button" paddingY="auto"
onClick={drawerDisclose.onClose} as="button"
boxShadow={'0px 2px 4px ' + colors.back[900]} onClick={drawerDisclose.onClose}
backgroundColor={backColor} boxShadow={'0px 2px 4px ' + colors.back[900]}
color={useColorModeValue('brand.900', 'brand.50')} backgroundColor={backColor}
textTransform="uppercase" color={useColorModeValue('brand.900', 'brand.50')}
> textTransform="uppercase"
<HStack {...BUTTON_TOP_BAR_PROPERTY} cursor="pointer"> >
<LuArrowBigLeft /> <HStack {...BUTTON_TOP_BAR_PROPERTY} cursor="pointer">
<Span paddingLeft="3px">Karusic</Span> <LuArrowBigLeft />
</HStack> <Span paddingLeft="3px">Karusic</Span>
</DrawerHeader> </HStack>
<DrawerBody paddingX="0px"> </DrawerHeader>
<Box marginY="3" /> <DrawerBody paddingX="0px">
<ButtonMenuLeft <Box marginY="3" />
onClickEnd={drawerDisclose.onClose} <ButtonMenuLeft
dest="/" onClickEnd={drawerDisclose.onClose}
title="Home" dest="/"
icon={<MdHome />} title="Home"
/> icon={<MdHome />}
<ButtonMenuLeft />
onClickEnd={drawerDisclose.onClose} <ButtonMenuLeft
dest="/on-air" onClickEnd={drawerDisclose.onClose}
title="On air" dest="/on-air"
icon={<MdOutlinePlaylistPlay />} title="On air"
/> icon={<MdOutlinePlaylistPlay />}
<ButtonMenuLeft />
onClickEnd={drawerDisclose.onClose} <ButtonMenuLeft
dest="/add" onClickEnd={drawerDisclose.onClose}
title="Add Media" dest="/add"
icon={<MdOutlineUploadFile />} title="Add Media"
/> icon={<MdOutlineUploadFile />}
</DrawerBody> />
</DrawerContent> </DrawerBody>
</DrawerRoot> </DrawerContent>
</DrawerRoot>
)}
</Flex> </Flex>
); );
}; };

View File

@ -21,7 +21,7 @@ import { SettingsPage } from './home/SettingsPage';
import { OnAirPage } from './onAir/OnAirPage'; import { OnAirPage } from './onAir/OnAirPage';
export const AppRoutes = () => { export const AppRoutes = () => {
const { isReadable } = useHasRight('user'); const { isReadable } = useHasRight('USER');
return ( return (
<HistoryRouter <HistoryRouter
// @ts-expect-error // @ts-expect-error

View File

@ -1,48 +1,58 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Center, Heading, Image, Text } from '@chakra-ui/react'; import { Center, Flex, Heading, Image, Text } from '@chakra-ui/react';
import { MdFactCheck, MdWarning } from 'react-icons/md';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useEffectOnce } from 'react-use';
import avatar_generic from '@/assets/images/avatar_generic.svg'; import avatar_generic from '@/assets/images/avatar_generic.svg';
import { Icon } from '@/components';
import { PageLayoutInfoCenter } from '@/components/Layout/PageLayoutInfoCenter'; import { PageLayoutInfoCenter } from '@/components/Layout/PageLayoutInfoCenter';
import { TopBar } from '@/components/TopBar/TopBar'; import { TopBar } from '@/components/TopBar/TopBar';
import { isDevelopmentEnvironment } from '@/environment'; import { toaster } from '@/components/ui/toaster';
import { SessionState } from '@/service/SessionState';
import { useSessionService } from '@/service/session'; import { useSessionService } from '@/service/session';
import { b64_to_utf8 } from '@/utils/sso'; import { b64_to_utf8 } from '@/utils/sso';
export const SSOPage = () => { export const SSOPage = () => {
const { data, keepConnected, token } = useParams(); const { data, keepConnected, token } = useParams();
console.log(`- data: ${data}`); //const { data, keepConnected, token } = useState<string|undefined>();
console.log(`- keepConnected: ${keepConnected}`); // console.log(`- data: ${data}`);
console.log(`- token: ${token}`); // console.log(`- keepConnected: ${keepConnected}`);
// console.log(`- token: ${token}`);
const navigate = useNavigate(); const navigate = useNavigate();
const { state, setToken, login, clearToken } = useSessionService(); const { isConnected, errorSession, setToken, login } = useSessionService();
useEffect(() => { useEffectOnce(() => {
if (token) { if (token === undefined || token === '') {
setToken(token); return;
} else {
clearToken();
} }
}, [token, setToken, clearToken]); try {
const delay = isDevelopmentEnvironment() ? 2000 : 2000; setToken(token);
} catch (e) {
toaster.create({
title: 'Connection Fail',
description: `invalid token data model.`,
type: 'error',
});
navigate('/');
}
});
const delay = 1000;
useEffect(() => { useEffect(() => {
if (state === SessionState.CONNECTED) { if (isConnected) {
const destination = data ? b64_to_utf8(data) : '/'; toaster.create({
console.log(`program redirect to: ${destination} (${delay}ms)`); title: 'Connection Succeed',
description: `Welcome back: ${login}.`,
type: 'success',
});
const destination = data ? b64_to_utf8(data) : '/home';
const destinationFinal = destination === '' ? '/home' : destination;
console.log(`program redirect to: '${destinationFinal}' (${delay}ms)`);
setTimeout(() => { setTimeout(() => {
navigate(`/${destination}`); // note: check if the "navigate" is in the dependence of the use effect!!!
navigate(`/${destinationFinal}`, { replace: true });
}, delay); }, delay);
} }
}, [state]); }, [isConnected, login, navigate]);
/*
const [searchParams] = useSearchParams();
console.log(`data: ${searchParams.get('data')}`);
console.log(`keepConnected: ${searchParams.get('keepConnected')}`);
console.log(`token: ${searchParams.get('token')}`);
const dataFromParam = useGetCreateActionParams();
console.log(`data group: ${JSON.stringify(dataFromParam, null, 2)}`);
*/
return ( return (
<> <>
<TopBar /> <TopBar />
@ -54,31 +64,53 @@ export const SSOPage = () => {
<Center w="full"> <Center w="full">
<Image src={avatar_generic} boxSize="150px" borderRadius="full" /> <Image src={avatar_generic} boxSize="150px" borderRadius="full" />
</Center> </Center>
{token === '__CANCEL__' && ( {!isConnected && errorSession !== undefined && (
<Text>
<b>ERROR: </b> Request cancel of connection !
</Text>
)}
{token === '__FAIL__' && (
<Text>
<b>ERROR: </b> Connection FAIL !
</Text>
)}
{token === '__LOGOUT__' && (
<Text>
<b>Dis-connected: </b> Redirect soon!{' '}
</Text>
)}
{!['__LOGOUT__', '__FAIL__', '__CANCEL__'].includes(token ?? '') && (
<> <>
<Text> <Flex color="red.500" fontSize="25px" gap={2} marginX="auto">
<b>Connected: </b> Redirect soon! <Icon sizeIcon="55px">
</Text> <MdWarning />
<Text> </Icon>
<b>Welcome back: </b> {login} <Text marginY="auto">
</Text> <b>ERROR: </b> {errorSession.message}
</Text>
</Flex>
{errorSession.description && (
<Text
whiteSpace="pre-line"
fontSize="25px"
gap={2}
marginX="auto"
>
{errorSession.description}
</Text>
)}
</> </>
)} )}
{!isConnected && errorSession === undefined && (
<Flex color="red.500" fontSize="25px" gap={2} marginX="auto">
<Icon sizeIcon="55px">
<MdWarning />
</Icon>
<Text marginY="auto">
<b>ERROR: </b> Not connected !
</Text>
</Flex>
)}
{isConnected && (
<Flex direction="column">
<Flex color="green.500" fontSize="25px" gap={2} marginX="auto">
<MdFactCheck />
<Text marginY="auto">
<b>Connected: </b> Redirect soon!
</Text>
</Flex>
<Flex fontSize="25px" gap={2} marginX="auto">
<Text marginY="auto">
<b>Welcome back: </b> {login}
</Text>
</Flex>
</Flex>
)}
</PageLayoutInfoCenter> </PageLayoutInfoCenter>
</> </>
); );

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useMemo } from 'react';
import { Album, AlbumResource } from '@/back-api'; import { Album, AlbumResource } from '@/back-api';
import { useServiceContext } from '@/service/ServiceContext'; import { useServiceContext } from '@/service/ServiceContext';
@ -23,14 +23,14 @@ export const useAlbumServiceWrapped = (
{ {
restApiName: 'ALBUM', restApiName: 'ALBUM',
primaryKey: 'id', primaryKey: 'id',
available: session.token !== undefined, available: session.isConnected,
getsCall: () => { getsCall: () => {
return AlbumResource.gets({ return AlbumResource.gets({
restConfig: session.getRestConfig(), restConfig: session.getRestConfig(),
}); });
}, },
}, },
[session.token] [session.isConnected]
); );
return { store }; return { store };

View File

@ -23,14 +23,14 @@ export const useArtistServiceWrapped = (
{ {
restApiName: 'ARTIST', restApiName: 'ARTIST',
primaryKey: 'id', primaryKey: 'id',
available: session.token !== undefined, available: session.isConnected,
getsCall: () => { getsCall: () => {
return ArtistResource.gets({ return ArtistResource.gets({
restConfig: session.getRestConfig(), restConfig: session.getRestConfig(),
}); });
}, },
}, },
[session.token] [session.isConnected]
); );
return { store }; return { store };

View File

@ -23,14 +23,14 @@ export const useGenderServiceWrapped = (
{ {
restApiName: 'GENDER', restApiName: 'GENDER',
primaryKey: 'id', primaryKey: 'id',
available: session.token !== undefined, available: session.isConnected,
getsCall: () => { getsCall: () => {
return GenderResource.gets({ return GenderResource.gets({
restConfig: session.getRestConfig(), restConfig: session.getRestConfig(),
}); });
}, },
}, },
[session.token] [session.isConnected]
); );
return { store }; return { store };

View File

@ -1,5 +1,6 @@
import { ReactNode, createContext, useContext, useMemo } from 'react'; import { ReactNode, createContext, useContext, useMemo } from 'react';
import { Album, Artist, Gender, Track } from '@/back-api';
import { import {
ActivePlaylistServiceProps, ActivePlaylistServiceProps,
useActivePlaylistServiceWrapped, useActivePlaylistServiceWrapped,
@ -7,7 +8,6 @@ import {
import { AlbumServiceProps, useAlbumServiceWrapped } from '@/service/Album'; import { AlbumServiceProps, useAlbumServiceWrapped } from '@/service/Album';
import { ArtistServiceProps, useArtistServiceWrapped } from '@/service/Artist'; import { ArtistServiceProps, useArtistServiceWrapped } from '@/service/Artist';
import { useGenderServiceWrapped } from '@/service/Gender'; import { useGenderServiceWrapped } from '@/service/Gender';
import { SessionState } from '@/service/SessionState';
import { TrackServiceProps, useTrackServiceWrapped } from '@/service/Track'; import { TrackServiceProps, useTrackServiceWrapped } from '@/service/Track';
import { import {
RightPart, RightPart,
@ -15,7 +15,6 @@ import {
getRestConfig, getRestConfig,
useSessionServiceWrapped, useSessionServiceWrapped,
} from '@/service/session'; } from '@/service/session';
import { Album, Artist, Gender, Track } from '@/back-api';
import { DataStoreType } from '@/utils/data-store'; import { DataStoreType } from '@/utils/data-store';
export type ServiceContextType = { export type ServiceContextType = {
@ -27,7 +26,6 @@ export type ServiceContextType = {
activePlaylist: ActivePlaylistServiceProps; activePlaylist: ActivePlaylistServiceProps;
}; };
function emptyStore<TYPE>(): DataStoreType<TYPE> { function emptyStore<TYPE>(): DataStoreType<TYPE> {
return { return {
data: [] as TYPE[], data: [] as TYPE[],
@ -43,22 +41,29 @@ function emptyStore<TYPE>(): DataStoreType<TYPE> {
error: undefined, error: undefined,
update: (_request: Promise<TYPE>, _key?: string) => { update: (_request: Promise<TYPE>, _key?: string) => {
console.error('!!! WTF !!!'); console.error('!!! WTF !!!');
return new Promise((resolve, reject) => { reject("fail") }); return new Promise((resolve, reject) => {
reject('fail');
});
}, },
updateRaw: (_data: TYPE, _key?: string) => { }, updateRaw: (_data: TYPE, _key?: string) => {},
remove: (_id: number | string, _request: Promise<void>, _key?: string): void => { remove: (
_id: number | string,
_request: Promise<void>,
_key?: string
): void => {
console.error('!!! WTF !!!'); console.error('!!! WTF !!!');
} },
}; };
} }
export const ServiceContext = createContext<ServiceContextType>({ export const ServiceContext = createContext<ServiceContextType>({
session: { session: {
setToken: (token: string) => { }, isConnected: false,
clearToken: () => { }, errorSession: undefined,
setToken: (token: string) => {},
clearToken: () => {},
hasReadRight: (part: RightPart) => false, hasReadRight: (part: RightPart) => false,
hasWriteRight: (part: RightPart) => false, hasWriteRight: (part: RightPart) => false,
state: SessionState.NO_USER,
getRestConfig: getRestConfig, getRestConfig: getRestConfig,
}, },
track: { track: {

View File

@ -1,7 +0,0 @@
export enum SessionState {
NO_USER,
CONNECTING,
CONNECTION_FAIL,
CONNECTED,
DISCONNECT,
}

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useMemo } from 'react';
import { Track, TrackResource } from '@/back-api'; import { Track, TrackResource } from '@/back-api';
import { useServiceContext } from '@/service/ServiceContext'; import { useServiceContext } from '@/service/ServiceContext';
@ -23,14 +23,14 @@ export const useTrackServiceWrapped = (
{ {
restApiName: 'TRACK', restApiName: 'TRACK',
primaryKey: 'id', primaryKey: 'id',
available: session.token !== undefined, available: session.isConnected,
getsCall: () => { getsCall: () => {
return TrackResource.gets({ return TrackResource.gets({
restConfig: session.getRestConfig(), restConfig: session.getRestConfig(),
}); });
}, },
}, },
[session.token] [session.isConnected]
); );
return { store }; return { store };

View File

@ -1,18 +1,18 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { createListCollection } from '@chakra-ui/react'; import { createListCollection } from '@chakra-ui/react';
import { useEffectOnce } from 'react-use';
import { import {
JwtToken,
PartRight, PartRight,
RESTConfig, RESTConfig,
UserMe, RestErrorResponse,
UserResource, UserResource,
setErrorApiGlobalCallback, setErrorApiGlobalCallback,
} from '@/back-api'; } from '@/back-api';
import { toaster } from '@/components/ui/toaster';
import { environment, getApiUrl } from '@/environment'; import { environment, getApiUrl } from '@/environment';
import { useServiceContext } from '@/service/ServiceContext'; import { useServiceContext } from '@/service/ServiceContext';
import { SessionState } from '@/service/SessionState';
import { isBrowser } from '@/utils/layout'; import { isBrowser } from '@/utils/layout';
import { parseToken } from '@/utils/sso'; import { parseToken } from '@/utils/sso';
@ -37,7 +37,7 @@ export const USERS = {
export const getUserToken = () => { export const getUserToken = () => {
return localStorage.getItem(TOKEN_KEY); return localStorage.getItem(TOKEN_KEY);
}; };
export type RightPart = 'admin' | 'user'; export type RightPart = 'ADMIN' | 'USER';
export function getRestConfig(): RESTConfig { export function getRestConfig(): RESTConfig {
return { return {
@ -46,14 +46,48 @@ export function getRestConfig(): RESTConfig {
}; };
} }
const getDeltaPrint = (date: Date): string => {
const now = new Date();
let deltaSeconds = Math.floor((date.getTime() - now.getTime()) / 1000);
const isPast = deltaSeconds < 0;
deltaSeconds = Math.abs(deltaSeconds);
const days = Math.floor(deltaSeconds / 86400);
deltaSeconds %= 86400;
const hours = Math.floor(deltaSeconds / 3600);
deltaSeconds %= 3600;
const minutes = Math.floor(deltaSeconds / 60);
const seconds = deltaSeconds % 60;
const result = [days > 0 ? `${days}j` : '', `${hours}h${minutes}:${seconds}s`]
.filter(Boolean)
.join(' ');
return `${result}${isPast ? ' ago' : ''}`;
};
const isInThePast = (date: Date): boolean => {
const now = new Date();
return date.getTime() < now.getTime();
};
export type SessionErrorType = {
message: string;
description?: string;
};
export type SessionServiceProps = { export type SessionServiceProps = {
token?: string; token?: string;
isConnected: boolean;
errorSession?: SessionErrorType;
setToken: (token: string) => void; setToken: (token: string) => void;
clearToken: () => void; clearToken: () => void;
login?: string; login?: string;
hasReadRight: (part: RightPart) => boolean; hasReadRight: (part: RightPart) => boolean;
hasWriteRight: (part: RightPart) => boolean; hasWriteRight: (part: RightPart) => boolean;
state: SessionState;
getRestConfig: () => RESTConfig; getRestConfig: () => RESTConfig;
}; };
@ -63,115 +97,161 @@ export const useSessionService = (): SessionServiceProps => {
}; };
export const useSessionServiceWrapped = (): SessionServiceProps => { export const useSessionServiceWrapped = (): SessionServiceProps => {
const [token, setToken] = useState<string | undefined>( // Load the token from the system (init)
const [tokenStorage, setTokenStorage] = useState<string | undefined>(
isBrowser ? (localStorage.getItem(TOKEN_KEY) ?? undefined) : undefined isBrowser ? (localStorage.getItem(TOKEN_KEY) ?? undefined) : undefined
); );
const [state, setState] = useState<SessionState>(SessionState.NO_USER); // Token that is ready to use
const [config, setConfig] = useState<UserMe | undefined>(undefined); const [tokenStr, setTokenStr] = useState<string>('');
const [errorSession, setErrorSession] = useState<
SessionErrorType | undefined
>(undefined);
const [token, setToken] = useState<JwtToken | undefined>(undefined);
useEffectOnce(() => { const getRestConfigLocal = useCallback((): RESTConfig => {
setErrorApiGlobalCallback((response: Response) => { return {
if (response.status == 401) { server: getApiUrl(),
console.error('Detect 401 error ==> remove token'); token: tokenStr,
clearToken(); };
} }, [tokenStr]);
});
});
const updateRight = useCallback(() => { const updateRight = useEffect(() => {
console.log('update right...'); //console.log('Detect a new token...');
if (isBrowser) { if (isBrowser) {
console.log('Detect a new token...'); //console.log('update internal property');
if (tokenStorage === undefined) {
if (token === undefined) { //console.log(` ==> No User`);
console.log(` ==> No User`); setToken(undefined);
setState(SessionState.NO_USER); setTokenStr('');
setConfig(undefined);
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
} else { } else {
console.log(' ==> Login ... (try to keep right)'); //console.log(' ==> Login ... (try to keep right)');
setState(SessionState.CONNECTING); let tokenParsed: JwtToken | undefined = undefined;
localStorage.setItem(TOKEN_KEY, token); try {
tokenParsed = parseToken(tokenStorage);
} catch (e) {
setErrorSession({
message: 'Fail to parse the token',
});
return;
}
//console.log(`get new token: ${JSON.stringify(tokenParsed, null, 2)}`);
const exp = new Date(tokenParsed.payload.exp * 1000);
if (isInThePast(exp)) {
//console.log(`token expired at: exp: ${exp.toISOString()}: delta=${getDeltaPrint(exp)}`);
const iat = new Date(tokenParsed.payload.iat * 1000);
//console.log(`iat: ${iat.toISOString()}: delta=${getDeltaPrint(iat)}`);
setErrorSession({
message: 'The inserted token has expired',
description: `It expired at ${exp.toISOString()}\nSince: ${getDeltaPrint(exp)}`,
});
return;
}
// Validate token on the server:
UserResource.getMe({ UserResource.getMe({
restConfig: getRestConfig(), restConfig: {
server: getApiUrl(),
token: tokenStorage,
},
}) })
.then((response: UserMe) => { .then((_response) => {
//console.log(` ==> New right arrived to '${response.login}'`); // if the login work well, then the token does not fail with authentication
setState(SessionState.CONNECTED); //console.log('Authentication finished: ...');
setConfig(response); setTokenStr(tokenStorage);
setToken(tokenParsed);
localStorage.setItem(TOKEN_KEY, tokenStorage);
}) })
.catch((error) => { .catch((error: RestErrorResponse) => {
setState(SessionState.CONNECTION_FAIL); setErrorSession({
setConfig(undefined); message: 'The server reject the token.',
//console.log(` ==> Fail to get right: '${error}'`); description: `name: ${error.name}\nmessage: "${error.message}"`,
});
setTokenStr('');
setToken(undefined);
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
}); });
} }
} }
}, [localStorage, parseToken, token]); }, [
localStorage,
parseToken,
setErrorSession,
setToken,
setTokenStr,
tokenStorage,
]);
const setTokenLocal = useCallback( const setTokenLocal = useCallback(
(token?: string) => { (token?: string) => {
if (token ? token.startsWith('__') : false) { if (token ? token.startsWith('__') : false) {
token = undefined; token = undefined;
} }
setToken(token); localStorage.removeItem(TOKEN_KEY);
updateRight(); setTokenStorage(token);
setTokenStr('');
setToken(undefined);
setErrorSession(undefined);
}, },
[updateRight, setToken] [updateRight, setToken]
); );
const clearToken = useCallback(() => { const clearToken = useCallback(() => {
localStorage.removeItem(TOKEN_KEY);
setTokenLocal(undefined);
setToken(undefined); setToken(undefined);
updateRight();
}, [updateRight, setToken]); }, [updateRight, setToken]);
useEffect(() => {
setErrorApiGlobalCallback((response: Response) => {
if (response.status == 401) {
toaster.create({
title: 'API request rejected',
description: `API Authentication rejected by server.`,
type: 'error',
});
clearToken();
}
});
}, [clearToken]);
const hasReadRight = useCallback( const hasReadRight = useCallback(
(part: RightPart) => { (part: RightPart) => {
//console.log(`config = ${JSON.stringify(config, null, 2)}`); //console.log(`right = ${JSON.stringify(token?.payload?.right?.[environment.applName], null, 2)}`);
const right = config?.rights[environment.applName]; const right = token?.payload?.right?.[environment.applName];
if (right === undefined) { if (right === undefined) {
return false; return false;
} }
return [PartRight.READ, PartRight.READ_WRITE].includes(right[part]); return [PartRight.READ, PartRight.READ_WRITE].includes(right[part]);
}, },
[config] [token]
); );
const hasWriteRight = useCallback( const hasWriteRight = useCallback(
(part: RightPart) => { (part: RightPart) => {
const right = config?.rights[environment.applName]; const right = token?.payload?.right?.[environment.applName];
if (right === undefined) { if (right === undefined) {
return false; return false;
} }
return [PartRight.READ, PartRight.READ_WRITE].includes(right[part]); return [PartRight.READ, PartRight.READ_WRITE].includes(right[part]);
}, },
[config] [token]
); );
const getRestConfig = useCallback((): RESTConfig => {
return {
server: getApiUrl(),
token: token ?? '',
};
}, [token]);
useEffect(() => {
updateRight();
}, [updateRight]);
return { return {
token, token: tokenStr,
isConnected: token !== undefined,
errorSession,
setToken: setTokenLocal, setToken: setTokenLocal,
clearToken, clearToken,
login: config?.login, login: token?.payload?.login,
hasReadRight, hasReadRight,
hasWriteRight, hasWriteRight,
state, getRestConfig: getRestConfigLocal,
getRestConfig,
}; };
}; };
export const useHasRight = (part: RightPart) => { export const useHasRight = (part: RightPart) => {
const { token, hasReadRight, hasWriteRight } = useSessionService(); const { token, hasReadRight, hasWriteRight } = useSessionService();
const isReadable = useMemo(() => { const isReadable = useMemo(() => {
console.log(`get is read for: ${part} ==> ${hasReadRight(part)}`); //console.log(`get is read for: ${part} ==> ${hasReadRight(part)}`);
return hasReadRight(part); return hasReadRight(part);
}, [token, hasReadRight, part]); }, [token, hasReadRight, part]);
const isWritable = useMemo(() => { const isWritable = useMemo(() => {

View File

@ -1,10 +0,0 @@
import { RESTConfig } from '@/back-api';
import { getApiUrl } from '@/environment';
import { getUserToken } from '@/service/session';
export function getRestConfig(): RESTConfig {
return {
server: getApiUrl(),
token: getUserToken() ?? '',
};
}

View File

@ -1,3 +1,4 @@
import { JwtToken, ZodJwtToken } from '@/back-api';
import { environment } from '@/environment'; import { environment } from '@/environment';
import { getApplicationLocation } from '@/utils/applPath'; import { getApplicationLocation } from '@/utils/applPath';
@ -72,19 +73,28 @@ export function unHashLocalData(data: string): string | undefined {
return undefined; return undefined;
} }
export function parseToken(token: string): object { const convertBase64asJson = (data: string) => {
const cut = token.split('.'); try {
const decoded = b64_to_utf8(cut[1]); const jsonBuffer = b64_to_utf8(data);
const jsonModel = JSON.parse(decoded); return JSON.parse(jsonBuffer);
if (jsonModel.right === undefined) { } catch (e) {
return {}; console.error(`FAil to convert base64 data: ${e}`);
} }
if (jsonModel.right[environment.applName] === undefined) { };
return {};
}
return jsonModel.right[environment.applName];
}
export function parseToken(token: string): JwtToken {
const parts = token.split('.');
if (parts.length !== 3) {
console.error(`Wrong token model ${token}`);
throw new Error('Token JWT invalid');
}
const result = {
header: convertBase64asJson(parts[0]),
payload: convertBase64asJson(parts[1]),
signature: parts[2],
};
return ZodJwtToken.parse(result);
}
/** /**
* Request Open SSO Global website * Request Open SSO Global website
*/ */
@ -98,14 +108,14 @@ export function requestSignIn(name?: string): void {
console.log( console.log(
`Request sign-in: '${environment.ssoSignIn}' + '${hashLocalData(name)}'` `Request sign-in: '${environment.ssoSignIn}' + '${hashLocalData(name)}'`
); );
window.location.href = environment.ssoSignIn + hashLocalData(name) + "/"; window.location.href = environment.ssoSignIn + hashLocalData(name) + '/';
} }
/** /**
* Request SSO Disconnect * Request SSO Disconnect
*/ */
export function requestSignOut(name?: string): void { export function requestSignOut(name?: string): void {
const url = environment.ssoSignOut + hashLocalData(name); const url = environment.ssoSignOut + hashLocalData(name);
console.log(`Request just to the SSO: ${url}`) console.log(`Request just to the SSO: ${url}`);
// unlog from the SSO // unlog from the SSO
window.location.href = url; window.location.href = url;
} }
@ -114,7 +124,7 @@ export function requestSignOut(name?: string): void {
*/ */
export function requestSignUp(name?: string): void { export function requestSignUp(name?: string): void {
const url = environment.ssoSignUp + hashLocalData(name); const url = environment.ssoSignUp + hashLocalData(name);
console.log(`Request just to the SSO: ${url}`) console.log(`Request just to the SSO: ${url}`);
// unlog from the SSO // unlog from the SSO
window.location.href = url; window.location.href = url;
} }