From 23d5b28416e22704c408a25087762278fd65845b Mon Sep 17 00:00:00 2001 From: Gustavo Date: Tue, 20 Oct 2026 15:28:26 -0300 Subject: [PATCH 1/4] feat(applyflow): add explicit extension grant scopes for contacts Require reconnect consent for contacts:read/write; backfill legacy grants without contacts scopes and enforce Bearer capability checks end to end. --- .../account/extension-account-client.test.ts | 15 +- .../src/account/extension-account-client.ts | 9 +- .../extension-cloud-sync.contacts.test.ts | 115 +++ .../src/account/extension-cloud-sync.ts | 164 ++++ .../account/extension-grant-bridge.test.ts | 12 +- .../src/account/extension-grant-bridge.ts | 36 +- .../src/background/service-worker.ts | 167 +++- .../components/OptionsAccountConnection.tsx | 50 +- .../components/OptionsProfileSummary.tsx | 7 +- apps/applyflow-extension/src/panel/App.tsx | 9 +- .../panel/components/PanelContactsSection.tsx | 439 +++++++++++ .../migration.sql | 20 + apps/applyflow/prisma/schema.prisma | 3 + apps/applyflow/scripts/e2e/diagnose-seed.ts | 35 + .../e2e/extension-chrome-checklist-al.cjs | 32 +- .../scripts/e2e/extension-chrome-real.cjs | 15 +- .../scripts/e2e/extension-contacts-e2e.cjs | 716 ++++++++++++++++++ .../src/app/account/account-panel.tsx | 143 +++- .../app/api/applyflow/e2e/session/route.ts | 13 +- .../app/api/applyflow/v2/contacts/route.ts | 7 + .../applyflow/v2/extension/session/route.ts | 62 +- .../src/app/api/applyflow/v2/profile/route.ts | 5 + .../applications/application-http.ts | 12 +- .../src/lib/persistence-v2/jobs/job-http.ts | 12 +- .../personal/contacts-grant-scopes.test.ts | 86 +++ .../extension-grant-scope-guard.test.ts | 51 ++ .../personal/extension-grant-scope-guard.ts | 40 + .../personal/extension-grant-scopes.test.ts | 31 + .../personal/extension-grant-scopes.ts | 73 ++ .../personal/extension-grants.test.ts | 29 +- .../personal/extension-grants.ts | 60 +- .../personal/extension-handoff.ts | 2 + .../personal/runtime-role.local.test.ts | 66 +- .../e2e/v2-cloud-personal-persistence.spec.ts | 26 +- 34 files changed, 2485 insertions(+), 77 deletions(-) create mode 100644 apps/applyflow-extension/src/account/extension-cloud-sync.contacts.test.ts create mode 100644 apps/applyflow-extension/src/panel/components/PanelContactsSection.tsx create mode 100644 apps/applyflow/prisma/migrations/20261020140000_extension_grant_scopes/migration.sql create mode 100644 apps/applyflow/scripts/e2e/diagnose-seed.ts create mode 100644 apps/applyflow/scripts/e2e/extension-contacts-e2e.cjs create mode 100644 apps/applyflow/src/lib/persistence-v2/personal/contacts-grant-scopes.test.ts create mode 100644 apps/applyflow/src/lib/persistence-v2/personal/extension-grant-scope-guard.test.ts create mode 100644 apps/applyflow/src/lib/persistence-v2/personal/extension-grant-scope-guard.ts create mode 100644 apps/applyflow/src/lib/persistence-v2/personal/extension-grant-scopes.test.ts create mode 100644 apps/applyflow/src/lib/persistence-v2/personal/extension-grant-scopes.ts diff --git a/apps/applyflow-extension/src/account/extension-account-client.test.ts b/apps/applyflow-extension/src/account/extension-account-client.test.ts index 7f1c9dda..47028a0d 100644 --- a/apps/applyflow-extension/src/account/extension-account-client.test.ts +++ b/apps/applyflow-extension/src/account/extension-account-client.test.ts @@ -12,7 +12,14 @@ describe("extension account client", () => { const fetchImpl = async (_url: unknown, init?: { headers?: { Authorization?: string } }) => { const header = init?.headers?.Authorization ?? ""; if (header === "Bearer token-a") { - return new Response(JSON.stringify({ accountId: "account-a", autoSubmit: false }), { status: 200 }); + return new Response( + JSON.stringify({ + accountId: "account-a", + scopes: ["profile:read", "contacts:read"], + autoSubmit: false, + }), + { status: 200 }, + ); } return new Response(JSON.stringify({ error: "unauthenticated" }), { status: 401 }); }; @@ -21,7 +28,11 @@ describe("extension account client", () => { token: "token-a", fetchImpl: fetchImpl as typeof fetch, }); - expect(active).toEqual({ ok: true, accountId: "account-a" }); + expect(active).toEqual({ + ok: true, + accountId: "account-a", + scopes: ["profile:read", "contacts:read"], + }); const signedOut = await readExtensionAccount({ origin: "http://127.0.0.1:3010", token: "token-a-revoked", diff --git a/apps/applyflow-extension/src/account/extension-account-client.ts b/apps/applyflow-extension/src/account/extension-account-client.ts index d26d474f..178a0193 100644 --- a/apps/applyflow-extension/src/account/extension-account-client.ts +++ b/apps/applyflow-extension/src/account/extension-account-client.ts @@ -12,7 +12,7 @@ export function extensionAccountProfileKey(accountId: string): string { } export type ExtensionAccountResult = - | { ok: true; accountId: string } + | { ok: true; accountId: string; scopes: string[] } | { ok: false; reason: "signed_out" | "misconfigured" }; export async function readExtensionAccount(input: { @@ -33,9 +33,12 @@ export async function readExtensionAccount(input: { }); if (response.status === 401) return { ok: false, reason: "signed_out" }; if (!response.ok) return { ok: false, reason: "misconfigured" }; - const body = (await response.json()) as { accountId?: string }; + const body = (await response.json()) as { accountId?: string; scopes?: unknown }; if (!body.accountId) return { ok: false, reason: "signed_out" }; - return { ok: true, accountId: body.accountId }; + const scopes = Array.isArray(body.scopes) + ? body.scopes.filter((item): item is string => typeof item === "string") + : []; + return { ok: true, accountId: body.accountId, scopes }; } export function selectExtensionProfileKey(input: { diff --git a/apps/applyflow-extension/src/account/extension-cloud-sync.contacts.test.ts b/apps/applyflow-extension/src/account/extension-cloud-sync.contacts.test.ts new file mode 100644 index 00000000..7ad14484 --- /dev/null +++ b/apps/applyflow-extension/src/account/extension-cloud-sync.contacts.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Contact, ContactInteraction } from "@devflow/applyflow-core"; + +import { listCloudContacts, saveCloudContact, saveCloudInteraction } from "./extension-cloud-sync.js"; + +const CONTACT: Contact = { + id: "extcontact_a", + name: "Ada Recruiter", + type: "recruiter", + status: "not_contacted", + createdAt: "2026-10-20T12:00:00.000Z", + updatedAt: "2026-10-20T12:00:00.000Z", +}; + +describe("extension cloud contacts sync", () => { + it("reconciles identical payload after a lost response (network after commit)", async () => { + const fetchImpl = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => { + if ((init?.method ?? "GET") === "PUT") { + throw new TypeError("network"); + } + return { + ok: true, + status: 200, + json: async () => ({ + contacts: [CONTACT], + interactions: [], + versions: { [CONTACT.id]: 1 }, + }), + }; + }); + + const lost = await saveCloudContact({ + origin: "http://127.0.0.1:3010", + token: "t".repeat(64), + contact: CONTACT, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(lost.ok).toBe(true); + if (lost.ok) { + expect(lost.reused).toBe(true); + expect(lost.version).toBe(1); + } + }); + + it("returns version_conflict when listed contact differs after network loss", async () => { + const diverged = { ...CONTACT, name: "Other Name" }; + const fetchImpl = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => { + if ((init?.method ?? "GET") === "PUT") { + throw new TypeError("network"); + } + return { + ok: true, + status: 200, + json: async () => ({ + contacts: [diverged], + interactions: [], + versions: { [CONTACT.id]: 2 }, + }), + }; + }); + + const result = await saveCloudContact({ + origin: "http://127.0.0.1:3010", + token: "t".repeat(64), + contact: CONTACT, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("version_conflict"); + expect(result.status).toBe(409); + } + }); + + it("lists contacts and registers an interaction", async () => { + const interaction: ContactInteraction = { + id: "extinteraction_a", + contactId: CONTACT.id, + type: "note", + occurredAt: "2026-10-20T12:05:00.000Z", + content: "manual", + }; + const fetchImpl = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + contacts: [CONTACT], + interactions: [], + versions: { [CONTACT.id]: 1 }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ interaction }), + }); + + const listed = await listCloudContacts({ + origin: "http://127.0.0.1:3010", + token: "t".repeat(64), + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(listed.ok).toBe(true); + + const saved = await saveCloudInteraction({ + origin: "http://127.0.0.1:3010", + token: "t".repeat(64), + interaction, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(saved.ok).toBe(true); + }); +}); diff --git a/apps/applyflow-extension/src/account/extension-cloud-sync.ts b/apps/applyflow-extension/src/account/extension-cloud-sync.ts index 493cb063..9cd6bb5f 100644 --- a/apps/applyflow-extension/src/account/extension-cloud-sync.ts +++ b/apps/applyflow-extension/src/account/extension-cloud-sync.ts @@ -2,6 +2,8 @@ import { JOB_MATCH_SCORING_VERSION, getDefaultResumeVariant, type CandidateProfile, + type Contact, + type ContactInteraction, type ResumeLibrary, type SaveApplicationInput, } from "@devflow/applyflow-core"; @@ -504,6 +506,168 @@ export async function registerCloudJobAndApplication(input: { return fail(createdApp.error, createdApp.status); } +export type ContactCloudResult = + | { + ok: true; + contact: Contact; + version: number; + reused: boolean; + } + | { + ok: false; + error: string; + status: number; + contact?: Contact; + version?: number; + }; + +export type InteractionCloudResult = + | { ok: true; interaction: ContactInteraction; reused: boolean } + | { ok: false; error: string; status: number }; + +export type ContactsListCloudResult = + | { + ok: true; + contacts: Contact[]; + interactions: ContactInteraction[]; + versions: Record; + } + | { ok: false; error: string; status: number }; + +export async function listCloudContacts(input: { + origin: string; + token: string; + signal?: AbortSignal; + fetchImpl?: typeof fetch; +}): Promise { + const result = await extensionApiFetch<{ + contacts?: Contact[]; + interactions?: ContactInteraction[]; + versions?: Record; + }>({ + origin: input.origin, + token: input.token, + path: "/api/applyflow/v2/contacts", + signal: input.signal, + fetchImpl: input.fetchImpl, + }); + if (!result.ok) return { ok: false, error: result.error, status: result.status }; + return { + ok: true, + contacts: result.body.contacts ?? [], + interactions: result.body.interactions ?? [], + versions: result.body.versions ?? {}, + }; +} + +/** + * Create or update a contact. Same id + same fingerprint is idempotent. + * On lost response after commit, caller should list and reconcile by id/fingerprint. + */ +export async function saveCloudContact(input: { + origin: string; + token: string; + contact: Contact; + expectedVersion?: number; + signal?: AbortSignal; + fetchImpl?: typeof fetch; +}): Promise { + const result = await extensionApiFetch<{ contact: Contact; version: number }>({ + origin: input.origin, + token: input.token, + path: "/api/applyflow/v2/contacts", + method: "PUT", + body: { + contact: input.contact, + ...(typeof input.expectedVersion === "number" + ? { expectedVersion: input.expectedVersion } + : {}), + }, + signal: input.signal, + fetchImpl: input.fetchImpl, + }); + if (result.ok) { + return { + ok: true, + contact: result.body.contact, + version: result.body.version, + reused: false, + }; + } + if (result.status === 0 && result.error === "network") { + const listed = await listCloudContacts({ + origin: input.origin, + token: input.token, + signal: input.signal, + fetchImpl: input.fetchImpl, + }); + if (listed.ok) { + const existing = listed.contacts.find((item) => item.id === input.contact.id); + const version = listed.versions[input.contact.id]; + if (existing && version != null && JSON.stringify(existing) === JSON.stringify(input.contact)) { + return { ok: true, contact: existing, version, reused: true }; + } + if (existing && version != null) { + return { + ok: false, + error: "version_conflict", + status: 409, + contact: existing, + version, + }; + } + } + return { ok: false, error: "network", status: 0 }; + } + if (result.status === 409) { + return { ok: false, error: result.error || VERSION_CONFLICT, status: 409 }; + } + return { ok: false, error: result.error, status: result.status }; +} + +export async function saveCloudInteraction(input: { + origin: string; + token: string; + interaction: ContactInteraction; + signal?: AbortSignal; + fetchImpl?: typeof fetch; +}): Promise { + const result = await extensionApiFetch<{ interaction: ContactInteraction }>({ + origin: input.origin, + token: input.token, + path: "/api/applyflow/v2/contacts", + method: "POST", + body: { interaction: input.interaction }, + signal: input.signal, + fetchImpl: input.fetchImpl, + }); + if (result.ok) { + return { ok: true, interaction: result.body.interaction, reused: false }; + } + if (result.status === 0 && result.error === "network") { + const listed = await listCloudContacts({ + origin: input.origin, + token: input.token, + signal: input.signal, + fetchImpl: input.fetchImpl, + }); + if (listed.ok) { + const existing = listed.interactions.find((item) => item.id === input.interaction.id); + if (existing && JSON.stringify(existing) === JSON.stringify(input.interaction)) { + return { ok: true, interaction: existing, reused: true }; + } + if (existing) { + return { ok: false, error: "version_conflict", status: 409 }; + } + } + return { ok: false, error: "network", status: 0 }; + } + if (result.status === 409) { + return { ok: false, error: result.error || VERSION_CONFLICT, status: 409 }; + } + return { ok: false, error: result.error, status: result.status }; +} + export async function markCloudApplicationSent(input: { origin: string; token: string; diff --git a/apps/applyflow-extension/src/account/extension-grant-bridge.test.ts b/apps/applyflow-extension/src/account/extension-grant-bridge.test.ts index a3eb3048..209cb42d 100644 --- a/apps/applyflow-extension/src/account/extension-grant-bridge.test.ts +++ b/apps/applyflow-extension/src/account/extension-grant-bridge.test.ts @@ -13,6 +13,7 @@ const GRANT = { expiresAt: "2026-10-12T12:00:00.000Z", token: TOKEN, origin: "http://127.0.0.1:3012", + scopes: ["profile:read", "contacts:read", "contacts:write"], }; describe("extension grant bridge", () => { @@ -31,8 +32,15 @@ describe("extension grant bridge", () => { now: Date.parse("2026-10-05T12:00:00.000Z"), }); expect(accepted.ok).toBe(true); + if (accepted.ok) { + expect(accepted.grant.scopes).toEqual(GRANT.scopes); + } const status = extensionAccountStatus(accepted.ok ? accepted.grant : null, Date.parse("2026-10-05T12:00:00.000Z")); - expect(status).toEqual({ signedIn: true, accountId: GRANT.accountId }); + expect(status).toEqual({ + signedIn: true, + accountId: GRANT.accountId, + scopes: GRANT.scopes, + }); expect(status).not.toHaveProperty("token"); expect( acceptExternalGrant({ @@ -50,12 +58,14 @@ describe("extension grant bridge", () => { expiresAt: GRANT.expiresAt, token: "b".repeat(64), origin: GRANT.origin, + scopes: ["profile:read"], }); expect(next.accountId).not.toBe(GRANT.accountId); expect(next.token).not.toBe(GRANT.token); expect(extensionAccountStatus(next, Date.parse("2026-10-13T12:00:00.000Z"))).toEqual({ signedIn: false, accountId: null, + scopes: [], }); }); }); diff --git a/apps/applyflow-extension/src/account/extension-grant-bridge.ts b/apps/applyflow-extension/src/account/extension-grant-bridge.ts index 3566abb4..3fd4b3a4 100644 --- a/apps/applyflow-extension/src/account/extension-grant-bridge.ts +++ b/apps/applyflow-extension/src/account/extension-grant-bridge.ts @@ -4,7 +4,13 @@ export const APPLYFLOW_EXTENSION_ID = origins.extensionId; export const EXTENSION_GRANT_STORAGE_KEY = "applyflow.extension.grant"; export type ExtensionGrantMessage = - | { type: "APPLYFLOW_BIND_GRANT"; accountId: string; expiresAt: string; token: string } + | { + type: "APPLYFLOW_BIND_GRANT"; + accountId: string; + expiresAt: string; + token: string; + scopes?: string[]; + } | { type: "APPLYFLOW_CLEAR_GRANT" } | { type: "APPLYFLOW_ACCOUNT_STATUS" }; @@ -13,6 +19,7 @@ export type StoredExtensionGrant = { expiresAt: string; token: string; origin: string; + scopes: string[]; }; export function extensionOriginsForTarget(target: "local" | "production"): readonly string[] { @@ -26,7 +33,7 @@ export function acceptExternalGrant(input: { now?: number; }): { ok: true; grant: StoredExtensionGrant } | { ok: false; reason: "rejected" } { if (!input.message || typeof input.message !== "object") return { ok: false, reason: "rejected" }; - const message = input.message as Partial; + const message = input.message as Partial & { scopes?: unknown }; if (message.type !== "APPLYFLOW_BIND_GRANT") return { ok: false, reason: "rejected" }; if (!input.senderUrl) return { ok: false, reason: "rejected" }; let origin: string; @@ -41,9 +48,18 @@ export function acceptExternalGrant(input: { if (typeof message.expiresAt !== "string") return { ok: false, reason: "rejected" }; const expiresAt = Date.parse(message.expiresAt); if (!Number.isFinite(expiresAt) || expiresAt <= (input.now ?? Date.now())) return { ok: false, reason: "rejected" }; + const scopes = Array.isArray(message.scopes) + ? message.scopes.filter((item): item is string => typeof item === "string") + : []; return { ok: true, - grant: { accountId: message.accountId, expiresAt: message.expiresAt, token: message.token, origin }, + grant: { + accountId: message.accountId, + expiresAt: message.expiresAt, + token: message.token, + origin, + scopes, + }, }; } @@ -58,8 +74,14 @@ export function replaceStoredGrant( export function extensionAccountStatus( grant: StoredExtensionGrant | null, now = Date.now(), -): { signedIn: false; accountId: null } | { signedIn: true; accountId: string } { - if (!grant) return { signedIn: false, accountId: null }; - if (Date.parse(grant.expiresAt) <= now) return { signedIn: false, accountId: null }; - return { signedIn: true, accountId: grant.accountId }; +): + | { signedIn: false; accountId: null; scopes: [] } + | { signedIn: true; accountId: string; scopes: string[] } { + if (!grant) return { signedIn: false, accountId: null, scopes: [] }; + if (Date.parse(grant.expiresAt) <= now) return { signedIn: false, accountId: null, scopes: [] }; + return { signedIn: true, accountId: grant.accountId, scopes: grant.scopes ?? [] }; +} + +export function grantHasLocalScope(grant: StoredExtensionGrant | null, scope: string): boolean { + return Boolean(grant?.scopes?.includes(scope)); } diff --git a/apps/applyflow-extension/src/background/service-worker.ts b/apps/applyflow-extension/src/background/service-worker.ts index 2bdb5f5e..f2c1e78c 100644 --- a/apps/applyflow-extension/src/background/service-worker.ts +++ b/apps/applyflow-extension/src/background/service-worker.ts @@ -31,14 +31,17 @@ import { } from "../account/extension-grant-bridge.js"; import { readExtensionAccount } from "../account/extension-account-client.js"; import { + listCloudContacts, loadAccountResumeLibrary, markCloudApplicationSent, pickAccountProfile, profileFieldsForContentScript, registerCloudJobAndApplication, + saveCloudContact, + saveCloudInteraction, type AccountProfileSnapshot, } from "../account/extension-cloud-sync.js"; -import type { SaveApplicationInput } from "@devflow/applyflow-core"; +import type { Contact, ContactInteraction, SaveApplicationInput } from "@devflow/applyflow-core"; const extensionAllowedOrigins = [ ...extensionOriginsForTarget("local"), @@ -74,7 +77,10 @@ async function readStoredGrant(): Promise { const stored = await chrome.storage.session.get(EXTENSION_GRANT_STORAGE_KEY); const grant = stored[EXTENSION_GRANT_STORAGE_KEY] as StoredExtensionGrant | undefined; if (!grant?.token || !grant.accountId || !grant.origin) return null; - return grant; + return { + ...grant, + scopes: Array.isArray(grant.scopes) ? grant.scopes : [], + }; } async function writeStoredGrant(grant: StoredExtensionGrant | null): Promise { @@ -287,6 +293,150 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; } + if (message?.type === "APPLYFLOW_LIST_CLOUD_CONTACTS") { + void (async () => { + const grant = await readStoredGrant(); + if (!grant) { + sendResponse({ + ok: false, + error: "signed_out", + status: 401, + reconnect: "Abra /account e ligue a extensão com permissões de contactos.", + }); + return; + } + if (!grant.scopes?.includes("contacts:read")) { + sendResponse({ + ok: false, + error: "grant_capability_denied", + status: 403, + reconnect: "Reconecte em /account e confirme contacts:read / contacts:write.", + }); + return; + } + const generation = accountRuntime.generation; + const controller = new AbortController(); + accountRuntime.inflight?.abort(); + accountRuntime.inflight = controller; + const result = await listCloudContacts({ + origin: grant.origin, + token: grant.token, + signal: controller.signal, + }); + if (accountRuntime.generation !== generation) { + sendResponse({ ok: false, error: "aborted", status: 0 }); + return; + } + if (!result.ok && result.status === 401) { + await writeStoredGrant(null); + bumpAccountRuntime(); + } + sendResponse(result); + })(); + return true; + } + + if (message?.type === "APPLYFLOW_SAVE_CLOUD_CONTACT") { + void (async () => { + const grant = await readStoredGrant(); + if (!grant) { + sendResponse({ + ok: false, + error: "signed_out", + status: 401, + reconnect: "Abra /account e ligue a extensão com permissões de contactos.", + }); + return; + } + if (!grant.scopes?.includes("contacts:write")) { + sendResponse({ + ok: false, + error: "grant_capability_denied", + status: 403, + reconnect: "Reconecte em /account e confirme contacts:write.", + }); + return; + } + const contact = message.contact as Contact | undefined; + if (!contact || typeof contact !== "object") { + sendResponse({ ok: false, error: "invalid_payload", status: 400 }); + return; + } + const expectedVersion = + typeof message.expectedVersion === "number" ? message.expectedVersion : undefined; + const generation = accountRuntime.generation; + const controller = new AbortController(); + accountRuntime.inflight?.abort(); + accountRuntime.inflight = controller; + const result = await saveCloudContact({ + origin: grant.origin, + token: grant.token, + contact, + expectedVersion, + signal: controller.signal, + }); + if (accountRuntime.generation !== generation) { + sendResponse({ ok: false, error: "aborted", status: 0 }); + return; + } + if (!result.ok && result.status === 401) { + await writeStoredGrant(null); + bumpAccountRuntime(); + } + sendResponse(result); + })(); + return true; + } + + if (message?.type === "APPLYFLOW_SAVE_CLOUD_INTERACTION") { + void (async () => { + const grant = await readStoredGrant(); + if (!grant) { + sendResponse({ + ok: false, + error: "signed_out", + status: 401, + reconnect: "Abra /account e ligue a extensão com permissões de contactos.", + }); + return; + } + if (!grant.scopes?.includes("contacts:write")) { + sendResponse({ + ok: false, + error: "grant_capability_denied", + status: 403, + reconnect: "Reconecte em /account e confirme contacts:write.", + }); + return; + } + const interaction = message.interaction as ContactInteraction | undefined; + if (!interaction || typeof interaction !== "object") { + sendResponse({ ok: false, error: "invalid_payload", status: 400 }); + return; + } + const generation = accountRuntime.generation; + const controller = new AbortController(); + accountRuntime.inflight?.abort(); + accountRuntime.inflight = controller; + const result = await saveCloudInteraction({ + origin: grant.origin, + token: grant.token, + interaction, + signal: controller.signal, + }); + if (accountRuntime.generation !== generation) { + sendResponse({ ok: false, error: "aborted", status: 0 }); + return; + } + if (!result.ok && result.status === 401) { + await writeStoredGrant(null); + bumpAccountRuntime(); + } + sendResponse(result); + })(); + return true; + } + if (message?.type === GET_PUBLIC_SETTINGS_MESSAGE) { const parsed = parseGetPublicSettingsRequest(message); if (!parsed) { @@ -380,9 +530,18 @@ chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => sendResponse({ ok: false, signedIn: false }); return; } - await writeStoredGrant(grant); + const withScopes: StoredExtensionGrant = { + ...grant, + scopes: account.scopes.length > 0 ? account.scopes : grant.scopes, + }; + await writeStoredGrant(withScopes); bumpAccountRuntime(); - sendResponse({ ok: true, accountId: grant.accountId, signedIn: true }); + sendResponse({ + ok: true, + accountId: withScopes.accountId, + signedIn: true, + scopes: withScopes.scopes, + }); })(); return true; }); diff --git a/apps/applyflow-extension/src/options/components/OptionsAccountConnection.tsx b/apps/applyflow-extension/src/options/components/OptionsAccountConnection.tsx index 86bb5e78..e9240170 100644 --- a/apps/applyflow-extension/src/options/components/OptionsAccountConnection.tsx +++ b/apps/applyflow-extension/src/options/components/OptionsAccountConnection.tsx @@ -8,21 +8,31 @@ export function OptionsAccountConnection() { const [state, setState] = useState< | { status: "loading" } | { status: "signed_out" } - | { status: "signed_in"; accountId: string } + | { status: "signed_in"; accountId: string; scopes: string[] } >({ status: "loading" }); useEffect(() => { let cancelled = false; void chrome.runtime .sendMessage({ type: "APPLYFLOW_ACCOUNT_STATUS" }) - .then((response: { signedIn?: boolean; accountId?: string | null } | undefined) => { - if (cancelled) return; - if (response?.signedIn && typeof response.accountId === "string") { - setState({ status: "signed_in", accountId: response.accountId }); - return; - } - setState({ status: "signed_out" }); - }) + .then( + ( + response: + | { signedIn?: boolean; accountId?: string | null; scopes?: string[] } + | undefined, + ) => { + if (cancelled) return; + if (response?.signedIn && typeof response.accountId === "string") { + setState({ + status: "signed_in", + accountId: response.accountId, + scopes: Array.isArray(response.scopes) ? response.scopes : [], + }); + return; + } + setState({ status: "signed_out" }); + }, + ) .catch(() => { if (!cancelled) setState({ status: "signed_out" }); }); @@ -44,18 +54,30 @@ export function OptionsAccountConnection() { state.accountId.length > 8 ? `${state.accountId.slice(0, 4)}…${state.accountId.slice(-4)}` : state.accountId; + const hasContacts = + state.scopes.includes("contacts:read") && state.scopes.includes("contacts:write"); return ( -

- Conta ApplyFlow ligada ({masked}). O token fica só no service worker; o painel LinkedIn não o recebe. -

+
+

+ Conta ApplyFlow ligada ({masked}). O token fica só no service worker; o painel LinkedIn não + o recebe. +

+

+ Permissões do grant:{" "} + {state.scopes.length > 0 ? state.scopes.join(", ") : "nenhuma (reconecte em /account)"}. + {hasContacts + ? " Contactos autorizados." + : " Sem contactos — reconecte e confirme contacts:read/write."} +

+
); } return (

Sem ligação à conta ApplyFlow (grant ausente ou expirado). Abra o dashboard em{" "} - /account, confirme a sessão e clique em Ligar extensão para - reconectar. Sem auto-submit. + /account, confirme as permissões pedidas e clique em Ligar extensão{" "} + para reconectar. Sem auto-submit.

); } diff --git a/apps/applyflow-extension/src/options/components/OptionsProfileSummary.tsx b/apps/applyflow-extension/src/options/components/OptionsProfileSummary.tsx index ddbe9488..632ddbb9 100644 --- a/apps/applyflow-extension/src/options/components/OptionsProfileSummary.tsx +++ b/apps/applyflow-extension/src/options/components/OptionsProfileSummary.tsx @@ -55,7 +55,7 @@ export function OptionsProfileSummary(props: { profile: CandidateProfile }) {
  • Pretensões (opcional)
  • -
  • Dados locais (sem envio remoto)
  • +
  • Perfil local disponível offline
  • Sem auto-submit
  • @@ -65,11 +65,12 @@ export function OptionsProfileSummary(props: { profile: CandidateProfile }) {
    Local-first chrome.storage.local - Sem backend + Conta com grant Sem auto-submit

    - Sugestões no LinkedIn são informativas; o envio da candidatura é sempre seu no site oficial. + Com grant activo, a extensão sincroniza dados autorizados com a conta ApplyFlow. Sugestões no + LinkedIn são informativas; o envio da candidatura é sempre seu no site oficial.

    diff --git a/apps/applyflow-extension/src/panel/App.tsx b/apps/applyflow-extension/src/panel/App.tsx index 14da45df..11e6aab7 100644 --- a/apps/applyflow-extension/src/panel/App.tsx +++ b/apps/applyflow-extension/src/panel/App.tsx @@ -19,6 +19,7 @@ import { JobSummaryCard } from "./components/JobSummaryCard"; import { PrepareApplicationCard } from "./components/PrepareApplicationCard"; import type { PanelAiBundle } from "./panel-ai.js"; import { openApplyFlowOptions } from "../runtime/open-options-page.js"; +import { PanelContactsSection } from "./components/PanelContactsSection"; import { PanelHistorySection } from "./components/PanelHistorySection"; export type PanelPhaseView = "waiting" | "modal_empty" | "fields"; @@ -246,6 +247,8 @@ export function App(props: { canSaveToHistory={props.applicationsHistoryAllowSave} /> + + {props.panelPhase === "fields" ? ( <> {preparation ? ( @@ -297,7 +300,11 @@ export function App(props: {