mirror of https://github.com/buster-so/buster.git
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:
parent
ba04450469
commit
3d632c96ad
|
@ -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) {
|
||||
|
|
|
@ -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}`);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue