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 sessions = new Map();
const resolvedSpaces = new Map();
function redacted(s) {
return {
@@ -58,11 +59,11 @@ function loadCapture() {
} catch { return null; }
}
function writeCapture(headers, space) {
function writeCapture(headers, rootDentryUuid) {
const payload = {
capturedAt: new Date().toISOString(),
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,
};
// The scanner reads this file through a single-file bind mount: replacing
@@ -101,12 +102,28 @@ function extractSpace(value) {
async function resolveSpace(spaceId) {
const headers = loadCapture();
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,
`/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" };
const sp = body && body.data && body.data.spaceProfile;
if (status !== 200 || !sp || !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) };
if (status !== 200 || !sp || String(sp.id) !== spaceId || !sp.rootDentryUuid || !sp.name) return { error: "space_not_found", http_status: status };
const resolved = { space_id: spaceId, name: String(sp.name).slice(0, 120), root: String(sp.rootDentryUuid) };
resolvedSpaces.set(spaceId, resolved);
return resolved;
}
function loadState() {
@@ -126,6 +143,7 @@ async function addSpace(spaceId, label) {
if (!roots[rootKey]) {
roots[rootKey] = {
configured_root_id: resolved.root,
space_id: resolved.space_id,
// Seed the live name so the refresh job matches this root immediately;
// the scanner confirms/overwrites it from the same spaceProfile.
live_space_name: resolved.name,
@@ -137,6 +155,7 @@ async function addSpace(spaceId, label) {
} else {
roots[rootKey].live_space_name = resolved.name;
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
// 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();
let capturedHeaders = null;
const finish = async (headers) => {
// Validate the space the operator actually opened. Validating against an
// unrelated default space rejects perfectly good logins when that account
// has no access to the old/default knowledge base.
const probe = await boxApi(headers, `/box/api/v2/dentry/list?dentryUuid=${encodeURIComponent(space)}&pageSize=1&orderType=SORT_KEY&sortType=desc&listDentrySource=2&withParentAncestors=true`);
if (probe.status === 200 && probe.body && probe.body.data && probe.body.data.spaceProfile) {
writeCapture(headers, space);
s.state = "success"; s.error = null;
} else {
s.state = "failed"; s.error = "capture_validation_failed";
}
const finish = (headers, profile) => {
const resolved = {
space_id: String(profile.id),
name: String(profile.name).slice(0, 120),
root: String(profile.rootDentryUuid),
};
resolvedSpaces.set(space, resolved);
writeCapture(headers, resolved.root);
s.state = "success"; s.error = null;
s.expiresAt = Math.max(s.expiresAt, Date.now() + 30000);
scheduleCleanup(s);
};
@@ -193,13 +210,14 @@ async function startSession(id, space) {
if (!h["a-token"] || !h.cookie) return;
let body;
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 };
delete capturedHeaders["content-length"];
delete capturedHeaders["accept-encoding"];
delete capturedHeaders["host"];
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
// 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 {
autoStart?: boolean
hideTrigger?: boolean
space?: string
onSuccess?: () => 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 [failure, setFailure] = useState<string | null>(null)
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)
successHandledRef.current = false
try {
const fresh = await startDingtalkSession()
const fresh = await startDingtalkSession(space)
idRef.current = fresh.id
setSession(fresh)
} catch (cause) {
@@ -59,7 +60,7 @@ export function DingtalkSessionControl({ autoStart = false, hideTrigger = false,
} finally {
setStarting(false)
}
}, [])
}, [space])
useEffect(() => {
if (!autoStart || autoStartedRef.current) return
+1
View File
@@ -59,6 +59,7 @@ export function DingtalkSpaceRegistrar({ onRegistered }: { onRegistered: () => v
{loginRequired ? <DingtalkSessionControl
autoStart
hideTrigger
space={url}
onSuccess={resumeAfterLogin}
onClose={() => setLoginRequired(false)}
/> : null}
+3 -1
View File
@@ -11,8 +11,10 @@ describe('DingTalk session client', () => {
}), { status: 201 }))
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(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 () => {