feat: implement redirect-after-login for BUS-1455

- Update middleware to capture original URL and add as 'next' query param
- Modify layout redirect to include redirect parameter
- Update LoginForm to extract and pass redirect to all auth methods
- Add redirect parameter support to all sign-in functions
- Implement URL validation with fallback to home page
- Update OAuth callback to handle redirects consistently
- Support all authentication methods: email/password, Google, GitHub, Azure

Co-Authored-By: nate@buster.so <nate@buster.so>
This commit is contained in:
Devin AI 2025-07-19 03:56:19 +00:00
parent ba04450469
commit 3d632c96ad
5 changed files with 65 additions and 32 deletions

View File

@ -40,7 +40,9 @@ export default async function Layout({
(supabaseContext.user?.is_anonymous && pathname !== loginRoute) ||
!supabaseContext?.user?.id
) {
return <ClientRedirect to={loginRoute} />;
const redirectParam = pathname ? encodeURIComponent(pathname) : '';
const loginUrlWithRedirect = redirectParam ? `${loginRoute}?next=${redirectParam}` : loginRoute;
return <ClientRedirect to={loginUrlWithRedirect} />;
}
if ((!userInfo?.organizations?.[0]?.id || !userInfo?.user?.name) && pathname !== newUserRoute) {

View File

@ -16,8 +16,8 @@ export async function GET(request: Request) {
const forwardedHost = request.headers.get('x-forwarded-host'); // original origin before load balancer
if (isDev) {
// we can be sure that there is no load balancer in between, so no need to watch for X-Forwarded-Host
return NextResponse.redirect(`${origin}/app`);
const redirectPath = next && next.startsWith('/') ? next : '/app';
return NextResponse.redirect(`${origin}${redirectPath}`);
}
if (forwardedHost) {
return NextResponse.redirect(`https://${forwardedHost}${next}`);

View File

@ -2,6 +2,7 @@
import Cookies from 'js-cookie';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import React, { useMemo, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { Button } from '@/components/ui/buttons';
@ -31,6 +32,8 @@ const DEFAULT_CREDENTIALS = {
};
export const LoginForm: React.FC = () => {
const searchParams = useSearchParams();
const redirectTo = searchParams.get('next');
const [loading, setLoading] = useState<'google' | 'github' | 'azure' | 'email' | null>(null);
const [errorMessages, setErrorMessages] = useState<string[]>([]);
const [signUpFlow, setSignUpFlow] = useState(true);
@ -40,7 +43,7 @@ export const LoginForm: React.FC = () => {
async ({ email, password }: { email: string; password: string }) => {
setLoading('email');
try {
const result = await signInWithEmailAndPassword({ email, password });
const result = await signInWithEmailAndPassword({ email, password, redirectTo });
if (result && 'success' in result && !result.success) {
setErrorMessages([result.error]);
setLoading(null);
@ -56,7 +59,7 @@ export const LoginForm: React.FC = () => {
const onSignInWithGoogle = useMemoizedFn(async () => {
setLoading('google');
try {
const result = await signInWithGoogle();
const result = await signInWithGoogle({ redirectTo });
if (result && 'success' in result && !result.success) {
setErrorMessages([result.error]);
setLoading(null);
@ -71,7 +74,7 @@ export const LoginForm: React.FC = () => {
const onSignInWithGithub = useMemoizedFn(async () => {
setLoading('github');
try {
const result = await signInWithGithub();
const result = await signInWithGithub({ redirectTo });
if (result && 'success' in result && !result.success) {
setErrorMessages([result.error]);
setLoading(null);
@ -86,7 +89,7 @@ export const LoginForm: React.FC = () => {
const onSignInWithAzure = useMemoizedFn(async () => {
setLoading('azure');
try {
const result = await signInWithAzure();
const result = await signInWithAzure({ redirectTo });
if (result && 'success' in result && !result.success) {
setErrorMessages([result.error]);
setLoading(null);
@ -101,7 +104,7 @@ export const LoginForm: React.FC = () => {
const onSignUp = useMemoizedFn(async (d: { email: string; password: string }) => {
setLoading('email');
try {
const result = await signUp(d);
const result = await signUp({ ...d, redirectTo });
if (result && 'success' in result && !result.success) {
setErrorMessages([result.error]);
setLoading(null);

View File

@ -9,15 +9,26 @@ const authURLFull = `${process.env.NEXT_PUBLIC_URL}${createBusterRoute({
route: BusterRoutes.AUTH_CALLBACK
})}`;
const isValidRedirectUrl = (url: string): boolean => {
try {
const decoded = decodeURIComponent(url);
return decoded.startsWith('/') && !decoded.startsWith('//');
} catch {
return false;
}
};
// Type for server action results
type ServerActionResult<T = void> = { success: true; data?: T } | { success: false; error: string };
export const signInWithEmailAndPassword = async ({
email,
password
password,
redirectTo
}: {
email: string;
password: string;
redirectTo?: string | null;
}): Promise<ServerActionResult> => {
'use server';
const supabase = await createSupabaseServerClient();
@ -34,22 +45,26 @@ export const signInWithEmailAndPassword = async ({
}
revalidatePath('/', 'layout');
return redirect(
createBusterRoute({
route: BusterRoutes.APP_HOME
})
);
const finalRedirect = redirectTo && isValidRedirectUrl(redirectTo)
? decodeURIComponent(redirectTo)
: createBusterRoute({ route: BusterRoutes.APP_HOME });
return redirect(finalRedirect);
};
export const signInWithGoogle = async (): Promise<ServerActionResult<string>> => {
export const signInWithGoogle = async ({ redirectTo }: { redirectTo?: string | null } = {}): Promise<ServerActionResult<string>> => {
'use server';
const supabase = await createSupabaseServerClient();
const callbackUrl = new URL(authURLFull);
if (redirectTo && isValidRedirectUrl(redirectTo)) {
callbackUrl.searchParams.set('next', redirectTo);
}
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: authURLFull
redirectTo: callbackUrl.toString()
}
});
@ -61,15 +76,20 @@ export const signInWithGoogle = async (): Promise<ServerActionResult<string>> =>
return redirect(data.url);
};
export const signInWithGithub = async (): Promise<ServerActionResult<string>> => {
export const signInWithGithub = async ({ redirectTo }: { redirectTo?: string | null } = {}): Promise<ServerActionResult<string>> => {
'use server';
const supabase = await createSupabaseServerClient();
const callbackUrl = new URL(authURLFull);
if (redirectTo && isValidRedirectUrl(redirectTo)) {
callbackUrl.searchParams.set('next', redirectTo);
}
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: authURLFull
redirectTo: callbackUrl.toString()
}
});
@ -81,15 +101,20 @@ export const signInWithGithub = async (): Promise<ServerActionResult<string>> =>
return redirect(data.url);
};
export const signInWithAzure = async (): Promise<ServerActionResult<string>> => {
export const signInWithAzure = async ({ redirectTo }: { redirectTo?: string | null } = {}): Promise<ServerActionResult<string>> => {
'use server';
const supabase = await createSupabaseServerClient();
const callbackUrl = new URL(authURLFull);
if (redirectTo && isValidRedirectUrl(redirectTo)) {
callbackUrl.searchParams.set('next', redirectTo);
}
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'azure',
options: {
redirectTo: authURLFull,
redirectTo: callbackUrl.toString(),
scopes: 'email'
}
});
@ -103,10 +128,12 @@ export const signInWithAzure = async (): Promise<ServerActionResult<string>> =>
export const signUp = async ({
email,
password
password,
redirectTo
}: {
email: string;
password: string;
redirectTo?: string | null;
}): Promise<ServerActionResult> => {
'use server';
const supabase = await createSupabaseServerClient();
@ -129,11 +156,10 @@ export const signUp = async ({
}
revalidatePath('/', 'layout');
return redirect(
createBusterRoute({
route: BusterRoutes.APP_HOME
})
);
const finalRedirect = redirectTo && isValidRedirectUrl(redirectTo)
? decodeURIComponent(redirectTo)
: createBusterRoute({ route: BusterRoutes.APP_HOME });
return redirect(finalRedirect);
};
export const signInWithAnonymousUser = async () => {

View File

@ -15,9 +15,10 @@ export const assetReroutes = async (
) => {
const userExists = !!user && !!user.id;
if (!userExists && !isPublicPage(request)) {
return NextResponse.redirect(
new URL(createBusterRoute({ route: BusterRoutes.AUTH_LOGIN }), request.url)
);
const originalUrl = `${request.nextUrl.pathname}${request.nextUrl.search}`;
const loginUrl = new URL(createBusterRoute({ route: BusterRoutes.AUTH_LOGIN }), request.url);
loginUrl.searchParams.set('next', encodeURIComponent(originalUrl));
return NextResponse.redirect(loginUrl);
}
if (!userExists && isShareableAssetPage(request)) {
@ -25,9 +26,10 @@ export const assetReroutes = async (
if (redirect) {
return NextResponse.redirect(new URL(redirect, request.url));
}
return NextResponse.redirect(
new URL(createBusterRoute({ route: BusterRoutes.AUTH_LOGIN }), request.url)
);
const originalUrl = `${request.nextUrl.pathname}${request.nextUrl.search}`;
const loginUrl = new URL(createBusterRoute({ route: BusterRoutes.AUTH_LOGIN }), request.url);
loginUrl.searchParams.set('next', encodeURIComponent(originalUrl));
return NextResponse.redirect(loginUrl);
}
return response;