Dual-provider auth — sign in with GitHub or LinkedIn, link both, get a green dot
Members can now sign in with GitHub OR LinkedIn — and link both. The User table is now one row per *person* (canonical id = lowercased roster email) with nullable provider columns rather than one row per login. The header indicator went tri-state — green when both providers are linked, amber 'partial' when only one is, red when not signed in — and the partial state opens a tooltip nudging the user to connect the second.
Changelog — 2026-04-29 (01)
Dual-provider auth + partial-link nudge
This entry consolidates five commits that landed today: db77206 (LinkedIn OAuth), 2e0c159 (signed-in header indicator), b5adaff (User-table redesign), dcd0f77 (SSR cookie-read fix), and 5633d0b (tri-state + tooltip).
Why Care?
Most Kauffman Fellows are roster’d by email, not by GitHub handle. Forcing a single provider on the launch session would have left a chunk of registrants unable to sign in. Adding LinkedIn brings the email path onstage. But once you accept two providers, the question becomes: what does it mean for the same person to sign in via both? Answering that cleanly is the part that matters — and it’s what unblocks every downstream feature (votes, proposals, profile edits) that needs a stable per-person identity.
The Identity Decision
The original User schema had one row per provider login: a Fellow who signed in with GitHub got one row, the same Fellow signing in with LinkedIn got a second. That model breaks the moment a feature wants to ask “is this person logged in?” because person and login aren’t the same thing. It also collides with the polling system, where Vote.user_id is already the lowercased roster email — joining to a per-login user table would have required a new mapping table.
Redesigned: one row per person, provider connections as nullable columns.
// db/config.ts
const User = defineTable({
columns: {
id: column.text({ primaryKey: true }), // canonical = lowercased roster email
email: column.text({ optional: true }),
name: column.text({ optional: true }),
avatar: column.text({ optional: true }),
github_handle: column.text({ optional: true }), // nullable
linkedin_sub: column.text({ optional: true }), // nullable
kauffman_class: column.number({ optional: true }),
firm: column.text({ optional: true }),
last_provider: column.text(), // 'github' | 'linkedin' — drives "you signed in with X last time"
first_login_at: column.date(),
last_login_at: column.date(),
created_at: column.date(),
updated_at: column.date(),
},
indexes: {
github_handle_unique: { on: ['github_handle'], unique: true },
linkedin_sub_unique: { on: ['linkedin_sub'], unique: true },
},
});
A Fellow signs in with GitHub → row created, github_handle filled, linkedin_sub null. Later signs in with LinkedIn against the same roster email → same row updated, linkedin_sub filled in. The roster email is the join key across providers and across DB tables. Vote.user_id already used this. Proposal.user_id (next changelog) does too.
Fallback: when a roster entry has no email (rare — GitHub-handle-only entries), id falls back to <provider>:<subject>. Those users can’t dual-provider anyway since LinkedIn matching requires email, so the fallback case stays single-provider by definition.
The Indicator
The header login element used to be two-state (out / in). It’s now tri-state, driven entirely off data-state + CSS:
| State | Dot | Behavior |
|---|---|---|
out | red, pulsing | ”Log in” pill, links to /login |
partial | amber | avatar shown, click opens a tooltip |
in | green | avatar shown, link is a no-op-ish |
The partial state exists to say “we know who you are, but you only have one provider linked — connect the other and we love you more.” Hovering or clicking the partial indicator opens a small tooltip:
┌─────────────────────────────────┐
│ If you connect both, │
│ we love you more! │
│ │
│ ┌──────┐ ┌──────────┐ │
│ │ GH │ │ in │ │
│ │ green│ │ amber │ │
│ │Connected Connect ↗│ │
│ └──────┘ └──────────┘ │
└─────────────────────────────────┘
Tooltip behavior is hover-to-peek + click-to-pin: a 120ms grace period when the cursor leaves the indicator lets it travel across the visual gap into the tooltip without snapping shut. Click promotes a hover-opened tooltip to “click mode” so it survives the next mouseleave; click outside or Escape closes everything.
The /api/me Query
/api/me now resolves which providers are linked by querying the User row by the active provider’s column:
const row = session.provider === 'github'
? await db.select().from(User).where(eq(User.github_handle, session.subject)).get()
: await db.select().from(User).where(eq(User.linkedin_sub, session.subject)).get();
const providers = row
? { github: !!row.github_handle, linkedin: !!row.linkedin_sub }
: { [session.provider]: true, /* the other */: false };
const allLinked = providers.github && providers.linkedin;
The inline hydration script in Header.astro reads this on every page load and flips the indicator to in (both linked) or partial (one linked). Best-effort: if the User row hasn’t been written yet (first login on freshly-deployed Turso) we fall back to “only the active provider is linked” so the indicator never lies.
SSR Cookie Read — The Unsexy Fix
dcd0f77 is the kind of fix that doesn’t make demo videos. The Header was reading Astro.cookies directly, which warned on every prerendered page build because Astro.request.headers isn’t available at build time. The cookie read was redundant anyway — the /api/me hydration script flips state on the client, and that script runs on every page load regardless of SSR-vs-SSG.
Dropped the cookie read. Header now ships a default “logged-out” markup; /api/me hydration corrects it. Single code path for SSR and SSG pages, no build-time warnings, ~80ms flash on prerendered pages where the user is already known to be logged in. Worth it for the rendering consistency.
Behavior
- Sign in with either provider → land on
/loginredirect target → header shows your avatar + a green or amber dot depending on link count. - If you’re roster’d by handle but signed in via LinkedIn (or vice versa), the GitHub email fallback in
lib/oauth-roster.tsstill resolves you onto the roster. - Voting writes a
Voterow withuser_id = lowercased_roster_email. Two logins by the same Fellow share oneVoterow, and the(poll_id, user_id)unique index correctly enforces one vote per person — not one vote per provider. - LinkedIn sign-in against the same roster email merges onto the existing
Userrow; the indicator flips frompartialtoinafter the next page load.
Open Work
- LinkedIn account-linking flow currently runs through the standard sign-in callback. A dedicated
/account/connect/linkedinflow that doesn’t replace the active session would be cleaner for the partial → full transition. - Audit-trail of provider linking events.
- Surface the
/accountpage where members can review which providers are connected and disconnect one.
Verification
- Sign in via GitHub → indicator green,
Userrow hasgithub_handleset,linkedin_subnull. - Sign out, sign in via LinkedIn against the same roster email → same
Userrow,linkedin_subnow populated, indicator green. - Sign in via GitHub on a Fellow whose LinkedIn isn’t yet linked → indicator amber, tooltip opens with one green-ringed and one amber-ringed provider card.
- Click the amber LinkedIn card → through OAuth, back to header now green.
- Vote on the same poll under both providers → one row in
Vote, total count increments by 1, not 2.
Files modified (9)
sites/fullstack-vc/db/config.tssites/fullstack-vc/src/lib/user-record.tssites/fullstack-vc/src/lib/session.tssites/fullstack-vc/src/pages/api/auth/github/callback.tssites/fullstack-vc/src/pages/api/auth/linkedin/callback.tssites/fullstack-vc/src/pages/api/me.tssites/fullstack-vc/src/pages/api/polls/[id]/votes.tssites/fullstack-vc/src/components/basics/Header.astrosites/fullstack-vc/src/components/basics/MobileNav.astro