Skip to content

Commit 1099065

Browse files
committed
move API key to server-side
1 parent 3fcdbcd commit 1099065

File tree

11 files changed

+228
-192
lines changed

11 files changed

+228
-192
lines changed

flagsmith-jira-app/src/backend/flagsmith.ts

Lines changed: 30 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import api, { APIResponse, Route, assumeTrustedRoute, route } from "@forge/api";
22

33
import { ApiArgs, ApiError, FLAGSMITH_API_V1 } from "../common";
4+
import { readApiKey } from "./storage";
45

56
type Model = {
67
id: number;
@@ -53,7 +54,7 @@ export type EnvironmentFeatureState = Model & {
5354
const flagsmithApi = async (
5455
apiKey: string,
5556
route: Route,
56-
{ method = "GET", headers, body, codes = [], jsonResponse = true }: ApiArgs = {}
57+
{ method = "GET", headers, body, codes = [], jsonResponse = true }: ApiArgs = {},
5758
): Promise<unknown> => {
5859
try {
5960
const url = `${FLAGSMITH_API_V1}${route.value}`;
@@ -84,13 +85,16 @@ const flagsmithApi = async (
8485
const checkResponse = (response: APIResponse, ...codes: number[]): void => {
8586
if (!response.ok && !codes.includes(response.status)) {
8687
console.warn(response.status, response.statusText);
87-
throw new ApiError("Unexpected Flagsmith API response:", response.status);
88+
throw new ApiError(
89+
`Unexpected Flagsmith API response: ${response.statusText}`,
90+
response.status,
91+
);
8892
}
8993
};
9094

9195
const unpaginate = async <TModel extends Model>(
9296
apiKey: string,
93-
data: PaginatedModels<TModel>
97+
data: PaginatedModels<TModel>,
9498
): Promise<TModel[]> => {
9599
let pageData = data;
96100
const results = pageData?.results ?? [];
@@ -103,13 +107,14 @@ const unpaginate = async <TModel extends Model>(
103107
};
104108

105109
const checkApiKey = (apiKey: string): void => {
106-
if (!apiKey) throw new ApiError("Flagsmith API Key not configured", 400);
110+
if (!apiKey) throw new ApiError("Flagsmith API Key not set", 400);
107111
};
108112

109-
export type ReadOrganisations = (args: { apiKey: string }) => Promise<Organisation[]>;
113+
export type ReadOrganisations = (args: { apiKey?: string }) => Promise<Organisation[]>;
110114

111-
/** Read Flagsmith Organisations for given API Key */
112-
export const readOrganisations: ReadOrganisations = async ({ apiKey }) => {
115+
/** Read Flagsmith Organisations for stored/given API Key */
116+
export const readOrganisations: ReadOrganisations = async ({ apiKey: givenApiKey }) => {
117+
const apiKey = givenApiKey ?? (await readApiKey());
113118
checkApiKey(apiKey);
114119
const path = route`/organisations/`;
115120
const data = (await flagsmithApi(apiKey, path)) as PaginatedModels<Organisation>;
@@ -118,15 +123,13 @@ export const readOrganisations: ReadOrganisations = async ({ apiKey }) => {
118123
return results;
119124
};
120125

121-
export type ReadProjects = (args: {
122-
apiKey: string;
123-
organisationId: string | undefined;
124-
}) => Promise<Project[]>;
126+
export type ReadProjects = (args: { organisationId: string | undefined }) => Promise<Project[]>;
125127

126-
/** Read Flagsmith Projects for given API Key and Organisation ID */
127-
export const readProjects: ReadProjects = async ({ apiKey, organisationId }) => {
128+
/** Read Flagsmith Projects for stored API Key and given Organisation ID */
129+
export const readProjects: ReadProjects = async ({ organisationId }) => {
130+
const apiKey = await readApiKey();
128131
checkApiKey(apiKey);
129-
if (!organisationId) throw new ApiError("Flagsmith organisation not configured", 400);
132+
if (!organisationId) throw new ApiError("Flagsmith organisation not connected", 400);
130133
const path = route`/projects/?organisation=${organisationId}`;
131134
const data = (await flagsmithApi(apiKey, path)) as Project[];
132135
// do not unpaginate as this API does not do pagination
@@ -135,31 +138,27 @@ export const readProjects: ReadProjects = async ({ apiKey, organisationId }) =>
135138
return results;
136139
};
137140

138-
export type ReadEnvironments = (args: {
139-
apiKey: string;
140-
projectId: string | undefined;
141-
}) => Promise<Environment[]>;
141+
export type ReadEnvironments = (args: { projectId: string | undefined }) => Promise<Environment[]>;
142142

143-
/** Read Flagsmith Environments for given API Key and Project ID */
144-
export const readEnvironments: ReadEnvironments = async ({ apiKey, projectId }) => {
143+
/** Read Flagsmith Environments for stored API Key and given Project ID */
144+
export const readEnvironments: ReadEnvironments = async ({ projectId }) => {
145+
const apiKey = await readApiKey();
145146
checkApiKey(apiKey);
146-
if (!projectId) throw new ApiError("Flagsmith project not configured", 400);
147+
if (!projectId) throw new ApiError("Flagsmith project not connected", 400);
147148
const path = route`/environments/?project=${projectId}`;
148149
const data = (await flagsmithApi(apiKey, path)) as PaginatedModels<Environment>;
149150
const results = await unpaginate(apiKey, data);
150151
if (results.length === 0) throw new ApiError("Flagsmith project has no environments", 404);
151152
return results;
152153
};
153154

154-
export type ReadFeatures = (args: {
155-
apiKey: string;
156-
projectId: string | undefined;
157-
}) => Promise<Feature[]>;
155+
export type ReadFeatures = (args: { projectId: string | undefined }) => Promise<Feature[]>;
158156

159-
/** Read Flagsmith Features for given API Key and Project ID */
160-
export const readFeatures: ReadFeatures = async ({ apiKey, projectId }) => {
157+
/** Read Flagsmith Features for stored API Key and given Project ID */
158+
export const readFeatures: ReadFeatures = async ({ projectId }) => {
159+
const apiKey = await readApiKey();
161160
checkApiKey(apiKey);
162-
if (!projectId) throw new ApiError("Flagsmith project not configured", 400);
161+
if (!projectId) throw new ApiError("Flagsmith project not connected", 400);
163162
const path = route`/projects/${projectId}/features/`;
164163
const data = (await flagsmithApi(apiKey, path)) as PaginatedModels<Feature>;
165164
// ignore archived features
@@ -169,19 +168,18 @@ export const readFeatures: ReadFeatures = async ({ apiKey, projectId }) => {
169168
};
170169

171170
export type ReadEnvironmentFeatureState = (args: {
172-
apiKey: string;
173171
envApiKey: string;
174172
featureName: string;
175173
}) => Promise<EnvironmentFeatureState>;
176174

177-
/** Read Flagsmith Feature State for given API Key, Environment API Key and Feature Name */
175+
/** Read Flagsmith Feature State for stored API Key and given Environment API Key and Feature Name */
178176
export const readEnvironmentFeatureState: ReadEnvironmentFeatureState = async ({
179-
apiKey,
180177
envApiKey,
181178
featureName,
182179
}) => {
180+
const apiKey = await readApiKey();
183181
checkApiKey(apiKey);
184-
if (!envApiKey) throw new ApiError("Flagsmith environment not configured", 400);
182+
if (!envApiKey) throw new ApiError("Flagsmith environment not connected", 400);
185183
const path = route`/environments/${envApiKey}/featurestates/?feature_name=${featureName}`;
186184
const data = (await flagsmithApi(apiKey, path)) as PaginatedModels<EnvironmentFeatureState>;
187185
const results = await unpaginate(apiKey, data);

flagsmith-jira-app/src/frontend/components/AppSettingsPage.tsx

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,22 @@ import {
1919
import React, { Fragment, useEffect, useId, useMemo, useState } from "react";
2020

2121
import { ApiError, usePromise } from "../../common";
22-
import { readOrganisations } from "../flagsmith";
22+
import { readOrganisations, readProjects } from "../flagsmith";
2323
import {
2424
deleteApiKey,
2525
deleteOrganisationId,
26-
readApiKey,
26+
readHasApiKey,
2727
readOrganisationId,
2828
writeApiKey,
2929
writeOrganisationId,
3030
} from "../storage";
3131
import { WrappableComponentProps } from "./ErrorWrapper";
3232

33+
// 40 chars, same length as API key
34+
const SENTINEL = "****************************************";
35+
3336
type AppSettingsFormProps = WrappableComponentProps & {
34-
apiKey: string | null;
37+
hasApiKey: boolean;
3538
saveApiKey: (apiKey: string) => Promise<void>;
3639
organisationId: string | null;
3740
saveOrganisationId: (organisationId: string) => Promise<void>;
@@ -44,10 +47,10 @@ const AppSettingsForm: React.FC<AppSettingsFormProps> = ({
4447
...props
4548
}) => {
4649
// set form state from props
47-
const [apiKey, setApiKey] = useState<string | null>(props.apiKey);
50+
const [apiKey, setApiKey] = useState<string | null>(props.hasApiKey ? SENTINEL : null);
4851
useEffect(() => {
49-
setApiKey(props.apiKey);
50-
}, [props.apiKey]);
52+
setApiKey(props.hasApiKey ? SENTINEL : null);
53+
}, [props.hasApiKey]);
5154

5255
const [organisationId, setOrganisationId] = useState<string | null>(props.organisationId);
5356
useEffect(() => {
@@ -59,7 +62,8 @@ const AppSettingsForm: React.FC<AppSettingsFormProps> = ({
5962
async () => {
6063
try {
6164
if (apiKey && apiKey.length === 40) {
62-
return await readOrganisations({ apiKey });
65+
// use stored API key if current value is sentinel i.e. unchanged
66+
return await readOrganisations(apiKey === SENTINEL ? {} : { apiKey });
6367
}
6468
} catch (error) {
6569
// ignore 401 (invalid API key) and 404 (no organisations) as that is handled by the form
@@ -74,11 +78,12 @@ const AppSettingsForm: React.FC<AppSettingsFormProps> = ({
7478
return [];
7579
},
7680
[apiKey],
77-
setError
81+
setError,
7882
);
83+
7984
const organisation = organisations?.find((each) => String(each.id) === String(organisationId));
8085
const currentOrganisation = organisations?.find(
81-
(each) => String(each.id) === String(props.organisationId)
86+
(each) => String(each.id) === String(props.organisationId),
8287
);
8388

8489
// update organisationId when organisations change
@@ -95,6 +100,21 @@ const AppSettingsForm: React.FC<AppSettingsFormProps> = ({
95100
}
96101
}, [organisations, organisation]);
97102

103+
// get projects for current organisation from Flagsmith API
104+
const [projects] = usePromise(async () => {
105+
try {
106+
if (props.organisationId) {
107+
return await readProjects({ organisationId: props.organisationId });
108+
} else {
109+
return undefined;
110+
}
111+
} catch (error) {
112+
// treat errors as "no projects"
113+
console.error(error);
114+
return [];
115+
}
116+
}, [props.organisationId]);
117+
98118
const apiKeyInputId = useId();
99119

100120
const organisationInputId = useId();
@@ -104,7 +124,7 @@ const AppSettingsForm: React.FC<AppSettingsFormProps> = ({
104124
label: each.name,
105125
value: String(each.id),
106126
})),
107-
[organisations]
127+
[organisations],
108128
);
109129

110130
const organisationValue =
@@ -115,7 +135,7 @@ const AppSettingsForm: React.FC<AppSettingsFormProps> = ({
115135
}
116136

117137
const onSave = async () => {
118-
await saveApiKey(apiKey ?? "");
138+
if (apiKey !== SENTINEL) await saveApiKey(apiKey ?? "");
119139
await saveOrganisationId(organisationId ?? "");
120140
};
121141

@@ -128,21 +148,28 @@ const AppSettingsForm: React.FC<AppSettingsFormProps> = ({
128148
setOrganisationId("");
129149
};
130150

131-
const apiKeyInvalid = props.apiKey?.length === 40 && organisations?.length === 0;
132-
const configured = !!props.apiKey && !!currentOrganisation;
151+
// this status is a bit confusing as it reflects the current form state
152+
// rather than keeping track of whether the saved API key and organisation ID match each other
153+
const apiKeyInvalid = props.hasApiKey && organisations?.length === 0;
154+
const configured = props.hasApiKey && !!currentOrganisation && projects && projects.length > 0;
133155

134156
return (
135157
<Fragment>
136158
<Box xcss={{ marginBottom: "space.300" }}>
137159
<Inline space="space.050" alignBlock="center">
138160
<Strong>Organisation:</Strong>{" "}
139161
{!!currentOrganisation && <Text>{currentOrganisation.name}</Text>}
140-
{!configured && !apiKeyInvalid && <Lozenge appearance="moved">Not connected</Lozenge>}
162+
{!apiKeyInvalid && !currentOrganisation && (
163+
<Lozenge appearance="moved">Not connected</Lozenge>
164+
)}
141165
{apiKeyInvalid && (
142166
<Lozenge appearance="removed" maxWidth={"15rem"}>
143167
Invalid key or no organisations
144168
</Lozenge>
145169
)}
170+
{!apiKeyInvalid && !!currentOrganisation && !!projects && !configured && (
171+
<Lozenge appearance="removed">No projects to connect</Lozenge>
172+
)}
146173
{configured && <Lozenge appearance="success">Connected</Lozenge>}
147174
</Inline>
148175
</Box>
@@ -194,7 +221,13 @@ const AppSettingsForm: React.FC<AppSettingsFormProps> = ({
194221

195222
const AppSettingsPage: React.FC<WrappableComponentProps> = ({ setError }) => {
196223
// get configuration from storage
197-
const [apiKey, setApiKey] = usePromise(readApiKey, [], setError);
224+
const [hasApiKey, setHasApiKey] = usePromise(
225+
async () => {
226+
return await readHasApiKey();
227+
},
228+
[],
229+
setError,
230+
);
198231
const [organisationId, setOrganisationId] = usePromise(readOrganisationId, [], setError);
199232

200233
/** Write API Key to storage and update form state */
@@ -204,7 +237,7 @@ const AppSettingsPage: React.FC<WrappableComponentProps> = ({ setError }) => {
204237
} else {
205238
await deleteApiKey();
206239
}
207-
setApiKey(apiKey);
240+
setHasApiKey(!!apiKey);
208241
};
209242

210243
/** Write Organisation ID to storage and update form state */
@@ -217,12 +250,12 @@ const AppSettingsPage: React.FC<WrappableComponentProps> = ({ setError }) => {
217250
setOrganisationId(organisationId);
218251
};
219252

220-
const ready = apiKey !== undefined && organisationId !== undefined;
253+
const ready = hasApiKey !== undefined && organisationId !== undefined;
221254

222255
return ready ? (
223256
<AppSettingsForm
224257
setError={setError}
225-
apiKey={apiKey}
258+
hasApiKey={hasApiKey}
226259
saveApiKey={saveApiKey}
227260
organisationId={organisationId}
228261
saveOrganisationId={saveOrganisationId}

flagsmith-jira-app/src/frontend/components/ErrorWrapper.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ const ErrorWrapper: React.FC<ErrorWrapperProps> = ({
4747
<SectionMessage title={error.message ?? "Something went wrong"} appearance={appearance}>
4848
<Text>{advice}</Text>
4949
</SectionMessage>
50-
<Box xcss={{ marginTop: "space.300" }}>
50+
<Box xcss={{ marginTop: "space.200" }}>
5151
<Stack alignInline="end">
5252
<ButtonGroup label="Actions">
5353
<Button onClick={() => setError(undefined)}>Retry</Button>

0 commit comments

Comments
 (0)