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
74 changes: 74 additions & 0 deletions packages/worker/client/provider-icons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { type Handle } from 'remix/ui'

/**
* Inline brand marks for the social login providers. Inlined as SVG (no
* external assets) so they server-render with the buttons and inherit sizing
* and color from the surrounding text.
*/
const iconSize = '1.25em'

function renderGitHubIcon() {
return (
<svg
viewBox="0 0 16 16"
width={iconSize}
height={iconSize}
aria-hidden="true"
fill="currentColor"
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z" />
</svg>
)
}

function renderGoogleIcon() {
return (
<svg
viewBox="0 0 24 24"
width={iconSize}
height={iconSize}
aria-hidden="true"
>
<path
fill="#4285F4"
d="M23.49 12.27c0-.79-.07-1.54-.19-2.27H12v4.51h6.47c-.29 1.48-1.14 2.73-2.4 3.58v3h3.86c2.26-2.09 3.56-5.17 3.56-8.82z"
/>
<path
fill="#34A853"
d="M12 24c3.24 0 5.95-1.08 7.93-2.91l-3.86-3c-1.08.72-2.45 1.16-4.07 1.16-3.13 0-5.78-2.11-6.73-4.96H1.29v3.09C3.26 21.3 7.31 24 12 24z"
/>
<path
fill="#FBBC05"
d="M5.27 14.29c-.25-.72-.38-1.49-.38-2.29s.14-1.57.38-2.29V6.62H1.29C.47 8.24 0 10.06 0 12s.47 3.76 1.29 5.38l3.98-3.09z"
/>
<path
fill="#EA4335"
d="M12 4.75c1.77 0 3.35.61 4.6 1.8l3.42-3.42C17.95 1.19 15.24 0 12 0 7.31 0 3.26 2.7 1.29 6.62l3.98 3.09c.95-2.85 3.6-4.96 6.73-4.96z"
/>
</svg>
)
}

function renderXIcon() {
return (
<svg
viewBox="0 0 24 24"
width={iconSize}
height={iconSize}
aria-hidden="true"
fill="currentColor"
>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
)
}

const providerIconRenderers: Record<string, () => JSX.Element> = {
github: renderGitHubIcon,
google: renderGoogleIcon,
x: renderXIcon,
}

export function ProviderIcon(handle: Handle<{ providerId: string }>) {
return () => providerIconRenderers[handle.props.providerId]?.() ?? null
}
16 changes: 15 additions & 1 deletion packages/worker/client/routes/account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type Handle, css } from 'remix/ui'
import { getOauthLoginErrorMessage } from '#app/oauth-login-errors.ts'
import { on } from '#client/event-mixin.ts'
import { readCurrentRouterHref } from '#client/client-router.tsx'
import { ProviderIcon } from '#client/provider-icons.tsx'
import {
startSocialSignIn,
type AuthProviderInfo,
Expand Down Expand Up @@ -788,10 +789,14 @@ export function AccountRoute(handle: Handle) {
<span mix={css({ display: 'grid', gap: spacing.xs })}>
<span
mix={css({
display: 'inline-flex',
alignItems: 'center',
gap: spacing.sm,
fontWeight: typography.fontWeight.medium,
color: colors.text,
})}
>
<ProviderIcon providerId={connection.provider} />
{connection.label}
</span>
<span
Expand Down Expand Up @@ -843,12 +848,13 @@ export function AccountRoute(handle: Handle) {
type="button"
disabled={connectionsBusy}
mix={[
css(secondaryButtonCss),
css(providerConnectButtonCss),
on('click', () =>
handleConnectProvider(provider.id),
),
]}
>
<ProviderIcon providerId={provider.id} />
Connect {provider.label}
</button>
))}
Expand Down Expand Up @@ -954,3 +960,11 @@ export function AccountRoute(handle: Handle) {
const primaryButtonCss = getPrimaryButtonCss()
const secondaryButtonCss = getSecondaryButtonCss()
const dangerButtonCss = getDangerButtonCss()

const providerConnectButtonCss = {
...secondaryButtonCss,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
}
4 changes: 3 additions & 1 deletion packages/worker/client/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import {
import { CommunityRoute, communityRouteLoader } from './community.tsx'
import { ConnectOauthRoute } from './connect-oauth.tsx'
import { HomeRoute, homeRouteLoader } from './home.tsx'
import { LoginRoute } from './login.tsx'
import { LoginRoute, authProvidersRouteLoader } from './login.tsx'
import { OnboardingRoute, onboardingRouteLoader } from './onboarding.tsx'
import { PrivacyRoute } from './privacy.tsx'
import {
Expand Down Expand Up @@ -89,6 +89,8 @@ export const clientRouteLoaders: Record<string, RouteLoader> = {
'/admin/system-email': adminSystemEmailRouteLoader,
'/community': communityRouteLoader,
'/community/:listingId': communityDetailRouteLoader,
'/login': authProvidersRouteLoader,
'/signup': authProvidersRouteLoader,
'/oauth/authorize': oauthAuthorizeRouteLoader,
'/onboarding': onboardingRouteLoader,
}
Expand Down
54 changes: 51 additions & 3 deletions packages/worker/client/routes/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import { buildAuthLink } from '#client/auth-links.ts'
import {
getPathname,
listenToRouterNavigation,
readCurrentRouterHref,
} from '#client/client-router.tsx'
import { tryConsumeRouteLoaderData } from '#client/loader-data-context.tsx'
import { ProviderIcon } from '#client/provider-icons.tsx'
import { type RouteLoaderResult } from '#client/route-loader.ts'
import {
readRouterPathname,
readRouterSearch,
Expand Down Expand Up @@ -70,12 +74,31 @@ function getCurrentRedirectTo(handle: Handle) {
return normalizeRedirectTo(getSearchParams(handle).get('redirectTo'))
}

/**
* SPA navigations to /login and /signup prefetch the enabled providers so
* the buttons render with the rest of the page (full-document loads embed
* the same payload during SSR).
*/
export async function authProvidersRouteLoader(
_url: URL,
signal: AbortSignal,
): Promise<RouteLoaderResult> {
const providers = await fetchEnabledAuthProviders(signal)
// A failed fetch yields no loader data, so the route's fallback fetch
// retries instead of rendering a permanently button-less page.
if (!providers) return {}
return { authProviders: { ok: true, providers } }
}

export function LoginRoute(handle: Handle) {
let status: AuthStatus = 'idle'
let message: string | null = null
let sessionStatus: SessionStatus = 'idle'
let sessionEmail = ''
let authProviders: Array<AuthProviderInfo> = []
// True once the provider list came from SSR-embedded or SPA-preloaded
// loader data (the normal paths); the client fetch is a fallback only.
let authProvidersReady = false
let activeMode = getCurrentAuthMode(handle)
let routePath: string | null = null

Expand Down Expand Up @@ -103,7 +126,7 @@ export function LoginRoute(handle: Handle) {

const [session, providers] = await Promise.all([
fetchSessionInfo(signal),
fetchEnabledAuthProviders(signal),
authProvidersReady ? null : fetchEnabledAuthProviders(signal),
])
if (signal.aborted) {
// Hydration re-renders abort in-flight queued tasks; reset so the
Expand All @@ -112,7 +135,10 @@ export function LoginRoute(handle: Handle) {
return
}
sessionEmail = session?.email ?? ''
authProviders = providers
if (providers && !authProvidersReady) {
authProviders = providers
authProvidersReady = true
}

sessionStatus = 'ready'
if (sessionEmail) {
Expand Down Expand Up @@ -264,6 +290,19 @@ export function LoginRoute(handle: Handle) {
}

return () => {
// Loader-data consumption runs during SSR as well, so the provider
// buttons are in the server-rendered HTML rather than popping in.
if (!authProvidersReady) {
const routeData = tryConsumeRouteLoaderData(
handle,
'authProviders',
readCurrentRouterHref(handle),
)
if (routeData) {
authProviders = routeData.providers
authProvidersReady = true
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
if (typeof document !== 'undefined' && sessionStatus === 'idle') {
handle.queueTask(loadSessionAndProviders)
}
Expand Down Expand Up @@ -445,10 +484,11 @@ export function LoginRoute(handle: Handle) {
type="button"
disabled={isSubmitting}
mix={[
css(secondaryButtonCss),
css(providerButtonCss),
on('click', () => handleProviderSignIn(provider.id)),
]}
>
<ProviderIcon providerId={provider.id} />
Continue with {provider.label}
</button>
))}
Expand Down Expand Up @@ -489,6 +529,14 @@ const primaryButtonCss = getPrimaryButtonCss({ size: 'lg', weight: 'semibold' })

const secondaryButtonCss = getSecondaryButtonCss({ size: 'lg' })

const providerButtonCss = {
...secondaryButtonCss,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
}

const actionLinkCss = {
...primaryLinkCss,
textAlign: 'left' as const,
Expand Down
10 changes: 7 additions & 3 deletions packages/worker/client/social-sign-in.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,20 @@ export function buildProviderStartPath(
: `/auth/${providerId}`
}

/**
* Returns the enabled providers, or null when the request failed — callers
* must not treat a transient failure as "no providers configured".
*/
export async function fetchEnabledAuthProviders(
signal?: AbortSignal,
): Promise<Array<AuthProviderInfo>> {
): Promise<Array<AuthProviderInfo> | null> {
try {
const response = await fetch('/auth/providers.json', {
headers: { Accept: 'application/json' },
signal,
})
const payload = await response.json().catch(() => null)
if (!response.ok || payload?.ok !== true) return []
if (!response.ok || payload?.ok !== true) return null
const providers = Array.isArray(payload.providers) ? payload.providers : []
return providers.filter(
(provider: unknown): provider is AuthProviderInfo =>
Expand All @@ -28,7 +32,7 @@ export async function fetchEnabledAuthProviders(
typeof (provider as AuthProviderInfo).label === 'string',
)
} catch {
return []
return null
}
}

Expand Down
21 changes: 20 additions & 1 deletion packages/worker/src/app/handlers/auth-page.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { normalizeRedirectTo } from '#app/auth-redirect.ts'
import {
getEnabledOauthProviders,
oauthProviderDefinitions,
} from '#app/oauth-providers.ts'
import { loadSessionInfo } from '#app/session-info.ts'
import { renderAppPage } from '#app/ssr-render.tsx'

Expand Down Expand Up @@ -27,7 +31,22 @@ export function createAuthPageHandler(env: Env) {
return Response.redirect(redirectUrl, 302)
}

return renderAppPage({ request, env })
// Server-render the social login buttons with the rest of the
// page: the enabled-provider list is deployment configuration,
// not per-user data, so there is nothing to lazily load.
return renderAppPage({
request,
env,
loaderData: {
authProviders: {
ok: true,
providers: getEnabledOauthProviders(env).map((provider) => ({
id: provider,
label: oauthProviderDefinitions[provider].label,
})),
},
},
})
},
}
}
6 changes: 6 additions & 0 deletions packages/worker/src/app/loader-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,11 @@ export type AccountSecretsLoaderData = {
approvalError: string | null
}

export type AuthProvidersLoaderData = {
ok: true
providers: Array<{ id: string; label: string }>
}

export type OAuthAuthorizeLoaderData =
| {
ok: true
Expand Down Expand Up @@ -458,6 +463,7 @@ export type AppLoaderData = {
accountRemoteConnectors?: AccountRemoteConnectorsLoaderData
accountPackageInvocationTokens?: AccountPackageInvocationTokensLoaderData
accountSecrets?: AccountSecretsLoaderData
authProviders?: AuthProvidersLoaderData
emailVerification?: EmailVerificationLoaderData
oauthAuthorize?: OAuthAuthorizeLoaderData
}
Expand Down
2 changes: 1 addition & 1 deletion packages/worker/src/d1-retry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { errorCauseChainIncludes } from '@kody-internal/shared/error-message.ts'
import type { ErrorEvent } from '@sentry/core'
import { type ErrorEvent } from '@sentry/core'

export const d1LockRetryMaxAttempts = 6

Expand Down
2 changes: 1 addition & 1 deletion packages/worker/src/sentry-options.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type CloudflareOptions } from '@sentry/cloudflare'
import type { ErrorEvent } from '@sentry/core'
import { type ErrorEvent } from '@sentry/core'
import { isRetryableD1LockSentryEvent } from './d1-retry.ts'

/**
Expand Down
Loading