diff --git a/meteor-backend/server/main.js b/meteor-backend/server/main.js index 74c33870..07b6d232 100644 --- a/meteor-backend/server/main.js +++ b/meteor-backend/server/main.js @@ -275,10 +275,12 @@ WebApp.connectHandlers.use('/api/whoami', proxyWhoamiHandler); // fail with "not connected to the internet"). An HTML page that performs a // page-level navigation to the deep link — plus a tap-link fallback — is // honored consistently across iOS and Android WebViews. -function sendNativeAuthRedirect(res, token, resumeToken) { - const deepLink = - `timehuddle://auth?meteor_token=${encodeURIComponent(token)}&` + - `meteor_resume=${encodeURIComponent(resumeToken)}`; +function sendNativeAuthRedirect(res, token, resumeToken, extraParams = {}) { + const params = new URLSearchParams({ meteor_token: token, meteor_resume: resumeToken }); + for (const [key, value] of Object.entries(extraParams)) { + if (value) params.set(key, value); + } + const deepLink = `timehuddle://auth?${params.toString()}`; res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end( `` + @@ -295,6 +297,32 @@ function sendNativeAuthRedirect(res, token, resumeToken) { ); } +// OAuth `state` round-trips through the third-party IdP unmodified, so it's +// the only place to smuggle context (native flag + pending team/org join +// links) through the redirect-away-and-back flow. Encoded as base64url JSON. +function encodeOAuthState({ isNative, join, invite, orgInvite } = {}) { + const payload = { n: !!isNative, r: Random.secret() }; + if (join) payload.j = join; + if (invite) payload.i = invite; + if (orgInvite) payload.o = orgInvite; + return Buffer.from(JSON.stringify(payload)).toString('base64url'); +} + +function decodeOAuthState(state) { + try { + const payload = JSON.parse(Buffer.from(state || '', 'base64url').toString('utf8')); + return { + isNative: !!payload.n, + join: payload.j || null, + invite: payload.i || null, + orgInvite: payload.o || null, + }; + } catch { + // Legacy format (plain 'native_' prefix) — no join/invite context available. + return { isNative: state?.startsWith('native_') ?? false, join: null, invite: null, orgInvite: null }; + } +} + // ============================================================================ // GitHub OAuth Endpoints // ============================================================================ @@ -304,8 +332,14 @@ WebApp.connectHandlers.use('/auth/github', (req, res, next) => { // Strip the query string so ?native=1 doesn't break the path match. const pathname = req.url.split('?')[0]; if (pathname !== '/' && pathname !== '') { next(); return; } - const isNative = new URL(req.url, process.env.ROOT_URL).searchParams.get('native') === '1'; - const credentialToken = (isNative ? 'native_' : '') + Random.secret(); + const reqParams = new URL(req.url, process.env.ROOT_URL).searchParams; + const isNative = reqParams.get('native') === '1'; + const credentialToken = encodeOAuthState({ + isNative, + join: reqParams.get('join'), + invite: reqParams.get('invite'), + orgInvite: reqParams.get('org_invite'), + }); const callbackUrl = `${process.env.ROOT_URL}/auth/github/callback`; console.log('[github-oauth] Initiating OAuth flow, native=' + isNative); @@ -413,17 +447,25 @@ WebApp.connectHandlers.use('/auth/github/callback', async (req, res) => { .sign(secret); // Redirect back — native apps get a deep link, browsers get the frontend URL - const isNative = state?.startsWith('native_'); + const { isNative, join, invite, orgInvite } = decodeOAuthState(state); if (isNative) { - sendNativeAuthRedirect(res, token, stampedToken.token); + sendNativeAuthRedirect(res, token, stampedToken.token, { + join, + invite, + org_invite: orgInvite, + }); } else { const frontendUrl = process.env.CORS_ORIGINS?.split(',')[0] || 'http://localhost:3000'; + const redirectParams = new URLSearchParams({ + meteor_token: token, + meteor_resume: stampedToken.token, + }); + if (join) redirectParams.set('join', join); + if (invite) redirectParams.set('invite', invite); + if (orgInvite) redirectParams.set('org_invite', orgInvite); res.writeHead(302, { - Location: - `${frontendUrl}/app/dashboard` + - `?meteor_token=${token}&` + - `meteor_resume=${stampedToken.token}`, + Location: `${frontendUrl}/app/dashboard?${redirectParams.toString()}`, }); res.end(); } @@ -443,8 +485,14 @@ WebApp.connectHandlers.use('/auth/google', (req, res, next) => { // Strip the query string so ?native=1 doesn't break the path match. const pathname = req.url.split('?')[0]; if (pathname !== '/' && pathname !== '') { next(); return; } - const isNative = new URL(req.url, process.env.ROOT_URL).searchParams.get('native') === '1'; - const state = (isNative ? 'native_' : '') + Random.secret(); + const reqParams = new URL(req.url, process.env.ROOT_URL).searchParams; + const isNative = reqParams.get('native') === '1'; + const state = encodeOAuthState({ + isNative, + join: reqParams.get('join'), + invite: reqParams.get('invite'), + orgInvite: reqParams.get('org_invite'), + }); const callbackUrl = `${process.env.ROOT_URL}/auth/google/callback` @@ -472,7 +520,7 @@ WebApp.connectHandlers.use('/auth/google/callback', new URL(req.url, process.env.ROOT_URL).searchParams ) const { code } = callbackParams - const isNative = callbackParams.state?.startsWith('native_') + const { isNative, join, invite, orgInvite } = decodeOAuthState(callbackParams.state) if (!code) { res.writeHead(400) @@ -556,16 +604,24 @@ WebApp.connectHandlers.use('/auth/google/callback', // Redirect back — native apps get a deep link, browsers get the frontend URL if (isNative) { - sendNativeAuthRedirect(res, token, stampedToken.token) + sendNativeAuthRedirect(res, token, stampedToken.token, { + join, + invite, + org_invite: orgInvite, + }) } else { const frontendUrl = process.env.CORS_ORIGINS?.split(',')[0] || 'http://localhost:3000' + const redirectParams = new URLSearchParams({ + meteor_token: token, + meteor_resume: stampedToken.token, + }) + if (join) redirectParams.set('join', join) + if (invite) redirectParams.set('invite', invite) + if (orgInvite) redirectParams.set('org_invite', orgInvite) res.writeHead(302, { - Location: - `${frontendUrl}/app/dashboard` + - `?meteor_token=${token}&` + - `meteor_resume=${stampedToken.token}` + Location: `${frontendUrl}/app/dashboard?${redirectParams.toString()}` }) res.end() } @@ -587,8 +643,14 @@ WebApp.connectHandlers.use('/auth/apple', (req, res, next) => { // Strip the query string so ?native=1 doesn't break the path match. const pathname = req.url.split('?')[0]; if (pathname !== '/' && pathname !== '') { next(); return; } - const isNative = new URL(req.url, process.env.ROOT_URL).searchParams.get('native') === '1'; - const state = (isNative ? 'native_' : '') + Random.secret(); + const reqParams = new URL(req.url, process.env.ROOT_URL).searchParams; + const isNative = reqParams.get('native') === '1'; + const state = encodeOAuthState({ + isNative, + join: reqParams.get('join'), + invite: reqParams.get('invite'), + orgInvite: reqParams.get('org_invite'), + }); const callbackUrl = `${process.env.ROOT_URL}/auth/apple/callback` @@ -617,7 +679,7 @@ WebApp.connectHandlers.use('/auth/apple/callback', const code = params.get('code') const idToken = params.get('id_token') const userParam = params.get('user') - const isNative = params.get('state')?.startsWith('native_') ?? false + const { isNative, join, invite, orgInvite } = decodeOAuthState(params.get('state')) if (!code && !idToken) { res.writeHead(400) @@ -693,16 +755,25 @@ WebApp.connectHandlers.use('/auth/apple/callback', // Redirect back — native apps get a deep link, browsers get the frontend URL. if (isNative) { - sendNativeAuthRedirect(res, token, stampedToken.token); + sendNativeAuthRedirect(res, token, stampedToken.token, { + join, + invite, + org_invite: orgInvite, + }); } else { const frontendUrl = process.env.CORS_ORIGINS?.split(',')[0] || 'http://localhost:3000' - const redirectUrl = - `${frontendUrl}/app/dashboard` + - `?meteor_token=${token}&` + - `meteor_resume=${stampedToken.token}` + const redirectParams = new URLSearchParams({ + meteor_token: token, + meteor_resume: stampedToken.token, + }) + if (join) redirectParams.set('join', join) + if (invite) redirectParams.set('invite', invite) + if (orgInvite) redirectParams.set('org_invite', orgInvite) + + const redirectUrl = `${frontendUrl}/app/dashboard?${redirectParams.toString()}` // Use HTML redirect since Apple uses POST res.writeHead(200, { diff --git a/src/main.tsx b/src/main.tsx index ed48c99b..892bcf47 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -51,7 +51,7 @@ _log(`window.Capacitor=${JSON.stringify(Object.keys((window as any).Capacitor || import { InboxPage } from './features/inbox/InboxPage'; import { enterpriseApi } from './lib/api'; -import { subscribeNewNotifications } from './lib/ddp'; +import { getDdpClient, subscribeNewNotifications } from './lib/ddp'; import { MESSAGES_PENDING_THREAD_KEY } from './lib/constants'; import { autoRegisterPush, checkPushNotificationStatus } from './lib/nativePush'; import { SessionProvider, useSession } from './lib/useSession'; @@ -70,6 +70,12 @@ import { UsernameClaimModal } from './ui/UsernameClaimModal'; let _deepLinkToken: string | null = null; +// Pending team/org join context from an OAuth deep link (?join=/?invite=/ +// ?org_invite= carried through the native redirect-away-and-back flow, since +// the URL bar isn't available to read query params from on native). Applied +// once the App component sees an authenticated session. +let _pendingOAuthJoin: { join?: string; invite?: string; orgInvite?: string } | null = null; + if (Capacitor.isNativePlatform()) { void CapApp.addListener('appUrlOpen', ({ url }) => { try { @@ -78,6 +84,16 @@ if (Capacitor.isNativePlatform()) { // OAuth callback: timehuddle://auth?meteor_token=...&meteor_resume=... if (parsed.host === 'auth') { const meteorResume = parsed.searchParams.get('meteor_resume'); + const join = parsed.searchParams.get('join'); + const invite = parsed.searchParams.get('invite'); + const orgInvite = parsed.searchParams.get('org_invite'); + if (join || invite || orgInvite) { + _pendingOAuthJoin = { + join: join ?? undefined, + invite: invite ?? undefined, + orgInvite: orgInvite ?? undefined, + }; + } // Close the in-app browser and return to the app. void Browser.close(); if (meteorResume) { @@ -124,7 +140,7 @@ if (Capacitor.isNativePlatform()) { _log('App component defined — modules loaded'); const App: React.FC = () => { - const { user, loading, needsUsernameClaim } = useSession(); + const { user, loading, needsUsernameClaim, refetch } = useSession(); const [ownershipChecked, setOwnershipChecked] = React.useState(false); const [showTakeOwnershipModal, setShowTakeOwnershipModal] = React.useState(false); @@ -133,6 +149,38 @@ const App: React.FC = () => { if (user) void autoRegisterPush(user.id); }, [user]); + // Apply a pending team/org join from a social sign-in (?join=/?invite=/ + // ?org_invite=). Social sign-in redirects away to the IdP and back, so it + // never runs LoginForm's password-flow `acceptInvitation()` — this is the + // equivalent for OAuth, reading the params from the URL (web) or the + // `timehuddle://auth` deep link (native). + React.useEffect(() => { + if (!user) return; + const params = new URLSearchParams(window.location.search); + const join = params.get('join') ?? _pendingOAuthJoin?.join ?? null; + const invite = params.get('invite') ?? _pendingOAuthJoin?.invite ?? null; + const orgInvite = params.get('org_invite') ?? _pendingOAuthJoin?.orgInvite ?? null; + if (!join && !invite && !orgInvite) return; + _pendingOAuthJoin = null; + + void (async () => { + const ddp = getDdpClient(); + try { + if (invite) await ddp.acceptTeamInvitation(invite); + if (orgInvite) await ddp.acceptOrgInvitation(orgInvite); + if (join) await ddp.joinTeamByQrCode(join); + } catch (err) { + console.error('[oauth] pending team/org join failed:', err); + } + const url = new URL(window.location.href); + url.searchParams.delete('join'); + url.searchParams.delete('invite'); + url.searchParams.delete('org_invite'); + window.history.replaceState(null, '', url.toString()); + await refetch(); + })(); + }, [user, refetch]); + // SSE fallback: show a browser Notification for every incoming SSE event. // This fires even when FCM/VAPID push delivery is unreliable (e.g. localhost). // Skipped on native Capacitor (APNs handles it), when permission not granted, diff --git a/src/ui/LoginForm.tsx b/src/ui/LoginForm.tsx index 9498edd0..ddc5b431 100644 --- a/src/ui/LoginForm.tsx +++ b/src/ui/LoginForm.tsx @@ -382,12 +382,23 @@ export const LoginForm: React.FC = ({ initialMode }) => { setSocialLoadingId(provider.id); setSocialError(null); try { + // Pending team/org join context (from ?join=/?invite=/?org_invite= links) + // must survive the full redirect-away-and-back OAuth round trip, since + // sign-in via social providers never runs the password-flow + // `acceptInvitation()` logic below. + const pendingJoinParams: Record = {}; + if (joinTeamCode) pendingJoinParams.join = joinTeamCode; + if (invitationToken) pendingJoinParams.invite = invitationToken; + if (orgInvitationToken) pendingJoinParams.org_invite = orgInvitationToken; + if (provider.kind === 'meteor-oauth') { - const oauthUrl = `${METEOR_BASE_URL}${provider.meteorPath}`; + const oauthParams = new URLSearchParams(pendingJoinParams); + if (Capacitor.isNativePlatform()) oauthParams.set('native', '1'); + const oauthUrl = `${METEOR_BASE_URL}${provider.meteorPath}?${oauthParams.toString()}`; if (Capacitor.isNativePlatform()) { // On native, open in an in-app browser with ?native=1 so the backend // redirects to the timehuddle://auth deep link instead of the web URL. - await Browser.open({ url: `${oauthUrl}?native=1`, presentationStyle: 'popover' }); + await Browser.open({ url: oauthUrl, presentationStyle: 'popover' }); } else { window.location.href = oauthUrl; } @@ -395,7 +406,11 @@ export const LoginForm: React.FC = ({ initialMode }) => { } // Existing oauth2 and social handling - const callbackURL = `${window.location.origin}/app/dashboard`; + const callbackUrl = new URL(`${window.location.origin}/app/dashboard`); + for (const [key, value] of Object.entries(pendingJoinParams)) { + callbackUrl.searchParams.set(key, value); + } + const callbackURL = callbackUrl.toString(); const url = provider.kind === 'oauth2' ? await authApi.signInWithOAuth2(provider.id, callbackURL)