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 + { + props.onAdd && props.onAdd(serverApi.IntegrationType.SpotifyClientCredentials); + props.onClose && props.onClose(); + }} + >Spotify + +} + +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 + Edit Integration + { + setEditingIntegration({ + ...editingIntegration, + properties: i, + integration: makeIntegration(i, editingIntegration.id), + }); + }} + /> + +} + +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 - { - props.onAdd(serverApi.IntegrationType.SpotifyClientCredentials); - props.onClose(); - }} - >Spotify - -} - -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." })