feat: oauth-based login (#7)
This commit is contained in:
@@ -36,12 +36,11 @@ Optionally, you an set the debug level:
|
||||
VITE_LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
If you want to test the login locally you need to add the following variables:
|
||||
|
||||
If you want to run authentication against a local StackBlitz instance, add:
|
||||
```
|
||||
SESSION_SECRET=XXX
|
||||
LOGIN_PASSWORD=XXX
|
||||
VITE_CLIENT_ORIGIN=https://local.stackblitz.com:3000
|
||||
```
|
||||
`
|
||||
|
||||
**Important**: Never commit your `.env.local` file to version control. It's already included in .gitignore.
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { OpenStackBlitz } from './OpenStackBlitz.client';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
@@ -7,8 +8,11 @@ export function Header() {
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-2xl font-semibold text-accent">Bolt</div>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<div className="ml-auto flex gap-2">
|
||||
<ClientOnly>{() => <OpenStackBlitz />}</ClientOnly>
|
||||
<a href="/logout">
|
||||
<IconButton icon="i-ph:sign-out" />
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -8,12 +8,34 @@ export function verifyPassword(password: string, cloudflareEnv: Env) {
|
||||
return password === loginPassword;
|
||||
}
|
||||
|
||||
export async function handleAuthRequest({ request, context }: LoaderFunctionArgs, body: object = {}) {
|
||||
const authenticated = await isAuthenticated(request, context.cloudflare.env);
|
||||
type RequestArgs = Pick<LoaderFunctionArgs, 'request' | 'context'>;
|
||||
|
||||
if (import.meta.env.DEV || authenticated) {
|
||||
return json(body);
|
||||
export async function handleAuthRequest<T extends RequestArgs>(args: T, body: object = {}) {
|
||||
const { request, context } = args;
|
||||
const { authenticated, response } = await isAuthenticated(request, context.cloudflare.env);
|
||||
|
||||
if (authenticated) {
|
||||
return json(body, response);
|
||||
}
|
||||
|
||||
return redirect('/login');
|
||||
return redirect('/login', response);
|
||||
}
|
||||
|
||||
export async function handleWithAuth<T extends RequestArgs>(args: T, handler: (args: T) => Promise<Response>) {
|
||||
const { request, context } = args;
|
||||
const { authenticated, response } = await isAuthenticated(request, context.cloudflare.env);
|
||||
|
||||
if (authenticated) {
|
||||
const handlerResponse = await handler(args);
|
||||
|
||||
if (response) {
|
||||
for (const [key, value] of Object.entries(response.headers)) {
|
||||
handlerResponse.headers.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return handlerResponse;
|
||||
}
|
||||
|
||||
return json({}, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -1,31 +1,89 @@
|
||||
import { createCookieSessionStorage, redirect } from '@remix-run/cloudflare';
|
||||
import { env } from 'node:process';
|
||||
import { request as doRequest } from '~/lib/fetch';
|
||||
import { CLIENT_ID, CLIENT_ORIGIN } from '~/lib/constants';
|
||||
import { logger } from '~/utils/logger';
|
||||
import { decode } from 'jsonwebtoken';
|
||||
|
||||
const USER_SESSION_KEY = 'userId';
|
||||
const DEV_SESSION_SECRET = import.meta.env.DEV ? 'LZQMrERo3Ewn/AbpSYJ9aw==' : undefined;
|
||||
|
||||
function createSessionStorage(cloudflareEnv: Env) {
|
||||
return createCookieSessionStorage({
|
||||
interface SessionData {
|
||||
refresh: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export async function isAuthenticated(request: Request, env: Env) {
|
||||
const { session, sessionStorage } = await getSession(request, env);
|
||||
const token = session.get('refresh');
|
||||
|
||||
const header = async (cookie: Promise<string>) => ({ headers: { 'Set-Cookie': await cookie } });
|
||||
const destroy = () => header(sessionStorage.destroySession(session));
|
||||
|
||||
if (token == null) {
|
||||
return { authenticated: false as const, response: await destroy() };
|
||||
}
|
||||
|
||||
const expiresAt = session.get('expiresAt') ?? 0;
|
||||
|
||||
if (Date.now() < expiresAt) {
|
||||
return { authenticated: true as const };
|
||||
}
|
||||
|
||||
let data: Awaited<ReturnType<typeof refreshToken>> | null = null;
|
||||
|
||||
try {
|
||||
data = await refreshToken(token);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (data != null) {
|
||||
const expiresAt = cookieExpiration(data.expires_in, data.created_at);
|
||||
session.set('expiresAt', expiresAt);
|
||||
|
||||
return { authenticated: true as const, response: await header(sessionStorage.commitSession(session)) };
|
||||
} else {
|
||||
return { authenticated: false as const, response: await destroy() };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createUserSession(
|
||||
request: Request,
|
||||
env: Env,
|
||||
tokens: { refresh: string; expires_in: number; created_at: number },
|
||||
): Promise<ResponseInit> {
|
||||
const { session, sessionStorage } = await getSession(request, env);
|
||||
|
||||
const expiresAt = cookieExpiration(tokens.expires_in, tokens.created_at);
|
||||
|
||||
session.set('refresh', tokens.refresh);
|
||||
session.set('expiresAt', expiresAt);
|
||||
|
||||
return {
|
||||
headers: {
|
||||
'Set-Cookie': await sessionStorage.commitSession(session, {
|
||||
maxAge: 3600 * 24 * 30, // 1 month
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getSessionStorage(cloudflareEnv: Env) {
|
||||
return createCookieSessionStorage<SessionData>({
|
||||
cookie: {
|
||||
name: '__session',
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
secrets: [env.SESSION_SECRET || cloudflareEnv.SESSION_SECRET],
|
||||
secure: false,
|
||||
secrets: [DEV_SESSION_SECRET || cloudflareEnv.SESSION_SECRET],
|
||||
secure: import.meta.env.PROD,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSession(request: Request, env: Env) {
|
||||
const sessionStorage = createSessionStorage(env);
|
||||
const cookie = request.headers.get('Cookie');
|
||||
|
||||
return { session: await sessionStorage.getSession(cookie), sessionStorage };
|
||||
}
|
||||
|
||||
export async function logout(request: Request, env: Env) {
|
||||
const { session, sessionStorage } = await getSession(request, env);
|
||||
|
||||
revokeToken(session.get('refresh'));
|
||||
|
||||
return redirect('/login', {
|
||||
headers: {
|
||||
'Set-Cookie': await sessionStorage.destroySession(session),
|
||||
@@ -33,23 +91,76 @@ export async function logout(request: Request, env: Env) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function isAuthenticated(request: Request, env: Env) {
|
||||
const { session } = await getSession(request, env);
|
||||
const userId = session.get(USER_SESSION_KEY);
|
||||
export function validateAccessToken(access: string) {
|
||||
const jwtPayload = decode(access);
|
||||
|
||||
return !!userId;
|
||||
const boltEnabled = typeof jwtPayload === 'object' && jwtPayload != null && jwtPayload.bolt === true;
|
||||
|
||||
return boltEnabled;
|
||||
}
|
||||
|
||||
export async function createUserSession(request: Request, env: Env): Promise<ResponseInit> {
|
||||
const { session, sessionStorage } = await getSession(request, env);
|
||||
async function getSession(request: Request, env: Env) {
|
||||
const sessionStorage = getSessionStorage(env);
|
||||
const cookie = request.headers.get('Cookie');
|
||||
|
||||
session.set(USER_SESSION_KEY, 'anonymous_user');
|
||||
return { session: await sessionStorage.getSession(cookie), sessionStorage };
|
||||
}
|
||||
|
||||
return {
|
||||
headers: {
|
||||
'Set-Cookie': await sessionStorage.commitSession(session, {
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days,
|
||||
async function refreshToken(refresh: string): Promise<{ expires_in: number; created_at: number }> {
|
||||
const response = await doRequest(`${CLIENT_ORIGIN}/oauth/token`, {
|
||||
method: 'POST',
|
||||
body: urlParams({ grant_type: 'refresh_token', client_id: CLIENT_ID, refresh_token: refresh }),
|
||||
});
|
||||
|
||||
const body = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to refresh token\n${JSON.stringify(body)}`);
|
||||
}
|
||||
|
||||
const { access_token: access } = body;
|
||||
|
||||
if (!validateAccessToken(access)) {
|
||||
throw new Error('User is no longer authorized for Bolt');
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function cookieExpiration(expireIn: number, createdAt: number) {
|
||||
return (expireIn + createdAt - 10 * 60) * 1000;
|
||||
}
|
||||
|
||||
async function revokeToken(refresh?: string) {
|
||||
if (refresh == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await doRequest(`${CLIENT_ORIGIN}/oauth/revoke`, {
|
||||
method: 'POST',
|
||||
body: urlParams({
|
||||
token: refresh,
|
||||
token_type_hint: 'refresh_token',
|
||||
client_id: CLIENT_ID,
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to revoke token: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function urlParams(data: Record<string, string>) {
|
||||
const encoded = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
encoded.append(key, value);
|
||||
}
|
||||
|
||||
return encoded;
|
||||
}
|
||||
|
||||
4
packages/bolt/app/lib/auth.ts
Normal file
4
packages/bolt/app/lib/auth.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function forgetAuth() {
|
||||
// FIXME: use dedicated method
|
||||
localStorage.removeItem('__wc_api_tokens__');
|
||||
}
|
||||
2
packages/bolt/app/lib/constants.ts
Normal file
2
packages/bolt/app/lib/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const CLIENT_ID = 'bolt';
|
||||
export const CLIENT_ORIGIN = import.meta.env.VITE_CLIENT_ORIGIN ?? 'https://stackblitz.com';
|
||||
14
packages/bolt/app/lib/fetch.ts
Normal file
14
packages/bolt/app/lib/fetch.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
type CommonRequest = Omit<RequestInit, 'body'> & { body?: URLSearchParams };
|
||||
|
||||
export async function request(url: string, init?: CommonRequest) {
|
||||
if (import.meta.env.DEV) {
|
||||
const nodeFetch = await import('node-fetch');
|
||||
const https = await import('node:https');
|
||||
|
||||
const agent = url.startsWith('https') ? new https.Agent({ rejectUnauthorized: false }) : undefined;
|
||||
|
||||
return nodeFetch.default(url, { ...init, agent });
|
||||
}
|
||||
|
||||
return fetch(url, init);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { WebContainer } from '@webcontainer/api';
|
||||
import { WORK_DIR_NAME } from '~/utils/constants';
|
||||
import { forgetAuth } from '~/lib/auth';
|
||||
|
||||
interface WebContainerContext {
|
||||
loaded: boolean;
|
||||
@@ -21,7 +22,10 @@ if (!import.meta.env.SSR) {
|
||||
webcontainer =
|
||||
import.meta.hot?.data.webcontainer ??
|
||||
Promise.resolve()
|
||||
.then(() => WebContainer.boot({ workdirName: WORK_DIR_NAME }))
|
||||
.then(() => {
|
||||
forgetAuth();
|
||||
return WebContainer.boot({ workdirName: WORK_DIR_NAME });
|
||||
})
|
||||
.then((webcontainer) => {
|
||||
webcontainerContext.loaded = true;
|
||||
return webcontainer;
|
||||
|
||||
@@ -4,8 +4,13 @@ import { MAX_RESPONSE_SEGMENTS, MAX_TOKENS } from '~/lib/.server/llm/constants';
|
||||
import { CONTINUE_PROMPT } from '~/lib/.server/llm/prompts';
|
||||
import { streamText, type Messages, type StreamingOptions } from '~/lib/.server/llm/stream-text';
|
||||
import SwitchableStream from '~/lib/.server/llm/switchable-stream';
|
||||
import { handleWithAuth } from '~/lib/.server/login';
|
||||
|
||||
export async function action({ context, request }: ActionFunctionArgs) {
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
return handleWithAuth(args, chatAction);
|
||||
}
|
||||
|
||||
async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
const { messages } = await request.json<{ messages: Messages }>();
|
||||
|
||||
const stream = new SwitchableStream();
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { type ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { StreamingTextResponse, parseStreamPart } from 'ai';
|
||||
import { streamText } from '~/lib/.server/llm/stream-text';
|
||||
import { handleWithAuth } from '~/lib/.server/login';
|
||||
import { stripIndents } from '~/utils/stripIndent';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
export async function action({ context, request }: ActionFunctionArgs) {
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
return handleWithAuth(args, enhancerAction);
|
||||
}
|
||||
|
||||
async function enhancerAction({ context, request }: ActionFunctionArgs) {
|
||||
const { message } = await request.json<{ message: string }>();
|
||||
|
||||
try {
|
||||
|
||||
@@ -3,49 +3,96 @@ import {
|
||||
redirect,
|
||||
type ActionFunctionArgs,
|
||||
type LoaderFunctionArgs,
|
||||
type TypedResponse,
|
||||
redirectDocument,
|
||||
} from '@remix-run/cloudflare';
|
||||
import { Form, useActionData } from '@remix-run/react';
|
||||
import { verifyPassword } from '~/lib/.server/login';
|
||||
import { createUserSession, isAuthenticated } from '~/lib/.server/sessions';
|
||||
|
||||
interface Errors {
|
||||
password?: string;
|
||||
}
|
||||
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 { request as doRequest } from '~/lib/fetch';
|
||||
import { CLIENT_ID, CLIENT_ORIGIN } from '~/lib/constants';
|
||||
import { logger } from '~/utils/logger';
|
||||
|
||||
export async function loader({ request, context }: LoaderFunctionArgs) {
|
||||
const authenticated = await isAuthenticated(request, context.cloudflare.env);
|
||||
const { authenticated, response } = await isAuthenticated(request, context.cloudflare.env);
|
||||
|
||||
if (authenticated) {
|
||||
return redirect('/');
|
||||
return redirect('/', response);
|
||||
}
|
||||
|
||||
return json({});
|
||||
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): Promise<TypedResponse<{ errors?: Errors }>> {
|
||||
export async function action({ request, context }: ActionFunctionArgs) {
|
||||
const formData = await request.formData();
|
||||
const password = String(formData.get('password'));
|
||||
|
||||
const errors: Errors = {};
|
||||
const payload = {
|
||||
access: String(formData.get('access')),
|
||||
refresh: String(formData.get('refresh')),
|
||||
};
|
||||
|
||||
if (!password) {
|
||||
errors.password = 'Please provide a password';
|
||||
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 failure');
|
||||
logger.warn(error);
|
||||
|
||||
return json({ error: 'invalid-token' as const }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!verifyPassword(password, context.cloudflare.env)) {
|
||||
errors.password = 'Invalid password';
|
||||
const boltEnabled = validateAccessToken(payload.access);
|
||||
|
||||
if (!boltEnabled) {
|
||||
return json({ error: 'bolt-access' as const }, { status: 401 });
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return json({ errors });
|
||||
}
|
||||
const tokenInfo: { expires_in: number; created_at: number } = await response.json();
|
||||
|
||||
return redirect('/', await createUserSession(request, context.cloudflare.env));
|
||||
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 actionData = useActionData<typeof action>();
|
||||
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">
|
||||
@@ -53,38 +100,93 @@ export default function Login() {
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Login</h2>
|
||||
</div>
|
||||
<Form className="mt-8 space-y-6" method="post" noValidate>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
required
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none"
|
||||
placeholder="Password"
|
||||
/>
|
||||
{actionData?.errors?.password ? (
|
||||
<em className="flex items-center space-x-1.5 p-2 mt-2 bg-negative-200 text-negative-600 rounded-lg">
|
||||
<div className="i-ph:x-circle text-xl"></div>
|
||||
<span>{actionData?.errors.password}</span>
|
||||
</em>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
10
packages/bolt/app/routes/logout.tsx
Normal file
10
packages/bolt/app/routes/logout.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { LoaderFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { logout } from '~/lib/.server/sessions';
|
||||
|
||||
export async function loader({ request, context }: LoaderFunctionArgs) {
|
||||
return logout(request, context.cloudflare.env);
|
||||
}
|
||||
|
||||
export default function Logout() {
|
||||
return '';
|
||||
}
|
||||
@@ -11,7 +11,7 @@ interface Logger {
|
||||
setLevel: (level: DebugLevel) => void;
|
||||
}
|
||||
|
||||
let currentLevel: DebugLevel = import.meta.env.VITE_LOG_LEVEL ?? 'warn';
|
||||
let currentLevel: DebugLevel = import.meta.env.VITE_LOG_LEVEL ?? import.meta.env.DEV ? 'debug' : 'info';
|
||||
|
||||
export const logger: Logger = {
|
||||
trace: (...messages: any[]) => log('trace', undefined, messages),
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"framer-motion": "^11.2.12",
|
||||
"isbot": "^4.1.0",
|
||||
"istextorbinary": "^9.5.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nanostores": "^0.10.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
@@ -64,9 +65,11 @@
|
||||
"@cloudflare/workers-types": "^4.20240620.0",
|
||||
"@remix-run/dev": "^2.10.0",
|
||||
"@types/diff": "^5.2.1",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/react": "^18.2.20",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"fast-glob": "^3.3.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"typescript": "^5.5.2",
|
||||
"unified": "^11.0.5",
|
||||
"unocss": "^0.61.3",
|
||||
|
||||
Reference in New Issue
Block a user