diff --git a/client/package-lock.json b/client/package-lock.json
index b8c35fe..74d3f63 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -11286,6 +11286,14 @@
"resolved": "https://registry.npmjs.org/react-double-scrollbar/-/react-double-scrollbar-0.0.15.tgz",
"integrity": "sha1-6RWrjLO5WYdwdfSUNt6/2wQoj+Q="
},
+ "react-error-boundary": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.0.2.tgz",
+ "integrity": "sha512-KVzCusRTFpUYG0OFJbzbdRuxNQOBiGXVCqyNpBXM9z5NFsFLzMjUXMjx8gTja6M6WH+D2PvP3yKz4d8gD1PRaA==",
+ "requires": {
+ "@babel/runtime": "^7.11.2"
+ }
+ },
"react-error-overlay": {
"version": "6.0.7",
"resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.7.tgz",
diff --git a/client/package.json b/client/package.json
index 1fb3ac0..7c82c01 100644
--- a/client/package.json
+++ b/client/package.json
@@ -24,6 +24,7 @@
"react-dnd": "^11.1.3",
"react-dnd-html5-backend": "^11.1.3",
"react-dom": "^16.13.1",
+ "react-error-boundary": "^3.0.2",
"react-router-dom": "^5.2.0",
"react-scripts": "^3.4.3",
"typescript": "~3.7.2",
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 08a6107..45f7fa5 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -5,13 +5,12 @@ import { HTML5Backend } from 'react-dnd-html5-backend';
import MainWindow from './components/MainWindow';
import { ProvideAuth } from './lib/useAuth';
+import { ProvideIntegrations } from './lib/integration/useIntegrations';
function App() {
return (
-
-
-
+
);
}
diff --git a/client/src/api.ts b/client/src/api.ts
index 22bcb68..92333f1 100644
--- a/client/src/api.ts
+++ b/client/src/api.ts
@@ -364,7 +364,6 @@ export enum IntegrationType {
export interface SpotifyClientCredentialsDetails {
clientId: string,
- clientSecret: string,
}
export interface SpotifyClientCredentialsSecretDetails {
diff --git a/client/src/components/MainWindow.tsx b/client/src/components/MainWindow.tsx
index 32ae543..ff2110a 100644
--- a/client/src/components/MainWindow.tsx
+++ b/client/src/components/MainWindow.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useEffect } from 'react';
import { ThemeProvider, CssBaseline, createMuiTheme } from '@material-ui/core';
import { grey } from '@material-ui/core/colors';
import AppBar, { AppBarTab } from './appbar/AppBar';
@@ -8,11 +8,13 @@ import AlbumWindow from './windows/album/AlbumWindow';
import TagWindow from './windows/tag/TagWindow';
import SongWindow from './windows/song/SongWindow';
import ManageTagsWindow from './windows/manage_tags/ManageTagsWindow';
-import { BrowserRouter, Switch, Route, Redirect } from 'react-router-dom';
+import { BrowserRouter, Switch, Route, Redirect, useHistory } from 'react-router-dom';
import LoginWindow from './windows/login/LoginWindow';
-import { useAuth } from '../lib/useAuth';
+import { useAuth, ProvideAuth } from '../lib/useAuth';
import RegisterWindow from './windows/register/RegisterWindow';
import SettingsWindow from './windows/settings/SettingsWindow';
+import { ErrorBoundary } from 'react-error-boundary';
+import { ProvideIntegrations } from '../lib/integration/useIntegrations';
const darkTheme = createMuiTheme({
palette: {
@@ -45,48 +47,52 @@ function PrivateRoute(props: any) {
export default function MainWindow(props: any) {
return
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
}
\ No newline at end of file
diff --git a/client/src/components/windows/album/AlbumWindow.tsx b/client/src/components/windows/album/AlbumWindow.tsx
index 7d14329..28ef06d 100644
--- a/client/src/components/windows/album/AlbumWindow.tsx
+++ b/client/src/components/windows/album/AlbumWindow.tsx
@@ -12,6 +12,8 @@ import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryAlbums, querySongs } from '../../../lib/backend/queries';
import { songGetters } from '../../../lib/songGetters';
import { useParams } from 'react-router';
+import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request';
+import { useAuth } from '../../../lib/useAuth';
export type AlbumMetadata = serverApi.AlbumDetails;
export type AlbumMetadataChanges = serverApi.ModifyAlbumRequest;
@@ -55,7 +57,8 @@ export async function getAlbumMetadata(id: number) {
},
offset: 0,
limit: 1,
- }))[0];
+ })
+ )[0];
}
export default function AlbumWindow(props: {}) {
@@ -77,6 +80,7 @@ export function AlbumWindowControlled(props: {
}) {
let { id: albumId, metadata, pendingChanges, songsOnAlbum } = props.state;
let { dispatch } = props;
+ let auth = useAuth();
// Effect to get the album's metadata.
useEffect(() => {
@@ -87,6 +91,7 @@ export function AlbumWindowControlled(props: {
value: m
});
})
+ .catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) })
}, [albumId, dispatch]);
// Effect to get the album's songs.
@@ -102,7 +107,8 @@ export function AlbumWindowControlled(props: {
},
offset: 0,
limit: -1,
- });
+ })
+ .catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) });
dispatch({
type: AlbumWindowStateActions.SetSongs,
value: songs,
@@ -153,6 +159,7 @@ export function AlbumWindowControlled(props: {
type: AlbumWindowStateActions.Reload
})
})
+ .catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) })
}} />
{applying && }
diff --git a/client/src/components/windows/artist/ArtistWindow.tsx b/client/src/components/windows/artist/ArtistWindow.tsx
index f9008cc..513a556 100644
--- a/client/src/components/windows/artist/ArtistWindow.tsx
+++ b/client/src/components/windows/artist/ArtistWindow.tsx
@@ -12,6 +12,8 @@ import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryArtists, querySongs } from '../../../lib/backend/queries';
import { songGetters } from '../../../lib/songGetters';
import { useParams } from 'react-router';
+import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request';
+import { useAuth } from '../../../lib/useAuth';
export type ArtistMetadata = serverApi.ArtistDetails;
export type ArtistMetadataChanges = serverApi.ModifyArtistRequest;
@@ -82,6 +84,7 @@ export function ArtistWindowControlled(props: {
}) {
let { metadata, id: artistId, pendingChanges, songsByArtist } = props.state;
let { dispatch } = props;
+ let auth = useAuth();
// Effect to get the artist's metadata.
useEffect(() => {
@@ -92,6 +95,7 @@ export function ArtistWindowControlled(props: {
value: m
});
})
+ .catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) })
}, [artistId, dispatch]);
// Effect to get the artist's songs.
@@ -107,7 +111,8 @@ export function ArtistWindowControlled(props: {
},
offset: 0,
limit: -1,
- });
+ })
+ .catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) });
dispatch({
type: ArtistWindowStateActions.SetSongs,
value: songs,
@@ -158,6 +163,7 @@ export function ArtistWindowControlled(props: {
type: ArtistWindowStateActions.Reload
})
})
+ .catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) })
}} />
{applying && }
diff --git a/client/src/components/windows/manage_tags/ManageTagsWindow.tsx b/client/src/components/windows/manage_tags/ManageTagsWindow.tsx
index 9e4dd7e..e6e6c62 100644
--- a/client/src/components/windows/manage_tags/ManageTagsWindow.tsx
+++ b/client/src/components/windows/manage_tags/ManageTagsWindow.tsx
@@ -11,6 +11,8 @@ import NewTagMenu from './NewTagMenu';
import { v4 as genUuid } from 'uuid';
import Alert from '@material-ui/lab/Alert';
import { useHistory } from 'react-router';
+import { NotLoggedInError, handleNotLoggedIn } from '../../../lib/backend/request';
+import { useAuth } from '../../../lib/useAuth';
var _ = require('lodash');
export interface ManageTagsWindowState extends WindowState {
@@ -355,6 +357,7 @@ export function ManageTagsWindowControlled(props: {
const [newTagMenuPos, setNewTagMenuPos] = React.useState(null);
let { fetchedTags } = props.state;
let { dispatch } = props;
+ let auth = useAuth();
const onOpenNewTagMenu = (e: any) => {
setNewTagMenuPos([e.clientX, e.clientY])
@@ -422,7 +425,9 @@ export function ManageTagsWindowControlled(props: {
props.dispatch({
type: ManageTagsWindowActions.Reset
});
- }).catch((e: Error) => {
+ })
+ .catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) })
+ .catch((e: Error) => {
props.dispatch({
type: ManageTagsWindowActions.SetAlert,
value: Failed to save changes: {e.message},
diff --git a/client/src/components/windows/settings/IntegrationSettings.tsx b/client/src/components/windows/settings/IntegrationSettings.tsx
new file mode 100644
index 0000000..dfb7ade
--- /dev/null
+++ b/client/src/components/windows/settings/IntegrationSettings.tsx
@@ -0,0 +1,330 @@
+import React, { useState, useEffect } from 'react';
+import { Box, CircularProgress, IconButton, Typography, MenuItem, TextField, Menu, Button, Card, CardHeader, CardContent, CardActions, Dialog, DialogTitle } from '@material-ui/core';
+import { getIntegrations, createIntegration, modifyIntegration, deleteIntegration } from '../../../lib/backend/integrations';
+import AddIcon from '@material-ui/icons/Add';
+import EditIcon from '@material-ui/icons/Edit';
+import CheckIcon from '@material-ui/icons/Check';
+import DeleteIcon from '@material-ui/icons/Delete';
+import ClearIcon from '@material-ui/icons/Clear';
+import * as serverApi from '../../../api';
+import { v4 as genUuid } from 'uuid';
+import { useIntegrations, IntegrationClasses, IntegrationState, isIntegrationState, makeDefaultIntegrationProperties, makeIntegration } from '../../../lib/integration/useIntegrations';
+import Alert from '@material-ui/lab/Alert';
+import Integration from '../../../lib/integration/Integration';
+let _ = require('lodash')
+
+// This widget is used to either display or edit a few
+// specifically needed for Spotify Client credentials integration.
+function EditSpotifyClientCredentialsDetails(props: {
+ clientId: string,
+ clientSecret: string | null,
+ editing: boolean,
+ onChangeClientId: (v: string) => void,
+ onChangeClientSecret: (v: string) => void,
+}) {
+ return
+
+ props.onChangeClientId(e.target.value)}
+ />
+
+
+ {
+ props.onChangeClientSecret(e.target.value)
+ }}
+ onFocus={(e: any) => {
+ if (props.clientSecret === null) {
+ // Change from dots to empty input
+ console.log("Focus!")
+ props.onChangeClientSecret('');
+ }
+ }}
+ />
+
+ ;
+}
+
+// An editing widget which is meant to either display or edit properties
+// of an integration.
+function EditIntegration(props: {
+ upstreamId?: number,
+ integration: serverApi.CreateIntegrationRequest,
+ editing?: boolean,
+ showSubmitButton?: boolean | "InProgress",
+ showDeleteButton?: boolean | "InProgress",
+ showEditButton?: boolean,
+ showTestButton?: boolean | "InProgress",
+ showCancelButton?: boolean,
+ flashMessage?: React.ReactFragment,
+ isNew: boolean,
+ onChange?: (p: serverApi.CreateIntegrationRequest) => void,
+ onSubmit?: (p: serverApi.CreateIntegrationRequest) => void,
+ onDelete?: () => void,
+ onEdit?: () => void,
+ onTest?: () => void,
+ onCancel?: () => void,
+}) {
+ let IntegrationHeaders: Record = {
+ [serverApi.IntegrationType.SpotifyClientCredentials]:
+
+
+ {IntegrationClasses[serverApi.IntegrationType.SpotifyClientCredentials].getIcon({
+ style: { height: '40px', width: '40px' }
+ })}
+
+ Spotify (using Client Credentials)
+
+ }
+ let IntegrationDescription: Record = {
+ [serverApi.IntegrationType.SpotifyClientCredentials]:
+
+ This integration allows using the Spotify API to make requests that are
+ tied to any specific user, such as searching items and retrieving item
+ metadata.
+ Please see the Spotify API documentation on how to generate a client ID
+ and client secret. Once set, you will only be able to overwrite the secret
+ here, not read it.
+
+ }
+
+ return
+
+
+
+ {IntegrationDescription[props.integration.type]}
+
+ props.onChange && props.onChange({
+ ...props.integration,
+ name: e.target.value,
+ })}
+ />
+
+ {props.integration.type === serverApi.IntegrationType.SpotifyClientCredentials &&
+ props.onChange && props.onChange({
+ ...props.integration,
+ details: {
+ ...props.integration.details,
+ clientId: v,
+ }
+ })}
+ onChangeClientSecret={(v: string) => props.onChange && props.onChange({
+ ...props.integration,
+ secretDetails: {
+ ...props.integration.secretDetails,
+ clientSecret: v,
+ }
+ })}
+ />
+ }
+ {props.flashMessage && props.flashMessage}
+
+
+ {props.showEditButton && }
+ {props.showSubmitButton && props.onSubmit && props.onSubmit(props.integration)}
+ >}
+ {props.showDeleteButton && }
+ {props.showCancelButton && }
+ {props.showTestButton && }
+
+
+}
+
+let EditorWithTest = (props: any) => {
+ const [testFlashMessage, setTestFlashMessage] =
+ React.useState(undefined);
+ let { integration, ...rest } = props;
+ return {
+ integration.integration.test({})
+ .then(() => {
+ setTestFlashMessage(
+ Integration is active.
+ )
+ })
+ }}
+ flashMessage={testFlashMessage}
+ showTestButton={true}
+ integration={integration.properties}
+ {...rest}
+ />;
+}
+
+function AddIntegrationMenu(props: {
+ position: null | number[],
+ open: boolean,
+ onClose?: () => void,
+ onAdd?: (type: serverApi.IntegrationType) => void,
+}) {
+ const pos = props.open && props.position ?
+ { left: props.position[0], top: props.position[1] }
+ : { left: 0, top: 0 }
+
+ return
+}
+
+function EditIntegrationDialog(props: {
+ open: boolean,
+ onClose?: () => void,
+ upstreamId?: number,
+ integration: IntegrationState,
+ onSubmit?: (p: serverApi.CreateIntegrationRequest) => void,
+ isNew: boolean,
+}) {
+ let [editingIntegration, setEditingIntegration] =
+ useState(props.integration);
+
+ useEffect(() => { setEditingIntegration(props.integration); }, [props.integration]);
+
+ return
+}
+
+export default function IntegrationSettings(props: {}) {
+ const [addMenuPos, setAddMenuPos] = React.useState(null);
+ const [editingState, setEditingState] = React.useState(null);
+
+ let {
+ state: integrations,
+ addIntegration,
+ modifyIntegration,
+ deleteIntegration,
+ updateFromUpstream,
+ } = useIntegrations();
+
+ const onOpenAddMenu = (e: any) => {
+ setAddMenuPos([e.clientX, e.clientY])
+ };
+ const onCloseAddMenu = () => {
+ setAddMenuPos(null);
+ };
+
+ return <>
+
+ {integrations === null && }
+ {Array.isArray(integrations) &&
+ {integrations.map((state: IntegrationState) =>
+ { setEditingState(state); }}
+ onDelete={() => {
+ deleteIntegration(state.id)
+ .then(updateFromUpstream)
+ }}
+ />
+ )}
+
+
+
+ }
+
+ {
+ let p = makeDefaultIntegrationProperties(type);
+ setEditingState({
+ properties: p,
+ integration: makeIntegration(p, -1),
+ id: -1,
+ })
+ }}
+ />
+ {editingState && { setEditingState(null); }}
+ integration={editingState}
+ isNew={editingState.id === -1}
+ onSubmit={(v: serverApi.CreateIntegrationRequest) => {
+ if (editingState.id >= 0) {
+ const id = editingState.id;
+ setEditingState(null);
+ modifyIntegration(id, v)
+ .then(updateFromUpstream)
+ } else {
+ setEditingState(null);
+ createIntegration({
+ ...v,
+ secretDetails: v.secretDetails || {},
+ })
+ .then(updateFromUpstream)
+ }
+ }}
+ />}
+ >;
+}
\ No newline at end of file
diff --git a/client/src/components/windows/settings/IntegrationSettingsEditor.tsx b/client/src/components/windows/settings/IntegrationSettingsEditor.tsx
deleted file mode 100644
index ad58bc1..0000000
--- a/client/src/components/windows/settings/IntegrationSettingsEditor.tsx
+++ /dev/null
@@ -1,352 +0,0 @@
-import React, { useState, useEffect, useCallback } from 'react';
-import { useAuth } from '../../../lib/useAuth';
-import { Box, CircularProgress, IconButton, Typography, FormControl, Select, MenuItem, TextField, Menu, Button, Card, CardHeader, CardContent, CardActions } from '@material-ui/core';
-import { getIntegrations, createIntegration, modifyIntegration, deleteIntegration } from '../../../lib/backend/integrations';
-import AddIcon from '@material-ui/icons/Add';
-import EditIcon from '@material-ui/icons/Edit';
-import CheckIcon from '@material-ui/icons/Check';
-import DeleteIcon from '@material-ui/icons/Delete';
-import * as serverApi from '../../../api';
-import StoreLinkIcon, { ExternalStore } from '../../common/StoreLinkIcon';
-import { v4 as genUuid } from 'uuid';
-import { testSpotify } from '../../../lib/integration/spotify/spotifyClientCreds';
-let _ = require('lodash')
-
-interface EditorIntegrationState extends serverApi.IntegrationDetailsResponse {
- secretDetails?: any,
-}
-
-interface EditIntegrationProps {
- upstreamId: number | null,
- integration: EditorIntegrationState,
- original: EditorIntegrationState,
- editing: boolean,
- submitting: boolean,
- onChange: (p: EditorIntegrationState, editing: boolean) => void,
- onSubmit: () => void,
- onDelete: () => void,
-}
-
-function EditSpotifyClientCredentialsDetails(props: {
- clientId: string,
- clientSecret: string | null,
- editing: boolean,
- onChangeClientId: (v: string) => void,
- onChangeClientSecret: (v: string) => void,
-}) {
- return
-
- props.onChangeClientId(e.target.value)}
- />
-
-
- {
- props.onChangeClientSecret(e.target.value)
- }}
- onFocus={(e: any) => {
- if(props.clientSecret === null) {
- // Change from dots to empty input
- console.log("Focus!")
- props.onChangeClientSecret('');
- }
- }}
- />
-
- ;
-}
-
-function EditIntegration(props: EditIntegrationProps) {
- let IntegrationHeaders: Record = {
- [serverApi.IntegrationType.SpotifyClientCredentials]:
-
-
- Spotify (using Client Credentials)
-
- }
- let IntegrationDescription: Record = {
- [serverApi.IntegrationType.SpotifyClientCredentials]:
-
- This integration allows using the Spotify API to make requests that are
- tied to any specific user, such as searching items and retrieving item
- metadata.
- Please see the Spotify API documentation on how to generate a client ID
- and client secret. Once set, you will only be able to overwrite the secret
- here, not read it.
-
- }
-
- return
-
-
-
- {IntegrationDescription[props.integration.type]}
-
- props.onChange({
- ...props.integration,
- name: e.target.value,
- }, props.editing)}
- />
-
- {props.integration.type === serverApi.IntegrationType.SpotifyClientCredentials &&
- props.onChange({
- ...props.integration,
- details: {
- ...props.integration.details,
- clientId: v,
- }
- }, props.editing)}
- onChangeClientSecret={(v: string) => props.onChange({
- ...props.integration,
- secretDetails: {
- ...props.integration.secretDetails,
- clientSecret: v,
- }
- }, props.editing)}
- />
- }
-
-
- {!props.editing && !props.submitting && { props.onChange(props.integration, true); }}
- >}
- {props.editing && !props.submitting && { props.onSubmit(); }}
- >}
- {!props.submitting && { props.onDelete(); }}
- >}
- {!props.submitting && !props.editing && props.upstreamId !== null && }
- {props.submitting && }
-
-
-}
-
-function AddIntegrationMenu(props: {
- position: null | number[],
- open: boolean,
- onClose: () => void,
- onAdd: (type: serverApi.IntegrationType) => void,
-}) {
- const pos = props.open && props.position ?
- { left: props.position[0], top: props.position[1] }
- : { left: 0, top: 0 }
-
- return
-}
-
-export default function IntegrationSettingsEditor(props: {}) {
- interface EditorState {
- id: string, //uniquely identifies this editor in the window.
- upstreamId: number | null, //back-end ID for this integration if any.
- integration: EditorIntegrationState,
- original: EditorIntegrationState,
- editing: boolean,
- submitting: boolean,
- }
- let [editors, setEditors] = useState(null);
- const [addMenuPos, setAddMenuPos] = React.useState(null);
-
- const onOpenAddMenu = (e: any) => {
- setAddMenuPos([e.clientX, e.clientY])
- };
- const onCloseAddMenu = () => {
- setAddMenuPos(null);
- };
-
- const submitEditor = (state: EditorState) => {
- let integration: any = state.integration;
-
- if (state.upstreamId === null) {
- if (!state.integration.secretDetails) {
- throw new Error('Cannot create an integration without its secret details set.')
- }
- createIntegration(integration).then((response: any) => {
- if (!response.id) {
- throw new Error('failed to submit integration.')
- }
- let cpy = _.cloneDeep(editors);
- cpy.forEach((s: any) => {
- if (s.id === state.id) {
- s.submitting = false;
- s.editing = false;
- s.upstreamId = response.id;
- }
- })
- setEditors(cpy);
- })
- } else {
- modifyIntegration(state.upstreamId, integration).then(() => {
- let cpy = _.cloneDeep(editors);
- cpy.forEach((s: any) => {
- if (s.id === state.id) {
- s.submitting = false;
- s.editing = false;
- }
- })
- setEditors(cpy);
- })
- }
- }
-
- const deleteEditor = (state: EditorState) => {
- let promise: Promise = state.upstreamId ?
- deleteIntegration(state.upstreamId) :
- (async () => { })();
-
- promise.then((response: any) => {
- let cpy = _.cloneDeep(editors).filter(
- (e: any) => e.id !== state.id
- );
- setEditors(cpy);
- })
- }
-
- useEffect(() => {
- getIntegrations()
- .then((integrations: serverApi.ListIntegrationsResponse) => {
- setEditors(integrations.map((i: any, idx: any) => {
- return {
- integration: { ...i },
- original: { ...i },
- id: genUuid(),
- editing: false,
- submitting: false,
- upstreamId: i.id,
- }
- }));
- });
- }, []);
-
- return <>
-
- {editors === null && }
- {editors &&
- {editors.map((state: EditorState) =>
- {
- if (!editors) {
- throw new Error('cannot change editors before loading integrations.')
- }
- let cpy: EditorState[] = _.cloneDeep(editors);
- cpy.forEach((s: any) => {
- if (s.id === state.id) {
- s.integration = p;
- s.editing = editing;
- }
- })
- setEditors(cpy);
- }}
- onSubmit={() => {
- if (!editors) {
- throw new Error('cannot submit editors before loading integrations.')
- }
- let cpy: EditorState[] = _.cloneDeep(editors);
- cpy.forEach((s: EditorState) => {
- if (s.id === state.id) {
- s.submitting = true;
- s.integration.secretDetails = undefined;
- }
- })
- setEditors(cpy);
- submitEditor(state);
- }}
- onDelete={() => {
- if (!editors) {
- throw new Error('cannot submit editors before loading integrations.')
- }
- let cpy: EditorState[] = _.cloneDeep(editors);
- cpy.forEach((s: any) => {
- if (s.id === state.id) {
- s.submitting = true;
- }
- })
- setEditors(cpy);
- deleteEditor(state);
- }}
- />
- )}
-
-
-
- }
-
- {
- let cpy = _.cloneDeep(editors);
- cpy.push({
- integration: {
- type: serverApi.IntegrationType.SpotifyClientCredentials,
- details: {
- clientId: '',
- },
- secretDetails: {
- clientSecret: '',
- },
- name: '',
- },
- original: null,
- id: genUuid(),
- editing: true,
- submitting: false,
- upstreamId: null,
- })
- setEditors(cpy);
- }}
- />
- >;
-}
\ No newline at end of file
diff --git a/client/src/components/windows/settings/SettingsWindow.tsx b/client/src/components/windows/settings/SettingsWindow.tsx
index 02f0975..1fe79aa 100644
--- a/client/src/components/windows/settings/SettingsWindow.tsx
+++ b/client/src/components/windows/settings/SettingsWindow.tsx
@@ -5,7 +5,7 @@ import { useHistory } from 'react-router';
import { useAuth, Auth } from '../../../lib/useAuth';
import Alert from '@material-ui/lab/Alert';
import { Link } from 'react-router-dom';
-import IntegrationSettingsEditor from './IntegrationSettingsEditor';
+import IntegrationSettingsEditor from './IntegrationSettings';
export enum SettingsTab {
Integrations = 0,
diff --git a/client/src/lib/backend/integrations.tsx b/client/src/lib/backend/integrations.tsx
index 04213fe..f4468ff 100644
--- a/client/src/lib/backend/integrations.tsx
+++ b/client/src/lib/backend/integrations.tsx
@@ -1,4 +1,6 @@
import * as serverApi from '../../api';
+import { useAuth } from '../useAuth';
+import backendRequest from './request';
export async function createIntegration(details: serverApi.CreateIntegrationRequest) {
const requestOpts = {
@@ -7,10 +9,11 @@ export async function createIntegration(details: serverApi.CreateIntegrationRequ
body: JSON.stringify(details),
};
- const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.CreateIntegrationEndpoint, requestOpts)
+ const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.CreateIntegrationEndpoint, requestOpts)
if (!response.ok) {
throw new Error("Response to integration creation not OK: " + JSON.stringify(response));
}
+
return await response.json();
}
@@ -21,10 +24,11 @@ export async function modifyIntegration(id: number, details: serverApi.ModifyInt
body: JSON.stringify(details),
};
- const response = await fetch(
+ const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.ModifyIntegrationEndpoint.replace(':id', id.toString()),
requestOpts
);
+
if (!response.ok) {
throw new Error("Response to integration modification not OK: " + JSON.stringify(response));
}
@@ -35,7 +39,7 @@ export async function deleteIntegration(id: number) {
method: 'DELETE',
};
- const response = await fetch(
+ const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.DeleteIntegrationEndpoint.replace(':id', id.toString()),
requestOpts
);
@@ -49,7 +53,7 @@ export async function getIntegrations() {
method: 'GET',
};
- const response = await fetch(
+ const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.ListIntegrationsEndpoint,
requestOpts
);
diff --git a/client/src/lib/backend/queries.tsx b/client/src/lib/backend/queries.tsx
index d93ef40..d6c1aa9 100644
--- a/client/src/lib/backend/queries.tsx
+++ b/client/src/lib/backend/queries.tsx
@@ -1,5 +1,6 @@
import * as serverApi from '../../api';
import { QueryElem, toApiQuery } from '../query/Query';
+import backendRequest from './request';
export interface QueryArgs {
query?: QueryElem,
@@ -29,7 +30,7 @@ export async function queryArtists(args: QueryArgs) {
};
return (async () => {
- const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
+ const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
return json.artists;
})();
@@ -57,7 +58,7 @@ export async function queryAlbums(args: QueryArgs) {
};
return (async () => {
- const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
+ const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
return json.albums;
})();
@@ -85,7 +86,7 @@ export async function querySongs(args: QueryArgs) {
};
return (async () => {
- const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
+ const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
return json.songs;
})();
@@ -113,7 +114,7 @@ export async function queryTags(args: QueryArgs) {
};
return (async () => {
- const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
+ const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts);
let json: any = await response.json();
const tags = json.tags;
diff --git a/client/src/lib/backend/request.tsx b/client/src/lib/backend/request.tsx
new file mode 100644
index 0000000..b5fa5f5
--- /dev/null
+++ b/client/src/lib/backend/request.tsx
@@ -0,0 +1,24 @@
+import { ResponsiveFontSizesOptions } from "@material-ui/core/styles/responsiveFontSizes";
+import { useHistory } from "react-router";
+import { Auth } from "../useAuth";
+
+export class NotLoggedInError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "NotLoggedInError";
+ }
+}
+
+export default async function backendRequest(url: any, ...restArgs: any[]): Promise {
+ let response = await fetch(url, ...restArgs);
+ if (response.status === 401 && (await response.json()).reason === "NotLoggedIn") {
+ console.log("Not logged in!")
+ throw new NotLoggedInError("Not logged in.");
+ }
+ return response;
+}
+
+export function handleNotLoggedIn(auth: Auth, e: NotLoggedInError) {
+ console.log("Not logged in!")
+ auth.signout();
+}
\ No newline at end of file
diff --git a/client/src/lib/backend/tags.tsx b/client/src/lib/backend/tags.tsx
index 67146e9..0c3ea72 100644
--- a/client/src/lib/backend/tags.tsx
+++ b/client/src/lib/backend/tags.tsx
@@ -1,4 +1,5 @@
import * as serverApi from '../../api';
+import backendRequest from './request';
export async function createTag(details: serverApi.CreateTagRequest) {
const requestOpts = {
@@ -7,7 +8,7 @@ export async function createTag(details: serverApi.CreateTagRequest) {
body: JSON.stringify(details),
};
- const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.CreateTagEndpoint, requestOpts)
+ const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.CreateTagEndpoint, requestOpts)
if (!response.ok) {
throw new Error("Response to tag creation not OK: " + JSON.stringify(response));
}
@@ -21,7 +22,7 @@ export async function modifyTag(id: number, details: serverApi.ModifyTagRequest)
body: JSON.stringify(details),
};
- const response = await fetch(
+ const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.ModifyTagEndpoint.replace(':id', id.toString()),
requestOpts
);
@@ -35,7 +36,7 @@ export async function deleteTag(id: number) {
method: 'DELETE',
};
- const response = await fetch(
+ const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.DeleteTagEndpoint.replace(':id', id.toString()),
requestOpts
);
@@ -49,7 +50,7 @@ export async function mergeTag(fromId: number, toId: number) {
method: 'POST',
};
- const response = await fetch(
+ const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.MergeTagEndpoint
.replace(':id', fromId.toString())
.replace(':toId', toId.toString()),
diff --git a/client/src/lib/integration/Integration.tsx b/client/src/lib/integration/Integration.tsx
new file mode 100644
index 0000000..441262f
--- /dev/null
+++ b/client/src/lib/integration/Integration.tsx
@@ -0,0 +1,59 @@
+import React, { ReactFragment } from 'react';
+
+export interface IntegrationAlbum {
+ name?: string,
+ artist?: IntegrationArtist,
+ url?: string, // An URL to access the item externally.
+}
+
+export interface IntegrationArtist {
+ name?: string,
+ url?: string, // An URL to access the item externally.
+}
+
+export interface IntegrationSong {
+ title?: string,
+ album?: IntegrationAlbum,
+ artist?: IntegrationArtist,
+ url?: string, // An URL to access the item externally.
+}
+
+export enum IntegrationFeature {
+ // Used to test whether the integration is active.
+ Test = 0,
+
+ // Used to get a bucket of songs (typically: the whole library)
+ GetSongs,
+
+ // Used to search items and get some amount of candidate results.
+ SearchSong,
+ SearchAlbum,
+ SearchArtist,
+}
+
+export interface IntegrationDescriptor {
+ supports: IntegrationFeature[],
+}
+
+export default class Integration {
+ constructor(integrationId: number) { }
+
+ // Common
+ static getFeatures(): IntegrationFeature[] { return []; }
+ static getIcon(props: any): ReactFragment { return <>> }
+
+ // Requires feature: Test
+ async test(testParams: any): Promise {}
+
+ // Requires feature: GetSongs
+ async getSongs(getSongsParams: any): Promise { return []; }
+
+ // Requires feature: SearchSongs
+ async searchSong(songProps: IntegrationSong): Promise { return []; }
+
+ // Requires feature: SearchAlbum
+ async searchAlbum(albumProps: IntegrationAlbum): Promise { return []; }
+
+ // Requires feature: SearchArtist
+ async searchArtist(artistProps: IntegrationArtist): Promise { return []; }
+}
\ No newline at end of file
diff --git a/client/src/lib/integration/spotify/SpotifyClientCreds.tsx b/client/src/lib/integration/spotify/SpotifyClientCreds.tsx
new file mode 100644
index 0000000..d211907
--- /dev/null
+++ b/client/src/lib/integration/spotify/SpotifyClientCreds.tsx
@@ -0,0 +1,95 @@
+import React from 'react';
+import Integration, { IntegrationFeature, IntegrationAlbum, IntegrationArtist, IntegrationSong } from '../Integration';
+import StoreLinkIcon, { ExternalStore } from '../../../components/common/StoreLinkIcon';
+
+enum SearchType {
+ Song = 'song',
+ Artist = 'artist',
+ Album = 'album',
+};
+
+export default class SpotifyClientCreds extends Integration {
+ integrationId: number;
+
+ constructor(integrationId: number) {
+ super(integrationId);
+ this.integrationId = integrationId;
+ }
+
+ static getFeatures(): IntegrationFeature[] {
+ return [
+ IntegrationFeature.Test,
+ IntegrationFeature.SearchSong,
+ IntegrationFeature.SearchAlbum,
+ IntegrationFeature.SearchArtist,
+ ]
+ }
+
+ static getIcon(props: any) {
+ return
+ }
+
+ async test(testParams: {}) {
+ const response = await fetch(
+ (process.env.REACT_APP_BACKEND || "") +
+ `/integrations/${this.integrationId}/v1/search?q=queens&type=artist`);
+
+ if (!response.ok) {
+ throw new Error("Spttify Client Credentails test failed: " + JSON.stringify(response));
+ }
+ }
+
+ async searchSong(songProps: IntegrationSong): Promise { return []; }
+ async searchAlbum(albumProps: IntegrationAlbum): Promise { return []; }
+ async searchArtist(artistProps: IntegrationArtist): Promise { return []; }
+
+ async search(query: string, type: SearchType):
+ Promise {
+ const response = await fetch(
+ (process.env.REACT_APP_BACKEND || "") +
+ `/integrations/${this.integrationId}/v1/search?q=${encodeURIComponent(query)}&type=${type}`);
+
+ if (!response.ok) {
+ throw new Error("Spotify Client Credentails search failed: " + JSON.stringify(response));
+ }
+
+ switch(type) {
+ case SearchType.Song: {
+ return (await response.json()).tracks.items.map((r: any): IntegrationSong => {
+ return {
+ title: r.name,
+ url: r.external_urls.spotify,
+ artist: {
+ name: r.artists[0].name,
+ url: r.artists[0].external_urls.spotify,
+ },
+ album: {
+ name: r.albums[0].name,
+ url: r.albums[0].external_urls.spotify,
+ }
+ }
+ })
+ }
+ case SearchType.Artist: {
+ return (await response.json()).artists.items.map((r: any): IntegrationArtist => {
+ return {
+ name: r.name,
+ url: r.external_urls.spotify,
+ }
+ })
+ }
+ case SearchType.Album: {
+ return (await response.json()).albums.items.map((r: any): IntegrationAlbum => {
+ return {
+ name: r.name,
+ url: r.external_urls.spotify,
+ artist: {
+ name: r.artists[0].name,
+ url: r.artists[0].external_urls.spotify,
+ },
+ }
+ })
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/client/src/lib/integration/spotify/spotifyClientCreds.tsx b/client/src/lib/integration/spotify/spotifyClientCreds.tsx
deleted file mode 100644
index 82f88a1..0000000
--- a/client/src/lib/integration/spotify/spotifyClientCreds.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-export async function testSpotify(integrationId: number) {
- const requestOpts = {
- method: 'GET',
- };
-
- const response = await fetch(
- (process.env.REACT_APP_BACKEND || "") + `/integrations/${integrationId}/v1/search?q=queens&type=artist`,
- requestOpts
- );
- if (!response.ok) {
- throw new Error("Response to tag merge not OK: " + JSON.stringify(response));
- }
- console.log("Spotify response: ", response);
-}
\ No newline at end of file
diff --git a/client/src/lib/integration/useIntegrations.tsx b/client/src/lib/integration/useIntegrations.tsx
new file mode 100644
index 0000000..883b772
--- /dev/null
+++ b/client/src/lib/integration/useIntegrations.tsx
@@ -0,0 +1,161 @@
+import React, { useState, useContext, createContext, useReducer, useEffect } from "react";
+import Integration from "./Integration";
+import * as serverApi from '../../api';
+import SpotifyClientCreds from "./spotify/SpotifyClientCreds";
+import * as backend from "../backend/integrations";
+import { handleNotLoggedIn, NotLoggedInError } from "../backend/request";
+import { useAuth } from "../useAuth";
+
+export type IntegrationState = {
+ id: number,
+ integration: Integration,
+ properties: serverApi.CreateIntegrationRequest,
+};
+export type IntegrationsState = IntegrationState[] | "Loading";
+
+export function isIntegrationState(v: any) : v is IntegrationState {
+ return 'id' in v && 'integration' in v && 'properties' in v;
+}
+
+export interface Integrations {
+ state: IntegrationsState,
+ addIntegration: (v: serverApi.CreateIntegrationRequest) => Promise,
+ deleteIntegration: (id: number) => Promise,
+ modifyIntegration: (id: number, v: serverApi.CreateIntegrationRequest) => Promise,
+ updateFromUpstream: () => Promise,
+};
+
+export const IntegrationClasses: Record = {
+ [serverApi.IntegrationType.SpotifyClientCredentials]: SpotifyClientCreds,
+}
+
+export function makeDefaultIntegrationProperties(type: serverApi.IntegrationType):
+ serverApi.CreateIntegrationRequest {
+ switch(type) {
+ case serverApi.IntegrationType.SpotifyClientCredentials: {
+ return {
+ name: "Spotify",
+ type: type,
+ details: { clientId: "" },
+ secretDetails: { clientSecret: "" },
+ }
+ }
+ default: {
+ throw new Error("Unimplemented default integration.")
+ }
+ }
+}
+
+export function makeIntegration(p: serverApi.CreateIntegrationRequest, id: number) {
+ switch(p.type) {
+ case serverApi.IntegrationType.SpotifyClientCredentials: {
+ return new SpotifyClientCreds(id);
+ }
+ default: {
+ throw new Error("Unimplemented integration type.")
+ }
+ }
+}
+
+const integrationsContext = createContext({
+ state: [],
+ addIntegration: async () => 0,
+ modifyIntegration: async () => { },
+ deleteIntegration: async () => { },
+ updateFromUpstream: async () => { },
+});
+
+export function ProvideIntegrations(props: { children: any }) {
+ const integrations = useProvideIntegrations();
+ return {props.children};
+}
+
+export const useIntegrations = () => {
+ return useContext(integrationsContext);
+};
+
+function useProvideIntegrations(): Integrations {
+ let auth = useAuth();
+ enum IntegrationsActions {
+ SetItem = "SetItem",
+ Set = "Set",
+ DeleteItem = "DeleteItem",
+ AddItem = "AddItem",
+ }
+ let IntegrationsReducer = (state: IntegrationsState, action: any): IntegrationsState => {
+ switch (action.type) {
+ case IntegrationsActions.SetItem: {
+ if (state !== "Loading") {
+ return state.map((item: any) => {
+ return (item.id === action.id) ? action.value : item;
+ })
+ }
+ return state;
+ }
+ case IntegrationsActions.Set: {
+ return action.value;
+ }
+ case IntegrationsActions.DeleteItem: {
+ if (state !== "Loading") {
+ const newState = [...state];
+ return newState.filter((item: any) => item.id !== action.id);
+ }
+ return state;
+ }
+ case IntegrationsActions.AddItem: {
+ return [...state, action.value];
+ }
+ default:
+ throw new Error("Unimplemented Integrations state update.")
+ }
+ }
+
+ const [state, dispatch] = useReducer(IntegrationsReducer, [])
+
+ let updateFromUpstream = async () => {
+ backend.getIntegrations()
+ .then((integrations: serverApi.ListIntegrationsResponse) => {
+ dispatch({
+ type: IntegrationsActions.Set,
+ value: integrations.map((i: any) => {
+ return {
+ integration: new (IntegrationClasses[i.type])(i.id),
+ properties: { ...i },
+ id: i.id,
+ }
+ })
+ });
+ })
+ .catch((e: NotLoggedInError) => handleNotLoggedIn(auth, e));
+ }
+
+ let addIntegration = async (v: serverApi.CreateIntegrationRequest) => {
+ const id = await backend.createIntegration(v);
+ await updateFromUpstream();
+ return id;
+ }
+
+ let deleteIntegration = async (id: number) => {
+ await backend.deleteIntegration(id);
+ await updateFromUpstream();
+ }
+
+ let modifyIntegration = async (id: number, v: serverApi.CreateIntegrationRequest) => {
+ await backend.modifyIntegration(id, v);
+ await updateFromUpstream();
+ }
+
+ useEffect(() => {
+ if (auth.user) {
+ updateFromUpstream()
+ }
+ }, [auth]);
+
+ return {
+ state: state,
+ addIntegration: addIntegration,
+ modifyIntegration: modifyIntegration,
+ deleteIntegration: deleteIntegration,
+ updateFromUpstream: updateFromUpstream,
+ }
+}
\ No newline at end of file
diff --git a/client/src/lib/saveChanges.tsx b/client/src/lib/saveChanges.tsx
index db2205f..666c5af 100644
--- a/client/src/lib/saveChanges.tsx
+++ b/client/src/lib/saveChanges.tsx
@@ -1,4 +1,5 @@
import * as serverApi from '../api';
+import backendRequest from './backend/request';
export async function saveSongChanges(id: number, change: serverApi.ModifySongRequest) {
const requestOpts = {
@@ -8,7 +9,7 @@ export async function saveSongChanges(id: number, change: serverApi.ModifySongRe
};
const endpoint = serverApi.ModifySongEndpoint.replace(":id", id.toString());
- const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
+ const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) {
throw new Error("Failed to save song changes: " + response.statusText);
}
@@ -22,7 +23,7 @@ export async function saveTagChanges(id: number, change: serverApi.ModifyTagRequ
};
const endpoint = serverApi.ModifyTagEndpoint.replace(":id", id.toString());
- const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
+ const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) {
throw new Error("Failed to save tag changes: " + response.statusText);
}
@@ -36,7 +37,7 @@ export async function saveArtistChanges(id: number, change: serverApi.ModifyArti
};
const endpoint = serverApi.ModifyArtistEndpoint.replace(":id", id.toString());
- const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
+ const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) {
throw new Error("Failed to save artist changes: " + response.statusText);
}
@@ -50,7 +51,7 @@ export async function saveAlbumChanges(id: number, change: serverApi.ModifyAlbum
};
const endpoint = serverApi.ModifyAlbumEndpoint.replace(":id", id.toString());
- const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
+ const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) {
throw new Error("Failed to save album changes: " + response.statusText);
}
diff --git a/server/endpoints/Integration.ts b/server/endpoints/Integration.ts
index 2f33aa5..6e32c46 100644
--- a/server/endpoints/Integration.ts
+++ b/server/endpoints/Integration.ts
@@ -86,6 +86,8 @@ export const ListIntegrations: EndpointHandler = async (req: any, res: any, knex
const { id: userId } = req.user;
+ console.log("List integrations");
+
try {
const integrations: api.ListIntegrationsResponse = (
await knex.select(['id', 'name', 'type', 'details'])
@@ -100,6 +102,7 @@ export const ListIntegrations: EndpointHandler = async (req: any, res: any, knex
}
})
+ console.log("Found integrations:", integrations);
await res.send(integrations);
} catch (e) {
catchUnhandledErrors(e)
diff --git a/server/integrations/integrations.ts b/server/integrations/integrations.ts
index 1b34416..4b109d6 100644
--- a/server/integrations/integrations.ts
+++ b/server/integrations/integrations.ts
@@ -57,7 +57,7 @@ export function createIntegrations(knex: Knex) {
res.status(400).send({ reason: "An integration ID should be provided in the URL." });
return;
}
- req._integration = (await knex.select(['id', 'name', 'type', 'details'])
+ req._integration = (await knex.select(['id', 'name', 'type', 'details', 'secretDetails'])
.from('integrations')
.where({ 'user': req.user.id, 'id': req._integrationId }))[0];
if (!req._integration) {
@@ -66,6 +66,7 @@ export function createIntegrations(knex: Knex) {
}
req._integration.details = JSON.parse(req._integration.details);
+ req._integration.secretDetails = JSON.parse(req._integration.secretDetails);
switch (req._integration.type) {
case IntegrationType.SpotifyClientCredentials: {
@@ -73,7 +74,7 @@ export function createIntegrations(knex: Knex) {
// FIXME: persist the token
req._access_token = await getSpotifyCCAuthToken(
req._integration.details.clientId,
- req._integration.details.clientSecret,
+ req._integration.secretDetails.clientSecret,
)
if (!req._access_token) {
res.status(500).send({ reason: "Unable to get Spotify auth token." })