diff --git a/package.json b/package.json index 4f7ddc3..0b0dae4 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "check:syntax": "node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/performance.js && node --check public/core/auth-security-v1774.js && node --check public/core/data-layer-v1773.js && node --check public/core/server-automation-v1770.js && node --check public/core/hotfix-v1763.js && node --check public/core/ops-ux-v1762.js && node --check public/core/ux-fixes-v1764.js && node --check public/core/pdf-engine.js && node --check public/core/classic-offer-pdf-v1767.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js", - "test:static": "node tests/static-security.mjs && node tests/auth-security-v1774.mjs", + "test:static": "node tests/static-security.mjs && node tests/auth-security-v1774.mjs && node tests/employee-create-v1774.mjs", "check:release": "node tests/release-check.mjs", "check:deploy": "npm run check:syntax && npm run test:static && npm run check:release", "test:e2e": "playwright test --config=tests/playwright.config.mjs", diff --git a/supabase/functions/caterium-create-employee/index.ts b/supabase/functions/caterium-create-employee/index.ts index 5e31f54..9c57489 100644 --- a/supabase/functions/caterium-create-employee/index.ts +++ b/supabase/functions/caterium-create-employee/index.ts @@ -9,6 +9,14 @@ const cors = { }; const reply = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: cors }); +type PrepData = { + status?: string; + user_id?: string | null; + email?: string; + display_name?: string; + role?: string; +}; + Deno.serve(async (req: Request) => { if (req.method === "OPTIONS") return new Response("ok", { headers: cors }); if (req.method !== "POST") return reply({ error: "Method not allowed" }, 405); @@ -18,6 +26,7 @@ Deno.serve(async (req: Request) => { const anon = Deno.env.get("SUPABASE_ANON_KEY") || ""; const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || ""; if (!url || !anon || !serviceKey) return reply({ error: "Server auth configuration is incomplete" }, 500); + const caller = createClient(url, anon, { global: { headers: { Authorization: auth } }, auth: { persistSession: false, autoRefreshToken: false }, @@ -28,21 +37,66 @@ Deno.serve(async (req: Request) => { const email = String(body.email || "").trim().toLowerCase(); const displayName = String(body.display_name || "").trim(); const role = String(body.role || "manager"); - const prep = await caller.rpc("sun_employee_prepare_v28", { - p_workspace: workspaceId, - p_email: email, - p_display_name: displayName, - p_role: role, - }); - if (prep.error) return reply({ error: prep.error.message }, 400); - if (prep.data?.status !== "new") { - return reply({ status: prep.data?.status, user_id: prep.data?.user_id, email, display_name: displayName, role }, 200); + + const prepare = async () => { + const result = await caller.rpc("sun_employee_prepare_v28", { + p_workspace: workspaceId, + p_email: email, + p_display_name: displayName, + p_role: role, + }); + if (result.error) throw new Error(result.error.message); + return (result.data || {}) as PrepData; + }; + + const finalize = async (userId: string, created: boolean, temporaryPassword: string | null = null) => { + const fin = await caller.rpc("sun_employee_finalize_v28", { + p_workspace: workspaceId, + p_user_id: userId, + p_display_name: displayName, + p_role: role, + }); + if (fin.error) throw new Error(fin.error.message); + return reply({ + ...fin.data, + created, + temporary_password: temporaryPassword, + must_change_password: created, + }); + }; + + const prep = await prepare(); + const prepStatus = String(prep.status || ""); + + if (prepStatus === "already_member") { + return reply({ + status: "already_member", + user_id: prep.user_id, + email: prep.email || email, + display_name: prep.display_name || displayName, + role: prep.role || role, + created: false, + temporary_password: null, + must_change_password: false, + }); } + + if (prepStatus === "existing") { + const existingUserId = String(prep.user_id || ""); + if (!existingUserId) return reply({ error: "Existing employee account has no user id" }, 409); + return await finalize(existingUserId, false, null); + } + + if (prepStatus !== "new") { + return reply({ error: `Unexpected employee prepare status: ${prepStatus || "empty"}` }, 409); + } + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"; const bytes = crypto.getRandomValues(new Uint8Array(12)); let password = ""; for (const b of bytes) password += alphabet[b % alphabet.length]; password = password.slice(0, 6) + "-" + password.slice(6); + const created = await service.auth.admin.createUser({ email, password, @@ -53,15 +107,31 @@ Deno.serve(async (req: Request) => { registration_source: "caterium_employee_admin", }, }); - if (created.error || !created.data.user) return reply({ error: created.error?.message || "Create failed" }, 400); - const fin = await caller.rpc("sun_employee_finalize_v28", { - p_workspace: workspaceId, - p_user_id: created.data.user.id, - p_display_name: displayName, - p_role: role, - }); - if (fin.error) return reply({ error: fin.error.message }, 400); - return reply({ ...fin.data, created: true, temporary_password: password, must_change_password: true }); + + if (created.error || !created.data.user) { + // A concurrent request or a previous partial failure may have created Auth already. + // Re-read server state and finalize the existing account instead of leaving the workspace half-configured. + const retryPrep = await prepare(); + const retryStatus = String(retryPrep.status || ""); + if (retryStatus === "already_member") { + return reply({ + status: "already_member", + user_id: retryPrep.user_id, + email: retryPrep.email || email, + display_name: retryPrep.display_name || displayName, + role: retryPrep.role || role, + created: false, + temporary_password: null, + must_change_password: false, + }); + } + if (retryStatus === "existing" && retryPrep.user_id) { + return await finalize(String(retryPrep.user_id), false, null); + } + return reply({ error: created.error?.message || "Create failed" }, 400); + } + + return await finalize(created.data.user.id, true, password); } catch (e) { return reply({ error: e instanceof Error ? e.message : String(e) }, 500); } diff --git a/tests/employee-create-v1774.mjs b/tests/employee-create-v1774.mjs new file mode 100644 index 0000000..82a9847 --- /dev/null +++ b/tests/employee-create-v1774.mjs @@ -0,0 +1,10 @@ +import fs from 'node:fs'; +const src=fs.readFileSync('supabase/functions/caterium-create-employee/index.ts','utf8'); +let bad=0;const check=(v,m)=>{console.log(`${v?'OK':'FAIL'}: ${m}`);if(!v)bad++}; +check(src.includes('prepStatus === "already_member"'),'already_member returns without duplicate creation'); +check(src.includes('prepStatus === "existing"'),'existing auth user is handled explicitly'); +check(src.includes('return await finalize(existingUserId, false, null)'),'existing auth user is finalized into workspace membership'); +check(src.includes('retryStatus === "existing"'),'partial/concurrent auth creation retries finalize'); +check(src.includes('sun_employee_finalize_v28'),'employee finalize RPC remains required'); +check(!src.includes('if (prep.data?.status !== "new")'),'old early-return bug is removed'); +if(bad)process.exit(1);