diff --git a/client/src/api.ts b/client/src/api.ts index b4733c1..22bcb68 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -367,7 +367,12 @@ export interface SpotifyClientCredentialsDetails { clientSecret: string, } +export interface SpotifyClientCredentialsSecretDetails { + clientSecret: string, +} + export type IntegrationDetails = SpotifyClientCredentialsDetails; +export type IntegrationSecretDetails = SpotifyClientCredentialsSecretDetails; // Create a new integration (POST). export const CreateIntegrationEndpoint = '/integration'; @@ -375,6 +380,7 @@ export interface CreateIntegrationRequest { name: string, type: IntegrationType, details: IntegrationDetails, + secretDetails: IntegrationSecretDetails, } export interface CreateIntegrationResponse { id: number; @@ -384,6 +390,7 @@ export function checkCreateIntegrationRequest(req: any): boolean { "name" in req.body && "type" in req.body && "details" in req.body && + "secretDetails" in req.body && (req.body.type in IntegrationType); } @@ -393,6 +400,7 @@ export interface ModifyIntegrationRequest { name?: string, type?: IntegrationType, details?: IntegrationDetails, + secretDetails?: IntegrationSecretDetails, } export interface ModifyIntegrationResponse { } export function checkModifyIntegrationRequest(req: any): boolean { diff --git a/client/src/components/windows/settings/IntegrationSettingsEditor.tsx b/client/src/components/windows/settings/IntegrationSettingsEditor.tsx index 0210d58..ad58bc1 100644 --- a/client/src/components/windows/settings/IntegrationSettingsEditor.tsx +++ b/client/src/components/windows/settings/IntegrationSettingsEditor.tsx @@ -12,20 +12,24 @@ 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: serverApi.IntegrationDetailsResponse, - original: serverApi.IntegrationDetailsResponse, + integration: EditorIntegrationState, + original: EditorIntegrationState, editing: boolean, submitting: boolean, - onChange: (p: serverApi.IntegrationDetailsResponse, editing: boolean) => void, + onChange: (p: EditorIntegrationState, editing: boolean) => void, onSubmit: () => void, onDelete: () => void, } function EditSpotifyClientCredentialsDetails(props: { clientId: string, - clientSecret: string, + clientSecret: string | null, editing: boolean, onChangeClientId: (v: string) => void, onChangeClientSecret: (v: string) => void, @@ -36,7 +40,7 @@ function EditSpotifyClientCredentialsDetails(props: { variant="outlined" disabled={!props.editing} value={props.clientId || ""} - label="Client Id:" + label="Client id" fullWidth onChange={(e: any) => props.onChangeClientId(e.target.value)} /> @@ -45,10 +49,19 @@ function EditSpotifyClientCredentialsDetails(props: { props.onChangeClientSecret(e.target.value)} + onChange={(e: any) => { + props.onChangeClientSecret(e.target.value) + }} + onFocus={(e: any) => { + if(props.clientSecret === null) { + // Change from dots to empty input + console.log("Focus!") + props.onChangeClientSecret(''); + } + }} /> ; @@ -62,9 +75,20 @@ function EditIntegration(props: EditIntegrationProps) { style={{ height: '40px', width: '40px' }} whichStore={ExternalStore.Spotify} /> - Spotify + 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({ @@ -90,7 +115,10 @@ function EditIntegration(props: EditIntegrationProps) { {props.integration.type === serverApi.IntegrationType.SpotifyClientCredentials && props.onChange({ ...props.integration, @@ -101,8 +129,8 @@ function EditIntegration(props: EditIntegrationProps) { }, props.editing)} onChangeClientSecret={(v: string) => props.onChange({ ...props.integration, - details: { - ...props.integration.details, + secretDetails: { + ...props.integration.secretDetails, clientSecret: v, } }, props.editing)} @@ -157,8 +185,8 @@ 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: serverApi.IntegrationDetailsResponse, - original: serverApi.IntegrationDetailsResponse, + integration: EditorIntegrationState, + original: EditorIntegrationState, editing: boolean, submitting: boolean, } @@ -173,9 +201,12 @@ export default function IntegrationSettingsEditor(props: {}) { }; const submitEditor = (state: EditorState) => { - let integration = state.integration; + 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.') @@ -205,9 +236,9 @@ export default function IntegrationSettingsEditor(props: {}) { } const deleteEditor = (state: EditorState) => { - let promise: Promise = state.upstreamId ? + let promise: Promise = state.upstreamId ? deleteIntegration(state.upstreamId) : - (async () => {})(); + (async () => { })(); promise.then((response: any) => { let cpy = _.cloneDeep(editors).filter( @@ -244,7 +275,7 @@ export default function IntegrationSettingsEditor(props: {}) { original={state.original} editing={state.editing} submitting={state.submitting} - onChange={(p: serverApi.IntegrationDetailsResponse, editing: boolean) => { + onChange={(p: EditorIntegrationState, editing: boolean) => { if (!editors) { throw new Error('cannot change editors before loading integrations.') } @@ -262,9 +293,10 @@ export default function IntegrationSettingsEditor(props: {}) { throw new Error('cannot submit editors before loading integrations.') } let cpy: EditorState[] = _.cloneDeep(editors); - cpy.forEach((s: any) => { + cpy.forEach((s: EditorState) => { if (s.id === state.id) { s.submitting = true; + s.integration.secretDetails = undefined; } }) setEditors(cpy); @@ -301,6 +333,8 @@ export default function IntegrationSettingsEditor(props: {}) { type: serverApi.IntegrationType.SpotifyClientCredentials, details: { clientId: '', + }, + secretDetails: { clientSecret: '', }, name: '', diff --git a/server/endpoints/Integration.ts b/server/endpoints/Integration.ts index b871ca3..2f33aa5 100644 --- a/server/endpoints/Integration.ts +++ b/server/endpoints/Integration.ts @@ -24,6 +24,7 @@ export const PostIntegration: EndpointHandler = async (req: any, res: any, knex: user: userId, type: reqObject.type, details: JSON.stringify(reqObject.details), + secretDetails: JSON.stringify(reqObject.secretDetails), } const integrationId = (await trx('integrations') .insert(integration) @@ -186,6 +187,7 @@ export const PutIntegration: EndpointHandler = async (req: any, res: any, knex: if ("name" in reqObject) { update["name"] = reqObject.name; } if ("details" in reqObject) { update["details"] = JSON.stringify(reqObject.details); } if ("type" in reqObject) { update["type"] = reqObject.type; } + if ("secretDetails" in reqObject) { update["secretDetails"] = JSON.stringify(reqObject.details); } await trx('integrations') .where({ 'user': userId, 'id': req.params.id }) .update(update) diff --git a/server/migrations/20201113155620_add_integrations.ts b/server/migrations/20201113155620_add_integrations.ts index c560218..08dbc43 100644 --- a/server/migrations/20201113155620_add_integrations.ts +++ b/server/migrations/20201113155620_add_integrations.ts @@ -11,6 +11,8 @@ export async function up(knex: Knex): Promise { table.string('name').notNullable(); // Uniquely identifies this integration configuration for the user. table.string('type').notNullable(); // Enumerates different supported integration types (e.g. Spotify) table.json('details'); // Stores anything that might be needed for the integration to work. + table.json('secretDetails'); // Stores anything that might be needed for the integration to work and which + // should never leave the server. } ) } diff --git a/server/test/integration/flows/IntegrationFlow.js b/server/test/integration/flows/IntegrationFlow.js index 3b17a4c..cd693c0 100644 --- a/server/test/integration/flows/IntegrationFlow.js +++ b/server/test/integration/flows/IntegrationFlow.js @@ -30,10 +30,11 @@ describe('POST /integration with missing or wrong data', () => { let agent = await init(); let req = agent.keepOpen(); try { - await helpers.createIntegration(req, { name: "A", type: IntegrationType.spotify }, 400); - await helpers.createIntegration(req, { details: {}, type: IntegrationType.spotify }, 400); - await helpers.createIntegration(req, { name: "A", details: {} }, 400); - await helpers.createIntegration(req, { name: "A", type: "NonexistentType", details: {} }, 400); + await helpers.createIntegration(req, { type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 400); + await helpers.createIntegration(req, { name: "A", details: {}, secretDetails: {} }, 400); + await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, secretDetails: {} }, 400); + await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, }, 400); + await helpers.createIntegration(req, { name: "A", type: "NonexistentType", details: {}, secretDetails: {} }, 400); } finally { req.close(); agent.close(); @@ -47,7 +48,7 @@ describe('POST /integration with a correct request', () => { let agent = await init(); let req = agent.keepOpen(); try { - await helpers.createIntegration(req, { name: "A", type: IntegrationType.spotify, details: {} }, 200, { id: 1 }); + await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); } finally { req.close(); agent.close(); @@ -61,9 +62,9 @@ describe('PUT /integration with a correct request', () => { let agent = await init(); let req = agent.keepOpen(); try { - await helpers.createIntegration(req, { name: "A", type: IntegrationType.spotify, details: {} }, 200, { id: 1 }); - await helpers.modifyIntegration(req, 1, { name: "B", type: IntegrationType.spotify, details: { secret: 'cat' } }, 200); - await helpers.checkIntegration(req, 1, 200, { name: "B", type: IntegrationType.spotify, details: { secret: 'cat' } }) + await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); + await helpers.modifyIntegration(req, 1, { name: "B", type: IntegrationType.SpotifyClientCredentials, details: { secret: 'cat' }, secretDetails: {} }, 200); + await helpers.checkIntegration(req, 1, 200, { name: "B", type: IntegrationType.SpotifyClientCredentials, details: { secret: 'cat' } }) } finally { req.close(); agent.close(); @@ -77,8 +78,8 @@ describe('PUT /integration with wrong data', () => { let agent = await init(); let req = agent.keepOpen(); try { - await helpers.createIntegration(req, { name: "A", type: IntegrationType.spotify, details: {} }, 200, { id: 1 }); - await helpers.modifyIntegration(req, 1, { name: "B", type: "UnknownType", details: {} }, 400); + await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); + await helpers.modifyIntegration(req, 1, { name: "B", type: "UnknownType", details: {}, secretDetails: {} }, 400); } finally { req.close(); agent.close(); @@ -92,8 +93,8 @@ describe('DELETE /integration with a correct request', () => { let agent = await init(); let req = agent.keepOpen(); try { - await helpers.createIntegration(req, { name: "A", type: IntegrationType.spotify, details: {} }, 200, { id: 1 }); - await helpers.checkIntegration(req, 1, 200, { name: "A", type: IntegrationType.spotify, details: {} }) + await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); + await helpers.checkIntegration(req, 1, 200, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {} }) await helpers.deleteIntegration(req, 1, 200); await helpers.checkIntegration(req, 1, 404); } finally { @@ -109,13 +110,13 @@ describe('GET /integration list with a correct request', () => { let agent = await init(); let req = agent.keepOpen(); try { - await helpers.createIntegration(req, { name: "A", type: IntegrationType.spotify, details: {} }, 200, { id: 1 }); - await helpers.createIntegration(req, { name: "B", type: IntegrationType.spotify, details: {} }, 200, { id: 2 }); - await helpers.createIntegration(req, { name: "C", type: IntegrationType.spotify, details: {} }, 200, { id: 3 }); + await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 }); + await helpers.createIntegration(req, { name: "B", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 2 }); + await helpers.createIntegration(req, { name: "C", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 3 }); await helpers.listIntegrations(req, 200, [ - { id: 1, name: "A", type: IntegrationType.spotify, details: {} }, - { id: 2, name: "B", type: IntegrationType.spotify, details: {} }, - { id: 3, name: "C", type: IntegrationType.spotify, details: {} }, + { id: 1, name: "A", type: IntegrationType.SpotifyClientCredentials, details: {} }, + { id: 2, name: "B", type: IntegrationType.SpotifyClientCredentials, details: {} }, + { id: 3, name: "C", type: IntegrationType.SpotifyClientCredentials, details: {} }, ]); } finally { req.close(); diff --git a/server/test/integration/flows/helpers.js b/server/test/integration/flows/helpers.js index d905b55..12677cd 100644 --- a/server/test/integration/flows/helpers.js +++ b/server/test/integration/flows/helpers.js @@ -249,7 +249,7 @@ export async function logout( export async function createIntegration( req, - props = { name: "Integration", type: IntegrationType.Spotify, details: {} }, + props = { name: "Integration", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, expectStatus = undefined, expectResponse = undefined ) { @@ -266,7 +266,7 @@ export async function createIntegration( export async function modifyIntegration( req, id = 1, - props = { name: "NewIntegration", type: IntegrationType.Spotify, details: {} }, + props = { name: "NewIntegration", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, expectStatus = undefined, ) { await req