fix(settings): resume DingTalk space registration after login

This commit is contained in:
姚章浩
2026-09-15 00:08:40 +08:00
parent f82283abd4
commit 2005e4e7da
4 changed files with 44 additions and 22 deletions
+36 -18
View File
@@ -17,6 +17,7 @@ const QR_TTL_MS = Number(process.env.QR_TTL_MS || 5 * 60 * 1000);
const SCAN_URL = (space) => `https://alidocs.dingtalk.com/i/spaces/${space}/overview`; const SCAN_URL = (space) => `https://alidocs.dingtalk.com/i/spaces/${space}/overview`;
const sessions = new Map(); const sessions = new Map();
const resolvedSpaces = new Map();
function redacted(s) { function redacted(s) {
return { return {
@@ -58,11 +59,11 @@ function loadCapture() {
} catch { return null; } } catch { return null; }
} }
function writeCapture(headers, space) { function writeCapture(headers, rootDentryUuid) {
const payload = { const payload = {
capturedAt: new Date().toISOString(), capturedAt: new Date().toISOString(),
method: "GET", method: "GET",
url: `https://alidocs.dingtalk.com/box/api/v2/dentry/list?dentryUuid=${space}`, url: `https://alidocs.dingtalk.com/box/api/v2/dentry/list?dentryUuid=${rootDentryUuid}`,
headers, headers,
}; };
// The scanner reads this file through a single-file bind mount: replacing // The scanner reads this file through a single-file bind mount: replacing
@@ -101,12 +102,28 @@ function extractSpace(value) {
async function resolveSpace(spaceId) { async function resolveSpace(spaceId) {
const headers = loadCapture(); const headers = loadCapture();
if (!headers) return { error: "login_required" }; if (!headers) return { error: "login_required" };
let known = resolvedSpaces.get(spaceId);
if (!known && STATE_FILE) {
try {
const roots = loadState().roots || {};
const entry = Object.values(roots).find((root) => root.space_id === spaceId);
if (entry?.configured_root_id && entry?.live_space_name) {
known = { space_id: spaceId, name: entry.live_space_name, root: entry.configured_root_id };
}
} catch { /* a browser session can rediscover it */ }
}
// A space URL contains the space id, while dentry/list accepts only the root
// dentry UUID. Unknown spaces must be opened in Chromium once to discover
// that mapping; returning login_required reuses the same recoverable modal.
if (!known) return { error: "login_required" };
const { status, body } = await boxApi(headers, const { status, body } = await boxApi(headers,
`/box/api/v2/dentry/list?dentryUuid=${encodeURIComponent(spaceId)}&withParentAncestors=true&orderType=SORT_KEY&sortType=desc&listDentrySource=2&pageSize=5`); `/box/api/v2/dentry/list?dentryUuid=${encodeURIComponent(known.root)}&withParentAncestors=true&orderType=SORT_KEY&sortType=desc&listDentrySource=2&pageSize=5`);
if (status === 401 || (body && body.code === "52600001")) return { error: "login_required" }; if (status === 401 || (body && body.code === "52600001")) return { error: "login_required" };
const sp = body && body.data && body.data.spaceProfile; const sp = body && body.data && body.data.spaceProfile;
if (status !== 200 || !sp || !sp.rootDentryUuid || !sp.name) return { error: "space_not_found", http_status: status }; if (status !== 200 || !sp || String(sp.id) !== spaceId || !sp.rootDentryUuid || !sp.name) return { error: "space_not_found", http_status: status };
return { space_id: spaceId, name: String(sp.name).slice(0, 120), root: String(sp.rootDentryUuid) }; const resolved = { space_id: spaceId, name: String(sp.name).slice(0, 120), root: String(sp.rootDentryUuid) };
resolvedSpaces.set(spaceId, resolved);
return resolved;
} }
function loadState() { function loadState() {
@@ -126,6 +143,7 @@ async function addSpace(spaceId, label) {
if (!roots[rootKey]) { if (!roots[rootKey]) {
roots[rootKey] = { roots[rootKey] = {
configured_root_id: resolved.root, configured_root_id: resolved.root,
space_id: resolved.space_id,
// Seed the live name so the refresh job matches this root immediately; // Seed the live name so the refresh job matches this root immediately;
// the scanner confirms/overwrites it from the same spaceProfile. // the scanner confirms/overwrites it from the same spaceProfile.
live_space_name: resolved.name, live_space_name: resolved.name,
@@ -137,6 +155,7 @@ async function addSpace(spaceId, label) {
} else { } else {
roots[rootKey].live_space_name = resolved.name; roots[rootKey].live_space_name = resolved.name;
roots[rootKey].configured_root_id = resolved.root; roots[rootKey].configured_root_id = resolved.root;
roots[rootKey].space_id = resolved.space_id;
} }
// The inventory state is reached through a DIRECTORY bind mount: readers // The inventory state is reached through a DIRECTORY bind mount: readers
// open it by path, so an atomic temp+rename swap is safe and avoids // open it by path, so an atomic temp+rename swap is safe and avoids
@@ -165,17 +184,15 @@ async function startSession(id, space) {
const page = context.pages()[0] ?? await context.newPage(); const page = context.pages()[0] ?? await context.newPage();
let capturedHeaders = null; let capturedHeaders = null;
const finish = async (headers) => { const finish = (headers, profile) => {
// Validate the space the operator actually opened. Validating against an const resolved = {
// unrelated default space rejects perfectly good logins when that account space_id: String(profile.id),
// has no access to the old/default knowledge base. name: String(profile.name).slice(0, 120),
const probe = await boxApi(headers, `/box/api/v2/dentry/list?dentryUuid=${encodeURIComponent(space)}&pageSize=1&orderType=SORT_KEY&sortType=desc&listDentrySource=2&withParentAncestors=true`); root: String(profile.rootDentryUuid),
if (probe.status === 200 && probe.body && probe.body.data && probe.body.data.spaceProfile) { };
writeCapture(headers, space); resolvedSpaces.set(space, resolved);
s.state = "success"; s.error = null; writeCapture(headers, resolved.root);
} else { s.state = "success"; s.error = null;
s.state = "failed"; s.error = "capture_validation_failed";
}
s.expiresAt = Math.max(s.expiresAt, Date.now() + 30000); s.expiresAt = Math.max(s.expiresAt, Date.now() + 30000);
scheduleCleanup(s); scheduleCleanup(s);
}; };
@@ -193,13 +210,14 @@ async function startSession(id, space) {
if (!h["a-token"] || !h.cookie) return; if (!h["a-token"] || !h.cookie) return;
let body; let body;
try { body = await response.json(); } catch { return; } try { body = await response.json(); } catch { return; }
if (!body?.data?.spaceProfile) return; const profile = body?.data?.spaceProfile;
if (!profile || String(profile.id) !== space || !profile.name || !profile.rootDentryUuid) return;
capturedHeaders = { ...h }; capturedHeaders = { ...h };
delete capturedHeaders["content-length"]; delete capturedHeaders["content-length"];
delete capturedHeaders["accept-encoding"]; delete capturedHeaders["accept-encoding"];
delete capturedHeaders["host"]; delete capturedHeaders["host"];
s.state = s.state === "qr" ? "scanned" : "reusing"; s.state = s.state === "qr" ? "scanned" : "reusing";
void finish(capturedHeaders); finish(capturedHeaders, profile);
}; };
// Login may redirect the original tab or open another page. Listening on // Login may redirect the original tab or open another page. Listening on
// the whole context keeps credential capture alive across both paths. // the whole context keeps credential capture alive across both paths.
+4 -3
View File
@@ -12,11 +12,12 @@ const finished = new Set(['success', 'expired', 'failed', 'closed'])
interface DingtalkSessionControlProps { interface DingtalkSessionControlProps {
autoStart?: boolean autoStart?: boolean
hideTrigger?: boolean hideTrigger?: boolean
space?: string
onSuccess?: () => void onSuccess?: () => void
onClose?: () => void onClose?: () => void
} }
export function DingtalkSessionControl({ autoStart = false, hideTrigger = false, onSuccess, onClose }: DingtalkSessionControlProps = {}) { export function DingtalkSessionControl({ autoStart = false, hideTrigger = false, space, onSuccess, onClose }: DingtalkSessionControlProps = {}) {
const [session, setSession] = useState<DingtalkSessionView | null>(null) const [session, setSession] = useState<DingtalkSessionView | null>(null)
const [failure, setFailure] = useState<string | null>(null) const [failure, setFailure] = useState<string | null>(null)
const [starting, setStarting] = useState(false) const [starting, setStarting] = useState(false)
@@ -51,7 +52,7 @@ export function DingtalkSessionControl({ autoStart = false, hideTrigger = false,
setOpen(true); setStarting(true); setFailure(null); setSession(null); setLastSuccess(false) setOpen(true); setStarting(true); setFailure(null); setSession(null); setLastSuccess(false)
successHandledRef.current = false successHandledRef.current = false
try { try {
const fresh = await startDingtalkSession() const fresh = await startDingtalkSession(space)
idRef.current = fresh.id idRef.current = fresh.id
setSession(fresh) setSession(fresh)
} catch (cause) { } catch (cause) {
@@ -59,7 +60,7 @@ export function DingtalkSessionControl({ autoStart = false, hideTrigger = false,
} finally { } finally {
setStarting(false) setStarting(false)
} }
}, []) }, [space])
useEffect(() => { useEffect(() => {
if (!autoStart || autoStartedRef.current) return if (!autoStart || autoStartedRef.current) return
+1
View File
@@ -59,6 +59,7 @@ export function DingtalkSpaceRegistrar({ onRegistered }: { onRegistered: () => v
{loginRequired ? <DingtalkSessionControl {loginRequired ? <DingtalkSessionControl
autoStart autoStart
hideTrigger hideTrigger
space={url}
onSuccess={resumeAfterLogin} onSuccess={resumeAfterLogin}
onClose={() => setLoginRequired(false)} onClose={() => setLoginRequired(false)}
/> : null} /> : null}
+3 -1
View File
@@ -11,8 +11,10 @@ describe('DingTalk session client', () => {
}), { status: 201 })) }), { status: 201 }))
vi.stubGlobal('fetch', fetchMock) vi.stubGlobal('fetch', fetchMock)
await expect(startDingtalkSession()).resolves.toMatchObject({ id: 'session-1', state: 'starting' }) const target = 'https://alidocs.dingtalk.com/i/spaces/9JOGOQneRgkneX4Q/overview'
await expect(startDingtalkSession(target)).resolves.toMatchObject({ id: 'session-1', state: 'starting' })
expect(fetchMock).toHaveBeenCalledWith('/api/v1/dingtalk-session', expect.objectContaining({ method: 'POST' })) expect(fetchMock).toHaveBeenCalledWith('/api/v1/dingtalk-session', expect.objectContaining({ method: 'POST' }))
expect(JSON.parse(String(fetchMock.mock.calls[0][1]?.body))).toEqual({ space: target })
}) })
it('preserves login_required as a machine-readable error for automatic QR fallback', async () => { it('preserves login_required as a machine-readable error for automatic QR fallback', async () => {