Skip to content
Open
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
2 changes: 2 additions & 0 deletions .github/workflows/configure-azure.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ jobs:
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ vars.OTEL_EXPORTER_OTLP_PROTOCOL || 'http/protobuf' }}
OTEL_RESOURCE_ATTRIBUTES: ${{ vars.OTEL_RESOURCE_ATTRIBUTES }}
OTEL_SERVICE_NAME: ${{ vars.OTEL_SERVICE_NAME }}
PAYMENTS_BASE_URL: ${{ vars.PAYMENTS_BASE_URL }}
ROSETTACLIENT__BASEURL: ${{ vars.ROSETTACLIENT__BASEURL }}
ROSETTACLIENT__CLIENTID: ${{ vars.ROSETTACLIENT__CLIENTID }}
ROSETTACLIENT__CLIENTSECRET: ${{ secrets.ROSETTACLIENT__CLIENTSECRET }}
Expand Down Expand Up @@ -246,6 +247,7 @@ jobs:
add_setting "OTEL_EXPORTER_OTLP_PROTOCOL" "$OTEL_EXPORTER_OTLP_PROTOCOL"
add_setting "OTEL_RESOURCE_ATTRIBUTES" "$OTEL_RESOURCE_ATTRIBUTES"
add_setting "OTEL_SERVICE_NAME" "$OTEL_SERVICE_NAME"
add_setting "Payments__BaseUrl" "$PAYMENTS_BASE_URL"
add_setting "RosettaClient__BaseUrl" "$ROSETTACLIENT__BASEURL"
add_setting "RosettaClient__ClientId" "$ROSETTACLIENT__CLIENTID"
add_setting "RosettaClient__ClientSecret" "$ROSETTACLIENT__CLIENTSECRET"
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/deploy-azure-appservice.yml
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ jobs:
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ vars.OTEL_EXPORTER_OTLP_PROTOCOL || 'http/protobuf' }}
OTEL_RESOURCE_ATTRIBUTES: ${{ vars.OTEL_RESOURCE_ATTRIBUTES }}
OTEL_SERVICE_NAME: ${{ vars.OTEL_SERVICE_NAME }}
PAYMENTS_BASE_URL: ${{ vars.PAYMENTS_BASE_URL }}
ROSETTACLIENT__BASEURL: ${{ vars.ROSETTACLIENT__BASEURL }}
ROSETTACLIENT__CLIENTID: ${{ vars.ROSETTACLIENT__CLIENTID }}
ROSETTACLIENT__CLIENTSECRET: ${{ secrets.ROSETTACLIENT__CLIENTSECRET }}
Expand Down Expand Up @@ -279,6 +280,7 @@ jobs:
add_setting "OTEL_EXPORTER_OTLP_PROTOCOL" "$OTEL_EXPORTER_OTLP_PROTOCOL"
add_setting "OTEL_RESOURCE_ATTRIBUTES" "$OTEL_RESOURCE_ATTRIBUTES"
add_setting "OTEL_SERVICE_NAME" "$OTEL_SERVICE_NAME"
add_setting "Payments__BaseUrl" "$PAYMENTS_BASE_URL"
add_setting "RosettaClient__BaseUrl" "$ROSETTACLIENT__BASEURL"
add_setting "RosettaClient__ClientId" "$ROSETTACLIENT__CLIENTID"
add_setting "RosettaClient__ClientSecret" "$ROSETTACLIENT__CLIENTSECRET"
Expand Down
44 changes: 34 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,16 +218,40 @@ Key Vault settings because the client is created on demand. The sandbox does not
your host's Azure CLI login; use the backend running on your host to test features that
need the real test vault.

Future payments integration can pass `Team.PaymentsApiKeySecretName` to
`GetSecretAsync` or `SetSecretAsync`. The service returns the latest secret value and
creates a new version on each write. Keep only the secret name in the team record;
never return the credential to the browser or log it. Missing secrets and Azure
authentication or permission failures propagate to the caller. No payments API calls
or credential-management endpoints are included yet.

For example, a future server-side payments caller can resolve the API key with
`var apiKey = await secretsService.GetSecretAsync(team.PaymentsApiKeySecretName!, cancellationToken);`
after checking that the team has a secret name configured.
The secrets service returns the latest secret value and creates a new version on each
write. Keep only the secret name in the team record; never return the saved credential
to the browser or log it. Missing secrets and Azure authentication or permission
failures propagate to the caller.

### Team payments connection

Set `Payments__BaseUrl` in `server/.env` to the HTTPS Payments service root, such as
`https://payments-test.ucdavis.edu`. Azure deployments use the GitHub Environment
variable `PAYMENTS_BASE_URL`; see [payments deployment settings](infrastructure/azure/README.md#payments-base-url).
Configure Key Vault access as described above. Teams without a payments connection
can still be viewed without either service configured.

Site administrators and team administrators can set or replace a Payments API key
from the team's **Overview** page. Booking validates the submitted key with
`GET /api/team/` before saving it, then stores it under a generated Key Vault secret
name and saves that reference and the returned `PaymentsTeamSlug` together. Each save
uses a new secret name so concurrent replacements cannot mix credentials and slugs.
Previous secrets are retained; a failed database save can leave an unused new secret.

The overview checks the current stored key on load and through **Check connection**.
It shows the Payments team name when verification succeeds, distinguishes rejected
or disabled keys from temporary connection failures, and retains the saved slug when
verification fails. Only administrators receive a masked saved key: its first and
last three characters with `*` between them (keys of six or fewer characters are
fully masked). The saved raw key is never returned to the browser.

`IPaymentsService.GetTeamForApiKeyAsync` verifies a candidate without saving it.
`GetTeamAsync` accepts a team's `PaymentsApiKeySecretName` and reads its latest value
for each call. Both return `null` for a rejected key and throw on connection failures.
The service takes explicit credentials or vault references and cancellation tokens,
with no dependency on the current user or HTTP request, so jobs and workers can reuse
it. Web endpoints enforce team authorization before invoking it. Only team lookup is
implemented; other Payments operations can be added to this service later.

### Rosetta directory lookup

Expand Down
230 changes: 230 additions & 0 deletions client/src/features/teams/EditTeamPaymentsDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { useMutation } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import type { TeamPaymentsSettings } from './models/TeamPaymentsSettings.ts';
import { HttpError } from '@/lib/api.ts';
import { saveTeamPaymentsSettings } from '@/queries/teams.ts';
import { useAppForm } from '@/shared/forms/formContext.tsx';

export function EditTeamPaymentsDialog({
isReplacing,
onAccessDenied,
onClose,
onSaved,
teamSlug,
}: {
isReplacing: boolean;
onAccessDenied: () => void;
onClose: () => void;
onSaved: (settings: TeamPaymentsSettings) => void | Promise<void>;
teamSlug: string;
}) {
const dialogRef = useRef<HTMLDialogElement>(null);
const requestRef = useRef<AbortController | null>(null);
const activeRef = useRef(true);
const [errorMessage, setErrorMessage] = useState('');
const saveMutation = useMutation<TeamPaymentsSettings, Error, void>({
gcTime: 0,
// Read the candidate from the form so it never enters mutation variables.
mutationFn: () =>
saveTeamPaymentsSettings(
teamSlug,
form.getFieldValue('apiKey'),
requestRef.current?.signal
),
retry: false,
});
const { reset: resetMutation } = saveMutation;
const form = useAppForm({
defaultValues: { apiKey: '' },
onSubmit: async (): Promise<void> => {
if (saveMutation.isPending) {
return;
}
setErrorMessage('');
requestRef.current = new AbortController();
try {
const settings = await saveMutation.mutateAsync();
if (activeRef.current) {
form.reset();
await onSaved(settings);
}
} catch (error) {
if (!activeRef.current) {
return;
}
if (error instanceof HttpError && error.status === 403) {
form.reset();
onAccessDenied();
} else {
setErrorMessage(
error instanceof HttpError && error.status === 400
? 'This API key could not be validated. Check the key and try again. Your saved payments settings have not changed.'
: 'We could not confirm the save. Check the connection before trying again.'
);
}
} finally {
resetMutation();
}
},
});

useEffect(() => {
activeRef.current = true;
const dialog = dialogRef.current;
const previousFocus = document.activeElement as HTMLElement | null;
dialog?.showModal();

return () => {
activeRef.current = false;
requestRef.current?.abort();
resetMutation();
dialog?.close();
if (previousFocus?.isConnected) {
previousFocus.focus?.();
}
};
}, [resetMutation]);

function close() {
form.reset();
resetMutation();
onClose();
}

return (
<dialog
aria-describedby="edit-team-payments-description"
aria-labelledby="edit-team-payments-title"
className="modal"
onCancel={(event) => {
event.preventDefault();
if (!saveMutation.isPending) {
close();
}
}}
ref={dialogRef}
>
<div className="modal-box max-w-xl">
<h2
className="text-2xl font-semibold text-primary"
id="edit-team-payments-title"
>
{isReplacing ? 'Replace payments API key' : 'Set payments API key'}
</h2>
<p
className="mt-2 text-base-content/70"
id="edit-team-payments-description"
>
Enter your team&apos;s Payments API key. We will verify it before
saving and use it to identify your Payments team.
</p>
<form
className="mt-6"
noValidate
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<form.AppField
name="apiKey"
validators={{
onBlur: ({ value }) =>
value.trim() ? undefined : 'Enter a Payments API key.',
onSubmit: ({ value }) =>
value.trim() ? undefined : 'Enter a Payments API key.',
}}
>
{(field) => {
const hasError =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<div>
<label
className="mb-2 block text-sm font-semibold"
htmlFor="team-payments-api-key"
>
Payments API key
</label>
<input
aria-describedby={
hasError ? 'team-payments-api-key-error' : undefined
}
aria-invalid={hasError || undefined}
autoCapitalize="none"
autoComplete="new-password"
className={`input input-bordered w-full ${hasError ? 'input-error' : ''}`}
disabled={saveMutation.isPending}
id="team-payments-api-key"
maxLength={4096}
name={field.name}
onBlur={field.handleBlur}
onChange={(event) => {
field.handleChange(event.target.value);
setErrorMessage('');
}}
required
spellCheck={false}
type="password"
value={field.state.value}
/>
{hasError && (
<p
className="mt-2 text-sm text-error"
id="team-payments-api-key-error"
role="alert"
>
{field.state.meta.errors.join(' ')}
</p>
)}
</div>
);
}}
</form.AppField>
{errorMessage && (
<p className="alert alert-error mt-5" role="alert">
{errorMessage}
</p>
)}
<div className="modal-action">
<button
className="btn btn-ghost"
disabled={saveMutation.isPending}
onClick={close}
type="button"
>
Cancel
</button>
<form.Subscribe
selector={(state) =>
[
state.canSubmit,
state.isSubmitting,
state.values.apiKey,
] as const
}
>
{([canSubmit, isSubmitting, apiKey]) => (
<button
className="btn btn-primary"
disabled={
!canSubmit ||
isSubmitting ||
saveMutation.isPending ||
!apiKey.trim()
}
type="submit"
>
{saveMutation.isPending
? 'Verifying and saving…'
: 'Verify and save'}
</button>
)}
</form.Subscribe>
</div>
</form>
</div>
</dialog>
);
}
Loading
Loading