Skip to content
Merged
6 changes: 4 additions & 2 deletions docs/guides/integration-bootstrap.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,10 @@ Even in that case:

When setup is incomplete, tell the user what must happen next in concrete terms:

- what connect URL to open
- what provider settings or redirect URI to register
- what connect URL to open (`https://heykody.dev/...` — the origin users open
Kody on)
- what provider settings or redirect URI to register (exactly
`https://heykody.dev/connect/oauth`)
- that you are waiting for confirmation before building the dependent package or
package app
- that you will run a minimal authenticated verification step after setup
Expand Down
29 changes: 22 additions & 7 deletions docs/guides/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ package or package app that depends on the resulting integration or tokens.

## Default path: `/connect/oauth`

Send the signed-in user to `/connect/oauth` on your deployment host with query
Send the signed-in user to `https://heykody.dev/connect/oauth` with query
parameters that describe the provider. The page runs authorize -> callback ->
token exchange in a full browser context and persists access and refresh tokens
through the account secrets flow.
Expand All @@ -17,15 +17,28 @@ This path does not require package-app-specific OAuth code.

Example shape:

`https://<your-kody-origin>/connect/oauth?provider=...&authorizeUrl=...&tokenUrl=...`
`https://heykody.dev/connect/oauth?provider=...&authorizeUrl=...&tokenUrl=...`

## Redirect URI

Register this redirect URI in the provider console:
The redirect URI is:

`{origin}/connect/oauth`
`https://heykody.dev/connect/oauth`

Use the same origin the user uses to open Kody.
Register it in the provider console exactly as written. Users connect to Kody at
`https://heykody.dev`, so connect URLs use `https://heykody.dev/...`. The
`/connect/oauth` page shows the redirect URI for the current origin with a copy
button. A self-hosted deployment uses its own origin plus `/connect/oauth`.

## Provider setup checklist

The provider-side setup is the same for every provider:

1. Create an OAuth app in the provider's developer console.
2. Register the exact redirect URI above.
3. Enable any APIs and scopes the integration needs.
4. Paste the client ID (and client secret for confidential flows) into the
`/connect/oauth` setup form in Kody.

## Required query parameters

Expand Down Expand Up @@ -127,8 +140,10 @@ routes) are for clients authenticating to Kody itself.
## Agent checklist

1. Confirm OAuth is the right auth shape.
2. Build the `/connect/oauth` URL with the required params.
3. Tell the user the exact redirect URI to register.
2. Build the connect URL with the required params:
`https://heykody.dev/connect/oauth?...`.
3. Tell the user the exact redirect URI to register:
`https://heykody.dev/connect/oauth`. The page shows it with a copy button.
4. Have the user open the URL while signed in and wait for success.
5. Run the authenticated smoke test from `integration_bootstrap`.
6. Continue with the package or package app only after the smoke test passes.
Expand Down
65 changes: 62 additions & 3 deletions packages/worker/client/provider-icons.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { type Handle } from 'remix/ui'

/**
* Inline brand marks for the social login providers. Inlined as SVG (no
* external assets) so they server-render with the buttons and inherit sizing
* and color from the surrounding text.
* Inline brand marks for the social login providers and integration provider
* suggestions. Inlined as SVG (no external assets) so they server-render with
* the buttons and inherit sizing and color from the surrounding text.
*/
const iconSize = '1.25em'

Expand Down Expand Up @@ -63,10 +63,69 @@ function renderXIcon() {
)
}

function renderSlackIcon() {
return (
<svg
viewBox="0 0 24 24"
width={iconSize}
height={iconSize}
aria-hidden="true"
>
<path
fill="#E01E5A"
d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522z"
/>
<path
fill="#36C5F0"
d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521z"
/>
<path
fill="#2EB67D"
d="M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522zm-1.27 0a2.528 2.528 0 0 1-2.522 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.164 0a2.528 2.528 0 0 1 2.522 2.522z"
/>
<path
fill="#ECB22E"
d="M15.165 18.956a2.528 2.528 0 0 1 2.522 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522zm0-1.27a2.527 2.527 0 0 1-2.52-2.521 2.527 2.527 0 0 1 2.52-2.521h6.313A2.528 2.528 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.521z"
/>
</svg>
)
}

function renderSpotifyIcon() {
return (
<svg
viewBox="0 0 24 24"
width={iconSize}
height={iconSize}
aria-hidden="true"
fill="#1DB954"
>
<path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" />
</svg>
)
}

function renderDiscordIcon() {
return (
<svg
viewBox="0 0 24 24"
width={iconSize}
height={iconSize}
aria-hidden="true"
fill="#5865F2"
>
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
)
}

const providerIconRenderers: Record<string, () => JSX.Element> = {
github: renderGitHubIcon,
google: renderGoogleIcon,
x: renderXIcon,
slack: renderSlackIcon,
spotify: renderSpotifyIcon,
discord: renderDiscordIcon,
}

export function ProviderIcon(handle: Handle<{ providerId: string }>) {
Expand Down
33 changes: 32 additions & 1 deletion packages/worker/client/routes/account-integrations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,27 @@ function buildConnectOauthHref(integration: AccountIntegrationListItem) {
return `/connect/oauth?${params.toString()}`
}

function PlugIcon() {
return (
<svg
viewBox="0 0 24 24"
width="1.25em"
height="1.25em"
aria-hidden="true"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 22v-5" />
<path d="M9 8V2" />
<path d="M15 8V2" />
<path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z" />
</svg>
)
}

function renderIntegrationDetail(label: string, value: string) {
return (
<div mix={css(detailItemCss)}>
Expand Down Expand Up @@ -377,7 +398,17 @@ export function AccountIntegrationsRoute(handle: Handle) {
<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>
<h3
mix={css({
...cardTitleCss,
display: 'flex',
alignItems: 'center',
gap: spacing.sm,
})}
>
{PlugIcon()}
Something else
</h3>
<p mix={css(descriptionCss)}>
If it has an API, your Kody can learn to use it.
</p>
Expand Down
75 changes: 71 additions & 4 deletions packages/worker/client/routes/account-remote-connectors.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { formatTimestamp } from '#client/format-timestamp.ts'
import { type Handle, css } from 'remix/ui'
import toggle from 'remix/ui/toggle'
import { on } from '#client/event-mixin.ts'
import { passwordManagerIgnoreProps } from '#client/password-manager-ignore.ts'
import { readCurrentRouterHref } from '#client/client-router.tsx'
Expand All @@ -24,7 +23,14 @@ import {
AccountManagementSidebar,
AccountPageHeader,
} from '#client/routes/account-management-components.tsx'
import { colors, radius, spacing, typography } from '#client/styles/tokens.ts'
import {
colors,
radius,
shadows,
spacing,
transitions,
typography,
} from '#client/styles/tokens.ts'
import {
cardCss,
cardTitleCss,
Expand Down Expand Up @@ -261,6 +267,65 @@ function CopyToClipboard(handle: Handle<{ url: string }>) {
}
}

/**
* Theme-aware switch styling for the enabled/attached checkboxes. The
* `remix/ui/toggle` mixin ships hardcoded light-mode colors that clash with
* the app theme (especially in dark mode), so the track and thumb are built
* from the shared design tokens instead.
*/
const switchCss = {
appearance: 'none' as const,
WebkitAppearance: 'none' as const,
position: 'relative' as const,
flex: 'none',
width: '2.75rem',
height: '1.5rem',
margin: 0,
padding: 0,
border: `1px solid ${colors.border}`,
borderRadius: radius.full,
backgroundColor: colors.background,
cursor: 'pointer',
transition: `background-color ${transitions.normal}, border-color ${transitions.normal}`,
'&::before': {
content: '""',
position: 'absolute' as const,
top: '50%',
left: '0.125rem',
width: '1.125rem',
height: '1.125rem',
borderRadius: radius.full,
backgroundColor: colors.textMuted,
boxShadow: shadows.sm,
transform: 'translateY(-50%)',
transition: `transform ${transitions.normal}, background-color ${transitions.normal}`,
},
'&:not(:disabled):hover': {
borderColor: colors.primary,
},
'&:checked': {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
'&:checked::before': {
backgroundColor: colors.onPrimary,
transform: 'translateY(-50%) translateX(1.25rem)',
},
'&:focus-visible': {
outline: 'none',
boxShadow: `0 0 0 3px ${colors.primarySoftStrong}`,
},
'&:disabled': {
cursor: 'not-allowed',
opacity: 0.55,
},
'@media (prefers-reduced-motion: reduce)': {
'&::before': {
transition: 'none',
},
},
}

const iconButtonCss = {
position: 'absolute' as const,
right: spacing.sm,
Expand Down Expand Up @@ -828,10 +893,11 @@ export function AccountRemoteConnectorsRoute(handle: Handle) {
<input
name="enabled"
type="checkbox"
role="switch"
checked={editorState.enabled}
disabled={isMutating}
mix={[
toggle(),
css(switchCss),
on('change', (event) => {
editorState = {
...editorState,
Expand Down Expand Up @@ -861,10 +927,11 @@ export function AccountRemoteConnectorsRoute(handle: Handle) {
<input
name="attached"
type="checkbox"
role="switch"
checked={editorState.attached}
disabled={isMutating || !editorState.enabled}
mix={[
toggle(),
css(switchCss),
on('change', (event) => {
if (!editorState.enabled) {
event.currentTarget.checked = editorState.attached
Expand Down
Loading
Loading