mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-05-05 12:44:38 +00:00
191 lines
4.9 KiB
TypeScript
191 lines
4.9 KiB
TypeScript
import {
|
|
json,
|
|
redirect,
|
|
redirectDocument,
|
|
type ActionFunctionArgs,
|
|
type LoaderFunctionArgs,
|
|
} from '@remix-run/cloudflare';
|
|
import { useFetcher, useLoaderData } from '@remix-run/react';
|
|
import { auth, type AuthAPI } from '@webcontainer/api';
|
|
import { useEffect, useState } from 'react';
|
|
import { createUserSession, isAuthenticated, validateAccessToken } from '~/lib/.server/sessions';
|
|
import { CLIENT_ID, CLIENT_ORIGIN } from '~/lib/constants';
|
|
import { request as doRequest } from '~/lib/fetch';
|
|
import { logger } from '~/utils/logger';
|
|
|
|
export async function loader({ request, context }: LoaderFunctionArgs) {
|
|
const { authenticated, response } = await isAuthenticated(request, context.cloudflare.env);
|
|
|
|
if (authenticated) {
|
|
return redirect('/', response);
|
|
}
|
|
|
|
const url = new URL(request.url);
|
|
|
|
return json(
|
|
{
|
|
redirected: url.searchParams.has('code') || url.searchParams.has('error'),
|
|
},
|
|
response,
|
|
);
|
|
}
|
|
|
|
export async function action({ request, context }: ActionFunctionArgs) {
|
|
const formData = await request.formData();
|
|
|
|
const payload = {
|
|
access: String(formData.get('access')),
|
|
refresh: String(formData.get('refresh')),
|
|
};
|
|
|
|
let response: Awaited<ReturnType<typeof doRequest>> | undefined;
|
|
|
|
try {
|
|
response = await doRequest(`${CLIENT_ORIGIN}/oauth/token/info`, {
|
|
headers: { authorization: `Bearer ${payload.access}` },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw await response.json();
|
|
}
|
|
} catch (error) {
|
|
logger.warn('Authentication failed');
|
|
logger.warn(error);
|
|
|
|
return json({ error: 'invalid-token' as const }, { status: 401 });
|
|
}
|
|
|
|
const boltEnabled = validateAccessToken(payload.access);
|
|
|
|
if (!boltEnabled) {
|
|
return json({ error: 'bolt-access' as const }, { status: 401 });
|
|
}
|
|
|
|
const tokenInfo: { expires_in: number; created_at: number } = await response.json();
|
|
|
|
const init = await createUserSession(request, context.cloudflare.env, { ...payload, ...tokenInfo });
|
|
|
|
return redirectDocument('/', init);
|
|
}
|
|
|
|
type LoginState =
|
|
| {
|
|
kind: 'error';
|
|
error: string;
|
|
description: string;
|
|
}
|
|
| { kind: 'pending' };
|
|
|
|
const ERRORS = {
|
|
'bolt-access': 'You do not have access to Bolt.',
|
|
'invalid-token': 'Authentication failed.',
|
|
};
|
|
|
|
export default function Login() {
|
|
const { redirected } = useLoaderData<typeof loader>();
|
|
|
|
useEffect(() => {
|
|
if (!import.meta.hot?.data.wcAuth) {
|
|
auth.init({ clientId: CLIENT_ID, scope: 'public', editorOrigin: CLIENT_ORIGIN });
|
|
}
|
|
|
|
if (import.meta.hot) {
|
|
import.meta.hot.data.wcAuth = true;
|
|
}
|
|
}, []);
|
|
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center">
|
|
<div className="max-w-md w-full space-y-8 p-10 bg-white rounded-lg shadow">
|
|
<div>
|
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Login</h2>
|
|
</div>
|
|
{redirected ? 'Processing auth...' : <LoginForm />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LoginForm() {
|
|
const [login, setLogin] = useState<LoginState | null>(null);
|
|
|
|
const fetcher = useFetcher<typeof action>();
|
|
|
|
useEffect(() => {
|
|
auth.logout({ ignoreRevokeError: true });
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (fetcher.data?.error) {
|
|
auth.logout({ ignoreRevokeError: true });
|
|
|
|
setLogin({
|
|
kind: 'error' as const,
|
|
...{ error: fetcher.data.error, description: ERRORS[fetcher.data.error] },
|
|
});
|
|
}
|
|
}, [fetcher.data]);
|
|
|
|
async function attemptLogin() {
|
|
startAuthFlow();
|
|
|
|
function startAuthFlow() {
|
|
auth.startAuthFlow({ popup: true });
|
|
|
|
Promise.race([authEvent(auth, 'auth-failed'), auth.loggedIn()]).then((error) => {
|
|
if (error) {
|
|
setLogin({ kind: 'error', ...error });
|
|
} else {
|
|
onTokens();
|
|
}
|
|
});
|
|
}
|
|
|
|
function onTokens() {
|
|
const tokens = auth.tokens()!;
|
|
|
|
fetcher.submit(tokens, {
|
|
method: 'POST',
|
|
});
|
|
|
|
setLogin({ kind: 'pending' });
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
className="w-full text-white bg-accent-600 hover:bg-accent-700 focus:ring-4 focus:outline-none font-medium rounded-lg text-sm px-5 py-2.5 text-center"
|
|
onClick={attemptLogin}
|
|
disabled={login?.kind === 'pending'}
|
|
>
|
|
{login?.kind === 'pending' ? 'Authenticating...' : 'Continue with StackBlitz'}
|
|
</button>
|
|
{login?.kind === 'error' && (
|
|
<div>
|
|
<h2>
|
|
<code>{login.error}</code>
|
|
</h2>
|
|
<p>{login.description}</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface AuthError {
|
|
error: string;
|
|
description: string;
|
|
}
|
|
|
|
function authEvent(auth: AuthAPI, event: 'logged-out'): Promise<void>;
|
|
function authEvent(auth: AuthAPI, event: 'auth-failed'): Promise<AuthError>;
|
|
function authEvent(auth: AuthAPI, event: 'logged-out' | 'auth-failed') {
|
|
return new Promise((resolve) => {
|
|
const unsubscribe = auth.on(event as any, (arg: any) => {
|
|
unsubscribe();
|
|
resolve(arg);
|
|
});
|
|
});
|
|
}
|