mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-22 04:07:04 +00:00
feat: add admin Model Access console page (#935)
* feat: add admin Model Access console page Lets an admin grant or revoke a provider-tier model for a tenant account from the console, cross-referencing the model catalog with that account's current grants via the existing signed-proxy actions. * feat: replace free-text account id with a real account picker Sources the Model Access account selector from /admin/ListAccounts via a new signed JSON admin-API fetch helper, sorted by name and excluding the global account. Falls back to a validated 12-digit free-text account id when enumeration is unauthorized or empty, since that call requires a long-lived IAM user in the super-admin account.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{n as e,o as t,r as n,t as r}from"./compiler-runtime.js";import{n as i}from"./QueryClientProvider.js";import{t as a}from"./useMutation.js";import{t as o}from"./useSuspenseQuery.js";import{t as s}from"./useNavigate.js";import{a as c}from"./cluster-config.js";import{t as l}from"./back-link.js";import{t as u}from"./circle-arrow-up.js";import{t as d}from"./trash-2.js";import{t as f}from"./button.js";import{n as p}from"./admin-context.js";import{n as m}from"./signed-fetch.js";import{a as h}from"./ec2.js";import{B as g}from"./ec22.js";import{t as _}from"./_id4.js";import{t as v}from"./page-heading.js";import{t as y}from"./state-badge.js";import{t as b}from"./error-banner.js";import{a as x,i as S,l as C,n as w,o as T,r as E,s as D,t as O}from"./alert-dialog.js";import{t as k}from"./delete-confirmation-dialog.js";import{i as A}from"./system-managed.js";import{t as j}from"./ami-details.js";var M=t(n()),N=r();function P(){let e=(0,N.c)(2),t=i(),n;return e[0]===t?n=e[1]:(n={mutationFn:F,onSuccess:()=>{t.invalidateQueries({queryKey:[`ec2`,`images`]})}},e[0]=t,e[1]=n),a(n)}async function F(e){let t=c();if(!t)throw Error(`Not authenticated`);return await m({action:`PromoteImage`,credentials:t,params:{ImageId:e}})}var I=e(),L=`000000000000`;function R(){let{id:e}=_.useParams(),t=s(),{data:n}=o(h(e)),r=n?.Images?.[0],{isAdmin:i}=p(),a=g(),c=P(),[m,N]=(0,M.useState)(!1),[F,R]=(0,M.useState)(!1);if(!r?.ImageId||A(r))return(0,I.jsxs)(I.Fragment,{children:[(0,I.jsx)(l,{to:`/ec2/describe-images`,children:`Back to images`}),(0,I.jsx)(`p`,{className:`text-muted-foreground`,children:`Image not found.`})]});let z=r.OwnerId===L;async function B(){try{await a.mutateAsync(e),t({to:`/ec2/describe-images`})}finally{N(!1)}}async function V(){try{await c.mutateAsync(e),R(!1)}catch{}}return(0,I.jsxs)(I.Fragment,{children:[(0,I.jsx)(l,{to:`/ec2/describe-images`,children:`Back to images`}),a.error&&(0,I.jsx)(b,{error:a.error,msg:`Failed to deregister image`}),c.error&&(0,I.jsx)(b,{error:c.error,msg:`Failed to promote image`}),(0,I.jsxs)(`div`,{className:`space-y-6`,children:[(0,I.jsx)(v,{actions:(0,I.jsxs)(`div`,{className:`flex items-center gap-2`,children:[i&&!z&&(0,I.jsxs)(f,{onClick:()=>{R(!0)},size:`sm`,variant:`outline`,children:[(0,I.jsx)(u,{className:`size-4`}),`Promote to System`]}),(0,I.jsxs)(f,{onClick:()=>{N(!0)},size:`sm`,variant:`destructive`,children:[(0,I.jsx)(d,{className:`size-4`}),`Deregister`]}),(0,I.jsx)(y,{state:r.State})]}),subtitle:`Image Details`,title:r.ImageId}),(0,I.jsx)(j,{image:r,showExtendedDetails:!0})]}),(0,I.jsx)(k,{description:`Are you sure you want to deregister "${r.ImageId}"? The backing snapshot will not be deleted.`,isPending:a.isPending,onConfirm:B,onOpenChange:N,open:m,title:`Deregister Image`}),(0,I.jsx)(O,{onOpenChange:R,open:F,children:(0,I.jsxs)(S,{children:[(0,I.jsxs)(D,{children:[(0,I.jsx)(C,{children:`Promote to System Image`}),(0,I.jsxs)(x,{children:[`This will make “`,r.ImageId,`” visible to all accounts as a system image. This cannot be undone via the UI.`]})]}),(0,I.jsxs)(T,{children:[(0,I.jsx)(E,{children:`Cancel`}),(0,I.jsx)(w,{disabled:c.isPending,onClick:V,children:c.isPending?`Promoting…`:`Promote`})]})]})})]})}export{R as component};
|
||||
import{n as e,o as t,r as n,t as r}from"./compiler-runtime.js";import{n as i}from"./QueryClientProvider.js";import{t as a}from"./useMutation.js";import{t as o}from"./useSuspenseQuery.js";import{t as s}from"./useNavigate.js";import{a as c}from"./cluster-config.js";import{t as l}from"./back-link.js";import{t as u}from"./circle-arrow-up.js";import{t as d}from"./trash-2.js";import{t as f}from"./button.js";import{n as p}from"./admin-context.js";import{r as m}from"./signed-fetch.js";import{a as h}from"./ec2.js";import{B as g}from"./ec22.js";import{t as _}from"./_id4.js";import{t as v}from"./page-heading.js";import{t as y}from"./state-badge.js";import{t as b}from"./error-banner.js";import{a as x,i as S,l as C,n as w,o as T,r as E,s as D,t as O}from"./alert-dialog.js";import{t as k}from"./delete-confirmation-dialog.js";import{i as A}from"./system-managed.js";import{t as j}from"./ami-details.js";var M=t(n()),N=r();function P(){let e=(0,N.c)(2),t=i(),n;return e[0]===t?n=e[1]:(n={mutationFn:F,onSuccess:()=>{t.invalidateQueries({queryKey:[`ec2`,`images`]})}},e[0]=t,e[1]=n),a(n)}async function F(e){let t=c();if(!t)throw Error(`Not authenticated`);return await m({action:`PromoteImage`,credentials:t,params:{ImageId:e}})}var I=e(),L=`000000000000`;function R(){let{id:e}=_.useParams(),t=s(),{data:n}=o(h(e)),r=n?.Images?.[0],{isAdmin:i}=p(),a=g(),c=P(),[m,N]=(0,M.useState)(!1),[F,R]=(0,M.useState)(!1);if(!r?.ImageId||A(r))return(0,I.jsxs)(I.Fragment,{children:[(0,I.jsx)(l,{to:`/ec2/describe-images`,children:`Back to images`}),(0,I.jsx)(`p`,{className:`text-muted-foreground`,children:`Image not found.`})]});let z=r.OwnerId===L;async function B(){try{await a.mutateAsync(e),t({to:`/ec2/describe-images`})}finally{N(!1)}}async function V(){try{await c.mutateAsync(e),R(!1)}catch{}}return(0,I.jsxs)(I.Fragment,{children:[(0,I.jsx)(l,{to:`/ec2/describe-images`,children:`Back to images`}),a.error&&(0,I.jsx)(b,{error:a.error,msg:`Failed to deregister image`}),c.error&&(0,I.jsx)(b,{error:c.error,msg:`Failed to promote image`}),(0,I.jsxs)(`div`,{className:`space-y-6`,children:[(0,I.jsx)(v,{actions:(0,I.jsxs)(`div`,{className:`flex items-center gap-2`,children:[i&&!z&&(0,I.jsxs)(f,{onClick:()=>{R(!0)},size:`sm`,variant:`outline`,children:[(0,I.jsx)(u,{className:`size-4`}),`Promote to System`]}),(0,I.jsxs)(f,{onClick:()=>{N(!0)},size:`sm`,variant:`destructive`,children:[(0,I.jsx)(d,{className:`size-4`}),`Deregister`]}),(0,I.jsx)(y,{state:r.State})]}),subtitle:`Image Details`,title:r.ImageId}),(0,I.jsx)(j,{image:r,showExtendedDetails:!0})]}),(0,I.jsx)(k,{description:`Are you sure you want to deregister "${r.ImageId}"? The backing snapshot will not be deleted.`,isPending:a.isPending,onConfirm:B,onOpenChange:N,open:m,title:`Deregister Image`}),(0,I.jsx)(O,{onOpenChange:R,open:F,children:(0,I.jsxs)(S,{children:[(0,I.jsxs)(D,{children:[(0,I.jsx)(C,{children:`Promote to System Image`}),(0,I.jsxs)(x,{children:[`This will make “`,r.ImageId,`” visible to all accounts as a system image. This cannot be undone via the UI.`]})]}),(0,I.jsxs)(T,{children:[(0,I.jsx)(E,{children:`Cancel`}),(0,I.jsx)(w,{disabled:c.isPending,onClick:V,children:c.isPending?`Promoting…`:`Promote`})]})]})})]})}export{R as component};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{n as e,o as t,r as n,t as r}from"./compiler-runtime.js";import{a as i}from"./cluster-config.js";import{a,i as o,n as s,o as c,r as l}from"./sts.js";import{n as u}from"./signed-fetch.js";var d=class extends a(l,o,`GetCallerIdentity`,c){},f=r(),p=t(n(),1),m=e(),h=`000000000001`;function g(e){let t=e?.indexOf(`:user/`)??-1;return t===-1||e===void 0?null:e.slice(t+6)||null}var _=(0,p.createContext)(void 0);function v(e){let t=(0,f.c)(6),{children:n}=e,r;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(r={isAdmin:!1,accountId:null,userName:null,version:null,license:null,loading:!0},t[0]=r):r=t[0];let[a,o]=(0,p.useState)(r),c,l;t[1]===Symbol.for(`react.memo_cache_sentinel`)?(c=()=>{(async function(){let e=i();if(!e){o(b);return}try{let t=await s().send(new d({})),n=t.Account??null,r=n===h,i=null;if(r)try{i=await u({action:`GetVersion`,credentials:e})}catch{}o({isAdmin:r,accountId:n,userName:g(t.Arn),version:i,license:i?.license??null,loading:!1})}catch{o(y)}})()},l=[],t[1]=c,t[2]=l):(c=t[1],l=t[2]),(0,p.useEffect)(c,l);let v;return t[3]!==n||t[4]!==a?(v=(0,m.jsx)(_,{value:a,children:n}),t[3]=n,t[4]=a,t[5]=v):v=t[5],v}function y(e){return{...e,loading:!1}}function b(e){return{...e,loading:!1}}function x(){let e=(0,p.useContext)(_);if(e===void 0)throw Error(`useAdmin must be used within an AdminProvider`);return e}export{x as n,v as t};
|
||||
import{n as e,o as t,r as n,t as r}from"./compiler-runtime.js";import{a as i}from"./cluster-config.js";import{a,i as o,n as s,o as c,r as l}from"./sts.js";import{r as u}from"./signed-fetch.js";var d=class extends a(l,o,`GetCallerIdentity`,c){},f=r(),p=t(n(),1),m=e(),h=`000000000001`;function g(e){let t=e?.indexOf(`:user/`)??-1;return t===-1||e===void 0?null:e.slice(t+6)||null}var _=(0,p.createContext)(void 0);function v(e){let t=(0,f.c)(6),{children:n}=e,r;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(r={isAdmin:!1,accountId:null,userName:null,version:null,license:null,loading:!0},t[0]=r):r=t[0];let[a,o]=(0,p.useState)(r),c,l;t[1]===Symbol.for(`react.memo_cache_sentinel`)?(c=()=>{(async function(){let e=i();if(!e){o(b);return}try{let t=await s().send(new d({})),n=t.Account??null,r=n===h,i=null;if(r)try{i=await u({action:`GetVersion`,credentials:e})}catch{}o({isAdmin:r,accountId:n,userName:g(t.Arn),version:i,license:i?.license??null,loading:!1})}catch{o(y)}})()},l=[],t[1]=c,t[2]=l):(c=t[1],l=t[2]),(0,p.useEffect)(c,l);let v;return t[3]!==n||t[4]!==a?(v=(0,m.jsx)(_,{value:a,children:n}),t[3]=n,t[4]=a,t[5]=v):v=t[5],v}function y(e){return{...e,loading:!1}}function b(e){return{...e,loading:!1}}function x(){let e=(0,p.useContext)(_);if(e===void 0)throw Error(`useAdmin must be used within an AdminProvider`);return e}export{x as n,v as t};
|
||||
@@ -1 +1 @@
|
||||
import{t as e}from"./queryOptions.js";import{a as t}from"./cluster-config.js";import{n}from"./signed-fetch.js";var r=e({queryKey:[`admin`,`nodes`],queryFn:async()=>{let e=t();if(!e)throw Error(`Not authenticated`);return await n({action:`GetNodes`,credentials:e})},staleTime:1e4}),i=e({queryKey:[`admin`,`vms`],queryFn:async()=>{let e=t();if(!e)throw Error(`Not authenticated`);return await n({action:`GetVMs`,credentials:e})},staleTime:1e4}),a=e({queryKey:[`admin`,`storageStatus`],queryFn:async()=>{let e=t();if(!e)throw Error(`Not authenticated`);return await n({action:`GetStorageStatus`,credentials:e})},staleTime:1e4}),o=e({queryKey:[`admin`,`ochreCatalog`],queryFn:async()=>{let e=t();if(!e)throw Error(`Not authenticated`);return await n({action:`ListOchreCatalog`,credentials:e})},staleTime:1e4});export{i,o as n,a as r,r as t};
|
||||
import{t as e}from"./queryOptions.js";import{a as t}from"./cluster-config.js";import{n,r}from"./signed-fetch.js";var i=e({queryKey:[`admin`,`nodes`],queryFn:async()=>{let e=t();if(!e)throw Error(`Not authenticated`);return await r({action:`GetNodes`,credentials:e})},staleTime:1e4}),a=e({queryKey:[`admin`,`vms`],queryFn:async()=>{let e=t();if(!e)throw Error(`Not authenticated`);return await r({action:`GetVMs`,credentials:e})},staleTime:1e4}),o=e({queryKey:[`admin`,`storageStatus`],queryFn:async()=>{let e=t();if(!e)throw Error(`Not authenticated`);return await r({action:`GetStorageStatus`,credentials:e})},staleTime:1e4}),s=e({queryKey:[`admin`,`ochreCatalog`],queryFn:async()=>{let e=t();if(!e)throw Error(`Not authenticated`);return await r({action:`ListOchreCatalog`,credentials:e})},staleTime:1e4}),c=e({queryKey:[`admin`,`accounts`],queryFn:async()=>{let e=t();if(!e)throw Error(`Not authenticated`);return await n({method:`ListAccounts`,credentials:e,body:{}})},retry:!1,staleTime:3e4}),l=n=>e({queryKey:[`admin`,`modelAccess`,n],queryFn:async()=>{let e=t();if(!e)throw Error(`Not authenticated`);return await r({action:`ListModelAccess`,credentials:e,params:{AccountId:n}})},enabled:n!==``,staleTime:1e4});async function u({accountId:e,modelId:n}){let i=t();if(!i)throw Error(`Not authenticated`);return await r({action:`GrantModelAccess`,credentials:i,params:{AccountId:e,ModelId:n}})}async function d({accountId:e,modelId:n}){let i=t();if(!i)throw Error(`Not authenticated`);return await r({action:`RevokeModelAccess`,credentials:i,params:{AccountId:e,ModelId:n}})}export{o as a,d as c,s as i,l as n,a as o,i as r,u as s,c as t};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -16,6 +16,7 @@ import {
|
||||
IdCard,
|
||||
Image,
|
||||
Key,
|
||||
KeyRound,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
LayoutList,
|
||||
@@ -627,6 +628,19 @@ export function SidebarLayout() {
|
||||
</Link>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<SidebarMenuItem>
|
||||
<Link to="/bedrock/list-model-access">
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/bedrock/list-model-access")}
|
||||
tooltip="Model Access"
|
||||
>
|
||||
<KeyRound className="size-4" />
|
||||
<span>Model Access</span>
|
||||
</SidebarMenuButton>
|
||||
</Link>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
{isAdmin && (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import type { SessionCredentials } from "./auth"
|
||||
import { isStaleCredentialsError } from "./auth-error"
|
||||
import { SignedFetchError, signedFetch } from "./signed-fetch"
|
||||
import { signedAdminFetch, SignedFetchError, signedFetch } from "./signed-fetch"
|
||||
|
||||
const credentials: SessionCredentials = {
|
||||
accessKeyId: "ASIAIOSFODNN7EXAMPLE",
|
||||
@@ -92,3 +92,65 @@ describe("signedFetch", () => {
|
||||
expect(isStaleCredentialsError(err)).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe("signedAdminFetch", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it("POSTs a JSON body to /proxy/awsgw/admin/<Method>", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response('{"accounts":[],"count":0}', { status: 200 }),
|
||||
)
|
||||
vi.stubGlobal("fetch", fetchMock)
|
||||
|
||||
await signedAdminFetch({ method: "ListAccounts", credentials, body: {} })
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain("/proxy/awsgw/admin/ListAccounts")
|
||||
expect(init.method).toBe("POST")
|
||||
expect(init.body).toBe("{}")
|
||||
const headers = init.headers as Record<string, string>
|
||||
expect(findHeader(headers, "content-type")).toBe("application/json")
|
||||
})
|
||||
|
||||
it("defaults a missing body to an empty JSON object", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response("{}", { status: 200 }))
|
||||
vi.stubGlobal("fetch", fetchMock)
|
||||
|
||||
await signedAdminFetch({ method: "ListAccounts", credentials })
|
||||
|
||||
const init = fetchMock.mock.calls[0]?.[1] as RequestInit
|
||||
expect(init.body).toBe("{}")
|
||||
})
|
||||
|
||||
it("decodes the /admin JSON error envelope", async () => {
|
||||
stubFetch(
|
||||
'{"error":{"code":"AccessDenied","message":"not the super-admin account"},"requestId":"req-1"}',
|
||||
403,
|
||||
)
|
||||
|
||||
const err = await rejection(
|
||||
signedAdminFetch({ method: "ListAccounts", credentials }),
|
||||
)
|
||||
|
||||
expect(err).toBeInstanceOf(SignedFetchError)
|
||||
expect(err).toMatchObject({ name: "AccessDenied", status: 403 })
|
||||
expect(err.message).toContain("not the super-admin account")
|
||||
})
|
||||
|
||||
it("falls back to a generic name when the body is not the envelope", async () => {
|
||||
stubFetch("gateway unavailable", 503)
|
||||
|
||||
const err = await rejection(
|
||||
signedAdminFetch({ method: "ListAccounts", credentials }),
|
||||
)
|
||||
|
||||
expect(err).toBeInstanceOf(SignedFetchError)
|
||||
expect(err).toMatchObject({ name: "SignedFetchError", status: 503 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -178,3 +178,103 @@ export async function signedFetch<T>({
|
||||
body: `Action=${action}${extraParams}`,
|
||||
})
|
||||
}
|
||||
|
||||
// ADMIN_JSON_CONTENT_TYPE is what the /admin surface expects, distinct from
|
||||
// JSON_CONTENT_TYPE (JSON-1.1, used by the Action= target callers).
|
||||
export const ADMIN_JSON_CONTENT_TYPE = "application/json"
|
||||
|
||||
export interface SignedAdminFetchOptions<TBody = Record<string, never>> {
|
||||
method: string
|
||||
credentials: SessionCredentials
|
||||
body?: TBody
|
||||
}
|
||||
|
||||
const adminErrorSchema = z.object({
|
||||
error: z
|
||||
.object({ code: z.string().optional(), message: z.string().optional() })
|
||||
.optional(),
|
||||
requestId: z.string().optional(),
|
||||
})
|
||||
|
||||
// adminErrorFromBody reads the /admin surface's JSON error envelope
|
||||
// (`{error:{code,message},requestId}`), which is shaped differently from the
|
||||
// Action= and JSON-1.1 error bodies errorFromBody handles.
|
||||
function adminErrorFromBody(
|
||||
label: string,
|
||||
status: number,
|
||||
detail: string,
|
||||
): SignedFetchError {
|
||||
let code: string | undefined
|
||||
let message: string | undefined
|
||||
try {
|
||||
const parsed = adminErrorSchema.safeParse(JSON.parse(detail))
|
||||
if (parsed.success) {
|
||||
code = parsed.data.error?.code
|
||||
message = parsed.data.error?.message
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON body.
|
||||
}
|
||||
return new SignedFetchError(
|
||||
`${label} failed: ${status}${message ? ` - ${message}` : ""}`,
|
||||
code ?? "SignedFetchError",
|
||||
status,
|
||||
)
|
||||
}
|
||||
|
||||
// signedAdminFetch SigV4-signs and POSTs to the private JSON /admin/<Method>
|
||||
// surface (mirrors the Go CLI's callAdmin), distinct from the Action= query
|
||||
// surface signedFetch drives against path "/".
|
||||
export async function signedAdminFetch<T, TBody = Record<string, never>>({
|
||||
method,
|
||||
credentials,
|
||||
body,
|
||||
}: SignedAdminFetchOptions<TBody>): Promise<T> {
|
||||
const protocol = window.location.protocol.replace(":", "")
|
||||
const path = `/admin/${method}`
|
||||
const payload = JSON.stringify(body ?? {})
|
||||
|
||||
const headers = {
|
||||
host: `localhost:${GATEWAY_PORT}`,
|
||||
"content-type": ADMIN_JSON_CONTENT_TYPE,
|
||||
}
|
||||
|
||||
const request = new HttpRequest({
|
||||
method: "POST",
|
||||
protocol,
|
||||
hostname: "localhost",
|
||||
port: GATEWAY_PORT,
|
||||
path,
|
||||
headers,
|
||||
body: payload,
|
||||
})
|
||||
|
||||
const signer = new SignatureV4({
|
||||
credentials: {
|
||||
accessKeyId: credentials.accessKeyId,
|
||||
secretAccessKey: credentials.secretAccessKey,
|
||||
sessionToken: credentials.sessionToken,
|
||||
},
|
||||
region: getRegion(),
|
||||
service: "spinifex",
|
||||
sha256: Sha256,
|
||||
})
|
||||
|
||||
const signed = await signer.sign(request)
|
||||
const signedHeaders = { ...signed.headers }
|
||||
|
||||
const proxyUrl = `${window.location.protocol}//${window.location.host}/proxy/awsgw${path}`
|
||||
const response = await fetch(proxyUrl, {
|
||||
method: "POST",
|
||||
headers: signedHeaders,
|
||||
body: payload,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "")
|
||||
throw adminErrorFromBody(method, response.status, detail)
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- response.json() returns Promise<any>
|
||||
return await (response.json() as Promise<T>)
|
||||
}
|
||||
|
||||
@@ -6,21 +6,27 @@ vi.mock("@/lib/auth", () => ({
|
||||
|
||||
vi.mock("@/lib/signed-fetch", () => ({
|
||||
signedFetch: vi.fn(),
|
||||
signedAdminFetch: vi.fn(),
|
||||
}))
|
||||
|
||||
import { getCredentials } from "@/lib/auth"
|
||||
import { signedFetch } from "@/lib/signed-fetch"
|
||||
import { signedAdminFetch, signedFetch } from "@/lib/signed-fetch"
|
||||
import { callQueryFn } from "@/test/query"
|
||||
|
||||
import {
|
||||
adminListAccountsQueryOptions,
|
||||
adminModelAccessQueryOptions,
|
||||
adminNodesQueryOptions,
|
||||
adminOchreCatalogQueryOptions,
|
||||
adminStorageStatusQueryOptions,
|
||||
adminVMsQueryOptions,
|
||||
grantModelAccess,
|
||||
revokeModelAccess,
|
||||
} from "./admin"
|
||||
|
||||
const mockGetCredentials = vi.mocked(getCredentials)
|
||||
const mockSignedFetch = vi.mocked(signedFetch)
|
||||
const mockSignedAdminFetch = vi.mocked(signedAdminFetch)
|
||||
|
||||
describe("adminNodesQueryOptions", () => {
|
||||
it("has the correct query key", () => {
|
||||
@@ -151,3 +157,149 @@ describe("adminOchreCatalogQueryOptions", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("adminListAccountsQueryOptions", () => {
|
||||
it("has the correct query key", () => {
|
||||
expect(adminListAccountsQueryOptions.queryKey).toStrictEqual([
|
||||
"admin",
|
||||
"accounts",
|
||||
])
|
||||
})
|
||||
|
||||
it("does not retry, so a 403 settles fast", () => {
|
||||
expect(adminListAccountsQueryOptions.retry).toBeFalsy()
|
||||
})
|
||||
|
||||
it("throws when not authenticated", async () => {
|
||||
mockGetCredentials.mockReturnValue(null)
|
||||
await expect(callQueryFn(adminListAccountsQueryOptions)).rejects.toThrow(
|
||||
"Not authenticated",
|
||||
)
|
||||
})
|
||||
|
||||
it("calls signedAdminFetch with the ListAccounts method and an empty body", async () => {
|
||||
const creds = {
|
||||
accessKeyId: "ASIAak",
|
||||
secretAccessKey: "sk",
|
||||
sessionToken: "token",
|
||||
expiration: new Date(Date.now() + 60_000).toISOString(),
|
||||
}
|
||||
mockGetCredentials.mockReturnValue(creds)
|
||||
mockSignedAdminFetch.mockResolvedValue({ accounts: [], count: 0 })
|
||||
|
||||
await callQueryFn(adminListAccountsQueryOptions)
|
||||
|
||||
expect(mockSignedAdminFetch).toHaveBeenCalledWith({
|
||||
method: "ListAccounts",
|
||||
credentials: creds,
|
||||
body: {},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("adminModelAccessQueryOptions", () => {
|
||||
it("has the correct query key", () => {
|
||||
expect(adminModelAccessQueryOptions("000000000002").queryKey).toStrictEqual(
|
||||
["admin", "modelAccess", "000000000002"],
|
||||
)
|
||||
})
|
||||
|
||||
it("is disabled when the account id is empty", () => {
|
||||
expect(adminModelAccessQueryOptions("").enabled).toBeFalsy()
|
||||
})
|
||||
|
||||
it("is enabled when the account id is non-empty", () => {
|
||||
expect(adminModelAccessQueryOptions("000000000002").enabled).toBeTruthy()
|
||||
})
|
||||
|
||||
it("throws when not authenticated", async () => {
|
||||
mockGetCredentials.mockReturnValue(null)
|
||||
await expect(
|
||||
callQueryFn(adminModelAccessQueryOptions("000000000002")),
|
||||
).rejects.toThrow("Not authenticated")
|
||||
})
|
||||
|
||||
it("calls signedFetch with ListModelAccess action and the account id", async () => {
|
||||
const creds = {
|
||||
accessKeyId: "ASIAak",
|
||||
secretAccessKey: "sk",
|
||||
sessionToken: "token",
|
||||
expiration: new Date(Date.now() + 60_000).toISOString(),
|
||||
}
|
||||
mockGetCredentials.mockReturnValue(creds)
|
||||
mockSignedFetch.mockResolvedValue({
|
||||
AccountId: "000000000002",
|
||||
ModelIds: [],
|
||||
})
|
||||
|
||||
await callQueryFn(adminModelAccessQueryOptions("000000000002"))
|
||||
|
||||
expect(mockSignedFetch).toHaveBeenCalledWith({
|
||||
action: "ListModelAccess",
|
||||
credentials: creds,
|
||||
params: { AccountId: "000000000002" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("grantModelAccess", () => {
|
||||
it("throws when not authenticated", async () => {
|
||||
mockGetCredentials.mockReturnValue(null)
|
||||
await expect(
|
||||
grantModelAccess({ accountId: "000000000002", modelId: "model-a" }),
|
||||
).rejects.toThrow("Not authenticated")
|
||||
})
|
||||
|
||||
it("calls signedFetch with GrantModelAccess action and params", async () => {
|
||||
const creds = {
|
||||
accessKeyId: "ASIAak",
|
||||
secretAccessKey: "sk",
|
||||
sessionToken: "token",
|
||||
expiration: new Date(Date.now() + 60_000).toISOString(),
|
||||
}
|
||||
mockGetCredentials.mockReturnValue(creds)
|
||||
mockSignedFetch.mockResolvedValue({
|
||||
AccountId: "000000000002",
|
||||
ModelId: "model-a",
|
||||
})
|
||||
|
||||
await grantModelAccess({ accountId: "000000000002", modelId: "model-a" })
|
||||
|
||||
expect(mockSignedFetch).toHaveBeenCalledWith({
|
||||
action: "GrantModelAccess",
|
||||
credentials: creds,
|
||||
params: { AccountId: "000000000002", ModelId: "model-a" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("revokeModelAccess", () => {
|
||||
it("throws when not authenticated", async () => {
|
||||
mockGetCredentials.mockReturnValue(null)
|
||||
await expect(
|
||||
revokeModelAccess({ accountId: "000000000002", modelId: "model-a" }),
|
||||
).rejects.toThrow("Not authenticated")
|
||||
})
|
||||
|
||||
it("calls signedFetch with RevokeModelAccess action and params", async () => {
|
||||
const creds = {
|
||||
accessKeyId: "ASIAak",
|
||||
secretAccessKey: "sk",
|
||||
sessionToken: "token",
|
||||
expiration: new Date(Date.now() + 60_000).toISOString(),
|
||||
}
|
||||
mockGetCredentials.mockReturnValue(creds)
|
||||
mockSignedFetch.mockResolvedValue({
|
||||
AccountId: "000000000002",
|
||||
ModelId: "model-a",
|
||||
})
|
||||
|
||||
await revokeModelAccess({ accountId: "000000000002", modelId: "model-a" })
|
||||
|
||||
expect(mockSignedFetch).toHaveBeenCalledWith({
|
||||
action: "RevokeModelAccess",
|
||||
credentials: creds,
|
||||
params: { AccountId: "000000000002", ModelId: "model-a" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { queryOptions } from "@tanstack/react-query"
|
||||
|
||||
import { getCredentials } from "@/lib/auth"
|
||||
import { signedFetch } from "@/lib/signed-fetch"
|
||||
import { signedAdminFetch, signedFetch } from "@/lib/signed-fetch"
|
||||
|
||||
interface InstanceTypeCap {
|
||||
name: string
|
||||
@@ -145,6 +145,32 @@ interface ListOchreCatalogOutput {
|
||||
entries: AdminCatalogEntry[]
|
||||
}
|
||||
|
||||
// ModelAccessList and ModelAccessChange mirror the gateway's wire shape,
|
||||
// which is PascalCase here unlike the catalog's camelCase.
|
||||
interface ModelAccessList {
|
||||
AccountId: string
|
||||
ModelIds: string[]
|
||||
}
|
||||
|
||||
interface ModelAccessChange {
|
||||
AccountId: string
|
||||
ModelId: string
|
||||
}
|
||||
|
||||
// AccountSummary and ListAccountsResponse mirror the /admin/ListAccounts
|
||||
// response, which — unlike the Action= surface — uses camelCase.
|
||||
interface AccountSummary {
|
||||
accountId: string
|
||||
accountName: string
|
||||
status: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface ListAccountsResponse {
|
||||
accounts: AccountSummary[]
|
||||
count: number
|
||||
}
|
||||
|
||||
export type {
|
||||
InstanceTypeCap,
|
||||
GPUSliceInfo,
|
||||
@@ -161,6 +187,10 @@ export type {
|
||||
CatalogAvailability,
|
||||
AdminCatalogEntry,
|
||||
ListOchreCatalogOutput,
|
||||
ModelAccessList,
|
||||
ModelAccessChange,
|
||||
AccountSummary,
|
||||
ListAccountsResponse,
|
||||
}
|
||||
|
||||
export const adminNodesQueryOptions = queryOptions({
|
||||
@@ -222,3 +252,76 @@ export const adminOchreCatalogQueryOptions = queryOptions({
|
||||
},
|
||||
staleTime: 10_000,
|
||||
})
|
||||
|
||||
// adminListAccountsQueryOptions may 403 for a session that is not a
|
||||
// long-lived IAM user in the super-admin account; retry: false so the page
|
||||
// gives up fast and falls back to the validated free-text account id.
|
||||
export const adminListAccountsQueryOptions = queryOptions({
|
||||
queryKey: ["admin", "accounts"],
|
||||
queryFn: async () => {
|
||||
const credentials = getCredentials()
|
||||
if (!credentials) {
|
||||
throw new Error("Not authenticated")
|
||||
}
|
||||
return await signedAdminFetch<ListAccountsResponse>({
|
||||
method: "ListAccounts",
|
||||
credentials,
|
||||
body: {},
|
||||
})
|
||||
},
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
export const adminModelAccessQueryOptions = (accountId: string) =>
|
||||
queryOptions({
|
||||
queryKey: ["admin", "modelAccess", accountId],
|
||||
queryFn: async () => {
|
||||
const credentials = getCredentials()
|
||||
if (!credentials) {
|
||||
throw new Error("Not authenticated")
|
||||
}
|
||||
return await signedFetch<ModelAccessList>({
|
||||
action: "ListModelAccess",
|
||||
credentials,
|
||||
params: { AccountId: accountId },
|
||||
})
|
||||
},
|
||||
enabled: accountId !== "",
|
||||
staleTime: 10_000,
|
||||
})
|
||||
|
||||
interface ModelAccessMutationInput {
|
||||
accountId: string
|
||||
modelId: string
|
||||
}
|
||||
|
||||
export async function grantModelAccess({
|
||||
accountId,
|
||||
modelId,
|
||||
}: ModelAccessMutationInput): Promise<ModelAccessChange> {
|
||||
const credentials = getCredentials()
|
||||
if (!credentials) {
|
||||
throw new Error("Not authenticated")
|
||||
}
|
||||
return await signedFetch<ModelAccessChange>({
|
||||
action: "GrantModelAccess",
|
||||
credentials,
|
||||
params: { AccountId: accountId, ModelId: modelId },
|
||||
})
|
||||
}
|
||||
|
||||
export async function revokeModelAccess({
|
||||
accountId,
|
||||
modelId,
|
||||
}: ModelAccessMutationInput): Promise<ModelAccessChange> {
|
||||
const credentials = getCredentials()
|
||||
if (!credentials) {
|
||||
throw new Error("Not authenticated")
|
||||
}
|
||||
return await signedFetch<ModelAccessChange>({
|
||||
action: "RevokeModelAccess",
|
||||
credentials,
|
||||
params: { AccountId: accountId, ModelId: modelId },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ import { Route as AuthBedrockguardrailsListGuardrailsIndexRouteImport } from './
|
||||
import { Route as AuthBedrockguardrailsListGuardrailsGuardrailIdRouteImport } from './routes/_auth/bedrock/(guardrails)/list-guardrails/$guardrailId'
|
||||
import { Route as AuthBedrockknowledgeBasesListKnowledgeBasesIndexRouteImport } from './routes/_auth/bedrock/(knowledge-bases)/list-knowledge-bases/index'
|
||||
import { Route as AuthBedrockknowledgeBasesListKnowledgeBasesKnowledgeBaseIdRouteImport } from './routes/_auth/bedrock/(knowledge-bases)/list-knowledge-bases/$knowledgeBaseId'
|
||||
import { Route as AuthBedrockmodelAccessListModelAccessIndexRouteImport } from './routes/_auth/bedrock/(model-access)/list-model-access/index'
|
||||
import { Route as AuthBedrockplaygroundPlaygroundIndexRouteImport } from './routes/_auth/bedrock/(playground)/playground/index'
|
||||
import { Route as AuthEc2elasticIpsDescribeAddressesIndexRouteImport } from './routes/_auth/ec2/(elastic-ips)/describe-addresses/index'
|
||||
import { Route as AuthEc2elasticIpsDescribeAddressesIdRouteImport } from './routes/_auth/ec2/(elastic-ips)/describe-addresses/$id'
|
||||
@@ -353,6 +354,12 @@ const AuthBedrockknowledgeBasesListKnowledgeBasesKnowledgeBaseIdRoute =
|
||||
path: '/bedrock/list-knowledge-bases/$knowledgeBaseId',
|
||||
getParentRoute: () => AuthRouteRoute,
|
||||
} as any)
|
||||
const AuthBedrockmodelAccessListModelAccessIndexRoute =
|
||||
AuthBedrockmodelAccessListModelAccessIndexRouteImport.update({
|
||||
id: '/bedrock/(model-access)/list-model-access/',
|
||||
path: '/bedrock/list-model-access/',
|
||||
getParentRoute: () => AuthRouteRoute,
|
||||
} as any)
|
||||
const AuthBedrockplaygroundPlaygroundIndexRoute =
|
||||
AuthBedrockplaygroundPlaygroundIndexRouteImport.update({
|
||||
id: '/bedrock/(playground)/playground/',
|
||||
@@ -805,6 +812,7 @@ export interface FileRoutesByFullPath {
|
||||
'/bedrock/list-model-catalog/': typeof AuthBedrockcatalogListModelCatalogIndexRoute
|
||||
'/bedrock/list-guardrails/': typeof AuthBedrockguardrailsListGuardrailsIndexRoute
|
||||
'/bedrock/list-knowledge-bases/': typeof AuthBedrockknowledgeBasesListKnowledgeBasesIndexRoute
|
||||
'/bedrock/list-model-access/': typeof AuthBedrockmodelAccessListModelAccessIndexRoute
|
||||
'/bedrock/playground/': typeof AuthBedrockplaygroundPlaygroundIndexRoute
|
||||
'/ec2/describe-addresses/': typeof AuthEc2elasticIpsDescribeAddressesIndexRoute
|
||||
'/ec2/describe-images/': typeof AuthEc2imagesDescribeImagesIndexRoute
|
||||
@@ -910,6 +918,7 @@ export interface FileRoutesByTo {
|
||||
'/bedrock/list-model-catalog': typeof AuthBedrockcatalogListModelCatalogIndexRoute
|
||||
'/bedrock/list-guardrails': typeof AuthBedrockguardrailsListGuardrailsIndexRoute
|
||||
'/bedrock/list-knowledge-bases': typeof AuthBedrockknowledgeBasesListKnowledgeBasesIndexRoute
|
||||
'/bedrock/list-model-access': typeof AuthBedrockmodelAccessListModelAccessIndexRoute
|
||||
'/bedrock/playground': typeof AuthBedrockplaygroundPlaygroundIndexRoute
|
||||
'/ec2/describe-addresses': typeof AuthEc2elasticIpsDescribeAddressesIndexRoute
|
||||
'/ec2/describe-images': typeof AuthEc2imagesDescribeImagesIndexRoute
|
||||
@@ -1018,6 +1027,7 @@ export interface FileRoutesById {
|
||||
'/_auth/bedrock/(catalog)/list-model-catalog/': typeof AuthBedrockcatalogListModelCatalogIndexRoute
|
||||
'/_auth/bedrock/(guardrails)/list-guardrails/': typeof AuthBedrockguardrailsListGuardrailsIndexRoute
|
||||
'/_auth/bedrock/(knowledge-bases)/list-knowledge-bases/': typeof AuthBedrockknowledgeBasesListKnowledgeBasesIndexRoute
|
||||
'/_auth/bedrock/(model-access)/list-model-access/': typeof AuthBedrockmodelAccessListModelAccessIndexRoute
|
||||
'/_auth/bedrock/(playground)/playground/': typeof AuthBedrockplaygroundPlaygroundIndexRoute
|
||||
'/_auth/ec2/(elastic-ips)/describe-addresses/': typeof AuthEc2elasticIpsDescribeAddressesIndexRoute
|
||||
'/_auth/ec2/(images)/describe-images/': typeof AuthEc2imagesDescribeImagesIndexRoute
|
||||
@@ -1126,6 +1136,7 @@ export interface FileRouteTypes {
|
||||
| '/bedrock/list-model-catalog/'
|
||||
| '/bedrock/list-guardrails/'
|
||||
| '/bedrock/list-knowledge-bases/'
|
||||
| '/bedrock/list-model-access/'
|
||||
| '/bedrock/playground/'
|
||||
| '/ec2/describe-addresses/'
|
||||
| '/ec2/describe-images/'
|
||||
@@ -1231,6 +1242,7 @@ export interface FileRouteTypes {
|
||||
| '/bedrock/list-model-catalog'
|
||||
| '/bedrock/list-guardrails'
|
||||
| '/bedrock/list-knowledge-bases'
|
||||
| '/bedrock/list-model-access'
|
||||
| '/bedrock/playground'
|
||||
| '/ec2/describe-addresses'
|
||||
| '/ec2/describe-images'
|
||||
@@ -1338,6 +1350,7 @@ export interface FileRouteTypes {
|
||||
| '/_auth/bedrock/(catalog)/list-model-catalog/'
|
||||
| '/_auth/bedrock/(guardrails)/list-guardrails/'
|
||||
| '/_auth/bedrock/(knowledge-bases)/list-knowledge-bases/'
|
||||
| '/_auth/bedrock/(model-access)/list-model-access/'
|
||||
| '/_auth/bedrock/(playground)/playground/'
|
||||
| '/_auth/ec2/(elastic-ips)/describe-addresses/'
|
||||
| '/_auth/ec2/(images)/describe-images/'
|
||||
@@ -1673,6 +1686,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthBedrockknowledgeBasesListKnowledgeBasesKnowledgeBaseIdRouteImport
|
||||
parentRoute: typeof AuthRouteRoute
|
||||
}
|
||||
'/_auth/bedrock/(model-access)/list-model-access/': {
|
||||
id: '/_auth/bedrock/(model-access)/list-model-access/'
|
||||
path: '/bedrock/list-model-access'
|
||||
fullPath: '/bedrock/list-model-access/'
|
||||
preLoaderRoute: typeof AuthBedrockmodelAccessListModelAccessIndexRouteImport
|
||||
parentRoute: typeof AuthRouteRoute
|
||||
}
|
||||
'/_auth/bedrock/(playground)/playground/': {
|
||||
id: '/_auth/bedrock/(playground)/playground/'
|
||||
path: '/bedrock/playground'
|
||||
@@ -2201,6 +2221,7 @@ interface AuthRouteRouteChildren {
|
||||
AuthBedrockcatalogListModelCatalogIndexRoute: typeof AuthBedrockcatalogListModelCatalogIndexRoute
|
||||
AuthBedrockguardrailsListGuardrailsIndexRoute: typeof AuthBedrockguardrailsListGuardrailsIndexRoute
|
||||
AuthBedrockknowledgeBasesListKnowledgeBasesIndexRoute: typeof AuthBedrockknowledgeBasesListKnowledgeBasesIndexRoute
|
||||
AuthBedrockmodelAccessListModelAccessIndexRoute: typeof AuthBedrockmodelAccessListModelAccessIndexRoute
|
||||
AuthBedrockplaygroundPlaygroundIndexRoute: typeof AuthBedrockplaygroundPlaygroundIndexRoute
|
||||
AuthEc2elasticIpsDescribeAddressesIndexRoute: typeof AuthEc2elasticIpsDescribeAddressesIndexRoute
|
||||
AuthEc2imagesDescribeImagesIndexRoute: typeof AuthEc2imagesDescribeImagesIndexRoute
|
||||
@@ -2344,6 +2365,8 @@ const AuthRouteRouteChildren: AuthRouteRouteChildren = {
|
||||
AuthBedrockguardrailsListGuardrailsIndexRoute,
|
||||
AuthBedrockknowledgeBasesListKnowledgeBasesIndexRoute:
|
||||
AuthBedrockknowledgeBasesListKnowledgeBasesIndexRoute,
|
||||
AuthBedrockmodelAccessListModelAccessIndexRoute:
|
||||
AuthBedrockmodelAccessListModelAccessIndexRoute,
|
||||
AuthBedrockplaygroundPlaygroundIndexRoute:
|
||||
AuthBedrockplaygroundPlaygroundIndexRoute,
|
||||
AuthEc2elasticIpsDescribeAddressesIndexRoute:
|
||||
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
import { screen, waitFor } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
type AdminQueries = typeof import("@/queries/admin")
|
||||
|
||||
const { mockGrantModelAccess, mockRevokeModelAccess } = vi.hoisted(() => ({
|
||||
mockGrantModelAccess: vi.fn<AdminQueries["grantModelAccess"]>(),
|
||||
mockRevokeModelAccess: vi.fn<AdminQueries["revokeModelAccess"]>(),
|
||||
}))
|
||||
|
||||
vi.mock("@/queries/admin", async () => {
|
||||
const actual = await vi.importActual<AdminQueries>("@/queries/admin")
|
||||
return {
|
||||
...actual,
|
||||
grantModelAccess: mockGrantModelAccess,
|
||||
revokeModelAccess: mockRevokeModelAccess,
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
adminListAccountsQueryOptions,
|
||||
adminModelAccessQueryOptions,
|
||||
adminOchreCatalogQueryOptions,
|
||||
type AccountSummary,
|
||||
type AdminCatalogEntry,
|
||||
} from "@/queries/admin"
|
||||
import {
|
||||
createTestQueryClient,
|
||||
renderWithClient,
|
||||
} from "@/test/elbv2-integration"
|
||||
|
||||
import { ModelAccessPage } from "./model-access-page"
|
||||
|
||||
const CATALOG_ENTRY = {
|
||||
modelId: "meta.llama3-2-1b-instruct-v1:0",
|
||||
modelName: "Llama 3.2 1B Instruct",
|
||||
family: "vllm",
|
||||
inputModalities: ["TEXT"],
|
||||
outputModalities: ["TEXT"],
|
||||
responseStreamingSupported: false,
|
||||
inputPriceMicroUsdPerMillion: 0,
|
||||
outputPriceMicroUsdPerMillion: 0,
|
||||
priceKnown: false,
|
||||
minVramMib: 5120,
|
||||
instanceType: "g5.xlarge",
|
||||
coServeGroup: "ochre-demo-bundle",
|
||||
availability: "available",
|
||||
} satisfies AdminCatalogEntry
|
||||
|
||||
const OTHER_ENTRY = {
|
||||
...CATALOG_ENTRY,
|
||||
modelId: "anthropic.claude-3-haiku",
|
||||
modelName: "Claude 3 Haiku",
|
||||
} satisfies AdminCatalogEntry
|
||||
|
||||
const ACCOUNT_ID = "000000000002"
|
||||
const ACCOUNT_NAME = "Tenant One"
|
||||
|
||||
const ACCOUNT = {
|
||||
accountId: ACCOUNT_ID,
|
||||
accountName: ACCOUNT_NAME,
|
||||
status: "ACTIVE",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
} satisfies AccountSummary
|
||||
|
||||
function seed({
|
||||
entries = [],
|
||||
grantedModelIds = [],
|
||||
accounts,
|
||||
}: {
|
||||
entries?: AdminCatalogEntry[]
|
||||
grantedModelIds?: string[]
|
||||
accounts?: AccountSummary[]
|
||||
}) {
|
||||
const qc = createTestQueryClient()
|
||||
qc.setQueryData(adminOchreCatalogQueryOptions.queryKey, { entries })
|
||||
qc.setQueryData(adminModelAccessQueryOptions(ACCOUNT_ID).queryKey, {
|
||||
AccountId: ACCOUNT_ID,
|
||||
ModelIds: grantedModelIds,
|
||||
})
|
||||
if (accounts !== undefined) {
|
||||
qc.setQueryData(adminListAccountsQueryOptions.queryKey, {
|
||||
accounts,
|
||||
count: accounts.length,
|
||||
})
|
||||
}
|
||||
return qc
|
||||
}
|
||||
|
||||
async function selectAccount(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByLabelText("Account"))
|
||||
await user.click(
|
||||
screen.getByRole("option", { name: new RegExp(ACCOUNT_NAME) }),
|
||||
)
|
||||
}
|
||||
|
||||
describe("ModelAccessPage", () => {
|
||||
describe("account picker", () => {
|
||||
it("lists accounts sorted by name, excluding the global account", async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<ModelAccessPage />,
|
||||
seed({
|
||||
accounts: [
|
||||
{ ...ACCOUNT, accountId: "000000000003", accountName: "Zeta Co" },
|
||||
ACCOUNT,
|
||||
{
|
||||
accountId: "000000000000",
|
||||
accountName: "Global",
|
||||
status: "ACTIVE",
|
||||
createdAt: "",
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
await user.click(screen.getByLabelText("Account"))
|
||||
|
||||
const options = screen.getAllByRole("option").map((el) => el.textContent)
|
||||
expect(options).toStrictEqual([
|
||||
"Tenant One (000000000002) — ACTIVE",
|
||||
"Zeta Co (000000000003) — ACTIVE",
|
||||
])
|
||||
})
|
||||
|
||||
it("selecting an account drives the catalog cross-referenced with grants", async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<ModelAccessPage />,
|
||||
seed({
|
||||
entries: [CATALOG_ENTRY, OTHER_ENTRY],
|
||||
grantedModelIds: [CATALOG_ENTRY.modelId],
|
||||
accounts: [ACCOUNT],
|
||||
}),
|
||||
)
|
||||
|
||||
await selectAccount(user)
|
||||
|
||||
expect(screen.getByText("Llama 3.2 1B Instruct")).toBeInTheDocument()
|
||||
expect(screen.getByText("Claude 3 Haiku")).toBeInTheDocument()
|
||||
expect(screen.getByText("Granted")).toBeInTheDocument()
|
||||
expect(screen.getByText("Ungranted")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("grants access with the selected account id", async () => {
|
||||
mockGrantModelAccess.mockResolvedValue({
|
||||
AccountId: ACCOUNT_ID,
|
||||
ModelId: OTHER_ENTRY.modelId,
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<ModelAccessPage />,
|
||||
seed({ entries: [OTHER_ENTRY], accounts: [ACCOUNT] }),
|
||||
)
|
||||
|
||||
await selectAccount(user)
|
||||
await user.click(screen.getByRole("button", { name: "Grant" }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGrantModelAccess.mock.calls[0]?.[0]).toStrictEqual({
|
||||
accountId: ACCOUNT_ID,
|
||||
modelId: OTHER_ENTRY.modelId,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("revokes access with the selected account id", async () => {
|
||||
mockRevokeModelAccess.mockResolvedValue({
|
||||
AccountId: ACCOUNT_ID,
|
||||
ModelId: CATALOG_ENTRY.modelId,
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<ModelAccessPage />,
|
||||
seed({
|
||||
entries: [CATALOG_ENTRY],
|
||||
grantedModelIds: [CATALOG_ENTRY.modelId],
|
||||
accounts: [ACCOUNT],
|
||||
}),
|
||||
)
|
||||
|
||||
await selectAccount(user)
|
||||
await user.click(screen.getByRole("button", { name: "Revoke" }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRevokeModelAccess.mock.calls[0]?.[0]).toStrictEqual({
|
||||
accountId: ACCOUNT_ID,
|
||||
modelId: CATALOG_ENTRY.modelId,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("free-text fallback", () => {
|
||||
it("falls back to a validated text input when the account list is empty", () => {
|
||||
renderWithClient(<ModelAccessPage />, seed({ accounts: [] }))
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Enter a 12-digit account ID — account list unavailable.",
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("falls back to a validated text input when the account list errors", async () => {
|
||||
// adminListAccountsQueryOptions is left unseeded, so its real queryFn
|
||||
// runs and throws "Not authenticated" (no stored session in this env).
|
||||
renderWithClient(<ModelAccessPage />, seed({}))
|
||||
await expect(
|
||||
screen.findByText(
|
||||
"Enter a 12-digit account ID — account list unavailable.",
|
||||
),
|
||||
).resolves.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("rejects a non-12-digit account id and does not show the catalog", async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<ModelAccessPage />,
|
||||
seed({ entries: [CATALOG_ENTRY], accounts: [] }),
|
||||
)
|
||||
|
||||
await user.type(screen.getByPlaceholderText("123456789012"), "12345")
|
||||
|
||||
expect(
|
||||
screen.getByText("Enter a 12-digit numeric account ID."),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText("Llama 3.2 1B Instruct"),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows the catalog once a valid 12-digit account id is entered", async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<ModelAccessPage />,
|
||||
seed({
|
||||
entries: [CATALOG_ENTRY],
|
||||
grantedModelIds: [CATALOG_ENTRY.modelId],
|
||||
accounts: [],
|
||||
}),
|
||||
)
|
||||
|
||||
await user.type(screen.getByPlaceholderText("123456789012"), ACCOUNT_ID)
|
||||
|
||||
expect(screen.getByText("Llama 3.2 1B Instruct")).toBeInTheDocument()
|
||||
expect(screen.getByText("Granted")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("grants access with the validated free-text account id", async () => {
|
||||
mockGrantModelAccess.mockResolvedValue({
|
||||
AccountId: ACCOUNT_ID,
|
||||
ModelId: CATALOG_ENTRY.modelId,
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<ModelAccessPage />,
|
||||
seed({ entries: [CATALOG_ENTRY], accounts: [] }),
|
||||
)
|
||||
|
||||
await user.type(screen.getByPlaceholderText("123456789012"), ACCOUNT_ID)
|
||||
await user.click(screen.getByRole("button", { name: "Grant" }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGrantModelAccess.mock.calls[0]?.[0]).toStrictEqual({
|
||||
accountId: ACCOUNT_ID,
|
||||
modelId: CATALOG_ENTRY.modelId,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
|
||||
import { PageHeading } from "@/components/page-heading"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Field, FieldTitle } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
adminListAccountsQueryOptions,
|
||||
adminModelAccessQueryOptions,
|
||||
adminOchreCatalogQueryOptions,
|
||||
grantModelAccess,
|
||||
revokeModelAccess,
|
||||
type AccountSummary,
|
||||
type AdminCatalogEntry,
|
||||
} from "@/queries/admin"
|
||||
|
||||
// GlobalAccountID is granted every model unconditionally, so it is not a
|
||||
// meaningful target for this page and is excluded from the picker.
|
||||
const GLOBAL_ACCOUNT_ID = "000000000000"
|
||||
|
||||
const ACCOUNT_ID_RE = /^[0-9]{12}$/
|
||||
|
||||
function accountLabel(account: AccountSummary): string {
|
||||
return `${account.accountName} (${account.accountId}) — ${account.status}`
|
||||
}
|
||||
|
||||
function ModelAccessRow({
|
||||
entry,
|
||||
accountId,
|
||||
granted,
|
||||
}: {
|
||||
entry: AdminCatalogEntry
|
||||
accountId: string
|
||||
granted: boolean
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const modelAccessQueryKey = adminModelAccessQueryOptions(accountId).queryKey
|
||||
|
||||
const grant = useMutation({
|
||||
mutationFn: grantModelAccess,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: modelAccessQueryKey })
|
||||
},
|
||||
})
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationFn: revokeModelAccess,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: modelAccessQueryKey })
|
||||
},
|
||||
})
|
||||
|
||||
const pending = grant.isPending || revoke.isPending
|
||||
|
||||
return (
|
||||
<tr className="border-b last:border-0" key={entry.modelId}>
|
||||
<td className="py-1.5 pr-4">
|
||||
<div className="font-medium">{entry.modelName}</div>
|
||||
<div className="font-mono text-muted-foreground">{entry.modelId}</div>
|
||||
</td>
|
||||
<td className="py-1.5 pr-4">
|
||||
<Badge
|
||||
className="text-[0.625rem]"
|
||||
variant={granted ? "default" : "secondary"}
|
||||
>
|
||||
{granted ? "Granted" : "Ungranted"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-1.5">
|
||||
{granted ? (
|
||||
<Button
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
revoke.mutate({ accountId, modelId: entry.modelId })
|
||||
}}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
grant.mutate({ accountId, modelId: entry.modelId })
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Grant
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelAccessTable({
|
||||
entries,
|
||||
accountId,
|
||||
grantedModelIds,
|
||||
}: {
|
||||
entries: AdminCatalogEntry[]
|
||||
accountId: string
|
||||
grantedModelIds: Set<string>
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pr-4 pb-1 font-medium">Model</th>
|
||||
<th className="pr-4 pb-1 font-medium">Access</th>
|
||||
<th className="pb-1 font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<ModelAccessRow
|
||||
accountId={accountId}
|
||||
entry={entry}
|
||||
granted={grantedModelIds.has(entry.modelId)}
|
||||
key={entry.modelId}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelAccessAccountPicker({
|
||||
accounts,
|
||||
accountId,
|
||||
onAccountIdChange,
|
||||
}: {
|
||||
accounts: AccountSummary[]
|
||||
accountId: string
|
||||
onAccountIdChange: (accountId: string) => void
|
||||
}) {
|
||||
const sorted = accounts.toSorted((a, b) =>
|
||||
a.accountName.localeCompare(b.accountName),
|
||||
)
|
||||
return (
|
||||
<Field>
|
||||
<FieldTitle>
|
||||
<label htmlFor="model-access-account">Account</label>
|
||||
</FieldTitle>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
onAccountIdChange(value ?? "")
|
||||
}}
|
||||
value={accountId}
|
||||
>
|
||||
<SelectTrigger className="w-full" id="model-access-account">
|
||||
<SelectValue placeholder="Select an account" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sorted.map((account) => (
|
||||
<SelectItem key={account.accountId} value={account.accountId}>
|
||||
{accountLabel(account)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-[0.625rem] text-muted-foreground">
|
||||
Select an account.
|
||||
</p>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelAccessAccountFallback({
|
||||
accountId,
|
||||
onAccountIdChange,
|
||||
}: {
|
||||
accountId: string
|
||||
onAccountIdChange: (accountId: string) => void
|
||||
}) {
|
||||
const invalid = accountId !== "" && !ACCOUNT_ID_RE.test(accountId)
|
||||
return (
|
||||
<Field>
|
||||
<FieldTitle>
|
||||
<label htmlFor="model-access-account">Account ID</label>
|
||||
</FieldTitle>
|
||||
<Input
|
||||
aria-invalid={invalid}
|
||||
id="model-access-account"
|
||||
onChange={(event) => {
|
||||
onAccountIdChange(event.target.value)
|
||||
}}
|
||||
placeholder="123456789012"
|
||||
value={accountId}
|
||||
/>
|
||||
<p className="mt-1 text-[0.625rem] text-muted-foreground">
|
||||
{invalid
|
||||
? "Enter a 12-digit numeric account ID."
|
||||
: "Enter a 12-digit account ID — account list unavailable."}
|
||||
</p>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelAccessBody({
|
||||
accountId,
|
||||
entries,
|
||||
grantedModelIds,
|
||||
}: {
|
||||
accountId: string
|
||||
entries: AdminCatalogEntry[]
|
||||
grantedModelIds: Set<string>
|
||||
}) {
|
||||
if (accountId === "") {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter an account ID to view and manage its model access.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
return <p className="text-xs text-muted-foreground">No models found.</p>
|
||||
}
|
||||
return (
|
||||
<ModelAccessTable
|
||||
accountId={accountId}
|
||||
entries={entries}
|
||||
grantedModelIds={grantedModelIds}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelAccessPage() {
|
||||
const [accountId, setAccountId] = useState("")
|
||||
|
||||
const accountsQuery = useQuery(adminListAccountsQueryOptions)
|
||||
const accounts = (accountsQuery.data?.accounts ?? []).filter(
|
||||
(account) => account.accountId !== GLOBAL_ACCOUNT_ID,
|
||||
)
|
||||
const pickerAvailable = accountsQuery.isSuccess && accounts.length > 0
|
||||
|
||||
const validAccountId = pickerAvailable
|
||||
? accountId !== ""
|
||||
: ACCOUNT_ID_RE.test(accountId)
|
||||
const effectiveAccountId = validAccountId ? accountId : ""
|
||||
|
||||
const { data: catalogData } = useQuery(adminOchreCatalogQueryOptions)
|
||||
const { data: accessData } = useQuery(
|
||||
adminModelAccessQueryOptions(effectiveAccountId),
|
||||
)
|
||||
|
||||
const entries = (catalogData?.entries ?? []).toSorted((a, b) =>
|
||||
a.modelName.localeCompare(b.modelName),
|
||||
)
|
||||
const grantedModelIds = new Set(accessData?.ModelIds)
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeading title="Model Access" />
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="mb-3 max-w-sm">
|
||||
{pickerAvailable ? (
|
||||
<ModelAccessAccountPicker
|
||||
accountId={accountId}
|
||||
accounts={accounts}
|
||||
onAccountIdChange={setAccountId}
|
||||
/>
|
||||
) : (
|
||||
<ModelAccessAccountFallback
|
||||
accountId={accountId}
|
||||
onAccountIdChange={setAccountId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ModelAccessBody
|
||||
accountId={effectiveAccountId}
|
||||
entries={entries}
|
||||
grantedModelIds={grantedModelIds}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router"
|
||||
|
||||
import { useAdmin } from "@/contexts/admin-context"
|
||||
|
||||
import { ModelAccessPage } from "../-components/model-access-page"
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_auth/bedrock/(model-access)/list-model-access/",
|
||||
)({
|
||||
head: () => ({
|
||||
meta: [{ title: "Model Access | Ochre | Mulga" }],
|
||||
}),
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
const { isAdmin } = useAdmin()
|
||||
|
||||
if (!isAdmin) {
|
||||
throw redirect({ to: "/" })
|
||||
}
|
||||
|
||||
return <ModelAccessPage />
|
||||
}
|
||||
+1
-1
@@ -162,7 +162,7 @@ describe("PlaygroundPage", () => {
|
||||
// A bare Enter sends without clicking the button.
|
||||
await user.type(input, "{Enter}")
|
||||
await expect(screen.findByText("hi")).resolves.toBeInTheDocument()
|
||||
expect(mockSend).toHaveBeenCalledTimes(1)
|
||||
expect(mockSend).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("renders assistant markdown as formatted HTML", async () => {
|
||||
|
||||
Reference in New Issue
Block a user