add login page for locked server

This commit is contained in:
jeffvli
2025-11-18 17:45:29 -08:00
parent 92d4681a23
commit 70242c4044
8 changed files with 246 additions and 13 deletions
@@ -1,4 +1,5 @@
import { openModal } from '@mantine/modals';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import { Navigate } from 'react-router';
@@ -16,6 +17,8 @@ import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Stack } from '/@/shared/components/stack/stack';
const localSettings = isElectron() ? window.api.localSettings : null;
const ActionRequiredRoute = () => {
const { t } = useTranslation();
const currentServer = useCurrentServerWithCredential();
@@ -60,7 +63,7 @@ const ActionRequiredRoute = () => {
<Stack mt="2rem">
{canReturnHome && <Navigate to={AppRoute.HOME} />}
{/* This should be displayed if a credential is required */}
{isCredentialRequired && (
{isCredentialRequired && !localSettings?.env.SERVER_LOCK && (
<Group justify="center" wrap="nowrap">
<Button
fullWidth
@@ -0,0 +1,203 @@
import { useForm } from '@mantine/form';
import isElectron from 'is-electron';
import { nanoid } from 'nanoid/non-secure';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Navigate } from 'react-router';
import { api } from '/@/renderer/api';
import { PageHeader } from '/@/renderer/components/page-header/page-header';
import JellyfinIcon from '/@/renderer/features/servers/assets/jellyfin.png';
import NavidromeIcon from '/@/renderer/features/servers/assets/navidrome.png';
import SubsonicIcon from '/@/renderer/features/servers/assets/opensubsonic.png';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { AppRoute } from '/@/renderer/router/routes';
import { useAuthStoreActions, useCurrentServer } from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button';
import { Center } from '/@/shared/components/center/center';
import { Paper } from '/@/shared/components/paper/paper';
import { PasswordInput } from '/@/shared/components/password-input/password-input';
import { Stack } from '/@/shared/components/stack/stack';
import { TextInput } from '/@/shared/components/text-input/text-input';
import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast';
import { AuthenticationResponse, ServerListItemWithCredential } from '/@/shared/types/domain-types';
import { ServerType, toServerType } from '/@/shared/types/types';
const localSettings = isElectron() ? window.api.localSettings : null;
const SERVER_ICONS: Record<ServerType, string> = {
[ServerType.JELLYFIN]: JellyfinIcon,
[ServerType.NAVIDROME]: NavidromeIcon,
[ServerType.SUBSONIC]: SubsonicIcon,
};
const SERVER_NAMES: Record<ServerType, string> = {
[ServerType.JELLYFIN]: 'Jellyfin',
[ServerType.NAVIDROME]: 'Navidrome',
[ServerType.SUBSONIC]: 'OpenSubsonic',
};
const LoginRoute = () => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const { addServer, setCurrentServer } = useAuthStoreActions();
const currentServer = useCurrentServer();
// Check if server lock is configured
const serverLock = localSettings?.env.SERVER_LOCK || false;
const serverType = localSettings?.env.SERVER_TYPE
? toServerType(localSettings.env.SERVER_TYPE)
: null;
const serverName = localSettings?.env.SERVER_NAME || '';
const serverUrl = localSettings?.env.SERVER_URL || '';
const form = useForm({
initialValues: {
password: '',
username: '',
},
});
if (!serverLock || !serverType || currentServer) {
return <Navigate replace to={AppRoute.HOME} />;
}
const handleSubmit = form.onSubmit(async (values) => {
const authFunction = api.controller.authenticate;
if (!authFunction) {
return toast.error({
message: t('error.invalidServer', { postProcess: 'sentenceCase' }),
});
}
try {
setIsLoading(true);
const data: AuthenticationResponse | undefined = await authFunction(
serverUrl,
{
legacy: false,
password: values.password,
username: values.username,
},
serverType,
);
if (!data) {
return toast.error({
message: t('error.authenticationFailed', { postProcess: 'sentenceCase' }),
});
}
const serverItem: ServerListItemWithCredential = {
credential: data.credential,
id: nanoid(),
name: serverName,
type: serverType,
url: serverUrl.replace(/\/$/, ''),
userId: data.userId,
username: data.username,
};
if (data.ndCredential !== undefined) {
serverItem.ndCredential = data.ndCredential;
}
addServer(serverItem);
setCurrentServer(serverItem);
toast.success({
message: t('form.addServer.success', { postProcess: 'sentenceCase' }),
});
if (localSettings && values.password) {
const saved = await localSettings.passwordSet(values.password, serverItem.id);
if (!saved) {
toast.error({
message: t('form.addServer.error', {
context: 'savePassword',
postProcess: 'sentenceCase',
}),
});
}
}
} catch (err: any) {
setIsLoading(false);
return toast.error({ message: err?.message });
}
return setIsLoading(false);
});
const isSubmitDisabled = !form.values.username || !form.values.password;
const serverIcon = SERVER_ICONS[serverType];
const serverDisplayName = SERVER_NAMES[serverType];
return (
<AnimatedPage>
<PageHeader />
<Center style={{ height: '100%', width: '100vw' }}>
<Paper p="xl" style={{ maxWidth: '400px', width: '100%' }}>
<form onSubmit={handleSubmit}>
<Stack gap="xl">
<Stack align="center" gap="md">
<img
alt={serverDisplayName}
height="80"
src={serverIcon}
width="80"
/>
<Text fw={600} size="xl">
{serverDisplayName}
</Text>
{serverName && (
<Text c="dimmed" size="sm">
{serverName}
</Text>
)}
</Stack>
<Stack gap="md">
<TextInput
data-autofocus
label={t('form.addServer.input', {
context: 'username',
postProcess: 'titleCase',
})}
required
variant="filled"
{...form.getInputProps('username')}
/>
<PasswordInput
label={t('form.addServer.input', {
context: 'password',
postProcess: 'titleCase',
})}
required
variant="filled"
{...form.getInputProps('password')}
/>
</Stack>
<Button
disabled={isSubmitDisabled}
fullWidth
loading={isLoading}
type="submit"
variant="filled"
>
{t('common.login', {
defaultValue: 'Login',
postProcess: 'titleCase',
})}
</Button>
</Stack>
</form>
</Paper>
</Center>
</AnimatedPage>
);
};
export default LoginRoute;
@@ -1,4 +1,5 @@
import { openModal } from '@mantine/modals';
import isElectron from 'is-electron';
import { Dispatch, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
@@ -9,6 +10,8 @@ import { AppRoute } from '/@/renderer/router/routes';
import { useAuthStoreActions, useServerList } from '/@/renderer/store';
import { ServerListItemWithCredential } from '/@/shared/types/domain-types';
const localSettings = isElectron() ? window.api.localSettings : null;
interface ServerCommandsProps {
handleClose: () => void;
setPages: (pages: CommandPalettePages[]) => void;
@@ -42,6 +45,8 @@ export const ServerCommands = ({ handleClose, setPages, setQuery }: ServerComman
[handleClose, navigate, setCurrentServer, setPages, setQuery],
);
const serverLock = localSettings?.env.SERVER_LOCK || false;
return (
<>
<Command.Group
@@ -54,11 +59,13 @@ export const ServerCommands = ({ handleClose, setPages, setQuery }: ServerComman
>{`${serverList[key].name}...`}</Command.Item>
))}
</Command.Group>
<Command.Group heading={t('common.manage', { postProcess: 'sentenceCase' })}>
<Command.Item onSelect={handleManageServersModal}>
{t('page.appMenu.manageServers', { postProcess: 'sentenceCase' })}...
</Command.Item>
</Command.Group>
{!serverLock && (
<Command.Group heading={t('common.manage', { postProcess: 'sentenceCase' })}>
<Command.Item onSelect={handleManageServersModal}>
{t('page.appMenu.manageServers', { postProcess: 'sentenceCase' })}...
</Command.Item>
</Command.Group>
)}
<Command.Separator />
</>
);
@@ -301,7 +301,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
})}
/>
)}
<Group grow>
<Group grow justify="flex-end">
{onCancel && (
<ModalButton onClick={onCancel}>{t('common.cancel')}</ModalButton>
)}
@@ -1,5 +1,6 @@
import { openModal } from '@mantine/modals';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
@@ -16,6 +17,8 @@ import { Icon } from '/@/shared/components/icon/icon';
import { ServerListItemWithCredential, ServerType } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
const localSettings = isElectron() ? window.api.localSettings : null;
export const ServerSelectorItems = () => {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -87,6 +90,8 @@ export const ServerSelectorItems = () => {
});
};
const serverLock = localSettings?.env.SERVER_LOCK || false;
return (
<>
<DropdownMenu.Label>
@@ -120,12 +125,14 @@ export const ServerSelectorItems = () => {
</DropdownMenu.Item>
);
})}
<DropdownMenu.Item
leftSection={<Icon icon="edit" />}
onClick={handleManageServersModal}
>
{t('page.appMenu.manageServers', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
{!serverLock && (
<DropdownMenu.Item
leftSection={<Icon icon="edit" />}
onClick={handleManageServersModal}
>
{t('page.appMenu.manageServers', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
)}
{musicFolders && musicFolders.items.length > 0 && (
<>
<DropdownMenu.Divider />
+9
View File
@@ -1,4 +1,5 @@
import { NuqsAdapter } from '@offlegacy/nuqs-hash-router';
import isElectron from 'is-electron';
import { useMemo } from 'react';
import { Navigate, Outlet } from 'react-router';
@@ -9,10 +10,14 @@ import { Center } from '/@/shared/components/center/center';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { AuthState } from '/@/shared/types/types';
const localSettings = isElectron() ? window.api.localSettings : null;
export const AppOutlet = () => {
const currentServer = useCurrentServer();
const authState = useServerAuthenticated();
const serverLock = localSettings?.env.SERVER_LOCK || false;
const isActionsRequired = useMemo(() => {
const isServerRequired = !currentServer;
@@ -30,6 +35,10 @@ export const AppOutlet = () => {
);
}
if (serverLock && !currentServer) {
return <Navigate replace to={AppRoute.LOGIN} />;
}
if (isActionsRequired || authState === AuthState.INVALID) {
return <Navigate replace to={AppRoute.ACTION_REQUIRED} />;
}
+3
View File
@@ -35,6 +35,8 @@ const InvalidRoute = lazy(
() => import('/@/renderer/features/action-required/routes/invalid-route'),
);
const LoginRoute = lazy(() => import('/@/renderer/features/login/routes/login-route'));
const HomeRoute = lazy(() => import('/@/renderer/features/home/routes/home-route'));
const ArtistListRoute = lazy(() => import('/@/renderer/features/artists/routes/artist-list-route'));
@@ -209,6 +211,7 @@ export const AppRouter = () => {
element={<ActionRequiredRoute />}
path={AppRoute.ACTION_REQUIRED}
/>
<Route element={<LoginRoute />} path={AppRoute.LOGIN} />
</Route>
</Route>
</Routes>
+1
View File
@@ -1,6 +1,7 @@
export enum AppRoute {
ACTION_REQUIRED = '/action-required',
EXPLORE = '/explore',
LOGIN = '/login',
FAKE_LIBRARY_ALBUM_DETAILS = '/library/albums/dummy/:albumId',
HOME = '/',
LIBRARY_ALBUM_ARTISTS = '/library/album-artists',