fix(settings): resume DingTalk space registration after login
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user