Skip to content
8 changes: 7 additions & 1 deletion docs/contributing/package-invocation-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ The backing same-origin JSON endpoint is:
- `POST /account/package-invocation-tokens.json` with `action: "create"` —
create a token from a raw token and allowlists
- `POST /account/package-invocation-tokens.json` with `action: "update"` —
update token name and allowlists without changing the stored token hash
update token name, allowlists, and optionally replace the stored token hash
from a new raw token
- `POST /account/package-invocation-tokens.json` with `action: "revoke"` —
revoke a token by id
- `POST /account/package-invocation-tokens.json` with `action: "unrevoke"` —
Expand Down Expand Up @@ -118,6 +119,11 @@ Snake case and kebab case aliases are accepted for list fields, such as
values. Do not put the raw token in the URL; paste it into the form or submit it
over the authenticated same-origin JSON API.

When editing an existing token in the account UI, the raw token field is
write-only and optional: leave it blank to preserve the current token hash, or
paste a new raw token to rotate the bearer value. Existing raw token material
and stored hashes are never returned by list/detail payloads.

Agents should load `kody_official_guide` with
`guide: "package_invocation_token_setup"` before sending a user to this page.

Expand Down
7 changes: 7 additions & 0 deletions docs/guides/account-package-invocation-token-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ Use it when:
Do **not** ask the user to paste bearer tokens into chat. Do **not** include a
`rawToken`, `token`, `bearer`, or token hash query parameter.

Existing token values can be rotated from the account token editor. That field
is write-only and optional: leaving it blank keeps the current bearer value,
while pasting a new raw token replaces the stored hash. The UI never shows the
existing raw token or token hash.

## URL format

Provide the user a URL like:
Expand Down Expand Up @@ -62,6 +67,8 @@ package id scope.
scope, export scope, and optional `allowedSources`.
3. Ask the user to open the URL, paste their locally generated raw token into
the Raw token field, and create the token.
- For rotation, ask the user to open the existing token detail URL and paste
the new raw token into the write-only replacement field.
4. Instruct the external caller to send:
- `Authorization: Bearer <raw-token>`
- JSON `source` matching one of the allowed sources when sources are scoped
Expand Down
320 changes: 320 additions & 0 deletions packages/worker/client/routes/account-management-components.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,320 @@
import { css, type Handle } from 'remix/ui'
import { on } from '#client/event-mixin.ts'
import {
colors,
mq,
radius,
spacing,
typography,
} from '#client/styles/tokens.ts'
import {
cardCss,
cardTitleCss,
descriptionCss,
fieldLabelCss,
} from '#client/styles/style-primitives.ts'

type AccountManagementSlot = any

type AccountManagementShellProps = {
maxWidth?: string
children: AccountManagementSlot
}

export function AccountManagementShell(
handle: Handle<AccountManagementShellProps>,
) {
return () => (
<section
mix={css({
maxWidth: handle.props.maxWidth ?? '96rem',
margin: '0 auto',
display: 'grid',
gap: spacing.xl,
})}
>
{handle.props.children}
</section>
)
}

type AccountManagementHeaderProps = {
title: string
description: string
actions?: AccountManagementSlot
}

export function AccountManagementHeader(
handle: Handle<AccountManagementHeaderProps>,
) {
return () => (
<header
mix={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: spacing.md,
flexWrap: 'wrap',
})}
>
<div mix={css({ display: 'grid', gap: spacing.xs })}>
<h1
mix={css({
fontSize: typography.fontSize.xl,
fontWeight: typography.fontWeight.semibold,
color: colors.text,
margin: 0,
})}
>
{handle.props.title}
</h1>
<p mix={css({ color: colors.textMuted, margin: 0 })}>
{handle.props.description}
</p>
</div>
{handle.props.actions ? (
<div
mix={css({
display: 'flex',
gap: spacing.sm,
flexWrap: 'wrap',
})}
>
{handle.props.actions}
</div>
) : null}
</header>
)
}

type AccountManagementMessageProps = {
children: AccountManagementSlot
tone?: 'info' | 'error'
}

export function AccountManagementMessage(
handle: Handle<AccountManagementMessageProps>,
) {
return () => (
<p
role="alert"
mix={css({
color: handle.props.tone === 'error' ? colors.error : colors.text,
margin: 0,
})}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
>
{handle.props.children}
</p>
)
}

type AccountManagementLayoutProps = {
sidebar: AccountManagementSlot
children: AccountManagementSlot
sidebarWidth?: string
}

export function AccountManagementLayout(
handle: Handle<AccountManagementLayoutProps>,
) {
return () => (
<section
mix={css({
display: 'grid',
gridTemplateColumns: `${handle.props.sidebarWidth ?? 'minmax(18rem, 22rem)'} minmax(0, 1fr)`,
gap: spacing.lg,
alignItems: 'start',
[mq.mobile]: {
gridTemplateColumns: '1fr',
},
})}
>
{handle.props.sidebar}
<div mix={css({ display: 'grid', gap: spacing.lg })}>
{handle.props.children}
</div>
</section>
)
}

type AccountManagementSidebarProps = {
title: string
description: string
children: AccountManagementSlot
}

export function AccountManagementSidebar(
handle: Handle<AccountManagementSidebarProps>,
) {
return () => (
<aside
mix={css({
...cardCss,
alignSelf: 'start',
})}
>
<div mix={css({ display: 'grid', gap: spacing.xs })}>
<h2 mix={css(cardTitleCss)}>{handle.props.title}</h2>
<p mix={css(descriptionCss)}>{handle.props.description}</p>
</div>
{handle.props.children}
</aside>
)
}

type AccountManagementListProps = {
children: AccountManagementSlot
maxHeight?: string
}

export function AccountManagementList(
handle: Handle<AccountManagementListProps>,
) {
return () => (
<div
mix={css({
maxHeight: handle.props.maxHeight,
overflowY: handle.props.maxHeight ? 'auto' : undefined,
overflowX: handle.props.maxHeight ? 'hidden' : undefined,
paddingRight: handle.props.maxHeight ? spacing.xs : undefined,
})}
>
<ul
mix={css({
listStyle: 'none',
padding: 0,
margin: 0,
display: 'grid',
gap: spacing.sm,
})}
>
{handle.props.children}
</ul>
</div>
)
}

type AccountManagementListItemButtonProps = {
active: boolean
onClick: () => void
children: AccountManagementSlot
}

export function AccountManagementListItemButton(
handle: Handle<AccountManagementListItemButtonProps>,
) {
return () => (
<button
type="button"
mix={[
on('click', handle.props.onClick),
css({
width: '100%',
minWidth: 0,
textAlign: 'left',
display: 'grid',
gap: spacing.xs,
padding: spacing.md,
overflow: 'hidden',
borderRadius: radius.md,
border: `1px solid ${
handle.props.active ? colors.primary : colors.border
}`,
backgroundColor: handle.props.active
? colors.primarySoftest
: colors.background,
color: colors.text,
cursor: 'pointer',
}),
]}
>
{handle.props.children}
</button>
)
}

type AccountManagementPanelProps = {
title?: string
description?: string
children?: AccountManagementSlot
asForm?: boolean
onSubmit?: (event: SubmitEvent) => void
}

export function AccountManagementPanel(
handle: Handle<AccountManagementPanelProps>,
) {
const content = () => (
<>
{handle.props.title || handle.props.description ? (
<div mix={css({ display: 'grid', gap: spacing.xs })}>
{handle.props.title ? (
<h2 mix={css(cardTitleCss)}>{handle.props.title}</h2>
) : null}
{handle.props.description ? (
<p mix={css(descriptionCss)}>{handle.props.description}</p>
) : null}
</div>
) : null}
{handle.props.children}
</>
)

return () =>
handle.props.asForm ? (
<form
method="post"
noValidate
mix={[
css(cardCss),
handle.props.onSubmit
? on('submit', (event) => handle.props.onSubmit?.(event))
: null,
]}
>
{content()}
</form>
) : (
<section mix={css(cardCss)}>{content()}</section>
)
}

type MetadataGridProps = {
items: Array<{
label: string
value: AccountManagementSlot
}>
columns?: 2 | 3
}

export function MetadataGrid(handle: Handle<MetadataGridProps>) {
return () => (
<dl
mix={css({
display: 'grid',
gridTemplateColumns: `repeat(${handle.props.columns ?? 2}, minmax(0, 1fr))`,
gap: spacing.md,
margin: 0,
[mq.mobile]: {
gridTemplateColumns: '1fr',
},
})}
>
{handle.props.items.map((item) => (
<div key={item.label}>
<dt mix={css(fieldLabelCss)}>{item.label}</dt>
<dd mix={css({ margin: 0, color: colors.text })}>{item.value}</dd>
</div>
))}
</dl>
)
}

export const noticeCardCss = {
display: 'grid',
gap: spacing.md,
padding: spacing.lg,
borderRadius: radius.lg,
border: `1px solid ${colors.primary}`,
backgroundColor: colors.primarySoftest,
}
Loading