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
13 changes: 7 additions & 6 deletions packages/junior/src/api/acp/authorization-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ function renderResult(
},
}[result];
return renderHtmlPage({
bodyHtml: ` <p style="margin:0;color:#b8b8b8;line-height:1.5">${escapeHtml(content.message)}</p>`,
bodyHtml: ` <p>${escapeHtml(content.message)}</p>`,
headers: ACP_PAGE_HEADERS,
status: content.status,
title: content.title,
Expand All @@ -59,11 +59,12 @@ function renderResult(

function renderConfirmation(agentName: string): Response {
return renderHtmlPage({
bodyHtml: ` <p style="margin:0 0 1rem;color:#b8b8b8;line-height:1.5">Only continue if you started sign-in from your ACP client. Enter the verification code shown by the client.</p>
<form method="post">
<label style="display:block;margin:0 0 1rem">Verification code<br><input name="code" required autocomplete="one-time-code" inputmode="text" style="box-sizing:border-box;margin-top:.5rem;padding:.625rem;width:100%"></label>
<button type="submit" style="border:0;border-radius:.375rem;background:#a78bfa;color:#000;cursor:pointer;font:inherit;font-weight:700;padding:.625rem 1rem">Connect</button>
</form>`,
bodyHtml: ` <p>Enter the verification code shown by your ACP client.</p>
<p class="warning" role="note">Only continue if you started sign-in from your ACP client.</p>
<form method="post">
<label>Verification code<input name="code" required autocomplete="one-time-code" inputmode="text"></label>
<button type="submit" class="primary">Connect</button>
</form>`,
headers: ACP_PAGE_HEADERS,
status: 200,
title: `Connect ${agentName}`,
Expand Down
114 changes: 108 additions & 6 deletions packages/junior/src/api/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,112 @@ export function requestHasSameOrigin(request: Request): boolean {
);
}

/*
* Sign-in page styles. Colors and fonts follow the dashboard tokens in
* `junior-dashboard/src/tailwind.css`. Pages load no resources, so the
* dashboard fonts apply only when the system has them.
*/
const PAGE_STYLE = `
:root {
color-scheme: dark;
--bg: #060607;
--panel: #0c0c0e;
--raised: #101013;
--hover: #17171a;
--text: #f4f4f5;
--muted: #a1a1aa;
--faint: #8a8a93;
--solid: #ffffff;
--subtle: #c4c4cc;
--inverse: #000000;
--focus: #beaaff;
--border: rgb(255 255 255 / 0.08);
--border-emphasis: rgb(255 255 255 / 0.15);
--border-heavy: rgb(255 255 255 / 0.3);
--warning-bg: rgb(251 191 36 / 0.08);
--warning-border: rgb(251 191 36 / 0.3);
--warning-text: #fcd34d;
--shadow: 0 16px 40px rgb(0 0 0 / 0.5);
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--bg: #ffffff;
--panel: #f9f9fa;
--raised: #ffffff;
--hover: #f1f1f3;
--text: #18181b;
--muted: #52525b;
--faint: #71717a;
--solid: #09090b;
--subtle: #3f3f46;
--inverse: #ffffff;
--focus: #7553ff;
--border: rgb(0 0 0 / 0.09);
--border-emphasis: rgb(0 0 0 / 0.16);
--border-heavy: rgb(0 0 0 / 0.32);
--warning-bg: rgb(245 158 11 / 0.1);
--warning-border: rgb(217 119 6 / 0.35);
--warning-text: #92400e;
--shadow: 0 16px 40px rgb(0 0 0 / 0.08);
}
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px 16px;
background: var(--bg);
color: var(--text);
font: 16px/1.5 Inter, -apple-system, system-ui, "Segoe UI", Roboto, sans-serif;
}
main {
width: 100%;
max-width: 28rem;
padding: 32px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--panel);
box-shadow: var(--shadow);
}
h1, h2 { font-family: Outfit, "Avenir Next", Avenir, "Segoe UI", system-ui, sans-serif; }
h1 { margin: 0 0 8px; font-size: 1.5rem; line-height: 1.25; font-weight: 600; text-wrap: balance; overflow-wrap: anywhere; }
h2 { margin: 24px 0 8px; font-size: 0.875rem; line-height: 1.35; font-weight: 600; color: var(--subtle); }
p { margin: 0 0 16px; color: var(--muted); text-wrap: pretty; }
p:last-child { margin-bottom: 0; }
strong { color: var(--text); font-weight: 600; }
code { font: 0.8125rem/1.35 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--faint); overflow-wrap: anywhere; }
.list { margin: 0 0 24px; padding: 0; list-style: none; border: 1px solid var(--border); border-radius: 8px; background: var(--raised); }
.list li { padding: 12px 16px; font-size: 0.875rem; line-height: 1.35; }
.list li + li { border-top: 1px solid var(--border); }
.list code { display: block; margin-top: 4px; }
.warning { margin: 0 0 24px; padding: 12px 16px; border: 1px solid var(--warning-border); border-radius: 8px; background: var(--warning-bg); color: var(--warning-text); font-size: 0.875rem; line-height: 1.35; }
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
label { display: block; margin: 0 0 16px; font-size: 0.875rem; font-weight: 600; color: var(--subtle); }
label input {
display: block; width: 100%; min-height: 44px; margin-top: 8px; padding: 8px 12px;
border: 1px solid var(--border-emphasis); border-radius: 6px; background: var(--raised);
color: var(--text); font: 1rem/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
button {
width: 100%; min-height: 44px; padding: 8px 16px; border: 1px solid var(--border-emphasis); border-radius: 6px;
background: var(--raised); color: var(--text); cursor: pointer;
font-family: inherit; font-size: 0.875rem; font-weight: 600; line-height: 1; transition: background-color 0.2s ease, border-color 0.2s ease;
}
button:hover { border-color: var(--border-heavy); background: var(--hover); }
button.primary { border-color: var(--solid); background: var(--solid); color: var(--inverse); }
button.primary:hover { border-color: var(--subtle); background: var(--subtle); }
button:focus-visible, label input:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
@media (max-width: 480px) { main { padding: 24px; } }
@media (prefers-reduced-motion: reduce) { button { transition: none; } }
`;

/**
* Render one sign-in page that browsers cannot frame or cache. `bodyHtml`
* follows the heading, and the caller escapes every value in it.
* follows the heading, and the caller escapes every value in it. Callers use
* the `list`, `warning`, `actions`, and `primary` classes from `PAGE_STYLE`.
*/
export function renderHtmlPage(page: {
bodyHtml: string;
Expand All @@ -34,13 +137,12 @@ export function renderHtmlPage(page: {
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(page.title)}</title>
<style>${PAGE_STYLE}</style>
</head>
<body style="margin:0;background:#000;color:#fff;font-family:ui-sans-serif,system-ui,sans-serif">
<main style="min-height:100vh;display:grid;place-items:center;padding:2rem">
<section style="max-width:32rem;border-left:4px solid #a78bfa;padding-left:1rem">
<h1 style="margin:0 0 .75rem;font-size:2rem">${escapeHtml(page.title)}</h1>
<body>
<main>
<h1>${escapeHtml(page.title)}</h1>
${page.bodyHtml}
</section>
</main>
</body>
</html>`,
Expand Down
21 changes: 11 additions & 10 deletions packages/junior/src/api/mcp/oauth/consent-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,23 +44,24 @@ function consentPage(args: {
}): Response {
const clientHost = new URL(args.authorization.clientId).host;
const clientName = args.authorization.clientName ?? clientHost;
const button = (action: string, label: string, primary: boolean) =>
`<button type="submit" name="action" value="${action}" style="border:0;border-radius:.375rem;cursor:pointer;font:inherit;font-weight:700;padding:.625rem 1rem;margin-right:.5rem;${primary ? "background:#a78bfa;color:#000" : "background:#333;color:#fff"}">${label}</button>`;
const scopeItems = (parseMcpScopes(args.authorization.scope) ?? [])
.map(
(scope) =>
`<li style="margin:0 0 .25rem">${escapeHtml(MCP_SCOPE_DESCRIPTIONS[scope])} <code style="color:#8a8a8a">${escapeHtml(scope)}</code></li>`,
`<li>${escapeHtml(MCP_SCOPE_DESCRIPTIONS[scope])}<code>${escapeHtml(scope)}</code></li>`,
)
.join("");
return renderMcpPage({
bodyHtml: ` <p style="margin:0 0 .5rem;color:#b8b8b8;line-height:1.5">The app is published at <strong style="color:#fff">${escapeHtml(clientHost)}</strong>. In ${escapeHtml(args.agentName)}, it can:</p>
<ul style="margin:0 0 1rem;padding-left:1.25rem;color:#b8b8b8;line-height:1.5">${scopeItems}</ul>
<form method="post">
<input type="hidden" name="csrf" value="${escapeHtml(args.csrfToken)}">
${button("approve", "Allow", true)}${button("deny", "Deny", false)}
</form>`,
bodyHtml: ` <p>The app is published at <strong>${escapeHtml(clientHost)}</strong>.</p>
<h2>In ${escapeHtml(args.agentName)}, it can:</h2>
<ul class="list">${scopeItems}</ul>
<p class="warning" role="note">Only continue if you started this connection.</p>
<form method="post" class="actions">
<input type="hidden" name="csrf" value="${escapeHtml(args.csrfToken)}">
<button type="submit" name="action" value="deny">Deny</button>
<button type="submit" name="action" value="approve" class="primary">Allow</button>
</form>`,
formOrigin: new URL(args.authorization.redirectUri).origin,
message: `${clientName} wants to connect to ${args.agentName}. Only continue if you started this connection.`,
message: `${clientName} wants to connect to ${args.agentName}.`,
status: 200,
title: `Connect ${clientName}`,
});
Expand Down
2 changes: 1 addition & 1 deletion packages/junior/src/api/mcp/oauth/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function renderMcpPage(page: {
title: string;
}): Response {
const formAction = ["'self'", ...(page.formOrigin ? [page.formOrigin] : [])];
const message = ` <p style="margin:0 0 1rem;color:#b8b8b8;line-height:1.5">${escapeHtml(page.message)}</p>`;
const message = ` <p>${escapeHtml(page.message)}</p>`;
return renderHtmlPage({
bodyHtml: page.bodyHtml ? `${message}\n${page.bodyHtml}` : message,
headers: {
Expand Down
Loading