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
10 changes: 10 additions & 0 deletions apps/web/src/pages/settings-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
AccountSection,
BenchSection,
ChatSection,
CredentialsSection,
GrantsSection,
PeopleSection,
RolesSection,
Expand Down Expand Up @@ -72,6 +73,15 @@ export function SettingsRoute() {
),
});
}
if (access.credentials === "allowed") {
sections.push({
id: "credentials",
title: "Credentials",
render: (ctx: SettingsContext) => (
<CredentialsSection tenantId={ctx.tenantId} />
),
});
}

return (
<PageShell width="full" className="page-fill">
Expand Down
14 changes: 8 additions & 6 deletions packages/settings-ui/src/access.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// Whether the People/Roles/Grants sections belong in the settings nav at
// all, decided the way the rest of this surface's og pages already gate
// access: never a disabled tab, just an absent one. There's no capability
// listing to read this off of, so this probes the one grant-checked route
// that requires no grant of its own — `evaluate` — for the resource each
// section is built on.
// Whether the People/Roles/Grants/Credentials sections belong in the
// settings nav at all, decided the way the rest of this surface's og
// pages already gate access: never a disabled tab, just an absent one.
// There's no capability listing to read this off of, so this probes the
// one grant-checked route that requires no grant of its own —
// `evaluate` — for the resource each section is built on.

import { useEffect, useState } from "react";

Expand All @@ -15,6 +15,7 @@ export type TenancyAccess = {
readonly people: SectionAccess;
readonly roles: SectionAccess;
readonly grants: SectionAccess;
readonly credentials: SectionAccess;
};

function useResourceAccess(
Expand Down Expand Up @@ -58,5 +59,6 @@ export function useTenancyAccess(
people: useResourceAccess(tenantId, principalId, "principal"),
roles: useResourceAccess(tenantId, principalId, "role"),
grants: useResourceAccess(tenantId, principalId, "grant"),
credentials: useResourceAccess(tenantId, principalId, "credential"),
};
}
111 changes: 111 additions & 0 deletions packages/settings-ui/src/credentials-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Credentials section seam to Interchange's native credential + provider
// routes. Secrets are write-only: list/get never return them; create
// accepts the secret once and the hub encrypts it.

import { type } from "arktype";
import type { ArkErrors } from "arktype";
import {
CredentialResponse,
ProviderResponse,
paginatedSchema,
type CredentialType,
} from "@intx/types";

export type Credential = typeof CredentialResponse.infer;
export type Provider = typeof ProviderResponse.infer;

const CredentialsPage = paginatedSchema(CredentialResponse);
const ProvidersPage = paginatedSchema(ProviderResponse);

export class CredentialsApiError extends Error {
constructor(
message: string,
readonly status?: number,
) {
super(message);
}
}

type Validator<T> = (data: unknown) => T | ArkErrors;

async function request<T>(
path: string,
schema: Validator<T>,
init?: RequestInit,
): Promise<T> {
let response: Response;
try {
response = await fetch(path, {
...init,
headers: { "content-type": "application/json", ...init?.headers },
});
} catch (cause) {
throw new CredentialsApiError(
cause instanceof Error ? cause.message : String(cause),
);
}
if (response.status === 401) {
throw new CredentialsApiError(`Not signed in for ${path}.`, 401);
}
if (response.status === 403) {
throw new CredentialsApiError(`Not permitted to view ${path}.`, 403);
}
if (!response.ok) {
throw new CredentialsApiError(
`The hub answered ${response.status} for ${path}.`,
response.status,
);
}
if (response.status === 204) return undefined as T;
const body: unknown = await response.json().catch(() => undefined);
const parsed = schema(body);
if (parsed instanceof type.errors) {
throw new CredentialsApiError(
`Unexpected response shape from ${path}: ${parsed.summary}`,
);
}
return parsed;
}

export function listCredentials(
tenantId: string,
): Promise<readonly Credential[]> {
return request(`/api/tenants/${tenantId}/credentials`, CredentialsPage).then(
(page) => page.data,
);
}

export function listProviders(tenantId: string): Promise<readonly Provider[]> {
return request(`/api/tenants/${tenantId}/providers`, ProvidersPage).then(
(page) => page.data,
);
}

export type CreateCredentialInput = {
readonly providerId: string;
readonly name: string;
readonly type: CredentialType;
readonly secret: string;
readonly description?: string;
};

export function createCredential(
tenantId: string,
input: CreateCredentialInput,
): Promise<Credential> {
return request(`/api/tenants/${tenantId}/credentials`, CredentialResponse, {
method: "POST",
body: JSON.stringify(input),
});
}

export function deleteCredential(
tenantId: string,
credentialId: string,
): Promise<void> {
return request<void>(
`/api/tenants/${tenantId}/credentials/${credentialId}`,
(data) => data as void,
{ method: "DELETE" },
);
}
Loading
Loading