Compare commits
2 Commits
e676d259b2
...
owliver-la
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ac4e1ee88 | |||
| 1ef0c6fc8a |
134
scripts/authreturnto-check.mjs
Normal file
134
scripts/authreturnto-check.mjs
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* returnTo validation check.
|
||||
*
|
||||
* Runs the REAL module through Vite's SSR loader, the same way
|
||||
* skill-check.mjs does, so the `@/` alias and the TypeScript compile are the
|
||||
* app's own rather than a reimplementation of them. No test framework is added
|
||||
* for a 130-line module; this follows the convention already in this directory.
|
||||
*
|
||||
* node scripts/authreturnto-check.mjs
|
||||
*
|
||||
* Exits non-zero on failure, so it can gate a build.
|
||||
*
|
||||
* WHAT THIS IS DEFENDING
|
||||
*
|
||||
* safeReturnTo decides whether a URL somebody else supplied may be navigated
|
||||
* to. The cases below are therefore mostly hostile input, and each asserts the
|
||||
* result is null rather than merely "not the attacker's value" — a wrong answer
|
||||
* that is still a navigation is not a pass.
|
||||
*/
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const results = [];
|
||||
const record = (name, pass, detail = '') => {
|
||||
results.push({ name, pass, detail });
|
||||
console.log(`[${pass ? ' ok ' : ' FAIL '}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
};
|
||||
|
||||
const ORIGIN = 'https://platform.krowforce.com';
|
||||
|
||||
// A real authorization URL, with every parameter the flow depends on, built the
|
||||
// way the Go server builds it: path + RawQuery, percent-escaped into ?returnTo=.
|
||||
const AUTHORIZE =
|
||||
'/oauth/authorize?client_id=989c3ec1-4afa-4d76-93fa-7f45f1d45e22' +
|
||||
'&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback' +
|
||||
'&response_type=code' +
|
||||
'&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' +
|
||||
'&code_challenge_method=S256' +
|
||||
'&resource=https%3A%2F%2Fplatform.krowforce.com%2Fmcp' +
|
||||
'&scope=krow.read' +
|
||||
'&state=vT7nQ2xK_Lp9';
|
||||
|
||||
const q = (v) => '?returnTo=' + encodeURIComponent(v);
|
||||
|
||||
const server = await createServer({ server: { middlewareMode: true }, appType: 'custom', logLevel: 'error' });
|
||||
try {
|
||||
globalThis.window = { location: { origin: ORIGIN, search: '' } };
|
||||
const { safeReturnTo } = await server.ssrLoadModule('/src/lib/authReturnTo.ts');
|
||||
|
||||
/* ── 1–2. The OAuth authorize URL, and its query byte for byte ─────────── */
|
||||
|
||||
const oauth = safeReturnTo(q(AUTHORIZE));
|
||||
record('1. /oauth/authorize is accepted', oauth !== null && oauth.path === AUTHORIZE,
|
||||
oauth ? `via=${oauth.via}` : 'returned null');
|
||||
record('12. and is marked for full browser navigation', oauth?.via === 'browser',
|
||||
`via=${oauth?.via} — React Router has no such route`);
|
||||
|
||||
for (const [name, literal] of [
|
||||
['client_id', 'client_id=989c3ec1-4afa-4d76-93fa-7f45f1d45e22'],
|
||||
['redirect_uri', 'redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback'],
|
||||
['response_type', 'response_type=code'],
|
||||
['code_challenge', 'code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'],
|
||||
['code_challenge_method', 'code_challenge_method=S256'],
|
||||
['resource', 'resource=https%3A%2F%2Fplatform.krowforce.com%2Fmcp'],
|
||||
['scope', 'scope=krow.read'],
|
||||
['state', 'state=vT7nQ2xK_Lp9'],
|
||||
]) {
|
||||
record(`2. ${name} preserved exactly`, Boolean(oauth?.path.includes(literal)));
|
||||
}
|
||||
record('2. percent-encoding is not rewritten', Boolean(oauth?.path.includes('%2F')),
|
||||
'%2F must not become /');
|
||||
record('2. an encoded space survives',
|
||||
safeReturnTo(q('/oauth/authorize?scope=krow.read%20krow.write'))?.path.includes('%20') === true,
|
||||
'%20 must not become +');
|
||||
|
||||
/* ── 3, 13. Internal admin routes keep router navigation ───────────────── */
|
||||
|
||||
const admin = safeReturnTo(q('/admin/candidates?stage=applied'));
|
||||
record('3. /admin/... is accepted', admin?.path === '/admin/candidates?stage=applied');
|
||||
record('13. and is marked for router navigation', admin?.via === 'router',
|
||||
`via=${admin?.via} — must not reload the app`);
|
||||
record('3. bare /admin is accepted', safeReturnTo(q('/admin'))?.via === 'router');
|
||||
|
||||
/* ── 4–10. Hostile and malformed values are refused ────────────────────── */
|
||||
|
||||
const refuse = [
|
||||
['4. external URL', 'https://evil.example'],
|
||||
['4. external URL with our path', 'https://evil.example/oauth/authorize'],
|
||||
['4. userinfo trick', 'https://platform.krowforce.com@evil.example/'],
|
||||
['4. another port on our host', 'https://platform.krowforce.com:8443/admin'],
|
||||
['5. protocol-relative', '//evil.example'],
|
||||
['5. protocol-relative with path', '//evil.example/steal'],
|
||||
['5. triple slash', '///evil.example'],
|
||||
['6. javascript:', 'javascript:alert(document.cookie)'],
|
||||
['6. javascript: mixed case', 'JaVaScRiPt:alert(1)'],
|
||||
['6. tab-obfuscated scheme', 'java\tscript:alert(1)'],
|
||||
['7. data:', 'data:text/html,<script>alert(1)</script>'],
|
||||
['8. backslash', '/\\evil.example'],
|
||||
// These two reach the slash/backslash guard specifically: the path is on
|
||||
// the allowlist, so only the guard can refuse them. Without them the guard
|
||||
// is unfalsifiable — removing it leaves every other case still passing,
|
||||
// which a mutation run showed.
|
||||
['8. backslash in the query of an allowed path', '/admin/candidates?a=\\evil'],
|
||||
['8. backslash escape smuggled past the allowlist', '/admin/x?next=/\\evil.example'],
|
||||
['8. dot-slash-slash', '/.//evil.example'],
|
||||
['9. /admin/login itself', '/admin/login'],
|
||||
['9. /admin/login with a query', '/admin/login?returnTo=%2Fadmin'],
|
||||
['9. bare /login', '/login'],
|
||||
['10. malformed', 'http://[::1'],
|
||||
['10. file scheme', 'file:///etc/passwd'],
|
||||
['10. unrelated backend route', '/oauth/token'],
|
||||
['10. unrelated app route', '/apply'],
|
||||
['10. the MCP endpoint', '/mcp'],
|
||||
];
|
||||
for (const [name, value] of refuse) {
|
||||
const got = safeReturnTo(q(value));
|
||||
record(`${name} is refused`, got === null, got ? `returned ${JSON.stringify(got)}` : '');
|
||||
}
|
||||
|
||||
/* ── 11. Absent or empty falls back safely ─────────────────────────────── */
|
||||
|
||||
for (const [name, search] of [
|
||||
['no query at all', ''],
|
||||
['other parameters only', '?foo=bar'],
|
||||
['empty returnTo', '?returnTo='],
|
||||
]) {
|
||||
record(`11. ${name} returns null`, safeReturnTo(search) === null);
|
||||
}
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
const failed = results.filter((r) => !r.pass).length;
|
||||
console.log(`\n${results.length - failed} passed, ${failed} failed\n`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -5755,6 +5755,48 @@ console.log('\n── Follow-ups ──');
|
||||
question: 'x', history: [],
|
||||
refresh: async () => [PAGE[0], PAGE[0], PAGE[1]],
|
||||
})).length === 2);
|
||||
|
||||
/* The defect that made the row appear exactly once per conversation: every
|
||||
chip ever shown was banned for good, so the second turn filtered out the
|
||||
same top-ranked catalogue the first turn had consumed and the row went
|
||||
silent for the rest of the thread. Offered is not asked — a suggestion
|
||||
passed over, with an answer since, is offerable again. */
|
||||
record('a chip offered earlier, but not under the last answer, comes back',
|
||||
JSON.stringify(said(await nextSteps({
|
||||
question: 'x',
|
||||
history: [
|
||||
{ role: 'user', text: 'a' },
|
||||
{ role: 'assistant', followUp: [PAGE[0]] },
|
||||
{ role: 'user', text: 'b' },
|
||||
{ role: 'assistant', followUp: [PAGE[1]] },
|
||||
],
|
||||
refresh: async () => [PAGE[0], PAGE[1]],
|
||||
}))) === JSON.stringify([PAGE[0].prompt]));
|
||||
|
||||
/* ...while the row directly above is still never redrawn verbatim. */
|
||||
record('...but the row under the answer directly above is not repeated',
|
||||
(await nextSteps({
|
||||
question: 'x',
|
||||
history: [
|
||||
{ role: 'user', text: 'a' },
|
||||
{ role: 'assistant', followUp: [PAGE[0], PAGE[1]] },
|
||||
],
|
||||
refresh: async () => [PAGE[0], PAGE[1]],
|
||||
})) === undefined);
|
||||
|
||||
/* A question actually put stays excluded however long ago it was asked —
|
||||
that exclusion is the permanent one. */
|
||||
record('a question asked earlier in the thread is never offered back',
|
||||
(await nextSteps({
|
||||
question: 'x',
|
||||
history: [
|
||||
{ role: 'user', text: 'How healthy is the platform right now?' },
|
||||
{ role: 'assistant', followUp: [] },
|
||||
{ role: 'user', text: 'b' },
|
||||
{ role: 'assistant', followUp: [] },
|
||||
],
|
||||
refresh: async () => [PAGE[0]],
|
||||
})) === undefined);
|
||||
}
|
||||
|
||||
console.log('\n── Streaming ──');
|
||||
@@ -7976,8 +8018,30 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
return html.slice(0, at) + html.slice(i);
|
||||
};
|
||||
|
||||
record('the layout controls are present and separable',
|
||||
now.includes('<div data-ui-controls') && stripControls(now).indexOf('data-ui-controls') === -1);
|
||||
/**
|
||||
* A page at rest draws no layout controls at all.
|
||||
*
|
||||
* This used to assert the opposite — that the controls were PRESENT — back
|
||||
* when they led with a `Customise layout` button that was on screen whether
|
||||
* or not anybody was arranging anything. That button is gone: Owliver is the
|
||||
* only way to propose a layout change now, and what is left is the bar that
|
||||
* lets a person accept or reject one. With nothing proposed and nothing
|
||||
* saved there is nothing to accept, so the component renders nothing.
|
||||
*
|
||||
* `stripControls` is kept and still exercised. The bar does appear once a
|
||||
* change is being previewed, and the comparison below must stay able to lift
|
||||
* it out when it does.
|
||||
*/
|
||||
record('a page at rest draws no layout controls',
|
||||
!now.includes('<div data-ui-controls'));
|
||||
|
||||
record('layout controls stay separable when they are drawn', (() => {
|
||||
const withBar = `<main><p>kept</p><div data-ui-controls="editor"><div><span>Apply</span></div></div><p>also kept</p></main>`;
|
||||
const stripped = stripControls(withBar);
|
||||
return !stripped.includes('data-ui-controls')
|
||||
&& stripped.includes('<p>kept</p>')
|
||||
&& stripped.includes('<p>also kept</p>');
|
||||
})());
|
||||
|
||||
now = stripControls(now);
|
||||
|
||||
@@ -8308,6 +8372,92 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
return r.ok && ids.indexOf('timeline') < ids.indexOf('activity-privileged-notice');
|
||||
})());
|
||||
|
||||
/* ── Where "to the top" is allowed to be said ──────────────────────────── */
|
||||
|
||||
/**
|
||||
* The destination phrasings, because the missing ones read as a broken feature.
|
||||
*
|
||||
* Reported from production: "show the pipeline move to top" was answered "I
|
||||
* could not find that on this page." The target resolved perfectly well — the
|
||||
* refusal came from `planMove`, which knew `to the top` and did not know
|
||||
* `to top`, and a move with no destination falls through to `unknown`. The
|
||||
* user cannot tell that apart from the section not existing.
|
||||
*/
|
||||
const movesFirst = (q) => {
|
||||
const m = ask(q);
|
||||
if (m?.kind !== 'plan' || m.op.op !== 'reorder') return false;
|
||||
return m.op.order[0] === 'timeline';
|
||||
};
|
||||
const movesLast = (q) => {
|
||||
const m = ask(q);
|
||||
if (m?.kind !== 'plan' || m.op.op !== 'reorder') return false;
|
||||
return m.op.order[m.op.order.length - 1] === 'timeline';
|
||||
};
|
||||
|
||||
for (const q of [
|
||||
'move timeline to top',
|
||||
'move timeline to the top',
|
||||
'move the timeline to the very top',
|
||||
'move timeline up',
|
||||
'move the timeline first',
|
||||
'put the timeline at the top',
|
||||
'show the timeline move to top',
|
||||
]) {
|
||||
record(`"${q}" → timeline first`, movesFirst(q));
|
||||
}
|
||||
|
||||
for (const q of [
|
||||
'move timeline to bottom',
|
||||
'move timeline to the bottom',
|
||||
'move the timeline to the end',
|
||||
'move timeline down',
|
||||
'move the timeline last',
|
||||
]) {
|
||||
record(`"${q}" → timeline last`, movesLast(q));
|
||||
}
|
||||
|
||||
/**
|
||||
* A destination word inside a section's NAME is not a destination.
|
||||
*
|
||||
* `over` lives inside `coverage`, `end` inside `trends`. Matched as
|
||||
* substrings, "move coverage trends to the bottom" satisfied both the top
|
||||
* reading and the bottom one — and the top is tested first, so it moved the
|
||||
* opposite way from the one asked for. Whole-word matching is what fixed it;
|
||||
* this is the case that proves it.
|
||||
*/
|
||||
const coverageCase = (() => {
|
||||
const placed = opsMod4.applyOperation(tree4, {
|
||||
op: 'add', parent: null,
|
||||
node: {
|
||||
id: 'cov-1', type: 'flow', data: { source: 'candidates.activity' },
|
||||
props: { title: 'Coverage trends' },
|
||||
},
|
||||
}, { registry: reg4 });
|
||||
if (!placed.ok) return { pass: false, detail: 'fixture could not be placed' };
|
||||
|
||||
/* Put it at the TOP first, so "to the bottom" is a real change. Added at
|
||||
the end, it is already there and the answer is a refusal rather than a
|
||||
plan — which would pass this check for the wrong reason. */
|
||||
const atTop = opsMod4.applyOperation(placed.tree, {
|
||||
op: 'reorder',
|
||||
parent: null,
|
||||
order: ['cov-1', ...placed.tree.map((n) => n.id).filter((id) => id !== 'cov-1')],
|
||||
}, { registry: reg4 });
|
||||
if (!atTop.ok) return { pass: false, detail: 'fixture could not be placed first' };
|
||||
|
||||
const m = intentMod.matchUiEdit('move coverage trends to the bottom',
|
||||
{ tree: atTop.tree, registry: reg4 });
|
||||
if (m?.kind !== 'plan' || m.op.op !== 'reorder') {
|
||||
return { pass: false, detail: JSON.stringify(m) };
|
||||
}
|
||||
return {
|
||||
pass: m.op.order[m.op.order.length - 1] === 'cov-1',
|
||||
detail: m.op.order.join(', '),
|
||||
};
|
||||
})();
|
||||
record('a section named "Coverage trends" still moves to the bottom',
|
||||
coverageCase.pass, coverageCase.detail);
|
||||
|
||||
record('"Change the hiring activity to a table." → replace with table', (() => {
|
||||
const m = ask('Change the hiring activity to a table.');
|
||||
return m?.kind === 'plan' && m.op.op === 'replace' && m.op.target === 'flow-1' && m.op.type === 'table';
|
||||
|
||||
@@ -31,6 +31,7 @@ import { buildIntro } from './dynamic';
|
||||
import { agentScopedDisabled } from '@/lib/agents/runtime';
|
||||
import { buildOwliverContext } from '@/lib/agents/context';
|
||||
import { AgentBadge } from './AgentBadge';
|
||||
import { useLanguage } from './language';
|
||||
import { useActiveAgent } from './AgentContext';
|
||||
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
|
||||
import { PromptInput } from './PromptInput';
|
||||
@@ -403,6 +404,13 @@ export default function KrowAssistant({
|
||||
*/
|
||||
const [view, setView] = React.useState('chat');
|
||||
|
||||
/* The account's language, chosen in the account menu rather than here. The
|
||||
panel reads it and never sets it: it is an application setting that happens
|
||||
to govern the one piece of text this product generates, not a property of
|
||||
the conversation. It applies from the next answer onward — answers already
|
||||
on screen stay as they were written. */
|
||||
const language = useLanguage();
|
||||
|
||||
/**
|
||||
* A skill asked for something to happen. The names come from the skill file;
|
||||
* `runAction` decides what they mean and refuses anything the file did not
|
||||
@@ -637,6 +645,7 @@ export default function KrowAssistant({
|
||||
agentCoversPage,
|
||||
agentSuggestion: defaultAgent,
|
||||
owliverContext,
|
||||
language,
|
||||
});
|
||||
|
||||
/* A block inside an answer asking the next question, in place. Same entry
|
||||
|
||||
142
src/components/ai-assistant/language.ts
Normal file
142
src/components/ai-assistant/language.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import * as React from 'react';
|
||||
|
||||
/**
|
||||
* The language Owliver answers in.
|
||||
*
|
||||
* One list, read by the header's selector and by the provider that sends the
|
||||
* run. A second copy would be two lists that could disagree, and the way that
|
||||
* shows up is a selector offering a language the backend does not answer in.
|
||||
*
|
||||
* The tags are the backend's: `internal/runtime/language.go` holds the same
|
||||
* set, and the string sent here SELECTS a directive there rather than becoming
|
||||
* one. That is deliberate and it is a security boundary, not a style — a tag
|
||||
* this file invented would silently fall back to English rather than being
|
||||
* interpolated into a prompt.
|
||||
*
|
||||
* This is the panel's language, not the product's. Nothing else in the
|
||||
* dashboard is translated, and the selector does not claim to be: it governs
|
||||
* what Owliver writes back, which is the only text here that is generated
|
||||
* rather than designed.
|
||||
*/
|
||||
|
||||
/** The offered languages, in the order the selector shows them. */
|
||||
export const LANGUAGES = [
|
||||
/* English first and English default. Every agent's instructions are written
|
||||
in English, so it is the language that needs no directive at all. */
|
||||
{ tag: 'en', label: 'English' },
|
||||
{ tag: 'es', label: 'Español' },
|
||||
];
|
||||
|
||||
export const DEFAULT_LANGUAGE = 'en';
|
||||
|
||||
/**
|
||||
* Where the choice is kept.
|
||||
*
|
||||
* `localStorage`, deliberately — not thread state. Somebody who reads in
|
||||
* Spanish reads in Spanish on every page and after every reload; making it
|
||||
* per-conversation would ask them to set it again each time the panel opened,
|
||||
* which is the behaviour of a filter rather than of a preference.
|
||||
*
|
||||
* Not persisted into the thread record either: replaying an old conversation
|
||||
* shows the answers as they were written, and re-labelling them with today's
|
||||
* preference would claim a Spanish answer had been given in English.
|
||||
*/
|
||||
const STORAGE_KEY = 'krow_owliver_language';
|
||||
|
||||
/** Whether `tag` is a language this build offers. */
|
||||
export function isLanguage(tag) {
|
||||
return LANGUAGES.some((l) => l.tag === tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored preference, or English.
|
||||
*
|
||||
* Tolerant of everything storage can do: a tag written by an older build, a
|
||||
* browser that denies access, private mode. The question "which language" must
|
||||
* always have an answer, and an unreadable store means the default rather than
|
||||
* a crash on the panel's first render.
|
||||
*/
|
||||
export function readLanguage() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return isLanguage(stored) ? stored : DEFAULT_LANGUAGE;
|
||||
} catch {
|
||||
return DEFAULT_LANGUAGE;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stores the preference. A failure to write is not worth breaking a render. */
|
||||
export function writeLanguage(tag) {
|
||||
try {
|
||||
if (isLanguage(tag)) localStorage.setItem(STORAGE_KEY, tag);
|
||||
} catch {
|
||||
/* Private mode, or storage denied. The selector still works for this
|
||||
session; it just will not be remembered after a reload. */
|
||||
}
|
||||
}
|
||||
|
||||
/** The label for a tag, for the selector and for an aria-label. */
|
||||
export function languageLabel(tag) {
|
||||
return LANGUAGES.find((l) => l.tag === tag)?.label || 'English';
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribers to the choice.
|
||||
*
|
||||
* The selector lives in the account menu and the thing it governs lives in the
|
||||
* Owliver panel — two subtrees with no parent between them short of the app
|
||||
* root. A context provider spanning both would be a provider added for one
|
||||
* string; an external store is the smaller change and it also gets cross-tab
|
||||
* sync for free, since `storage` fires in the OTHER tab and nothing else would
|
||||
* have told it.
|
||||
*/
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
/** Cached so `getSnapshot` is cheap and, more importantly, stable. */
|
||||
let current = null;
|
||||
|
||||
function snapshot() {
|
||||
if (current === null) current = readLanguage();
|
||||
return current;
|
||||
}
|
||||
|
||||
function subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
/* Another tab changed it. `storage` does not fire in the tab that wrote, so
|
||||
this handles only the other ones; `setLanguage` notifies this one. */
|
||||
const onStorage = (e) => {
|
||||
if (e.key !== null && e.key !== STORAGE_KEY) return;
|
||||
current = readLanguage();
|
||||
listeners.forEach((l) => l());
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
window.removeEventListener('storage', onStorage);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the language everywhere.
|
||||
*
|
||||
* Writes through to storage and notifies every reader in this tab. Ignores a
|
||||
* tag this build does not offer, so a stale menu cannot put the app into a
|
||||
* language the backend will not answer in.
|
||||
*/
|
||||
export function setLanguage(tag) {
|
||||
if (!isLanguage(tag) || tag === snapshot()) return;
|
||||
current = tag;
|
||||
writeLanguage(tag);
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
/**
|
||||
* The current language, re-rendering the caller when it changes.
|
||||
*
|
||||
* `useSyncExternalStore` rather than state plus an effect: the panel must read
|
||||
* the value that is true at render time, and a value corrected in an effect is
|
||||
* one question answered in the wrong language.
|
||||
*/
|
||||
export function useLanguage() {
|
||||
return React.useSyncExternalStore(subscribe, snapshot, () => DEFAULT_LANGUAGE);
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
* capability: string | null, // capability id, or null for free text
|
||||
* question: string, // what the user typed, or the chip's prompt
|
||||
* facts: object, // dashboard fact sheet (see insights.js)
|
||||
* language: string | null, // language tag to answer in (see language.js)
|
||||
* signal: AbortSignal, // aborts an in-flight response
|
||||
* }
|
||||
*/
|
||||
@@ -68,7 +69,7 @@ export function createAgentProvider({ baseUrl = '/api/v1' } = {}) {
|
||||
},
|
||||
|
||||
/** The run itself. Public only so `stream` can wrap it; call `stream`. */
|
||||
async *run({ question, agent = null, confirmation = null, agentVersion = 0, signal }) {
|
||||
async *run({ question, agent = null, confirmation = null, agentVersion = 0, language = null, signal }) {
|
||||
/* No agent, no run. The panel resolves which agent covers the page before
|
||||
calling; reaching here without one means the routing layer changed and
|
||||
this should say so rather than guess at an agent id. */
|
||||
@@ -98,6 +99,15 @@ export function createAgentProvider({ baseUrl = '/api/v1' } = {}) {
|
||||
stops an edit published mid-thread from silently changing which
|
||||
agent is answering. */
|
||||
agentVersion: agentVersion || undefined,
|
||||
/* The language to answer in. Omitted when it is the default, so an
|
||||
English run sends exactly the body it sent before the selector
|
||||
existed — and a deployment that does not know the field yet is
|
||||
unaffected by the common case.
|
||||
|
||||
A tag, never a sentence. The server maps it onto a closed set and
|
||||
an unrecognised one answers in English; the panel cannot write
|
||||
prompt text from here, which is the point of sending a tag. */
|
||||
language: language && language !== 'en' ? language : undefined,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import { agentRequest } from '@/lib/agents/runtime';
|
||||
import { createAssistantProvider } from './provider';
|
||||
import { preferAgent, resolveIntent } from './routing';
|
||||
import { doc, text as textBlock, toSnapshots } from './blocks';
|
||||
import { DEFAULT_LANGUAGE } from './language';
|
||||
|
||||
/** One provider instance for the app's lifetime. */
|
||||
const provider = createAssistantProvider();
|
||||
@@ -199,14 +200,26 @@ const asQuestion = (value) => String(value || '').trim().toLowerCase().replace(/
|
||||
*
|
||||
* Two rules, and the second is the one that matters. The suggestions are ranked
|
||||
* by the SERVER against the question just asked — the panel does not decide what
|
||||
* is worth asking, it only decides what has already been said — and then
|
||||
* anything this thread has asked or already offered is removed.
|
||||
* is worth asking, it only decides what has already been said — and then what
|
||||
* this thread has already covered is removed.
|
||||
*
|
||||
* Without that second rule the row repeats. A page carries a handful of intents
|
||||
* and the top of that list barely moves between turns, so the same three chips
|
||||
* come back after every answer, including the one the reader has just pressed.
|
||||
* Removing what has been used leaves genuinely new ground each time and runs out
|
||||
* honestly rather than looping.
|
||||
*
|
||||
* ASKED AND OFFERED ARE NOT THE SAME THING, and treating them as one is what
|
||||
* made the row appear exactly once per conversation. Every chip ever *shown*
|
||||
* was being banned permanently, in a set that only grew; since the server keeps
|
||||
* returning the top of the same small catalogue, the second turn's suggestions
|
||||
* were all already in it, the filter emptied the list, and an empty list means
|
||||
* no row. Every turn after the first was silent, whatever was asked.
|
||||
*
|
||||
* So the two are separated. A question this thread actually PUT is excluded for
|
||||
* good — it has an answer on screen, and offering to repeat it is not a follow-
|
||||
* up. A chip merely DISPLAYED and passed over is held back only from the turn
|
||||
* directly after it, which is enough to stop the row redrawing verbatim under
|
||||
* consecutive answers; beyond that it is offerable again, because a reader
|
||||
* ignoring a suggestion is not the same as having covered it.
|
||||
*
|
||||
* There is NO fallback to the page's own ranking, and that is the correction a
|
||||
* live run forced. Asking "Summarize hiring activity" matches nothing in the
|
||||
@@ -220,18 +233,34 @@ const asQuestion = (value) => String(value || '').trim().toLowerCase().replace(/
|
||||
export async function nextSteps({ question, history, refresh }) {
|
||||
if (!refresh) return undefined;
|
||||
|
||||
const used = new Set([asQuestion(question)]);
|
||||
/* Permanent: what this thread has actually asked, including the question
|
||||
being answered right now. */
|
||||
const asked = new Set([asQuestion(question)]);
|
||||
for (const message of history) {
|
||||
if (message.role === 'user') used.add(asQuestion(message.text));
|
||||
for (const chip of message.followUp || []) used.add(asQuestion(chip.prompt || chip.label));
|
||||
if (message.role === 'user') asked.add(asQuestion(message.text));
|
||||
}
|
||||
|
||||
/* Transient: the row under the answer directly above this one, and only
|
||||
that row. The immediately preceding assistant turn, whether or not it
|
||||
carried chips — if it offered none there is nothing to hold back, and a
|
||||
suggestion last seen further up the thread has had an answer between it
|
||||
and now, so repeating it is not the verbatim redraw this guards against. */
|
||||
const justOffered = new Set();
|
||||
for (let i = history.length - 1; i >= 0; i -= 1) {
|
||||
if (history[i]?.role !== 'assistant') continue;
|
||||
for (const chip of history[i].followUp || []) {
|
||||
justOffered.add(asQuestion(chip.prompt || chip.label));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const unused = (chips) => (chips || []).filter((chip) => {
|
||||
const key = asQuestion(chip.prompt || chip.label);
|
||||
if (!key || used.has(key)) return false;
|
||||
if (!key || asked.has(key) || justOffered.has(key) || seen.has(key)) return false;
|
||||
/* A list that repeats itself within one turn is the same defect at a
|
||||
smaller scale. */
|
||||
used.add(key);
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -336,6 +365,11 @@ export function useConversation({
|
||||
/* The page's own name, recorded with an archived thread so History can say
|
||||
where a conversation happened without resolving the context again. */
|
||||
pageLabel = '',
|
||||
/* The language the agent answers in — a tag from `language.js`, owned by the
|
||||
header's selector. Read here only to put it on the request: nothing in this
|
||||
hook branches on it, because translating is the model's job and not the
|
||||
panel's. */
|
||||
language = DEFAULT_LANGUAGE,
|
||||
}) {
|
||||
/**
|
||||
* One thread per agent per page.
|
||||
@@ -888,6 +922,9 @@ export function useConversation({
|
||||
agent: turnAgent ? agentRequest(turnAgent, turnContext) : null,
|
||||
owliverContext,
|
||||
agentVersion: pinnedVersionRef.current,
|
||||
/* The reader's choice, applied from the next answer onward. Answers
|
||||
already on screen are left as they were written. */
|
||||
language,
|
||||
})) {
|
||||
if (controller.signal.aborted) break;
|
||||
latest = snapshot;
|
||||
@@ -968,7 +1005,7 @@ export function useConversation({
|
||||
onScheduleInterview,
|
||||
workforce, setFlow, disabledSkills,
|
||||
customSkills, roles, skillCategories, courses, skillContext, companies, workers, postings,
|
||||
agent, agentCoversPage, agentSuggestion, owliverContext,
|
||||
agent, agentCoversPage, agentSuggestion, owliverContext, language,
|
||||
/* The layout session changes as a page's composition and the account's
|
||||
skills resolve, and a stale one means the tree Owliver inspects is the
|
||||
empty one from the first render — so a layout request falls through to
|
||||
@@ -1018,6 +1055,9 @@ export function useConversation({
|
||||
owliverContext,
|
||||
confirmation: block.token,
|
||||
agentVersion: pinnedVersionRef.current,
|
||||
/* The reader's choice, applied from the next answer onward. Answers
|
||||
already on screen are left as they were written. */
|
||||
language,
|
||||
signal: controller.signal,
|
||||
})) {
|
||||
if (controller.signal.aborted) break;
|
||||
@@ -1037,7 +1077,7 @@ export function useConversation({
|
||||
setPending(null);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [contextId, facts, agent, owliverContext, persist]);
|
||||
}, [contextId, facts, agent, owliverContext, language, persist]);
|
||||
|
||||
/**
|
||||
* States something in the thread without a question having been asked.
|
||||
|
||||
@@ -1,88 +1,61 @@
|
||||
import React from 'react';
|
||||
import { RotateCcw, SlidersHorizontal, Undo2 } from 'lucide-react';
|
||||
import { RotateCcw, Undo2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ds';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { inspectTree } from '@/lib/ui/inspect';
|
||||
import { nodeRegistry } from '@/lib/ui/registry';
|
||||
import { TreePanel } from './TreePanel';
|
||||
import { NodeInspector } from './NodeInspector';
|
||||
import { NodePicker } from './NodePicker';
|
||||
|
||||
/**
|
||||
* The visual editor.
|
||||
* The preview bar for a layout change.
|
||||
*
|
||||
* It is a *client* of the UI system, not a second implementation of it. Every
|
||||
* control it draws ends in one call — `propose(op)` on the editing session —
|
||||
* with an operation object of exactly the shape Owliver produces for the same
|
||||
* change. From there the two are indistinguishable: same validation, same
|
||||
* preview merge, same Apply, same `preferences.uiLayouts`.
|
||||
* control it draws ends in one call on the editing session, with an operation
|
||||
* of exactly the shape Owliver produces for the same change. From there the two
|
||||
* are indistinguishable: same validation, same preview merge, same Apply, same
|
||||
* `preferences.uiLayouts`.
|
||||
*
|
||||
* That is the whole architecture:
|
||||
* Owliver → operation → propose → validate → preview
|
||||
* ↓ Apply
|
||||
* preferences.uiLayouts
|
||||
*
|
||||
* editor / Owliver → operation → propose → validate → preview
|
||||
* ↓ Apply
|
||||
* preferences.uiLayouts
|
||||
* WHAT THIS USED TO BE, AND WHY IT IS NOT
|
||||
*
|
||||
* There is no page in this file, no component name, and no branch on what a
|
||||
* node is. What can be done to the selected node comes from its registration;
|
||||
* what can be added comes from the registry and the closed data vocabulary;
|
||||
* what it is showing comes from the tree. A page that migrates tomorrow is
|
||||
* editable tomorrow with nothing here changed.
|
||||
* There was a `Customise layout` button here, and behind it a tree panel, a
|
||||
* node inspector and a picker — a visual editor sitting beside Owliver, both
|
||||
* producing the same operations. It was removed on request: two ways to
|
||||
* rearrange a page is two things to explain, and the panel was on every admin
|
||||
* screen whether or not anybody was arranging anything.
|
||||
*
|
||||
* Owliver is now the only way to propose a layout change. This component keeps
|
||||
* the other half of that conversation — a person still has to SEE the change
|
||||
* and decide. `TreePanel`, `NodeInspector` and `NodePicker` are left in the
|
||||
* directory, unimported: they are the editor, intact, if it is ever wanted
|
||||
* back.
|
||||
*
|
||||
* Nothing about applying moved. `useAssistant` calls `apply` and `discard` on
|
||||
* the session directly, so "Apply" typed into the panel works exactly as the
|
||||
* button does — see the `ui-apply` branch there.
|
||||
*/
|
||||
export function UiEditor({ registry = nodeRegistry }) {
|
||||
export function UiEditor() {
|
||||
const editing = useUiEditing();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [selectedId, setSelectedId] = React.useState(null);
|
||||
|
||||
/* Rendered only where a page has opted into composition. A page that has not
|
||||
is not broken; it simply has nothing to arrange. */
|
||||
if (!editing) return null;
|
||||
|
||||
const {
|
||||
tree, propose, discard, apply, undo, reset,
|
||||
discard, apply, undo, reset,
|
||||
previewing, customised, saving, problems, skipped,
|
||||
/* The page this session belongs to. Handed to the picker so what can be
|
||||
added here is decided by the registry rather than by the picker being
|
||||
shown everything that exists. */
|
||||
page,
|
||||
} = editing;
|
||||
|
||||
const nodes = inspectTree(tree, { registry });
|
||||
const selected = nodes.find((node) => node.id === selectedId) || null;
|
||||
/* A node's own container, for the reorder buttons and for the picker. */
|
||||
const siblings = selected ? nodes.filter((node) => node.parent === selected.parent) : [];
|
||||
const parent = selected?.container ? selected : nodes.find((n) => n.id === selected?.parent) || null;
|
||||
|
||||
/**
|
||||
* The one door out of this component.
|
||||
*
|
||||
* Everything the panels do arrives here as an operation and goes straight to
|
||||
* the session. Nothing is applied, nothing is stored, and nothing is
|
||||
* validated locally — `propose` refuses what cannot be kept and the refusal
|
||||
* is shown below.
|
||||
*/
|
||||
const operate = (op) => {
|
||||
const result = propose(op);
|
||||
/* A removed node cannot stay selected; a replaced one keeps its id. */
|
||||
if (result.ok && op.op === 'remove') setSelectedId(null);
|
||||
};
|
||||
/* Nothing proposed and nothing saved means nothing to say. Without this the
|
||||
component drew an empty bar on every page, which is most of what the button
|
||||
was being blamed for. */
|
||||
if (!previewing && !customised && !problems.length && !skipped.length) return null;
|
||||
|
||||
return (
|
||||
<div data-ui-controls="editor" className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Button
|
||||
size="xs"
|
||||
variant={open ? 'default' : 'outline'}
|
||||
shape="rounded"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<SlidersHorizontal className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
|
||||
Customise layout
|
||||
</Button>
|
||||
|
||||
{/* The preview bar. Unsaved and saved have to be told apart at a
|
||||
glance, because the whole promise is that nothing is kept until
|
||||
somebody says so. */}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{/* Unsaved and saved have to be told apart at a glance, because the
|
||||
whole promise is that nothing is kept until somebody says so. */}
|
||||
{(previewing || customised) && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{previewing && (
|
||||
@@ -122,25 +95,6 @@ export function UiEditor({ registry = nodeRegistry }) {
|
||||
{skipped.length} saved change{skipped.length === 1 ? '' : 's'} no longer apply to this page.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="grid gap-3 rounded-xl border border-border bg-surface-subtle p-3 lg:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<TreePanel tree={tree} selectedId={selectedId} onSelect={setSelectedId} registry={registry} />
|
||||
<NodePicker tree={tree} parent={parent} page={page} onAdd={operate} registry={registry} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-3">
|
||||
{selected
|
||||
? <NodeInspector node={selected} siblings={siblings} page={page} onOperate={operate} registry={registry} />
|
||||
: (
|
||||
<p className="text-caption text-ink-3">
|
||||
Choose a section on the left to see what can be changed about it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export class UiNodeBoundary extends React.Component<UiNodeBoundaryProps, UiNodeB
|
||||
{node?.props?.title || 'This section could not be shown'}
|
||||
</p>
|
||||
<p className="mt-0.5 text-caption text-ink-4">
|
||||
It is still on the page and can be hidden or removed from Customise layout.
|
||||
It is still on the page. Ask Owliver to hide it.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Bell, ChevronDown, LayoutGrid, LogOut, Menu, Search, Settings, Shield,
|
||||
Bell, ChevronDown, Globe, LayoutGrid, LogOut, Menu, Search, Settings, Shield,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
@@ -11,10 +11,13 @@ import { Avatar } from '@/components/ds/Avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel,
|
||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { AssistantPanel, AssistantPanelProvider } from '@/components/ai-assistant';
|
||||
import { LANGUAGES, setLanguage, useLanguage } from '@/components/ai-assistant/language';
|
||||
import { UiEditingProvider } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { pageKeyForRoute } from '@/lib/skills/registry';
|
||||
import { endAdminSession } from '@/lib/admin/session';
|
||||
@@ -164,6 +167,11 @@ export default function AdminLayout() {
|
||||
const navigate = useNavigate();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
/* The account's language. Held outside React so the Owliver panel — a
|
||||
different subtree entirely — sees the change without a provider spanning
|
||||
both, and so a second tab picks it up. See ai-assistant/language. */
|
||||
const language = useLanguage();
|
||||
|
||||
/* Shared cache rather than a local fetch, so saving the profile updates the
|
||||
header avatar and account menu without a reload. */
|
||||
const { data: user } = useCurrentUser();
|
||||
@@ -289,6 +297,45 @@ export default function AdminLayout() {
|
||||
<DropdownMenuItem onClick={() => navigate('/admin/workspace/agents')} className="cursor-pointer">
|
||||
<LayoutGrid className="mr-2 h-4 w-4" /> Agent Registry
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Language.
|
||||
|
||||
A submenu rather than a row that navigates, because there
|
||||
is nothing to navigate TO: the whole setting is one choice
|
||||
from a closed list, and a page built to hold a single
|
||||
radio group would be a page nobody returns to.
|
||||
|
||||
A radio group rather than two items, so the control states
|
||||
what it is — one of these, exactly one selected — to a
|
||||
screen reader and to the eye, without the menu having to
|
||||
draw its own tick.
|
||||
|
||||
What it governs is Owliver's replies. Nothing else in the
|
||||
product is generated text, so nothing else changes; the
|
||||
row is in the account menu rather than in the panel
|
||||
because it is a property of the account and outlives any
|
||||
one conversation. */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="cursor-pointer">
|
||||
<Globe className="mr-2 h-4 w-4" /> Language
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup value={language} onValueChange={setLanguage}>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={lang.tag}
|
||||
value={lang.tag}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{lang.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={signOut}
|
||||
|
||||
133
src/lib/authReturnTo.ts
Normal file
133
src/lib/authReturnTo.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Where an interrupted flow resumes after sign-in.
|
||||
*
|
||||
* Kept in one module because it is security-sensitive and easy to drift: this is
|
||||
* the only place that decides whether a URL somebody else supplied may be
|
||||
* navigated to.
|
||||
*
|
||||
* WHO SETS ?returnTo=, AND WHY THIS EXISTS
|
||||
*
|
||||
* The API does. `GET /oauth/authorize` is the browser leg of the MCP OAuth
|
||||
* flow, and it needs a signed-in person to show a consent screen to. When
|
||||
* nobody is signed in it redirects to the configured login path, carrying its
|
||||
* own path and query so the authorization request survives the round trip:
|
||||
*
|
||||
* /admin/login?returnTo=%2Foauth%2Fauthorize%3Fclient_id%3D...%26state%3D...
|
||||
*
|
||||
* Without this module the person signs in, lands on the dashboard, and the
|
||||
* authorization request is gone — the connector can never finish.
|
||||
*
|
||||
* WHY AN ALLOWLIST RATHER THAN "ANY SAME-ORIGIN PATH"
|
||||
*
|
||||
* Only two kinds of destination are reachable this way, and they are reached
|
||||
* differently, so naming them is both safer and necessary:
|
||||
*
|
||||
* /oauth/authorize a BACKEND route. React Router has no such path, so
|
||||
* routing to it client-side renders the not-found page and
|
||||
* the request never reaches the server. It needs a real
|
||||
* browser navigation.
|
||||
* /admin/... an in-app route, which is what the existing
|
||||
* `location.state.from` mechanism already carries. Router
|
||||
* navigation, exactly as before.
|
||||
*
|
||||
* Anything else has no business arriving in this parameter, and the narrower
|
||||
* rule means a future backend route cannot be reached through here by accident.
|
||||
*
|
||||
* THE SAME-ORIGIN CHECK IS NOT ENOUGH ON ITS OWN
|
||||
*
|
||||
* Values like `/.//evil.example` and `/\evil.example` resolve same-origin and
|
||||
* then normalise to a protocol-relative `//evil.example` when assigned to
|
||||
* location — an open redirect through a check that appeared to pass. So the
|
||||
* resolved path must also begin with exactly one slash and contain no
|
||||
* backslash. The allowlist below would catch these anyway; both are kept
|
||||
* because each is load-bearing on its own, and a later edit that loosens the
|
||||
* allowlist must not silently lose the other.
|
||||
*
|
||||
* WHY THE QUERY STRING IS PASSED THROUGH UNTOUCHED
|
||||
*
|
||||
* `url.pathname + url.search` is the original text, byte for byte. Nothing here
|
||||
* reads, rewrites or re-serialises the parameters, and that is deliberate:
|
||||
* touching `url.searchParams` at all makes the browser re-encode the whole
|
||||
* query, which can turn `%20` into `+` inside `scope`, or re-spell the
|
||||
* percent-encoding of `redirect_uri` and `resource`. Every one of those is a
|
||||
* value the authorization server compares EXACTLY — `code_challenge` against
|
||||
* the verifier, `redirect_uri` against the registered URI, `state` against what
|
||||
* the client sent. A re-encoded query is a different query, and the failure
|
||||
* would surface much later as a mismatched PKCE challenge.
|
||||
*/
|
||||
|
||||
/** How the destination has to be reached. */
|
||||
export type ReturnVia = 'browser' | 'router';
|
||||
|
||||
export interface ReturnTarget {
|
||||
/** A path on this origin, with its query preserved exactly. */
|
||||
path: string;
|
||||
/**
|
||||
* 'browser' for a backend route, which must bypass React Router.
|
||||
* 'router' for an in-app route, which must not reload the page.
|
||||
*/
|
||||
via: ReturnVia;
|
||||
}
|
||||
|
||||
/** The backend route the OAuth browser leg returns to. */
|
||||
const OAUTH_AUTHORIZE_PATH = '/oauth/authorize';
|
||||
|
||||
/** The login itself, which would sign a person in and show them the login. */
|
||||
const LOGIN_PATH = '/admin/login';
|
||||
|
||||
/** The in-app console. Everything under it is a React Router destination. */
|
||||
const ADMIN_PREFIX = '/admin';
|
||||
|
||||
/**
|
||||
* Resolve a `returnTo` query parameter to a safe destination.
|
||||
*
|
||||
* @param search The query string to read, defaulting to the document's. The
|
||||
* login page passes the router's value explicitly so this never reaches for a
|
||||
* global it does not need.
|
||||
* @returns The destination and how to reach it, or null when there is nothing
|
||||
* safe to return to — in which case the caller keeps its existing behaviour.
|
||||
*/
|
||||
export function safeReturnTo(search?: string): ReturnTarget | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const raw = new URLSearchParams(search ?? window.location.search).get('returnTo');
|
||||
if (!raw) return null;
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw, window.location.origin);
|
||||
} catch {
|
||||
// Malformed. Nothing safe to do with it.
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cross-origin, and anything carrying a scheme of its own — javascript:,
|
||||
// data:, https://evil.example — fails here: the resolved origin is not ours.
|
||||
if (url.origin !== window.location.origin) return null;
|
||||
|
||||
const path = url.pathname + url.search;
|
||||
|
||||
// Exactly one leading slash, no backslash. See the note above on why the
|
||||
// origin check does not cover this. The backslash test also reaches values
|
||||
// the allowlist below would have accepted — a backslash survives unencoded
|
||||
// into `search`, so `/admin/x?next=/\evil.example` is on the allowlist and
|
||||
// refused only here. The cost is a legitimate `state` containing a raw
|
||||
// backslash being refused too; that fails closed, landing the person on the
|
||||
// dashboard rather than anywhere an attacker chose.
|
||||
if (!path.startsWith('/') || path.startsWith('//') || path.includes('\\')) return null;
|
||||
|
||||
if (url.pathname === OAUTH_AUTHORIZE_PATH) {
|
||||
return { path, via: 'browser' };
|
||||
}
|
||||
|
||||
// The login is refused before the admin prefix is considered, because it sits
|
||||
// underneath it.
|
||||
if (url.pathname === LOGIN_PATH) return null;
|
||||
|
||||
if (url.pathname === ADMIN_PREFIX || url.pathname.startsWith(ADMIN_PREFIX + '/')) {
|
||||
return { path, via: 'router' };
|
||||
}
|
||||
|
||||
// Everything else: not a destination this parameter is for.
|
||||
return null;
|
||||
}
|
||||
@@ -37,6 +37,22 @@ const canon = (value) => String(value ?? '')
|
||||
|
||||
const has = (text, ...words) => words.some((w) => text.includes(canon(w)));
|
||||
|
||||
/**
|
||||
* Whole-word match, for words that live inside longer ones.
|
||||
*
|
||||
* `has` is a substring test. That is right for phrases and wrong for short
|
||||
* words: `over` is inside `coverage` and `overtime`, `end` is inside `trends`
|
||||
* and `calendar`, `up` is inside `group`. Read as substrings, "move coverage to
|
||||
* the bottom" matched both a top reading and a bottom one — and the top is
|
||||
* tested first, so it moved the opposite way from the one asked for.
|
||||
*
|
||||
* Single words only. A phrase is unambiguous as a substring and stays on `has`.
|
||||
*/
|
||||
const hasWord = (text, ...words) => {
|
||||
const tokens = new Set(String(text ?? '').split(' '));
|
||||
return words.some((w) => tokens.has(canon(w)));
|
||||
};
|
||||
|
||||
/**
|
||||
* The verbs, as data.
|
||||
*
|
||||
@@ -443,8 +459,27 @@ function planUnhide(text, tree, registry) {
|
||||
function planMove(text, tree, registry) {
|
||||
const roots = tree.map((node) => describeNode(node, { registry }));
|
||||
|
||||
const before = has(text, 'above', 'before', 'over', 'on top of', 'to the top', 'first');
|
||||
const after = has(text, 'below', 'under', 'beneath', 'after', 'to the bottom', 'last', 'end');
|
||||
/**
|
||||
* Where it should end up.
|
||||
*
|
||||
* The phrase list is long because people say this a dozen ways and the ones
|
||||
* that were missing were the short ones: "move it to top" was refused while
|
||||
* "move it to the top" worked, which reads as the feature being broken rather
|
||||
* than as a phrasing it does not know. A destination is the whole of what
|
||||
* this function needs, so failing to recognise one costs the entire request.
|
||||
*
|
||||
* Short words go through `hasWord` — see its note. `top` and `bottom` alone
|
||||
* are enough on their own here: by the time a sentence has reached planMove
|
||||
* it is already a move, and a bare "top" in a move is a destination.
|
||||
*/
|
||||
const before = has(text, 'on top of', 'to the top', 'to top', 'to the very top',
|
||||
'at the top', 'to the start', 'to the front', 'right to the top')
|
||||
|| hasWord(text, 'above', 'before', 'over', 'top', 'topmost', 'first', 'up', 'upward', 'upwards');
|
||||
|
||||
const after = has(text, 'to the bottom', 'to bottom', 'to the very bottom',
|
||||
'at the bottom', 'to the end', 'to the back', 'right to the bottom')
|
||||
|| hasWord(text, 'below', 'under', 'underneath', 'beneath', 'after', 'bottom',
|
||||
'last', 'end', 'down', 'downward', 'downwards');
|
||||
|
||||
/* Split on the positional word so the two halves name two different nodes:
|
||||
"move timeline above the notice" is a subject and a reference. */
|
||||
|
||||
@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils';
|
||||
import { KROW_LOGO_URL } from '@/assets/brand';
|
||||
import { Checkbox } from '@/components/ds';
|
||||
import { useAuth } from '@/lib/AuthContext';
|
||||
import { safeReturnTo } from '@/lib/authReturnTo';
|
||||
|
||||
const DEMO_EMAIL = 'demo@krow.app';
|
||||
|
||||
@@ -26,6 +27,24 @@ export default function AdminLogin() {
|
||||
const location = useLocation();
|
||||
const { login } = useAuth();
|
||||
|
||||
/* Two kinds of "where was I going", and they are not interchangeable.
|
||||
|
||||
A ?returnTo= in the QUERY is put there by the API, not by this app. The
|
||||
OAuth authorization endpoint redirects here when nobody is signed in,
|
||||
carrying its own path and query so the authorization request survives the
|
||||
round trip. It names a route on the BACKEND (/oauth/authorize), which React
|
||||
Router does not have and must not be given — routing to it client-side
|
||||
renders the not-found page and the connector never finishes. So it is
|
||||
followed with a real navigation, which safeReturnTo reports as `browser`.
|
||||
|
||||
`location.state.from` is the in-app case: a guard bounced someone off a
|
||||
page in this bundle (ProtectedRoute and AdminRoute both set it). That is a
|
||||
router destination and stays one, unchanged.
|
||||
|
||||
The query wins when both exist. It is the more specific instruction, and it
|
||||
is the one the person is actually in the middle of. */
|
||||
const fromQuery = safeReturnTo(location.search);
|
||||
|
||||
const from = location.state?.from;
|
||||
const returnTo = typeof from === 'string' && from.startsWith('/admin') && from !== '/admin/login'
|
||||
? from
|
||||
@@ -79,7 +98,13 @@ export default function AdminLogin() {
|
||||
}
|
||||
|
||||
setStatus('success');
|
||||
setTimeout(() => navigate(returnTo, { replace: true }), 320);
|
||||
setTimeout(() => {
|
||||
// `replace`, not `assign`: the login should not sit in history between
|
||||
// the authorization request and the consent screen, or Back from consent
|
||||
// returns to a login the person has already completed.
|
||||
if (fromQuery?.via === 'browser') window.location.replace(fromQuery.path);
|
||||
else navigate(fromQuery?.path ?? returnTo, { replace: true });
|
||||
}, 320);
|
||||
};
|
||||
|
||||
const busy = status !== 'idle';
|
||||
|
||||
Reference in New Issue
Block a user