Skip to content
Draft
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
266 changes: 236 additions & 30 deletions packages/webapp/src/CloudApp.tsx

Large diffs are not rendered by default.

15 changes: 13 additions & 2 deletions packages/webapp/src/GrantApprovalDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ export function GrantApprovalDialog({
workspaces,
onClose,
onResolved,
onResolveStarted,
onResolveFailed,
initialError,
}: {
client: Pick<ControlPlaneClient, 'listOrgCredentials' | 'listMembers' | 'resolveGrantProposal'>;
proposal: GrantProposalView;
Expand All @@ -170,13 +173,18 @@ export function GrantApprovalDialog({
onClose: () => void;
/** The server's answer, once the person approved or rejected. */
onResolved: (proposal: GrantProposalView) => void;
/** Hide the pending proposal while its decision is in flight. */
onResolveStarted?: (proposalId: string) => void;
/** Restore a rejected optimistic decision with its visible refusal. */
onResolveFailed?: (proposalId: string, message: string) => void;
initialError?: string | null;
}) {
const closeButton = useRef<HTMLButtonElement>(null);
const [credentials, setCredentials] = useState<OrgCredentialView[] | null>(null);
const [members, setMembers] = useState<MemberView[]>([]);
const [edits, setEdits] = useState<ProposalEdit[]>(() => initialEdits(proposal));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(initialError ?? null);

useEffect(() => { closeButton.current?.focus(); }, []);
useEffect(() => {
Expand Down Expand Up @@ -217,14 +225,17 @@ export function GrantApprovalDialog({
if (busy) return;
setBusy(true);
setError(null);
onResolveStarted?.(proposal.id);
try {
const response = await client.resolveGrantProposal(proposal.id, {
approve,
changes: approve ? live : [],
});
onResolved(response.proposal);
} catch (caught) {
setError(caughtErrorMessage(caught, approve ? 'Approval failed.' : 'Rejection failed.'));
const message = caughtErrorMessage(caught, approve ? 'Approval failed.' : 'Rejection failed.');
if (onResolveFailed === undefined) setError(message);
else onResolveFailed(proposal.id, message);
} finally {
setBusy(false);
}
Expand Down
7 changes: 6 additions & 1 deletion packages/webapp/src/InlineComputeCredentialSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,12 @@ function InlineProviderCredential({
<div className="settings-credential-row">
<KeyIcon className="settings-compute-glyph" />
<div>
<h3>Add your {computeCredentialProviderTitle(provider)} key</h3>
<div className="settings-credential-row__title">
<h3>Add your {computeCredentialProviderTitle(provider)} key</h3>
{saving && (
<span className="workspace-state-badge workspace-state-badge--pending">validating</span>
)}
</div>
<p>{details?.detail}</p>
</div>
</div>
Expand Down
39 changes: 32 additions & 7 deletions packages/webapp/src/WorkspaceConnectionsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,29 @@ export function WorkspaceRequestsPanel({
onConnect?: (connectionName: string) => void;
}) {
const [resolving, setResolving] = useState<string | null>(null);
const [pendingRemovals, setPendingRemovals] = useState<ReadonlySet<string>>(() => new Set());
const [error, setError] = useState<string | null>(null);
const requestKey = requests.map(({ id }) => id).join(' ');
useEffect(() => {
const serverIds = new Set(requestKey === '' ? [] : requestKey.split(' '));
setPendingRemovals((current) => new Set(
[...current].filter((id) => serverIds.has(id)),
));
}, [requestKey]);

const resolve = async (request: CredentialRequestView, action: 'approve' | 'deny') => {
if (resolving !== null) return;
setResolving(request.id);
setPendingRemovals((current) => new Set([...current, request.id]));
setError(null);
try {
await onResolve(request, action);
} catch (caught) {
setPendingRemovals((current) => {
const next = new Set(current);
next.delete(request.id);
return next;
});
setError(caughtErrorMessage(caught, `Request ${action} failed.`));
} finally {
setResolving(null);
Expand All @@ -70,7 +84,7 @@ export function WorkspaceRequestsPanel({
{(error ?? loadError) && <p className="webapp-form-message" role="alert">{error ?? loadError}</p>}
{requests.length > 0 && (
<div className="wsc-list">
{requests.map((request) => (
{requests.filter(({ id }) => !pendingRemovals.has(id)).map((request) => (
<article className="wsc-tile wsc-tile--static wsc-tile--wanted" key={request.id}>
<div className="wsc-tile__head">
<ProviderGlyph className="wsc-tile__glyph" provider={request.connection_name} />
Expand Down Expand Up @@ -227,6 +241,7 @@ export function WorkspaceConnectionsPanel({
) => Promise<void>;
}) {
const [connected, noteConnected] = useConnectedProviders(workspaceConnections ?? []);
const [autoResolveError, setAutoResolveError] = useState<string | null>(null);
// Which provider row to open, and a version so asking twice for the same
// one re-opens a row the person closed. Two things ask: an agent's
// `blitz connections open`, and Connect on a request in the inbox.
Expand All @@ -239,17 +254,23 @@ export function WorkspaceConnectionsPanel({
ask(connectionsFocus.provider);
}, [ask, connectionsFocus]);

/** A provider just became usable here. The inbox entry that asked for it is
* the same question, so answering one answers the other. Nothing is pushed
* at the box: the next token ask reads the allow-list this write just
* changed. */
/** The local allow-list follows the press while the request is in flight. */
const onConnected = useCallback((connectionName: string) => {
noteConnected(connectionName, true);
}, [noteConnected]);

/** The mint is now authoritative, so the inbox entry can be approved. */
const onConnectionAcknowledged = useCallback((connectionName: string) => {
setAutoResolveError(null);
const pending = pendingRequests.find(
(request) => request.connection_name === connectionName,
);
if (pending !== undefined) void onResolveRequest(pending, 'approve');
}, [noteConnected, onResolveRequest, pendingRequests]);
if (pending !== undefined) {
void onResolveRequest(pending, 'approve').catch((caught) => {
setAutoResolveError(caughtErrorMessage(caught, 'Request approval failed.'));
});
}
}, [onResolveRequest, pendingRequests]);

const onDisconnected = useCallback((connectionName: string) => {
noteConnected(connectionName, false);
Expand All @@ -261,6 +282,9 @@ export function WorkspaceConnectionsPanel({
const wanted = pendingRequests.length > 0 || (pendingRequestsError ?? null) !== null;
return (
<div className="workspace-connections">
{autoResolveError !== null && (
<p className="webapp-form-message" role="alert">{autoResolveError}</p>
)}
{wanted && (
<>
<h3 className="workspace-sect workspace-sect--pending">Wanted here</h3>
Expand Down Expand Up @@ -290,6 +314,7 @@ export function WorkspaceConnectionsPanel({
focusVersion={opened?.version ?? 0}
readOnly={readOnly}
onConnected={onConnected}
onConnectionAcknowledged={onConnectionAcknowledged}
onDisconnected={onDisconnected}
/>
</div>
Expand Down
37 changes: 32 additions & 5 deletions packages/webapp/src/WorkspaceCredentialsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function WorkspaceCredentialsTab({
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState<{ kind: 'add' } | { kind: 'rotate'; name: string } | null>(null);
const [pendingPut, setPendingPut] = useState<PutOrgCredentialRequest | null>(null);

const reload = useCallback(async (signal?: AbortSignal) => {
try {
Expand Down Expand Up @@ -77,11 +78,25 @@ export function WorkspaceCredentialsTab({
{ credential, path: workspaceReadPath(credential, workspaceId, viewerMembershipId) }));

const put = async (input: PutOrgCredentialRequest) => {
await client.putOrgCredential(input);
setForm(null);
await reload();
setPendingPut(input);
try {
const { credential } = await client.putOrgCredential(input);
setCredentials((current) => {
const index = current.findIndex(({ name }) => name === credential.name);
if (index < 0) return [credential, ...current];
const next = [...current];
next[index] = credential;
return next;
});
setForm(null);
} finally {
setPendingPut(null);
}
};

const pendingAddsRow = pendingPut !== null
&& !credentials.some(({ name }) => name === pendingPut.name);

return (
<section
id="workspace-details-credentials-panel"
Expand All @@ -100,16 +115,28 @@ export function WorkspaceCredentialsTab({
{error !== null && <p className="workspace-details-error" role="alert">{error}</p>}
<div className="workspace-credential-rows">
{loading && <p className="workspace-members-empty" role="status">Loading credentials…</p>}
{!loading && visible.length === 0 && (
{!loading && visible.length === 0 && !pendingAddsRow && (
<p className="workspace-members-empty">No organization credential reaches this workspace yet.</p>
)}
{pendingAddsRow && pendingPut !== null && (
<div className="workspace-credential-row" key={`pending:${pendingPut.name}`}>
<span className="workspace-credential-name">
<strong><code>{pendingPut.name}</code></strong>
{pendingPut.comment !== undefined && pendingPut.comment !== null && <small>{pendingPut.comment}</small>}
</span>
<span className="workspace-credential-added">saving</span>
<span />
</div>
)}
{visible.map(({ credential, path }) => (
<div className="workspace-credential-row" key={credential.id}>
<span className="workspace-credential-name">
<strong><code>{credential.name}</code></strong>
{credential.comment !== null && <small>{credential.comment}</small>}
</span>
<span className="workspace-credential-added">{PATH_LABELS[path]}</span>
<span className="workspace-credential-added">
{pendingPut?.name === credential.name ? 'rotating' : PATH_LABELS[path]}
</span>
{credential.grants.length > 0 ? (
<button
className="webapp-action"
Expand Down
39 changes: 31 additions & 8 deletions packages/webapp/src/connections/WorkspaceProviderRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import type {
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import type { ControlPlaneClient } from '../api';
import { caughtErrorMessage } from '../error-message';
import { ModalOverlay } from '../ModalOverlay';
import { settingsPath } from '../sessions-page-state';
import {
grantInput,
lockedInstanceBaseUrl,
Expand Down Expand Up @@ -106,6 +104,7 @@ export function WorkspaceProviderRows({
focusVersion,
readOnly,
onConnected,
onConnectionAcknowledged,
onDisconnected,
}: {
client: ProviderRowsClient;
Expand All @@ -118,6 +117,8 @@ export function WorkspaceProviderRows({
readOnly?: boolean;
/** This workspace may now pull the provider. */
onConnected: (connectionName: string) => void;
/** The mint succeeded, so any request that asked for it may be approved. */
onConnectionAcknowledged?: (connectionName: string) => void;
/** This workspace may no longer pull the provider. */
onDisconnected: (connectionName: string) => void;
}) {
Expand All @@ -129,6 +130,8 @@ export function WorkspaceProviderRows({
const [replacing, setReplacing] = useState<string | null>(null);
const [formVersion, setFormVersion] = useState(0);
const [saving, setSaving] = useState(false);
const [connecting, setConnecting] = useState<string | null>(null);
const [backedOverrides, setBackedOverrides] = useState<ReadonlySet<string>>(() => new Set());
const [error, setError] = useState<string | null>(null);
const [removing, setRemoving] = useState<string | null>(null);

Expand All @@ -150,7 +153,14 @@ export function WorkspaceProviderRows({
useEffect(() => {
const abort = new AbortController();
void client.listConnectionGrants(abort.signal).then(
(response) => setGrants(response.grants),
(response) => {
setGrants(response.grants);
// Once the refetch contains a pasted grant, its temporary backing can
// retire; a later server-side revoke must be able to remove it again.
setBackedOverrides((current) => new Set([...current].filter((name) => (
!response.grants.some(({ provider }) => provider === name)
))));
},
() => undefined,
);
return () => abort.abort();
Expand Down Expand Up @@ -196,14 +206,18 @@ export function WorkspaceProviderRows({
const connectNow = async (row: ProviderRow) => {
if (saving) return;
setSaving(true);
setConnecting(row.name);
setError(null);
onConnected(row.name);
try {
await client.mintWorkspaceConnection(workspaceId, row.name);
onConnected(row.name);
onConnectionAcknowledged?.(row.name);
close();
} catch (caught) {
onDisconnected(row.name);
setError(caughtErrorMessage(caught, 'Connect failed.'));
} finally {
setConnecting(null);
setSaving(false);
}
};
Expand All @@ -212,10 +226,11 @@ export function WorkspaceProviderRows({
if (removing !== null) return;
setRemoving(row.name);
setError(null);
onDisconnected(row.name);
try {
await client.disconnectWorkspaceConnection(workspaceId, row.name);
onDisconnected(row.name);
} catch (caught) {
onConnected(row.name);
setError(caughtErrorMessage(caught, 'Disconnect failed.'));
} finally {
setRemoving(null);
Expand All @@ -231,20 +246,26 @@ export function WorkspaceProviderRows({
// A grant is always filed under the catalog id; the control plane refuses
// any other name, and the form's name field is read-only for that reason.
const provider = row.name;
const wasConnected = row.connected;
setSaving(true);
setConnecting(provider);
setError(null);
if (!wasConnected) onConnected(provider);
try {
await client.putConnectionGrant(provider, grantInput(entry, data));
setBackedOverrides((current) => new Set([...current, provider]));
// A key pasted inside a workspace was pasted in order to connect it, so
// the workspace is connected without a second click.
await client.mintWorkspaceConnection(workspaceId, provider);
onConnected(provider);
onConnectionAcknowledged?.(provider);
form.reset();
setGrantsVersion((current) => current + 1);
close();
} catch (caught) {
if (!wasConnected) onDisconnected(provider);
setError(caughtErrorMessage(caught, 'Connect failed.'));
} finally {
setConnecting(null);
setSaving(false);
}
};
Expand All @@ -257,7 +278,7 @@ export function WorkspaceProviderRows({
{error !== null && <p className="webapp-form-message" role="alert">{error}</p>}
<div className="wsc-list">
{rows.map((row) => {
const backed = isBacked(row);
const backed = isBacked(row) || backedOverrides.has(row.name);
const isOpen = expanded === row.name;
const showForm = replacing === row.name || (!backed && row.entry !== null);
const oauthHref = row.entry?.oauthConfigured === true
Expand All @@ -270,7 +291,9 @@ export function WorkspaceProviderRows({
&& ((row.entry.personalTokenLabel !== null
&& !(row.entry.personalTokenFallbackOnly && oauthHref !== null))
|| (row.entry.oauthAvailable && oauthHref !== null));
const state = tileState(row, backed, memberPath);
const state = connecting === row.name
? { kind: 'on' as const, word: 'Connecting' }
: tileState(row, backed, memberPath);
return (
<article
className={`wsc-tile wsc-tile--${state.kind}${
Expand Down
Loading
Loading