Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions frontend/src/plugins/sb-user-listeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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,
Expand Down
43 changes: 41 additions & 2 deletions frontend/src/tests/unit/auth-first-signin-gating.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
}>,
authStateCallback: undefined as
| ((event: string, session: unknown) => void)
| undefined,
Expand Down Expand Up @@ -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',
Expand All @@ -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();
Expand Down
69 changes: 53 additions & 16 deletions supabase/functions/mining-sources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,36 @@ async function authMiddleware(c: Context, next: () => Promise<void>) {
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<void> {
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);
Expand Down Expand Up @@ -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 });
});

Expand Down Expand Up @@ -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")
Expand Down
40 changes: 40 additions & 0 deletions supabase/functions/mining-sources/write-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,5 +288,45 @@ Deno.test(
.health as Record<string, unknown>;
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<string, unknown> | undefined;
assert(update, "no config update was issued after sign-in credentials");
const health = (update.config as Record<string, unknown>)
.health as Record<string, unknown>;
assertEquals(health.state, "active");
assertEquals(health.last_error, null);
assertEquals(health.notified_at, null);
},
);
Loading