diff --git a/e2e/account-email.spec.ts b/e2e/account-email.spec.ts new file mode 100644 index 0000000000..eda4bebbee --- /dev/null +++ b/e2e/account-email.spec.ts @@ -0,0 +1,28 @@ +import { expect, test } from './playwright-utils.ts' + +test('verified user sees the account email inbox heading and address or empty list', async ({ + page, + seedE2eUser, + login, +}) => { + const runId = Date.now() + const user = await seedE2eUser({ + email: `account-email-${runId}@example.com`, + username: `account-email-${runId}`, + password: 'account-email-password', + }) + await login({ + email: user.email, + password: user.password, + mode: 'login', + }) + + await page.goto('/account/email') + await expect( + page.getByRole('heading', { name: 'Email inbox', exact: true }), + ).toBeVisible() + + const inboxAddress = page.locator('text=/Inbox address:/i') + const emptyList = page.getByText('No messages in your inbox yet.') + await expect(inboxAddress.or(emptyList).first()).toBeVisible() +}) diff --git a/e2e/account-jobs.spec.ts b/e2e/account-jobs.spec.ts new file mode 100644 index 0000000000..4c6a08841c --- /dev/null +++ b/e2e/account-jobs.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from './playwright-utils.ts' +import { quoteSqlString } from '@kody-internal/shared/sql-literals.ts' +import { createStableUserIdFromEmail } from '../packages/worker/src/user-id.ts' +import { executeE2eD1Command } from './d1-utils.ts' + +test('account jobs page shows heading after login', async ({ + page, + seedE2eUser, + login, +}) => { + const runId = Date.now() + const user = await seedE2eUser({ + email: `account-jobs-${runId}@example.com`, + username: `account-jobs-${runId}`, + password: 'account-jobs-password', + }) + await login({ + email: user.email, + password: user.password, + mode: 'login', + }) + + await page.goto('/account/jobs') + await expect( + page.getByRole('heading', { name: 'Jobs', exact: true }), + ).toBeVisible() + await expect( + page.getByRole('heading', { name: 'Select a job', exact: true }), + ).toBeVisible() +}) + +test('account jobs detail URL persists across reload for a seeded job', async ({ + page, + seedE2eUser, + login, +}) => { + const runId = Date.now() + const email = `account-jobs-detail-${runId}@example.com` + const user = await seedE2eUser({ + email, + username: `account-jobs-detail-${runId}`, + password: 'account-jobs-password', + }) + const userId = await createStableUserIdFromEmail(email) + const jobId = crypto.randomUUID() + const jobName = `Seeded job ${runId}` + const now = new Date().toISOString() + const nextRunAt = new Date(Date.now() + 60 * 60 * 1000).toISOString() + executeE2eD1Command( + `INSERT INTO jobs ( + id, user_id, name, source_id, published_commit, storage_id, + params_json, schedule_json, timezone, enabled, kill_switch_enabled, + caller_context_json, created_at, updated_at, next_run_at, + run_count, success_count, error_count, run_history_json + ) VALUES ( + ${quoteSqlString(jobId)}, + ${quoteSqlString(userId)}, + ${quoteSqlString(jobName)}, + ${quoteSqlString(`source-${jobId}`)}, + NULL, + ${quoteSqlString(`job:${jobId}`)}, + NULL, + ${quoteSqlString(JSON.stringify({ type: 'interval', every: '1h' }))}, + 'UTC', + 1, + 0, + 'null', + ${quoteSqlString(now)}, + ${quoteSqlString(now)}, + ${quoteSqlString(nextRunAt)}, + 0, + 0, + 0, + '[]' + )`, + ) + + await login({ + email: user.email, + password: user.password, + mode: 'login', + }) + + await page.goto(`/account/jobs/${encodeURIComponent(jobId)}`) + await expect( + page.getByRole('heading', { name: 'Jobs', exact: true }), + ).toBeVisible() + await expect( + page.getByRole('heading', { name: jobName, exact: true }), + ).toBeVisible() + + const detailUrl = page.url() + await page.reload() + await expect(page).toHaveURL(detailUrl) + await expect( + page.getByRole('heading', { name: jobName, exact: true }), + ).toBeVisible() + await expect( + page.getByRole('definition').filter({ hasText: 'Runs every 1h' }), + ).toBeVisible() +}) diff --git a/e2e/account-memories.spec.ts b/e2e/account-memories.spec.ts new file mode 100644 index 0000000000..05a6aed2ab --- /dev/null +++ b/e2e/account-memories.spec.ts @@ -0,0 +1,86 @@ +import { expect, test } from './playwright-utils.ts' +import { quoteSqlString } from '@kody-internal/shared/sql-literals.ts' +import { createStableUserIdFromEmail } from '../packages/worker/src/user-id.ts' +import { executeE2eD1Command } from './d1-utils.ts' + +test('account memories list shows empty state', async ({ + page, + seedE2eUser, + login, +}) => { + const runId = Date.now() + const user = await seedE2eUser({ + email: `account-memories-${runId}@example.com`, + username: `account-memories-${runId}`, + password: 'account-memories-password', + }) + await login({ + email: user.email, + password: user.password, + mode: 'login', + }) + + await page.goto('/account/memories') + await expect( + page.getByRole('heading', { name: 'Memories', exact: true }), + ).toBeVisible() + await expect( + page.getByRole('heading', { name: 'Select a memory', exact: true }), + ).toBeVisible() +}) + +test('account memories detail URL shows a seeded memory', async ({ + page, + seedE2eUser, + login, +}) => { + const runId = Date.now() + const email = `account-memories-detail-${runId}@example.com` + const user = await seedE2eUser({ + email, + username: `account-memories-detail-${runId}`, + password: 'account-memories-password', + }) + const userId = await createStableUserIdFromEmail(email) + const memoryId = crypto.randomUUID() + const now = new Date().toISOString() + executeE2eD1Command( + `INSERT INTO mcp_memories ( + id, user_id, category, status, subject, summary, details, tags_json, + source_uris_json, dedupe_key, created_at, updated_at, last_accessed_at, deleted_at + ) VALUES ( + ${quoteSqlString(memoryId)}, + ${quoteSqlString(userId)}, + 'preferences', + 'active', + ${quoteSqlString(`Seeded memory ${runId}`)}, + 'Seeded for account memories e2e coverage.', + 'Details for the seeded memory.', + ${quoteSqlString(JSON.stringify(['e2e']))}, + ${quoteSqlString(JSON.stringify(['https://example.com/seeded-memory']))}, + ${quoteSqlString(`e2e:memory:${runId}`)}, + ${quoteSqlString(now)}, + ${quoteSqlString(now)}, + NULL, + NULL + )`, + ) + + await login({ + email: user.email, + password: user.password, + mode: 'login', + }) + + await page.goto(`/account/memories/${memoryId}`) + await expect( + page.getByRole('heading', { name: 'Memories', exact: true }), + ).toBeVisible() + await expect( + page.getByRole('heading', { + name: `Seeded memory ${runId}`, + exact: true, + }), + ).toBeVisible() + await expect(page.getByText('Details for the seeded memory.')).toBeVisible() +}) diff --git a/e2e/account-values.spec.ts b/e2e/account-values.spec.ts new file mode 100644 index 0000000000..4e4d99927b --- /dev/null +++ b/e2e/account-values.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from './playwright-utils.ts' + +test('account values use URL-backed list/detail selection', async ({ + page, + seedE2eUser, + login, +}) => { + const runId = Date.now() + const user = await seedE2eUser({ + email: `account-values-${runId}@example.com`, + username: `account-values-${runId}`, + password: 'account-values-password', + }) + await login({ + email: user.email, + password: user.password, + mode: 'login', + }) + + await page.goto('/account/values') + await expect( + page.getByRole('heading', { name: 'Values', exact: true }), + ).toBeVisible() + + await page.getByRole('button', { name: 'New value' }).click() + await expect(page).toHaveURL(/\/account\/values\/new$/) + await expect( + page.getByRole('heading', { name: 'New value', exact: true }), + ).toBeVisible() + + const valueName = `preferred-locale-${runId}` + const valueBody = `en-US-${runId}` + await page.getByLabel('Name', { exact: true }).fill(valueName) + await page.getByLabel('Description').fill('Preferred locale') + await page.getByLabel('Value', { exact: true }).fill(valueBody) + await page.getByRole('button', { name: 'Create value' }).click() + + await expect(page).toHaveURL( + new RegExp(`/account/values/${encodeURIComponent(valueName)}$`), + ) + await expect( + page.getByRole('heading', { name: valueName, exact: true }), + ).toBeVisible() + + const detailUrl = page.url() + await page.reload() + await expect(page).toHaveURL(detailUrl) + await expect( + page.getByRole('heading', { name: valueName, exact: true }), + ).toBeVisible() + await expect(page.getByLabel('Name', { exact: true })).toHaveValue(valueName) + await expect(page.getByLabel('Name', { exact: true })).toHaveAttribute( + 'readonly', + '', + ) + await expect(page.getByLabel('Value', { exact: true })).toHaveValue(valueBody) + + await page.getByLabel('Search', { exact: true }).fill(valueName) + await expect(page).toHaveURL(new RegExp(`q=${valueName}`)) + await expect(page.getByRole('button', { name: valueName })).toBeVisible() +}) diff --git a/packages/worker/client/routes/account-email.tsx b/packages/worker/client/routes/account-email.tsx new file mode 100644 index 0000000000..5cefac7f15 --- /dev/null +++ b/packages/worker/client/routes/account-email.tsx @@ -0,0 +1,736 @@ +import { buildAdminEmailHtmlPreviewDocument } from '#client/email-html-preview.ts' +import { formatNullableTimestamp } from '#client/format-timestamp.ts' +import { type Handle, css } from 'remix/ui' +import { Tab, TabList, TabPanel, Tabs } from 'remix/ui/tabs' +import { on } from '#client/event-mixin.ts' +import { navigate, readCurrentRouterHref } from '#client/client-router.tsx' +import { createListDetailRoute } from '#client/list-detail-route.ts' +import { replaceLocation } from '#client/replace-location.ts' +import { tryConsumeRouteLoaderData } from '#client/loader-data-context.tsx' +import { consumeStaleNavigationData } from '#client/navigation-data.ts' +import { readJson } from '#client/routes/account-approval-shared.ts' +import { + routeLoaderRedirect, + type RouteLoaderResult, +} from '#client/route-loader.ts' +import { + AccountManagementLayout, + AccountManagementList, + AccountManagementListItemButton, + AccountManagementMessage, + AccountManagementSearchField, + AccountManagementShell, + AccountManagementSidebar, + AccountPageHeader, + MetadataGrid, +} from '#client/routes/account-management-components.tsx' +import { colors, radius, spacing, typography } from '#client/styles/tokens.ts' +import { + cardCss, + getSecondaryButtonCss, +} from '#client/styles/style-primitives.ts' +import { + type AccountEmailLoaderData, + type AccountEmailMessageDetail, + type AccountEmailMessageListItem, +} from '#app/loader-data.ts' + +type PageStatus = 'loading' | 'ready' | 'error' + +const accountEmailApiPath = '/account/email.json' +const emailRoute = createListDetailRoute('/account/email') + +const emailBodyPreCss = css({ + margin: 0, + whiteSpace: 'pre-wrap', + overflowX: 'auto', +}) + +const emailBodyEmptyCss = css({ + margin: 0, + color: colors.textMuted, +}) + +const emailHtmlPreviewIframeCss = css({ + display: 'block', + width: '100%', + minHeight: '24rem', + border: `1px solid ${colors.border}`, + borderRadius: radius.md, + background: colors.surface, +}) + +const truncatedTextCss = { + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +} as const + +function readSearchQuery(href: string) { + return new URL(href, 'http://localhost').searchParams.get('q')?.trim() ?? '' +} + +function readPage(href: string) { + const raw = new URL(href, 'http://localhost').searchParams.get('page') + const parsed = raw ? Number.parseInt(raw, 10) : 1 + return Number.isFinite(parsed) && parsed > 0 ? parsed : 1 +} + +/** + * List window depends on search + page; selection also needs a server fetch + * for message bodies, so the data key includes the selected message id. + */ +function getDataKey(href: string) { + const url = new URL(href, 'http://localhost') + const selectedId = emailRoute.getSelection(href).selectedId ?? '' + return `${url.pathname}?q=${readSearchQuery(href)}&page=${readPage(href)}&pageSize=${url.searchParams.get('pageSize') ?? ''}&selected=${selectedId}` +} + +function buildEmailApiRequestUrl(href: string) { + const url = new URL(href, 'http://localhost') + const requestUrl = new URL(accountEmailApiPath, 'http://localhost') + const query = readSearchQuery(href) + if (query) requestUrl.searchParams.set('q', query) + const page = url.searchParams.get('page') + if (page) requestUrl.searchParams.set('page', page) + const pageSize = url.searchParams.get('pageSize') + if (pageSize) requestUrl.searchParams.set('pageSize', pageSize) + const selectedId = emailRoute.getSelection(href).selectedId + if (selectedId) requestUrl.searchParams.set('selected', selectedId) + return `${requestUrl.pathname}${requestUrl.search}` +} + +export async function accountEmailRouteLoader( + url: URL, + signal: AbortSignal, +): Promise { + const href = `${url.pathname}${url.search}` + const response = await fetch(buildEmailApiRequestUrl(href), { + headers: { Accept: 'application/json' }, + credentials: 'include', + signal, + }) + if (response.status === 401) { + return routeLoaderRedirect('/login') + } + const payload = await readJson(response) + if (!response.ok || !payload?.ok) { + throw new Error('Unable to load your email inbox.') + } + return { accountEmail: payload } +} + +function messageDate(message: AccountEmailMessageListItem) { + return message.received_at ?? message.sent_at ?? message.created_at +} + +function statusLabel(message: AccountEmailMessageListItem) { + if (message.delivery_status) return message.delivery_status + return message.processing_status +} + +function directionLabel(direction: AccountEmailMessageListItem['direction']) { + return direction === 'outbound' ? 'Outbound' : 'Inbound' +} + +function consumeAccountEmailPayload(handle: Handle, href: string) { + return tryConsumeRouteLoaderData(handle, 'accountEmail', href) +} + +export function AccountEmailRoute(handle: Handle) { + let status: PageStatus = 'loading' + let data: AccountEmailLoaderData | null = null + let message: string | null = null + let loadRequestId = 0 + let lastLoadedDataKey = '' + let loadingDataKey: string | null = null + let lastFailedDataKey: string | null = null + + const secondaryButtonCss = getSecondaryButtonCss() + + function getCurrentHref() { + return readCurrentRouterHref(handle) + } + + function getCurrentSearch() { + return new URL(getCurrentHref(), 'http://localhost').search + } + + function buildHrefWithUpdatedSearch(search: string) { + const nextUrl = new URL(getCurrentHref(), 'http://localhost') + if (search) nextUrl.searchParams.set('q', search) + else nextUrl.searchParams.delete('q') + nextUrl.searchParams.delete('page') + return `${nextUrl.pathname}${nextUrl.search}` + } + + function buildHrefWithPage(page: number) { + const nextUrl = new URL(getCurrentHref(), 'http://localhost') + if (page > 1) nextUrl.searchParams.set('page', String(page)) + else nextUrl.searchParams.delete('page') + return `${nextUrl.pathname}${nextUrl.search}` + } + + function applyPayload(payload: AccountEmailLoaderData, href: string) { + data = payload + const selectedId = emailRoute.getSelection(href).selectedId + message = !payload.emailVerified + ? payload.verificationMessage + : selectedId && !payload.selectedMessage + ? 'Message not found.' + : null + status = 'ready' + lastLoadedDataKey = getDataKey(href) + lastFailedDataKey = null + } + + async function loadAccountEmail() { + const href = getCurrentHref() + const dataKey = getDataKey(href) + loadingDataKey = dataKey + const requestId = ++loadRequestId + try { + const response = await fetch(buildEmailApiRequestUrl(href), { + headers: { Accept: 'application/json' }, + credentials: 'include', + }) + if ( + requestId !== loadRequestId || + getDataKey(getCurrentHref()) !== dataKey + ) + return + if (response.status === 401) { + window.location.assign('/login') + return + } + const payload = await readJson(response) + if (!response.ok || !payload?.ok) { + throw new Error('Unable to load your email inbox.') + } + applyPayload(payload, href) + handle.update() + } catch (error) { + if ( + requestId !== loadRequestId || + getDataKey(getCurrentHref()) !== dataKey + ) + return + status = 'error' + message = + error instanceof Error + ? error.message + : 'Unable to load your email inbox.' + lastFailedDataKey = dataKey + handle.update() + } finally { + if (requestId === loadRequestId && loadingDataKey === dataKey) { + loadingDataKey = null + } + } + } + + function applyRouteLoaderData(href: string) { + if (!emailRoute.isRoutePath(href)) return false + const routeData = consumeAccountEmailPayload(handle, href) + if (!routeData) return false + applyPayload(routeData, href) + return true + } + + let lastSeenDataKey = '' + + return () => { + const currentHref = getCurrentHref() + const currentDataKey = getDataKey(currentHref) + if (currentDataKey !== lastSeenDataKey) { + lastSeenDataKey = currentDataKey + lastFailedDataKey = null + } + const appliedRouteData = applyRouteLoaderData(currentHref) + const needsStaleRefresh = + consumeStaleNavigationData(currentHref) && !appliedRouteData + const needsLoad = + (status === 'loading' || + currentDataKey !== lastLoadedDataKey || + needsStaleRefresh) && + currentDataKey !== lastFailedDataKey && + loadingDataKey !== currentDataKey + if (!appliedRouteData && needsLoad && typeof document !== 'undefined') { + status = 'loading' + loadingDataKey = currentDataKey + handle.queueTask(loadAccountEmail) + } + + const selectedMessageId = emailRoute.getSelection(currentHref).selectedId + const selectedMessage: AccountEmailMessageDetail | null = + data?.selectedMessage ?? null + const totalPages = data + ? Math.max(1, Math.ceil(data.total / data.pageSize)) + : 1 + const currentPage = data?.page ?? readPage(currentHref) + const searchQuery = data?.query ?? readSearchQuery(currentHref) + const showUnverified = data != null && !data.emailVerified + + return ( + + + {status === 'loading' ? ( +

+ Loading email inbox… +

+ ) : null} + {message ? ( + + {message} + + ) : null} + {data && !showUnverified ? ( + <> + {data.usage || data.inboxAddress ? ( +
+ {data.inboxAddress ? ( +

+ Inbox address:{' '} + + {data.inboxAddress} + +

+ ) : null} + {data.usage ? ( +

+ Plan {data.usage.plan}: {data.usage.stored_messages.count}/ + {data.usage.stored_messages.limit} stored ·{' '} + {data.usage.receives_today.count}/ + {data.usage.receives_today.limit} received today ·{' '} + {data.usage.sends_today.count}/ + {data.usage.sends_today.limit} sent today +

+ ) : null} +
+ ) : null} + + { + replaceLocation(buildHrefWithUpdatedSearch(value)) + }} + /> + {status === 'ready' && data.messages.length === 0 ? ( +

+ {searchQuery + ? 'No messages match the current search.' + : 'No messages in your inbox yet.'} +

+ ) : ( + <> + + {data.messages.map((emailMessage) => ( +
  • + { + navigate( + emailRoute.buildDetailHref( + emailMessage.id, + getCurrentSearch(), + ), + ) + }} + > +
    + + {emailMessage.subject || '(no subject)'} + + + {formatNullableTimestamp( + messageDate(emailMessage), + 'Unknown', + )} + +
    + + {emailMessage.from_address ?? + emailMessage.envelope_from ?? + 'Unknown sender'} + + + {directionLabel(emailMessage.direction)} ·{' '} + {statusLabel(emailMessage)} + +
    +
  • + ))} +
    + {status === 'ready' ? ( +
    +

    + Page {currentPage} of {totalPages} · {data.total}{' '} + {data.total === 1 ? 'message' : 'messages'} +

    + {totalPages > 1 ? ( +
    + + +
    + ) : null} +
    + ) : null} + + )} + + } + > + {selectedMessage ? ( +
    +
    +

    + {selectedMessage.subject || '(no subject)'} +

    +

    + {directionLabel(selectedMessage.direction)} message +

    +
    + + {selectedMessage.attachments.length > 0 ? ( +
    +

    + Attachments +

    +
      + {selectedMessage.attachments.map((attachment) => ( +
    • + {attachment.filename || '(unnamed)'} + {attachment.content_type + ? ` · ${attachment.content_type}` + : ''} + {attachment.size != null + ? ` · ${attachment.size} bytes` + : ''} +
    • + ))} +
    +
    + ) : null} +
    +

    + Message body +

    + + + HTML + Text + HTML Source + + + {selectedMessage.html_body ? ( +