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
6 changes: 4 additions & 2 deletions frontend/src/components/console/task/task-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,15 @@ function isIdentityWithRepos(identity: DomainGitIdentity): boolean {

interface TaskInputProps {
repos: string[];
/** Initial task content (e.g. the Open Design #od-task= handoff prompt); applied on first mount only. */
initialContent?: string;
onTaskCreated: () => void;
}

export function TaskInput({ repos, onTaskCreated }: TaskInputProps) {
export function TaskInput({ repos, initialContent, onTaskCreated }: TaskInputProps) {
const navigate = useNavigate()
const { t } = useTranslation()
const [taskContent, setTaskContent] = useState<string>("");
const [taskContent, setTaskContent] = useState<string>(() => initialContent ?? "");
const taskType = ConstsTaskType.TaskTypeDevelop;
const [skillPopoverOpen, setSkillPopoverOpen] = useState<boolean>(false);
const [searchInput, setSearchInput] = useState<string>("");
Expand Down
26 changes: 25 additions & 1 deletion frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,28 @@ import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(duration);
dayjs.extend(relativeTime);

// Chrome refuses to construct fetch Requests on a document whose URL embeds
// basic-auth credentials (https://user:pass@host/...): every relative API call
// throws "Request cannot be constructed from a URL that includes credentials",
// which reads like a global sign-out. Gated test deployments are opened via
// such credentialed links (e.g. the Open Design export jump); that first
// navigation already primed the browser's HTTP auth cache, so replacing with
// the credential-free same-origin URL keeps access, keeps the fragment
// (#od-task= included), and unbreaks fetch. Must be absolute — a relative
// replace would resolve against the credentialed base and keep the userinfo.
function stripUrlCredentials(): boolean {
try {
const base = new URL(document.baseURI)
if (!base.username && !base.password) return false
window.location.replace(
window.location.origin + window.location.pathname + window.location.search + window.location.hash,
)
return true
} catch {
return false
}
}

function renderApp() {
createRoot(document.getElementById('root')!).render(
<StrictMode>
Expand All @@ -26,4 +48,6 @@ function renderApp() {
)
}

void initI18n().then(renderApp)
if (!stripUrlCredentials()) {
void initI18n().then(renderApp)
}
10 changes: 9 additions & 1 deletion frontend/src/pages/console/user/tasks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import dayjs from "dayjs";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useCommonData } from "@/components/console/data-provider";
import { confirmOdTaskConsumed, takeOdTaskContent } from "@/utils/od-task-import";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";

Expand All @@ -45,6 +46,12 @@ export default function TasksPage() {
const [stopping, setStopping] = useState(false)
const loadMoreRef = useRef<HTMLDivElement>(null)
const loadingRef = useRef(false)
// Open Design handoff: od-web carries the prompt in the #od-task= fragment.
// takeOdTaskContent() decodes it (persisting to sessionStorage and stripping
// the URL on the spot, so the value survives a 401 → /login round trip and
// pre-fills again on the post-login mount); the persisted copy is dropped
// after the first successful list fetch proves the user is signed in.
const [importedTaskContent] = useState<string | null>(() => takeOdTaskContent())

const handleConfirmDeleteTask = () => {
if (!taskToDelete?.id) {
Expand Down Expand Up @@ -135,6 +142,7 @@ export default function TasksPage() {
}
apiRequest('v1UsersTasksList', { page: pageNum, size: PAGE_SIZE }, [], (resp) => {
if (resp.code === 0) {
confirmOdTaskConsumed()
const newTasks = resp.data?.tasks || []
setTasks(prev => append ? [...prev, ...newTasks] : newTasks)
setHasMore(newTasks.length >= PAGE_SIZE)
Expand Down Expand Up @@ -191,7 +199,7 @@ export default function TasksPage() {
<div className="flex flex-col flex-1 items-center">
<h1 className="text-4xl pt-30 pb-10">{t("consoleTasks.title")}</h1>
<div className="max-w-[800px] w-full py-10">
<TaskInput repos={repos} onTaskCreated={() => { setPage(1); setHasMore(true); fetchTasks(1, false); reloadProjects(); reloadUnlinkedTasks(); }} />
<TaskInput repos={repos} initialContent={importedTaskContent ?? undefined} onTaskCreated={() => { setPage(1); setHasMore(true); fetchTasks(1, false); reloadProjects(); reloadUnlinkedTasks(); }} />
</div>
<Separator className="my-4"/>
<div className="grid grid-cols-[repeat(auto-fill,minmax(350px,1fr))] gap-4 w-full">
Expand Down
72 changes: 72 additions & 0 deletions frontend/src/utils/od-task-import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { MAX_TASK_CONTENT_LENGTH } from "@/components/console/task/task-content-limit"

// Open Design "export to MonkeyCode" handoff: od-web puts the development
// prompt into the URL fragment as `#od-task=<base64url(utf8)>`. Fragments are
// never sent to the server, so the presigned OSS link inside the prompt stays
// out of access logs. The tasks page decodes it and pre-fills the task input.
//
// The decoded prompt is persisted to sessionStorage the moment it is seen,
// because the 401 login redirect (window.location.href = '/login') discards
// the current URL: sessionStorage survives the login round trip in the same
// tab, so the pre-fill works whether or not the user was signed in when the
// link opened. The fragment itself is stripped immediately — it has no
// further job, and leaving it invites refresh re-fills and link leaks.

const OD_TASK_HASH_RE = /(?:^#|[#&])od-task=([A-Za-z0-9_-]+)/
const PENDING_KEY = "mc-od-task-pending"

function decodeOdTaskHash(): string | null {
const match = OD_TASK_HASH_RE.exec(window.location.hash)
if (!match) return null
try {
const b64 = match[1].replace(/-/g, "+").replace(/_/g, "/")
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4)
const bytes = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0))
let text = new TextDecoder().decode(bytes).slice(0, MAX_TASK_CONTENT_LENGTH)
// .slice counts UTF-16 units and can cut an astral pair in half; drop a
// trailing lone high surrogate rather than hand the textarea invalid text.
const last = text.charCodeAt(text.length - 1)
if (last >= 0xd800 && last <= 0xdbff) text = text.slice(0, -1)
return text
} catch {
return null
}
}

/**
* Resolve the pending Open Design prompt for the task input, if any: a fresh
* `#od-task=` fragment wins (persisted to sessionStorage and stripped from the
* URL on the spot), otherwise a previously persisted value — e.g. from the
* mount that ran right before a 401 bounced this tab through /login.
*/
export function takeOdTaskContent(): string | null {
const fromHash = decodeOdTaskHash()
if (fromHash != null) {
try {
sessionStorage.setItem(PENDING_KEY, fromHash)
} catch {
// sessionStorage unavailable (private mode etc.): the prompt still
// pre-fills this mount; it just won't survive a login round trip.
}
history.replaceState(null, "", window.location.pathname + window.location.search)
return fromHash
}
try {
return sessionStorage.getItem(PENDING_KEY)
} catch {
return null
}
}

/**
* Call once the user is proven signed in (first successful authenticated
* fetch on the tasks page): the displayed pre-fill is now stable, so drop the
* persisted copy to keep later visits from resurrecting a stale prompt.
*/
export function confirmOdTaskConsumed(): void {
try {
sessionStorage.removeItem(PENDING_KEY)
} catch {
// Best-effort cleanup; a leftover value is bounded by the tab's lifetime.
}
}