Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 84 additions & 2 deletions static/plans/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,85 @@ export default function App() {
[oauthUser?.userId]
);

const handleAddRedirectUri = useCallback(
async (app: DeveloperApp, uri: string) => {
if (oauthUser?.userId == null) throw new Error("Not logged in");
const token = sessionStorage.getItem(OAUTH_TOKEN_KEY);
const updatedUris = [...(app.redirect_uris ?? [])];
if (!updatedUris.includes(uri)) updatedUris.push(uri);
const res = await fetch(
`${API_BASE}/v1/developer-apps/${encodeURIComponent(
app.address
)}?user_id=${oauthUser.userId}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
...(token != null ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
name: app.name,
description: app.description ?? undefined,
image_url: app.image_url ?? undefined,
redirect_uris: updatedUris,
}),
}
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(
(err as { error?: string })?.error ?? "Failed to add redirect URI"
);
}
setDeveloperApps((prev) =>
prev.map((a) => {
if (a.address?.toLowerCase() !== app.address?.toLowerCase()) return a;
return { ...a, redirect_uris: updatedUris };
})
);
},
[oauthUser?.userId]
);

const handleRemoveRedirectUri = useCallback(
async (app: DeveloperApp, uri: string) => {
if (oauthUser?.userId == null) throw new Error("Not logged in");
const token = sessionStorage.getItem(OAUTH_TOKEN_KEY);
const updatedUris = (app.redirect_uris ?? []).filter((u) => u !== uri);
const res = await fetch(
`${API_BASE}/v1/developer-apps/${encodeURIComponent(
app.address
)}?user_id=${oauthUser.userId}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
...(token != null ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
name: app.name,
description: app.description ?? undefined,
image_url: app.image_url ?? undefined,
redirect_uris: updatedUris,
}),
}
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(
(err as { error?: string })?.error ?? "Failed to remove redirect URI"
);
}
setDeveloperApps((prev) =>
prev.map((a) => {
if (a.address?.toLowerCase() !== app.address?.toLowerCase()) return a;
return { ...a, redirect_uris: updatedUris };
})
);
},
[oauthUser?.userId]
);

const handleDeleteApp = useCallback(
async (app: { address: string; name: string }) => {
if (oauthUser?.userId == null) throw new Error("Not logged in");
Expand Down Expand Up @@ -1166,8 +1245,9 @@ export default function App() {
</Flex>
<Button
variant="primary"
onClick={handleLogin}
disabled={!!oauthUser}
onClick={
oauthUser ? () => setCreateKeyModalOpen(true) : handleLogin
}
css={css`
margin-top: 0.5rem;
`}
Expand Down Expand Up @@ -1309,6 +1389,8 @@ export default function App() {
onDelete={setDeleteAppModalApp}
onDeactivateAccessKey={handleDeactivateAccessKey}
onAddAccessKey={handleAddAccessKey}
onAddRedirectUri={handleAddRedirectUri}
onRemoveRedirectUri={handleRemoveRedirectUri}
/>
))}
</Flex>
Expand Down
156 changes: 156 additions & 0 deletions static/plans/src/components/DeveloperAppCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type DeveloperApp = {
request_count_all_time?: number;
is_legacy?: boolean;
api_access_keys?: ApiAccessKey[];
redirect_uris?: string[];
};

const messages = {
Expand All @@ -44,6 +45,12 @@ const messages = {
deleteApp: "Delete API Key",
revokeToken: "Revoke Bearer Token",
newBearerToken: "New Bearer Token",
redirectUrisLabel: "Redirect URIs",
redirectUrisHelp:
"Allowed callback URLs for OAuth2 authorization flows. Required when using OAuth2 PKCE to obtain user access tokens.",
removeUri: "Remove Redirect URI",
addUri: "Add Redirect URI",
addUriPlaceholder: "https://example.com/callback",
};

const PLACEHOLDER_COLORS = [
Expand Down Expand Up @@ -266,20 +273,161 @@ function BearerTokenField({
);
}

function RedirectUriRow({
value,
onRemove,
}: {
value: string;
onRemove: () => void | Promise<void>;
}) {
const [pending, setPending] = useState(false);

const handleRemove = async () => {
if (pending) return;
setPending(true);
try {
await onRemove();
} catch (err) {
console.error(err);
} finally {
setPending(false);
}
};

return (
<Flex gap="s" alignItems="center">
<input
type="text"
readOnly
value={value}
css={css`
flex: 1;
padding: 0.5rem 0.75rem;
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
font-size: 0.875rem;
border: 1px solid var(--harmony-neutral-neutral-3, #e0e0e0);
border-radius: 4px;
background: var(--harmony-background, #f5f5f5);
min-width: 0;
`}
/>
<Tooltip text={messages.removeUri} placement="top">
<IconButton
icon={IconClose}
size="s"
color="danger"
aria-label={messages.removeUri}
disabled={pending}
onClick={handleRemove}
/>
</Tooltip>
</Flex>
);
}

function AddRedirectUriRow({
onAdd,
}: {
onAdd: (uri: string) => void | Promise<void>;
}) {
const [value, setValue] = useState("");
const [pending, setPending] = useState(false);

const handleAdd = async () => {
if (pending || !value.trim()) return;
setPending(true);
try {
await onAdd(value.trim());
setValue("");
} catch (err) {
console.error(err);
} finally {
setPending(false);
}
};

return (
<Flex gap="s" alignItems="center">
<input
type="text"
placeholder={messages.addUriPlaceholder}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleAdd();
}}
css={css`
flex: 1;
padding: 0.5rem 0.75rem;
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
font-size: 0.875rem;
border: 1px solid var(--harmony-neutral-neutral-3, #e0e0e0);
border-radius: 4px;
background: white;
min-width: 0;
`}
/>
<Tooltip text={messages.addUri} placement="top">
<IconButton
icon={IconPlus}
size="s"
color="success"
aria-label={messages.addUri}
disabled={pending || !value.trim()}
onClick={handleAdd}
/>
</Tooltip>
</Flex>
);
}

function RedirectUrisField({
uris,
appAddress,
onRemove,
onAdd,
}: {
uris: string[];
appAddress: string;
onRemove: (uri: string) => void | Promise<void>;
onAdd: (uri: string) => void | Promise<void>;
}) {
return (
<Flex direction="column" gap="xs">
<Text tag="label" variant="body" strength="strong">
{messages.redirectUrisLabel}
</Text>
<Text variant="body" size="s" color="subdued">
{messages.redirectUrisHelp}
</Text>
{uris.map((uri) => (
<RedirectUriRow
key={`${appAddress}-uri-${uri}`}
value={uri}
onRemove={() => onRemove(uri)}
/>
))}
<AddRedirectUriRow onAdd={onAdd} />
</Flex>
);
}

type Props = {
app: DeveloperApp;
onDelete: (app: DeveloperApp) => void;
onDeactivateAccessKey: (app: DeveloperApp, apiAccessKey: string) => void;
onAddAccessKey: (app: DeveloperApp) => void;
onAddRedirectUri: (app: DeveloperApp, uri: string) => void;
onRemoveRedirectUri: (app: DeveloperApp, uri: string) => void;
};

export function DeveloperAppCard({
app,
onDelete,
onDeactivateAccessKey,
onAddAccessKey,
onAddRedirectUri,
onRemoveRedirectUri,
}: Props) {
return (
<Paper key={app.address} p="l">
Expand Down Expand Up @@ -410,6 +558,14 @@ export function DeveloperAppCard({
/>
)) ?? null
: null}
{!app.is_legacy ? (
<RedirectUrisField
uris={app.redirect_uris ?? []}
appAddress={app.address}
onRemove={(uri) => onRemoveRedirectUri(app, uri)}
onAdd={(uri) => onAddRedirectUri(app, uri)}
/>
) : null}
</Flex>
</Flex>
</Paper>
Expand Down
Loading