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
2 changes: 1 addition & 1 deletion e2e/social-login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ test('social login signs in via mock GitHub and manages connections', async ({

await expect(page).toHaveURL(/\/account$/)
await expect(
page.getByRole('heading', { name: 'mock-github-user account' }),
page.getByRole('heading', { name: 'Account', exact: true }),
).toBeVisible()
// The provider-verified email skips the verification-email flow entirely.
await expect(
Expand Down
120 changes: 60 additions & 60 deletions packages/worker/client/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,19 @@ export function App(handle: Handle<AppProps>) {
marginBottom: spacing.lg,
}

const navGroupCss = {
display: 'flex',
alignItems: 'center',
gap: spacing.md,
flexWrap: 'wrap',
[mq.tablet]: {
gap: spacing.sm,
},
[mq.mobile]: {
gap: spacing.sm,
},
}

return () => {
currentPathname = readRouterPathname(handle)
const sessionEmail = session?.email ?? ''
Expand Down Expand Up @@ -166,73 +179,60 @@ export function App(handle: Handle<AppProps>) {
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
flexWrap: 'wrap',
[mq.tablet]: compactNavCss,
[mq.mobile]: compactNavCss,
})}
>
<a href="/" aria-label="Home" mix={css(navHomeLinkCss)}>
<img
src="/logo.png"
alt=""
width={112}
height={28}
mix={css({
display: 'block',
height: '1.35em',
width: 'auto',
})}
/>
</a>
<a href="/community" mix={css(primaryLinkCss)}>
Community
</a>
{showAuthLinks ? (
<>
<a href={loginHref} mix={css(primaryLinkCss)}>
Login
</a>
<a href={signupHref} mix={css(primaryLinkCss)}>
Signup
</a>
</>
) : null}
{isLoggedIn ? (
<>
<a href="/account" mix={css(primaryLinkCss)}>
{sessionDisplayName}
</a>
<a href="/account/secrets" mix={css(primaryLinkCss)}>
Secrets
</a>
<a href="/account/integrations" mix={css(primaryLinkCss)}>
Integrations
</a>
<a
href="/account/package-invocation-tokens"
mix={css(primaryLinkCss)}
>
Package tokens
</a>
<a href="/account/remote-connectors" mix={css(primaryLinkCss)}>
Connectors
</a>
<a href="/account/mcp-servers" mix={css(primaryLinkCss)}>
MCP servers
</a>
{showAdminLink ? (
<a href="/admin/users" mix={css(primaryLinkCss)}>
Admin
<div mix={css(navGroupCss)}>
<a href="/" aria-label="Home" mix={css(navHomeLinkCss)}>
<img
src="/logo.png"
alt=""
width={112}
height={28}
mix={css({
display: 'block',
height: '1.35em',
width: 'auto',
})}
/>
</a>
<a href="/community" mix={css(primaryLinkCss)}>
Community
</a>
</div>
<div mix={css(navGroupCss)}>
{showAuthLinks ? (
<>
<a href={loginHref} mix={css(primaryLinkCss)}>
Login
</a>
<a href={signupHref} mix={css(primaryLinkCss)}>
Signup
</a>
</>
) : null}
{isLoggedIn ? (
<>
{showAdminLink ? (
<a href="/admin/users" mix={css(primaryLinkCss)}>
Admin
</a>
) : null}
<a href="/account" mix={css(primaryLinkCss)}>
{sessionDisplayName}
</a>
) : null}
<form method="post" action="/logout" mix={css({ margin: 0 })}>
<button type="submit" mix={css(logOutButtonCss)}>
Log out
</button>
</form>
</>
) : null}
<form method="post" action="/logout" mix={css({ margin: 0 })}>
<button type="submit" mix={css(logOutButtonCss)}>
Log out
</button>
</form>
</>
) : null}
</div>
</nav>
<main mix={css({ width: '100%', boxSizing: 'border-box' })}>
<Router
Expand Down
59 changes: 59 additions & 0 deletions packages/worker/client/copy-text-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { type Handle, css } from 'remix/ui'
import { writeClipboardText } from '#client/clipboard.ts'
import { on } from '#client/event-mixin.ts'
import {
getPrimaryButtonCss,
getSecondaryButtonCss,
} from '#client/styles/style-primitives.ts'

type CopyTextButtonProps = {
value: string
idleLabel?: string
variant?: 'primary' | 'secondary'
}

/**
* Button that copies `value` to the clipboard and shows transient
* "Copied" / "Copy failed" feedback before returning to the idle label.
*/
export function CopyTextButton(handle: Handle<CopyTextButtonProps>) {
let copyState: 'idle' | 'copied' | 'error' = 'idle'
let resetTimerId: ReturnType<typeof setTimeout> | null = null

async function copyValue() {
try {
await writeClipboardText(handle.props.value)
copyState = 'copied'
} catch {
copyState = 'error'
}
handle.update()
if (resetTimerId != null) clearTimeout(resetTimerId)
resetTimerId = setTimeout(() => {
resetTimerId = null
if (handle.signal.aborted) return
copyState = 'idle'
handle.update()
}, 2000)
}

return () => (
<button
type="button"
mix={[
css(
handle.props.variant === 'secondary'
? getSecondaryButtonCss()
: getPrimaryButtonCss(),
),
on('click', () => void copyValue()),
]}
>
{copyState === 'copied'
? 'Copied'
: copyState === 'error'
? 'Copy failed'
: (handle.props.idleLabel ?? 'Copy')}
</button>
)
}
111 changes: 86 additions & 25 deletions packages/worker/client/routes/account-integrations.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { formatTimestamp } from '#client/format-timestamp.ts'
import { type Handle, css } from 'remix/ui'
import { readCurrentRouterHref } from '#client/client-router.tsx'
import { CopyTextButton } from '#client/copy-text-button.tsx'
import { createRouteLoadLatch } from '#client/route-load-latch.ts'
import { tryConsumeRouteLoaderData } from '#client/loader-data-context.tsx'
import { consumeStaleNavigationData } from '#client/navigation-data.ts'
import { ProviderIcon } from '#client/provider-icons.tsx'
import {
type AccountStatus,
readJson,
Expand All @@ -13,10 +15,16 @@ import {
type RouteLoaderResult,
} from '#client/route-loader.ts'
import {
AccountManagementHeader,
AccountManagementMessage,
AccountManagementShell,
AccountPageHeader,
} from '#client/routes/account-management-components.tsx'
import { renderByokExplainer } from '#client/routes/byok-explainer.tsx'
import {
buildCustomIntegrationSetupPrompt,
buildIntegrationSetupPrompt,
integrationProviderSuggestions,
} from '#client/routes/integration-provider-catalog.ts'
import { colors, radius, spacing, typography } from '#client/styles/tokens.ts'
import {
cardCss,
Expand Down Expand Up @@ -67,6 +75,12 @@ type AccountIntegrationsPayload = {
const accountIntegrationsApiPath = '/account/integrations.json'
const accountIntegrationsPath = '/account/integrations'

const providerCatalogGridCss = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(min(22rem, 100%), 1fr))',
gap: spacing.lg,
}

function isAccountIntegrationsPath(href: string) {
return new URL(href, 'http://localhost').pathname === accountIntegrationsPath
}
Expand Down Expand Up @@ -118,7 +132,6 @@ function renderIntegrationDetail(label: string, value: string) {

export function AccountIntegrationsRoute(handle: Handle) {
let status: AccountStatus = 'loading'
let email = ''
let integrations: Array<AccountIntegrationListItem> = []
let message: string | null = null
const loadLatch = createRouteLoadLatch()
Expand All @@ -140,7 +153,6 @@ export function AccountIntegrationsRoute(handle: Handle) {
if (!response.ok || !payload?.ok) {
throw new Error('Unable to load integrations.')
}
email = payload.email
integrations = payload.integrations
status = 'ready'
message = null
Expand All @@ -164,7 +176,6 @@ export function AccountIntegrationsRoute(handle: Handle) {
href,
)
if (!routeData) return false
email = routeData.email
integrations = routeData.integrations
status = 'ready'
message = null
Expand All @@ -189,11 +200,17 @@ export function AccountIntegrationsRoute(handle: Handle) {
handle.queueTask(loadIntegrations)
}

const setupIntro =
integrations.length === 0
? 'No integrations yet. Pick a service and copy its prompt into your agent — setup takes a few minutes.'
: 'Add another service: copy a prompt into your agent.'

return (
<AccountManagementShell>
<AccountManagementHeader
title={email ? `${email} integrations` : 'Integrations'}
description="Review saved OAuth integrations and reconnect providers when tokens need to be refreshed."
<AccountPageHeader
title="Integrations"
description="Services Kody can act on for you — built on OAuth apps you create and own."
currentHref={currentHref}
/>

{status === 'loading' ? (
Expand All @@ -209,25 +226,8 @@ export function AccountIntegrationsRoute(handle: Handle) {
</AccountManagementMessage>
) : null}

{status === 'ready' && integrations.length === 0 ? (
<section mix={css(cardCss)}>
<h2 mix={css(cardTitleCss)}>No integrations yet</h2>
<p mix={css(descriptionCss)}>
OAuth integrations appear here after Kody saves provider
configuration through an OAuth setup flow.
</p>
</section>
) : null}

{status === 'ready' && integrations.length > 0 ? (
<section
mix={css({
display: 'grid',
gridTemplateColumns:
'repeat(auto-fit, minmax(min(22rem, 100%), 1fr))',
gap: spacing.lg,
})}
>
<section mix={css(providerCatalogGridCss)}>
{integrations.map((integration) => {
const connectHref = buildConnectOauthHref(integration)
return (
Expand Down Expand Up @@ -335,6 +335,67 @@ export function AccountIntegrationsRoute(handle: Handle) {
</section>
) : null}

{status === 'ready' ? (
<>
{renderByokExplainer({ image: 'keys' })}

<section mix={css({ display: 'grid', gap: spacing.lg })}>
<div mix={css({ display: 'grid', gap: spacing.xs })}>
<h2 mix={css(sectionTitleCss)}>Set up with your agent</h2>
<p mix={css(descriptionCss)}>{setupIntro}</p>
</div>

<div mix={css(providerCatalogGridCss)}>
{integrationProviderSuggestions.map((provider) => (
<article key={provider.id} mix={css(cardCss)}>
<div mix={css({ display: 'grid', gap: spacing.md })}>
<div mix={css({ display: 'grid', gap: spacing.xs })}>
<h3
mix={css({
...cardTitleCss,
display: 'flex',
alignItems: 'center',
gap: spacing.sm,
})}
>
<ProviderIcon providerId={provider.id} />
{provider.name}
</h3>
<p mix={css(descriptionCss)}>{provider.tagline}</p>
</div>
<div>
<CopyTextButton
value={buildIntegrationSetupPrompt(provider)}
idleLabel="Copy setup prompt"
variant="secondary"
/>
</div>
</div>
</article>
))}

<article mix={css(cardCss)}>
<div mix={css({ display: 'grid', gap: spacing.md })}>
<div mix={css({ display: 'grid', gap: spacing.xs })}>
<h3 mix={css(cardTitleCss)}>Something else</h3>
<p mix={css(descriptionCss)}>
If it has an API, your Kody can learn to use it.
</p>
</div>
<div>
<CopyTextButton
value={buildCustomIntegrationSetupPrompt()}
idleLabel="Copy setup prompt"
variant="secondary"
/>
</div>
</div>
</article>
</div>
</section>
</>
) : null}

<p mix={css({ margin: 0 })}>
<a href="/account" mix={css(primaryLinkCss)}>
Back to account
Expand Down
Loading
Loading