diff --git a/frontend/src/plugins/sb-user-listeners.ts b/frontend/src/plugins/sb-user-listeners.ts index 571c3f852..f8662afaf 100644 --- a/frontend/src/plugins/sb-user-listeners.ts +++ b/frontend/src/plugins/sb-user-listeners.ts @@ -6,6 +6,8 @@ import type { } from '@supabase/supabase-js'; import { useSupabaseUserProfile } from '~/composables/useSupabaseUserProfile'; import { useLeadminerStore } from '~/stores/leadminer'; +import { deriveSourceState } from '~/utils/miningSourceConfig'; +import { SourceHealthState } from '~/types/enums'; import type { Profile } from '~/types/profile'; /** @@ -130,12 +132,21 @@ async function handleFirstTimeSignIn(session: Session) { await $leadminerStore.ensureMiningSourcesLoaded(); const userEmail = user.email?.toLowerCase(); - const alreadyExists = $leadminerStore.miningSources.some( + const existingSource = $leadminerStore.miningSources.find( (source) => source.email?.toLowerCase() === userEmail && source.type === provider, ); - if (!alreadyExists) { + // Signing in again re-authorises the account, so it is the natural place to + // repair a broken source: without this the card keeps showing "credentials + // expired" until the user finds the Reconnect button. Healthy sources are + // left untouched so a normal sign-in does not rewrite their credentials. + const needsReauth = existingSource + ? deriveSourceState(existingSource).state === + SourceHealthState.NeedsReauth + : false; + + if (!existingSource || needsReauth) { await addMiningSourceFromProviderToken( provider, providerToken, diff --git a/frontend/src/tests/unit/auth-first-signin-gating.test.ts b/frontend/src/tests/unit/auth-first-signin-gating.test.ts index 176b1fbe7..c04c90523 100644 --- a/frontend/src/tests/unit/auth-first-signin-gating.test.ts +++ b/frontend/src/tests/unit/auth-first-signin-gating.test.ts @@ -27,7 +27,11 @@ const mocks = vi.hoisted(() => ({ updateUser: vi.fn(), saasEdgeFunctions: vi.fn(), ensureMiningSourcesLoaded: vi.fn(), - storeSources: [] as Array<{ email: string; type: string }>, + storeSources: [] as Array<{ + email: string; + type: string; + config?: Record; + }>, authStateCallback: undefined as | ((event: string, session: unknown) => void) | undefined, @@ -293,7 +297,7 @@ describe('sb-user-listeners first-sign-in side effects', () => { expect(mocks.saasEdgeFunctions).toHaveBeenCalledTimes(1); }); - it('skips the OAuth POST when the source already exists', async () => { + it('skips the OAuth POST when the existing source is healthy', async () => { mocks.storeSources = [{ email: 'user@example.com', type: 'google' }]; mockUser({ provider: 'google', @@ -311,6 +315,41 @@ describe('sb-user-listeners first-sign-in side effects', () => { expect(mocks.saasEdgeFunctions).not.toHaveBeenCalled(); }); + it('re-posts the OAuth source when the existing one needs re-auth', async () => { + mocks.storeSources = [ + { + email: 'user@example.com', + type: 'google', + config: { health: { state: 'needs_reauth' } }, + }, + ]; + mockUser({ + provider: 'google', + firstTimeSignin: true, + emailTemplateLanguage: 'en', + }); + registerListener(); + + emit('SIGNED_IN', { + provider_token: 'oauth-access-fixture', + provider_refresh_token: 'oauth-refresh-fixture', + }); + + await vi.waitFor(() => + expect(mocks.saasEdgeFunctions).toHaveBeenCalledWith( + 'mining-sources', + expect.objectContaining({ + method: 'POST', + body: expect.objectContaining({ + provider: 'google', + provider_token: 'oauth-access-fixture', + provider_refresh_token: 'oauth-refresh-fixture', + }), + }), + ), + ); + }); + it('initialises the email-template language only when it differs', async () => { mockUser({ firstTimeSignin: true }); registerListener(); diff --git a/supabase/functions/mining-sources/index.ts b/supabase/functions/mining-sources/index.ts index cc17ccbbb..48b9c21d6 100644 --- a/supabase/functions/mining-sources/index.ts +++ b/supabase/functions/mining-sources/index.ts @@ -93,6 +93,36 @@ async function authMiddleware(c: Context, next: () => Promise) { return next(); } +/** + * Fresh credentials were just stored, so any recorded auth failure is stale. + * Clears the flag and the notification marker through the canonical writer, so + * the UI stops showing "credentials expired" and a later failure notifies + * again. Non-fatal: the credential write has already succeeded. + */ +async function clearReauthFlag( + sourceId: string, + logContext: string, + userId: string, +): Promise { + try { + await applySourceConfig(sourceId, { + health: { + state: SourceHealthState.Active, + last_error: null, + notified_at: null, + }, + }); + logger.info(`Cleared stale re-auth flag ${logContext}`, { userId }); + } catch (healthError) { + logger.warn(`Failed to clear re-auth flag ${logContext}`, { + error: + healthError instanceof Error + ? healthError.message + : String(healthError), + }); + } +} + app.post("/", authMiddleware, async (c: Context) => { const body = await c.req.json().catch(() => ({})); const parsed = createSchema.safeParse(body); @@ -129,6 +159,24 @@ app.post("/", authMiddleware, async (c: Context) => { return c.json({ error: rpcError.message }, 500); } + // Fresh tokens were just stored, so any auth-failure flag is stale. Without + // this, signing in again leaves the source showing "credentials expired". + const { data: sourceData, error: sourceError } = await admin + .schema("private") + .from("mining_sources") + .select("id") + .eq("user_id", user.id) + .eq("email", user.email) + .single(); + + if (sourceError || !sourceData) { + logger.warn("Failed to get mining source ID after sign-in credentials", { + error: sourceError?.message, + }); + } else { + await clearReauthFlag(sourceData.id, "after sign-in credentials", user.id); + } + return c.json({ success: true }); }); @@ -237,22 +285,11 @@ app.get("/oauth/callback/:provider", async (c: Context) => { } else { // Fresh credentials were just stored: clear any stale auth-failure flag // so the UI stops showing "connection lost" for a source that now works. - // Canonical writer (read-merge-CAS-write); unconditional and idempotent. - try { - await applySourceConfig(sourceData.id, { - health: { state: SourceHealthState.Active, last_error: null }, - }); - logger.info("Cleared stale re-auth flag after OAuth reconnect", { - userId, - }); - } catch (healthError) { - // Non-fatal: the reconnect itself succeeded; the flag clears on next use. - logger.warn("Failed to clear re-auth flag after OAuth reconnect", { - error: healthError instanceof Error - ? healthError.message - : String(healthError), - }); - } + await clearReauthFlag( + sourceData.id, + "after OAuth reconnect", + userId, + ); const { error: smtpError } = await admin .schema("private") diff --git a/supabase/functions/mining-sources/write-path.test.ts b/supabase/functions/mining-sources/write-path.test.ts index 3f9f09b7c..a422015eb 100644 --- a/supabase/functions/mining-sources/write-path.test.ts +++ b/supabase/functions/mining-sources/write-path.test.ts @@ -288,5 +288,45 @@ Deno.test( .health as Record; assertEquals(health.state, "active"); assertEquals(health.last_error, null); + assertEquals(health.notified_at, null); + }, +); + +Deno.test( + "POST / clears a stale needs_reauth flag after sign-in credentials", + { sanitizeResources: false, sanitizeOps: false }, + async () => { + capturedUpsert = undefined; + capturedConfigUpdate = undefined; + + const res = await handler( + new Request("http://127.0.0.1:8000/mining-sources", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer user-jwt-from-gotrue", + }, + body: JSON.stringify({ + provider: "google", + // skipcq: SCT-A000 - fake test credential, not a real secret + provider_token: "handoff-access-token", + // skipcq: SCT-A000 - fake test credential, not a real secret + provider_refresh_token: "handoff-refresh-token", + }), + }), + ); + + assertEquals(res.status, 200); + await res.body?.cancel(); + + // Signing in again hands over fresh tokens: the canonical writer must + // reset the flag, otherwise the source shows "expired" forever. + const update = capturedConfigUpdate as Record | undefined; + assert(update, "no config update was issued after sign-in credentials"); + const health = (update.config as Record) + .health as Record; + assertEquals(health.state, "active"); + assertEquals(health.last_error, null); + assertEquals(health.notified_at, null); }, );