Move servers to separate feature/route

- Add deviceId to localstorage
- Add individual server auth to localstorage
This commit is contained in:
jeffvli
2022-06-04 17:08:35 -07:00
parent fe89ba21c2
commit ae87aea270
24 changed files with 428 additions and 197 deletions

View File

@@ -1,17 +1,15 @@
import { axios } from 'renderer/lib';
import { getServerUrl } from 'renderer/utils';
import { PingResponse, UserResponse } from './types';
const login = async (
server: string,
serverUrl: string,
body: {
password: string;
username: string;
}
) => {
const serverUrl = getServerUrl(server);
const { data } = await axios.post<UserResponse>(
`${serverUrl}/auth/login`,
`${serverUrl}/api/auth/login`,
body,
{
withCredentials: true,
@@ -21,9 +19,8 @@ const login = async (
return data;
};
const ping = async (server: string) => {
const serverUrl = getServerUrl(server);
const { data } = await axios.get<PingResponse>(`${serverUrl}/auth/ping`, {
const ping = async (serverUrl: string) => {
const { data } = await axios.get<PingResponse>(`${serverUrl}/api/auth/ping`, {
timeout: 2000,
});

View File

@@ -3,4 +3,3 @@ export * from './usersApi';
export * from './serversApi';
export * from './queries/useAlbum';
export * from './queryKeys';
export * from './types';

View File

@@ -1,12 +1,12 @@
import { axios } from 'renderer/lib';
import { ServerResponse, ServersResponse } from './types';
import { ServerResponse } from './types';
const getServers = async () => {
const { data } = await axios.get<ServersResponse>('/servers');
const { data } = await axios.get<ServerResponse[]>('/servers');
return data;
};
const create = async (body: {
const createServer = async (body: {
name: string;
remoteUserId: string;
token: string;
@@ -18,6 +18,6 @@ const create = async (body: {
};
export const serversApi = {
create,
createServer,
getServers,
};

View File

@@ -5,30 +5,20 @@ export type BaseResponse = {
statusCode: number;
};
export type ServerResponse = Server;
export type ServersResponse = Server[];
export type UserResponse = User;
export type UsersResponse = User[];
export type PingResponse = Ping;
export interface Server {
export type ServerResponse = {
createdAt: string;
id: number;
name: string;
remoteUserId: string;
serverFolder?: ServerFolder[];
serverFolder?: ServerFolderResponse[];
serverType: string;
token: string;
updatedAt: string;
url: string;
username: string;
}
};
export interface ServerFolder {
export type ServerFolderResponse = {
createdAt: string;
enabled: boolean;
id: number;
@@ -37,9 +27,9 @@ export interface ServerFolder {
remoteId: string;
serverId: number;
updatedAt: string;
}
};
export interface User {
export type UserResponse = {
createdAt: string;
enabled: boolean;
id: number;
@@ -47,10 +37,51 @@ export interface User {
password: string;
updatedAt: string;
username: string;
}
};
export interface Ping {
export type PingResponse = {
description: string;
name: string;
version: string;
}
};
export type GenreResponse = {
createdAt: string;
id: number;
name: string;
updatedAt: string;
};
export type AlbumResponse = {
albumArtistId: number;
createdAt: string;
date: string;
genres: GenreResponse[];
id: number;
name: string;
remoteCreatedAt: string;
remoteId: string;
serverFolderId: number;
songs: SongResponse[];
updatedAt: string;
year: number;
};
export type SongResponse = {
albumId: number;
artistName: null;
bitRate: number;
container: string;
createdAt: string;
date: string;
disc: number;
duration: number;
id: number;
name: string;
remoteCreatedAt: string;
remoteId: string;
serverFolderId: number;
track: number;
updatedAt: string;
year: number;
};

View File

@@ -1,19 +1,35 @@
import md5 from 'md5';
import { nanoid } from 'nanoid';
import { useMutation } from 'react-query';
import { authApi } from 'renderer/api';
import { useAppDispatch } from 'renderer/hooks';
import { login } from 'renderer/store/authSlice';
export const useLogin = (
server: string,
serverUrl: string,
body: {
password: string;
username: string;
}
) => {
const dispatch = useAppDispatch();
return useMutation({
mutationFn: () => authApi.login(server, body),
mutationFn: () => authApi.login(serverUrl, body),
onSuccess: () => {
dispatch(login(serverUrl));
if (!localStorage.getItem('device_id')) {
localStorage.setItem('device_id', nanoid());
}
localStorage.setItem(
'authentication',
JSON.stringify({ isAuthenticated: true, serverUrl: server })
JSON.stringify({
isAuthenticated: true,
key: md5(serverUrl),
serverUrl,
})
);
},
});

View File

@@ -4,7 +4,7 @@
margin: auto;
padding: 3rem;
background: rgba(50, 50, 50, 0.4);
border-radius: 5%;
border-radius: 5px;
}
.button {

View File

@@ -12,16 +12,13 @@ import { useDebouncedValue } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
import { AlertCircle, CircleCheck } from 'tabler-icons-react';
import { useAppDispatch } from 'renderer/hooks';
import { login } from 'renderer/store/authSlice';
import { getServerUrl } from 'renderer/utils';
import { normalizeServerUrl } from 'renderer/utils';
import { useLogin } from '../queries/useLogin';
import { usePingServer } from '../queries/usePingServer';
import styles from './LoginRoute.module.scss';
export const LoginRoute = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const [searchParams] = useSearchParams();
const [username, setUsername] = useState(searchParams.get('username') || '');
@@ -35,7 +32,7 @@ export const LoginRoute = () => {
mutate: handleLogin,
isLoading,
isError,
} = useLogin(server, {
} = useLogin(normalizeServerUrl(server), {
password,
username,
});
@@ -44,7 +41,7 @@ export const LoginRoute = () => {
isLoading: isCheckingServer,
isSuccess: isValidServer,
isFetched,
} = usePingServer(debouncedServer);
} = usePingServer(normalizeServerUrl(debouncedServer));
return (
<div className={styles.container}>
@@ -54,9 +51,7 @@ export const LoginRoute = () => {
e.preventDefault();
handleLogin(undefined, {
onError: () => {},
onSuccess: () => {
dispatch(login(getServerUrl(server)));
},
onSuccess: () => {},
});
}}
>

View File

@@ -0,0 +1,85 @@
import {
Button,
Checkbox,
Modal,
ModalProps,
PasswordInput,
SegmentedControl,
Stack,
TextInput,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useTranslation } from 'react-i18next';
import { useCreateServer, validateServer } from '../queries/useCreateServer';
export const AddServerModal = ({ ...rest }: ModalProps) => {
const { t } = useTranslation();
const form = useForm({
initialValues: {
legacyAuth: false,
name: '',
password: '',
serverType: 'jellyfin',
url: 'http://',
username: '',
},
});
const createServerMutation = useCreateServer();
return (
<Modal centered title={t('server.add.title')} {...rest}>
<form
onSubmit={form.onSubmit(async (values) => {
const res = await validateServer(values);
if (res?.token) {
createServerMutation.mutateAsync({
...values,
remoteUserId: res.userId,
token: res.token,
});
}
})}
>
<Stack>
<SegmentedControl
data={[
{ label: 'Jellyfin', value: 'jellyfin' },
{ label: 'Subsonic', value: 'subsonic' },
]}
{...form.getInputProps('serverType')}
/>
<TextInput
required
label={t('server.name')}
{...form.getInputProps('name')}
/>
<TextInput
required
label={t('server.url')}
{...form.getInputProps('url')}
/>
<TextInput
required
label={t('server.username')}
{...form.getInputProps('username')}
/>
<PasswordInput
required
label={t('server.password')}
{...form.getInputProps('password')}
/>
{form.getInputProps('serverType').value === 'subsonic' && (
<Checkbox
label={t('server.legacyauth')}
{...form.getInputProps('legacyAuth', { type: 'checkbox' })}
/>
)}
<Button type="submit">{t('server.submit')}</Button>
</Stack>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,63 @@
import {
Button,
Checkbox,
ModalProps,
PasswordInput,
SegmentedControl,
Stack,
TextInput,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useTranslation } from 'react-i18next';
import { ServerResponse } from 'renderer/api/types';
interface EditServerModalProps extends ModalProps {
server: ServerResponse | undefined;
}
export const EditServerModal = ({ server }: EditServerModalProps) => {
const { t } = useTranslation();
const form = useForm({
initialValues: {
legacyAuth: false,
name: server?.name,
password: '',
serverType: server?.serverType,
url: server?.url,
username: server?.username,
},
});
return (
<form onSubmit={form.onSubmit(async () => {})}>
<Stack>
<SegmentedControl
disabled
data={[
{ label: 'Jellyfin', value: 'jellyfin' },
{ label: 'Subsonic', value: 'subsonic' },
]}
{...form.getInputProps('serverType')}
/>
<TextInput label={t('server.name')} {...form.getInputProps('name')} />
<TextInput label={t('server.url')} {...form.getInputProps('url')} />
<TextInput
label={t('server.username')}
{...form.getInputProps('username')}
/>
<PasswordInput
label={t('server.password')}
{...form.getInputProps('password')}
/>
{form.getInputProps('serverType').value === 'subsonic' && (
<Checkbox
label={t('server.legacyauth')}
{...form.getInputProps('legacyAuth', { type: 'checkbox' })}
/>
)}
<Button type="submit">{t('server.submit')}</Button>
</Stack>
</form>
);
};

View File

@@ -0,0 +1,8 @@
.item {
display: flex;
justify-content: space-between;
max-width: 50vw;
margin: 1rem;
padding: 1rem;
outline: 1px #fff solid;
}

View File

@@ -0,0 +1,84 @@
import { useEffect, useState } from 'react';
import { Text } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { EditCircle } from 'tabler-icons-react';
import { ServerResponse } from 'renderer/api/types';
import { IconButton } from 'renderer/components';
import { useServers } from '../queries/useServers';
import { EditServerModal } from './EditServerModal';
import styles from './ServerList.module.scss';
export const ServerList = () => {
const { data: servers } = useServers();
const [editServerModal, editServerHandlers] = useDisclosure(false);
const [opened, setOpened] = useState(false);
const [selectedServer, setSelectedServer] = useState<
ServerResponse | undefined
>();
const handleClickServer = (server: ServerResponse) => {
setSelectedServer(server);
editServerHandlers.open();
};
const handleKeyDownServer = (
e: React.KeyboardEvent<HTMLDivElement>,
server: ServerResponse
) => {
if (e.key === ' ' || e.key === 'Enter') {
setSelectedServer(server);
editServerHandlers.open();
}
};
const handleCloseModal = () => {
setOpened(false);
editServerHandlers.close();
};
useEffect(() => {
if (editServerModal === true) {
setTimeout(() => setOpened(true));
} else {
setTimeout(() => setSelectedServer(undefined), 100);
}
}, [editServerModal]);
console.log('opened', opened);
return (
<>
{servers &&
servers.map((server) => (
<>
<div
className={styles.item}
role="button"
tabIndex={0}
onClick={() => handleClickServer(server)}
onKeyDown={(e) => handleKeyDownServer(e, server)}
>
<div>
{server.name}
<Text>Hello</Text>
</div>
<IconButton
icon={<EditCircle />}
onClick={() => editServerHandlers.toggle()}
>
Edit
</IconButton>
</div>
{selectedServer && (
<EditServerModal
opened={opened}
server={selectedServer}
onClose={handleCloseModal}
/>
)}
</>
))}
</>
);
};

View File

@@ -0,0 +1,5 @@
export * from './routes/ServersRoute';
export * from './queries/useCreateServer';
export * from './queries/useServers';
export * from './components/AddServerModal';
export * from './components/ServerList';

View File

@@ -61,9 +61,9 @@ export const validateServer = async (options: {
return null;
};
export const useCreateServers = () => {
export const useCreateServer = () => {
return useMutation({
mutationFn: serversApi.create,
mutationFn: serversApi.createServer,
onError: (e) => console.log(e),
onSuccess: (e) => console.log(e),
});

View File

@@ -0,0 +1,77 @@
import md5 from 'md5';
import { useQuery } from 'react-query';
import { queryKeys, serversApi } from 'renderer/api';
import { ServerFolderResponse } from 'renderer/api/types';
export interface ServerFolderAuth {
id: number;
locked: boolean;
serverId: number;
token: string;
type: string;
url: string;
userId: string;
username: string;
}
export const useServers = () => {
return useQuery({
onSuccess: (servers) => {
const { serverUrl } = JSON.parse(
localStorage.getItem('authentication') || '{}'
);
const storedServersKey = `servers_${md5(serverUrl)}`;
const serversFromLocalStorage = localStorage.getItem(storedServersKey);
// If a custom account/token is set for a server, use that instead of the default one
if (serversFromLocalStorage) {
const existingServers = JSON.parse(serversFromLocalStorage);
// The 'locked' property determines whether or not to skip updating the server auth
const skipped = existingServers.filter(
(server: ServerFolderAuth) => server.locked
);
const store = servers.flatMap((server) =>
server.serverFolder?.map((serverFolder: ServerFolderResponse) => {
if (skipped.includes(serverFolder.id)) {
return existingServers.find(
(s: ServerFolderAuth) => s.id === serverFolder.id
);
}
return {
id: serverFolder.id,
locked: false,
serverId: server.id,
token: server.token,
type: server.serverType,
url: server.url,
userId: server.remoteUserId,
username: server.username,
};
})
);
return localStorage.setItem(storedServersKey, JSON.stringify(store));
}
const store = servers.flatMap((server) =>
server.serverFolder?.map((serverFolder: ServerFolderResponse) => ({
id: serverFolder.id,
locked: false,
serverId: server.id,
token: server.token,
type: server.serverType,
url: server.url,
userId: server.remoteUserId,
username: server.username,
}))
);
return localStorage.setItem(storedServersKey, JSON.stringify(store));
},
queryFn: () => serversApi.getServers(),
queryKey: queryKeys.servers,
});
};

View File

@@ -0,0 +1,11 @@
import { Title } from '@mantine/core';
import { ServerList } from '../components/ServerList';
export const ServersRoute = () => {
return (
<div>
<Title>Servers</Title>
<ServerList />
</div>
);
};

View File

@@ -4,7 +4,7 @@ import { useNavigate } from 'react-router';
import { Logout, Server, Settings } from 'tabler-icons-react';
import { useAppDispatch } from 'renderer/hooks';
import { logout } from 'renderer/store/authSlice';
import { ServersModal } from './servers/components/ServersModal';
import { AddServerModal } from '../servers/components/AddServerModal';
import styles from './UserMenu.module.scss';
export const UserMenu = () => {
@@ -42,7 +42,7 @@ export const UserMenu = () => {
Logout
</Menu.Item>
</Menu>
<ServersModal
<AddServerModal
opened={addServerModal}
onClose={() => addServerHandlers.close()}
/>

View File

@@ -1,3 +0,0 @@
.container {
padding: 10px;
}

View File

@@ -1,23 +0,0 @@
import { Grid } from '@mantine/core';
import { Server } from 'server/types/types';
import styles from './ServerList.module.scss';
interface ServerListProps {
servers: Server[] | undefined;
}
export const ServerList = ({ servers }: ServerListProps) => {
return (
<>
{servers &&
servers.map((server) => (
<div className={styles.container}>
<Grid align="center" px="sm">
<Grid.Col span={9}>{server.name || server.url}</Grid.Col>
<Grid.Col span={3} />
</Grid>
</div>
))}
</>
);
};

View File

@@ -1,97 +0,0 @@
import {
Button,
Checkbox,
Modal,
ModalProps,
PasswordInput,
SegmentedControl,
Stack,
TextInput,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useBooleanToggle } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { useCreateServers, validateServer } from '../queries/useCreateServers';
import { useServers } from '../queries/useServers';
import { ServerList } from './ServerList';
export const ServersModal = ({ ...rest }: ModalProps) => {
const { t } = useTranslation();
const [createNew, toggleCreateNew] = useBooleanToggle(false);
const form = useForm({
initialValues: {
legacyAuth: false,
name: '',
password: '',
serverType: 'jellyfin',
url: 'http://',
username: '',
},
});
const { data } = useServers({});
const createServerMutation = useCreateServers();
return (
<Modal centered title={t('server.title')} {...rest}>
<ServerList servers={data} />
<Button variant="subtle" onClick={() => toggleCreateNew()}>
{t('server.new')}
</Button>
{createNew && (
<form
onSubmit={form.onSubmit(async (values) => {
const res = await validateServer(values);
if (res?.token) {
createServerMutation.mutateAsync({
...values,
remoteUserId: res.userId,
token: res.token,
});
}
})}
>
<Stack>
<SegmentedControl
data={[
{ label: 'Jellyfin', value: 'jellyfin' },
{ label: 'Subsonic', value: 'subsonic' },
]}
{...form.getInputProps('serverType')}
/>
<TextInput
required
label={t('server.name')}
{...form.getInputProps('name')}
/>
<TextInput
required
label={t('server.url')}
{...form.getInputProps('url')}
/>
<TextInput
required
label={t('server.username')}
{...form.getInputProps('username')}
/>
<PasswordInput
required
label={t('server.password')}
{...form.getInputProps('password')}
/>
{form.getInputProps('serverType').value === 'subsonic' && (
<Checkbox
label={t('server.legacyauth')}
{...form.getInputProps('legacyAuth', { type: 'checkbox' })}
/>
)}
<Button type="submit">{t('server.submit')}</Button>
</Stack>
</form>
)}
</Modal>
);
};

View File

@@ -1,18 +0,0 @@
import { useQuery } from 'react-query';
import { queryKeys } from 'renderer/api';
import { serversApi } from 'renderer/api/serversApi';
import { ExtractFnReturnType, QueryConfig } from 'renderer/lib';
type UseServersOptions = {
config?: QueryConfig<QueryFnType>;
};
type QueryFnType = typeof serversApi.getServers;
export const useServers = ({ config }: UseServersOptions) => {
return useQuery<ExtractFnReturnType<QueryFnType>>({
...config,
queryFn: () => serversApi.getServers(),
queryKey: queryKeys.servers,
});
};

View File

@@ -1,7 +1,9 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import md5 from 'md5';
export interface AuthState {
isAuthenticated: boolean;
key: string;
serverUrl: string;
}
@@ -11,6 +13,7 @@ const persistedAuthState = JSON.parse(
const initialState: AuthState = {
isAuthenticated: persistedAuthState.isAuthenticated,
key: persistedAuthState.key,
serverUrl: persistedAuthState.serverUrl,
};
@@ -21,6 +24,7 @@ export const authSlice = createSlice({
login: (state: AuthState, action: PayloadAction<string>) => {
state.isAuthenticated = true;
state.serverUrl = action.payload;
state.key = md5(action.payload);
},
logout: (state: AuthState) => {

View File

@@ -1,7 +0,0 @@
export const getServerUrl = (url: string) => {
if (url[url.length - 1] === '/') {
return `${url}api`;
}
return `${url}/api`;
};

View File

@@ -1,2 +1,2 @@
export * from './randomString';
export * from './getServerUrl';
export * from './normalizeServerUrl';

View File

@@ -0,0 +1,4 @@
export const normalizeServerUrl = (url: string) => {
// Remove trailing slash
return url.endsWith('/') ? url.slice(0, -1) : url;
};