Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ yarn-error.log*
next-env.d.ts

RESOURCES.md

# tmp
/tmp
213 changes: 213 additions & 0 deletions components/oauth-gate-screen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
"use client";

import { useState, useEffect, useRef, useCallback } from "react";

export interface ProviderStatus {
status: "missing" | "elevation_required";
access?: string;
currentAccess?: string;
requiredAccess?: string;
resourceId?: string;
}

interface OAuthGateScreenProps {
providers: Record<string, ProviderStatus>;
authUrls: Record<string, string>;
}

const OAUTH_POPUP_WIDTH = 600;
const OAUTH_POPUP_HEIGHT = 700;
const OAUTH_POLL_INTERVAL_MS = 500;

const PROVIDER_DISPLAY: Record<string, { name: string; logo: () => React.ReactNode }> = {
google: { name: "Google", logo: GoogleLogo },
};

export function OAuthGateScreen({ providers, authUrls }: OAuthGateScreenProps) {
const [oauthError, setOauthError] = useState<string | null>(null);
const popupRef = useRef<Window | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);

// Listen for postMessage from popup callback
useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.data?.type === "MAJOR_OAUTH_CONNECTED") {
popupRef.current?.close();
popupRef.current = null;
window.location.reload();
}

if (event.data?.type === "MAJOR_OAUTH_ERROR") {
setOauthError("Connection failed. Please try again.");
popupRef.current?.close();
popupRef.current = null;
}
};

window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, []);

const handleConnect = useCallback((authUrl: string) => {
const left = window.screenX + (window.outerWidth - OAUTH_POPUP_WIDTH) / 2;
const top = window.screenY + (window.outerHeight - OAUTH_POPUP_HEIGHT) / 2;

popupRef.current = window.open(
authUrl,
"oauth-connect",
`width=${OAUTH_POPUP_WIDTH},height=${OAUTH_POPUP_HEIGHT},left=${left},top=${top},popup=yes`,
);

// Poll for popup close as fallback (e.g. user closes popup manually)
if (pollRef.current) {
clearInterval(pollRef.current);
}

pollRef.current = setInterval(() => {
if (popupRef.current?.closed) {
popupRef.current = null;

if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}

window.location.reload();
}
}, OAUTH_POLL_INTERVAL_MS);
}, []);

const providerEntries = Object.entries(providers);

return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-card p-8 shadow-sm">
<div className="mb-6 text-center">
<h1 className="text-xl font-semibold text-foreground">
Connect Your Accounts
</h1>
<p className="mt-2 text-sm text-muted-foreground">
This app needs access to the following services to work properly.
</p>
</div>

{oauthError && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{oauthError}
</div>
)}

<div className="space-y-3">
{providerEntries.map(([provider, status]) => {
const authUrl = authUrls[provider];
const isElevation = status.status === "elevation_required";
const display = PROVIDER_DISPLAY[provider];
const displayName = display?.name || provider.charAt(0).toUpperCase() + provider.slice(1);

return (
<div key={provider} className="space-y-2">
{isElevation && (
<p className="text-xs text-muted-foreground">
This app needs{" "}
<span className="font-medium">{status.requiredAccess}</span>{" "}
access. You previously granted{" "}
<span className="font-medium">{status.currentAccess}</span>.
</p>
)}

<ProviderButton
provider={provider}
displayName={displayName}
isElevation={isElevation}
authUrl={authUrl}
logo={display?.logo}
onConnect={handleConnect}
/>
</div>
);
})}
</div>

{providerEntries.length > 1 && (
<p className="mt-4 text-center text-xs text-muted-foreground">
Step 1 of {providerEntries.length}
</p>
)}
</div>
</div>
);
}

function ProviderButton({
provider,
displayName,
isElevation,
authUrl,
logo: Logo,
onConnect,
}: {
provider: string;
displayName: string;
isElevation: boolean;
authUrl?: string;
logo?: () => React.ReactNode;
onConnect: (authUrl: string) => void;
}) {
const handleClick = () => {
if (authUrl) {
onConnect(authUrl);
}
};

if (provider === "google") {
return (
<button
onClick={handleClick}
disabled={!authUrl}
className="inline-flex h-10 w-full cursor-pointer items-center justify-center gap-3 rounded-md border border-[#747775] bg-white px-6 text-sm font-medium text-[#1f1f1f] transition-colors hover:bg-[#f2f2f2] active:bg-[#e8e8e8] disabled:pointer-events-none disabled:opacity-50"
>
{Logo && <Logo />}
<span>
{isElevation ? "Update Google Permissions" : "Sign in with Google"}
</span>
</button>
);
}

return (
<button
onClick={handleClick}
disabled={!authUrl}
className="inline-flex h-10 w-full cursor-pointer items-center justify-center gap-3 rounded-md bg-foreground px-6 text-sm font-medium text-background transition-colors hover:bg-foreground/90 disabled:pointer-events-none disabled:opacity-50"
>
<span>
{isElevation
? `Update ${displayName} Permissions`
: `Connect with ${displayName}`}
</span>
</button>
);
}

function GoogleLogo() {
return (
<svg width="18" height="18" viewBox="0 0 48 48" aria-hidden="true">
<path
fill="#EA4335"
d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"
/>
<path
fill="#4285F4"
d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"
/>
<path
fill="#FBBC05"
d="M10.53 28.59a14.5 14.5 0 0 1 0-9.18l-7.98-6.19a24.01 24.01 0 0 0 0 21.56l7.98-6.19z"
/>
<path
fill="#34A853"
d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
/>
</svg>
);
}
106 changes: 106 additions & 0 deletions components/oauth-gate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { headers } from "next/headers";
import type { ReactNode } from "react";

import { OAuthGateScreen } from "./oauth-gate-screen";
import type { ProviderStatus } from "./oauth-gate-screen";

// Pod-reachable URL for server-side API calls
const RESOURCE_API_URL =
process.env.RESOURCE_API_URL || process.env.MAJOR_API_BASE_URL || "https://go-api.prod.major.build";

interface StatusResponseProvider {
status: string;
access?: string;
currentAccess?: string;
requiredAccess?: string;
resourceId?: string;
}

interface StatusResponse {
providers: Record<string, StatusResponseProvider>;
}

export async function OAuthGate({ children }: { children: ReactNode }) {
const h = await headers();
const userJwt = h.get("x-major-user-jwt");

if (!userJwt) {
return <>{children}</>;
}

let statusData: StatusResponse | null = null;

try {
const res = await fetch(`${RESOURCE_API_URL}/internal/user-oauth/status`, {
headers: { "x-major-user-jwt": userJwt },
cache: "no-store",
});

if (res.ok) {
statusData = (await res.json()) as StatusResponse;
}
} catch {
// Fail open — platform outage should not block deployed apps
}

if (!statusData) {
return <>{children}</>;
}

const providers = statusData.providers;

if (!providers || Object.keys(providers).length === 0) {
return <>{children}</>;
}

// Check if all providers are connected
const needsConnection = Object.entries(providers).filter(
([, p]) => p.status === "missing" || p.status === "elevation_required"
);

if (needsConnection.length === 0) {
return <>{children}</>;
}

// Fetch actual OAuth authorization URLs server-side (the endpoint requires auth
// which is only available via the JWT, not from the browser).
// No returnUrl — the client always uses a popup flow so the callback uses
// postMessage + window.close() instead of redirecting.
const authUrls: Record<string, string> = {};
const gateProviders: Record<string, ProviderStatus> = {};

for (const [provider, status] of needsConnection) {
if (status.resourceId) {
const params = new URLSearchParams({
resourceId: status.resourceId,
});

try {
const authRes = await fetch(
`${RESOURCE_API_URL}/internal/user-oauth/${provider}/auth-url?${params.toString()}`,
{
headers: { "x-major-user-jwt": userJwt },
cache: "no-store",
},
);

if (authRes.ok) {
const authData = (await authRes.json()) as { authUrl: string };
authUrls[provider] = authData.authUrl;
}
} catch {
// Skip this provider if we can't get the auth URL
}
}

gateProviders[provider] = {
status: status.status as "missing" | "elevation_required",
access: status.access,
currentAccess: status.currentAccess,
requiredAccess: status.requiredAccess,
resourceId: status.resourceId,
};
}

return <OAuthGateScreen providers={gateProviders} authUrls={authUrls} />;
}