Skip to content
Draft
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
157 changes: 157 additions & 0 deletions frontend/src/Pages/Authentication/__tests__/forms.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render } from '@testing-library/react';
import { LoginForm, SignUpForm } from '../forms';
import { AuthContext } from '../../../context/authContext';
import {
GOOGLE_BUTTON_RESIZE_DEBOUNCE_MS,
GOOGLE_SCRIPT_POLL_MS,
GOOGLE_SCRIPT_TIMEOUT_MS,
} from '../../../hooks/useGoogleButton';

const renderButton = vi.fn();
const initialize = vi.fn();
const cancel = vi.fn();
const googleLogin = vi.fn();

function withAuth(ui: React.ReactElement) {
const auth = {
login: vi.fn(),
signup: vi.fn(),
googleLogin,
clearError: vi.fn(),
error: null,
loading: false,
};
return render(<AuthContext.Provider value={auth as never}>{ui}</AuthContext.Provider>);
}

const renderLogin = () => withAuth(<LoginForm startForgotPassword={vi.fn()} startOtpVerification={vi.fn()} />);

function installGoogle() {
window.google = { accounts: { id: { initialize, renderButton, prompt: vi.fn(), cancel } } };
}

// jsdom has no layout, so give the button container a measurable width.
let widthSpy: ReturnType<typeof vi.spyOn>;
function stubContainerWidth(width: number) {
widthSpy = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(width);
}

// ResizeObserver that tests can trigger by hand.
let resizeCallbacks: Array<() => void> = [];
const disconnect = vi.fn();
class FakeResizeObserver {
constructor(cb: () => void) {
resizeCallbacks.push(cb);
}
observe() {}
unobserve() {}
disconnect = disconnect;
}
const resize = (width: number) => {
widthSpy.mockReturnValue(width);
act(() => resizeCallbacks.forEach((cb) => cb()));
};

describe('Google sign-in button', () => {
beforeEach(() => {
vi.useFakeTimers();
[renderButton, initialize, cancel, googleLogin, disconnect].forEach((m) => m.mockReset());
resizeCallbacks = [];
vi.stubGlobal('ResizeObserver', FakeResizeObserver);
installGoogle();
});
afterEach(() => {
delete window.google;
vi.useRealTimers();
vi.unstubAllGlobals();
});

it('passes a pixel width matching the form (Google ignores percentages)', () => {
stubContainerWidth(320);
renderLogin();
const [element, options] = renderButton.mock.calls[0];
expect(element).toHaveAttribute('id', 'googleSignInButton');
expect(options).toMatchObject({ width: 320, text: 'signin_with', theme: 'outline', size: 'large' });
});

it("caps the width at Google's 400px maximum", () => {
stubContainerWidth(478);
renderLogin();
expect(renderButton.mock.calls[0][1].width).toBe(400);
});

it("never goes below Google's 200px minimum", () => {
stubContainerWidth(0);
withAuth(<SignUpForm startOtpVerification={vi.fn()} />);
const [element, options] = renderButton.mock.calls[0];
expect(element).toHaveAttribute('id', 'googleSignUpButton');
expect(options).toMatchObject({ width: 200, text: 'signup_with' });
});

it('redraws at the new width after a resize settles', () => {
stubContainerWidth(320);
renderLogin();
expect(renderButton).toHaveBeenCalledTimes(1);

resize(260);
expect(renderButton).toHaveBeenCalledTimes(1); // still debouncing
act(() => vi.advanceTimersByTime(GOOGLE_BUTTON_RESIZE_DEBOUNCE_MS));
expect(renderButton).toHaveBeenCalledTimes(2);
expect(renderButton.mock.calls[1][1].width).toBe(260);
});

it('does not redraw when the width is unchanged, and initializes Google once', () => {
stubContainerWidth(320);
renderLogin();
resize(320);
act(() => vi.advanceTimersByTime(GOOGLE_BUTTON_RESIZE_DEBOUNCE_MS));
resize(300);
act(() => vi.advanceTimersByTime(GOOGLE_BUTTON_RESIZE_DEBOUNCE_MS));
expect(renderButton).toHaveBeenCalledTimes(2);
expect(initialize).toHaveBeenCalledTimes(1);
});

it('waits for the Google script when it loads after the form', () => {
delete window.google;
stubContainerWidth(320);
renderLogin();
act(() => vi.advanceTimersByTime(GOOGLE_SCRIPT_POLL_MS * 3));
expect(renderButton).not.toHaveBeenCalled();

installGoogle();
act(() => vi.advanceTimersByTime(GOOGLE_SCRIPT_POLL_MS));
expect(initialize).toHaveBeenCalledTimes(1);
expect(renderButton.mock.calls[0][1].width).toBe(320);
});

it('stops waiting if the Google script never loads', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
delete window.google;
stubContainerWidth(320);
renderLogin();
act(() => vi.advanceTimersByTime(GOOGLE_SCRIPT_TIMEOUT_MS + GOOGLE_SCRIPT_POLL_MS));
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Google sign-in script did not load'));

// Loading it later no longer renders anything: polling has stopped.
installGoogle();
act(() => vi.advanceTimersByTime(GOOGLE_SCRIPT_POLL_MS * 5));
expect(renderButton).not.toHaveBeenCalled();
});

it('sends the Google credential to googleLogin', () => {
stubContainerWidth(320);
renderLogin();
const { callback } = initialize.mock.calls[0][0];
callback({ credential: 'id-token', select_by: 'btn' });
expect(googleLogin).toHaveBeenCalledWith('id-token');
});

it('cleans up on unmount', () => {
stubContainerWidth(320);
const { unmount } = renderLogin();
unmount();
expect(disconnect).toHaveBeenCalled();
expect(cancel).toHaveBeenCalled();
});
});
57 changes: 5 additions & 52 deletions frontend/src/Pages/Authentication/forms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input';
import { useContext, useState, useEffect } from 'react';
import { AuthContext } from '../../context/authContext';
import { useCallback } from "react";
import { useGoogleButton } from '../../hooks/useGoogleButton';

interface LoginFormProps {
startForgotPassword: () => void;
Expand Down Expand Up @@ -63,31 +64,7 @@ export const LoginForm: React.FC<LoginFormProps> = ({ startForgotPassword, infoM
},
[googleLogin]
);
useEffect(() => {
const google = window.google;
if (!google?.accounts) {
return;
}

google.accounts.id.initialize({
client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID,
callback: handleGoogleLogin,
});

const buttonElement = document.getElementById('googleSignInButton');
if (buttonElement) {
google.accounts.id.renderButton(buttonElement, {
theme: 'outline',
size: 'large',
text: 'signin_with',
width: '100%',
});
}

return () => {
google.accounts.id.cancel();
};
}, [handleGoogleLogin]);
const googleButtonRef = useGoogleButton('signin_with', handleGoogleLogin);

return (
<form className="w-full" onSubmit={handleSubmit}>
Expand Down Expand Up @@ -139,7 +116,7 @@ export const LoginForm: React.FC<LoginFormProps> = ({ startForgotPassword, infoM
<Button type="submit" className="w-full mb-2 border border-border dark:border-white" disabled={loading}>
{loading ? 'Signing In...' : 'Sign In With Email'}
</Button>
<div id="googleSignInButton" className="w-full"></div>
<div ref={googleButtonRef} id="googleSignInButton" className="w-full flex justify-center"></div>
</form>
);
};
Expand Down Expand Up @@ -195,31 +172,7 @@ export const SignUpForm: React.FC<SignUpFormProps> = ({ startOtpVerification })
);


useEffect(() => {
const google = window.google;
if (!google?.accounts) {
return;
}

google.accounts.id.initialize({
client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID,
callback: handleGoogleLogin,
});

const buttonElement = document.getElementById('googleSignUpButton');
if (buttonElement) {
google.accounts.id.renderButton(buttonElement, {
theme: 'outline',
size: 'large',
text: 'signup_with',
width: '100%',
});
}

return () => {
google.accounts.id.cancel();
};
}, [handleGoogleLogin]);
const googleButtonRef = useGoogleButton('signup_with', handleGoogleLogin);

return (
<form className="w-full" onSubmit={handleSubmit}>
Expand Down Expand Up @@ -268,7 +221,7 @@ export const SignUpForm: React.FC<SignUpFormProps> = ({ startOtpVerification })
<Button type="submit" className="w-full mb-2 border dark:border-white" disabled={loading}>
{loading ? 'Creating Account...' : 'Sign Up With Email'}
</Button>
<div id="googleSignUpButton" className="w-full"></div>
<div ref={googleButtonRef} id="googleSignUpButton" className="w-full flex justify-center"></div>
</form>
);
};
Expand Down
96 changes: 96 additions & 0 deletions frontend/src/hooks/useGoogleButton.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useEffect, useRef } from 'react';

type GoogleButtonText = 'signin_with' | 'signup_with' | 'continue_with';
type GoogleCredentialResponse = { credential: string; select_by: string };

// Google's renderButton only accepts a pixel width between 200 and 400; a
// percentage is ignored and the button shrinks to fit its text.
export const GOOGLE_BUTTON_MIN_WIDTH = 200;
export const GOOGLE_BUTTON_MAX_WIDTH = 400;
export const googleButtonWidth = (containerWidth: number) =>
Math.round(Math.min(GOOGLE_BUTTON_MAX_WIDTH, Math.max(GOOGLE_BUTTON_MIN_WIDTH, containerWidth)));

// The Google Identity Services script loads async, so it may arrive after
// the form mounts. Wait for it, but stop if it never comes (blocked, offline).
export const GOOGLE_SCRIPT_POLL_MS = 100;
export const GOOGLE_SCRIPT_TIMEOUT_MS = 10_000;
// Wait for resizing to settle before redrawing the button.
export const GOOGLE_BUTTON_RESIZE_DEBOUNCE_MS = 100;

/**
* Renders Google's sign-in button into the returned ref, as wide as its
* container, and redraws it when the container's width changes (window
* resize, phone rotation). Google is initialized once per mount.
*
* Responsive re-rendering adapted from the approach in #774 by @aryanpatil-07.
*/
export function useGoogleButton(
text: GoogleButtonText,
onCredential: (response: GoogleCredentialResponse) => void
) {
const ref = useRef<HTMLDivElement>(null);
// Keep the latest callback without re-initializing Google when it changes.
const callbackRef = useRef(onCredential);
useEffect(() => {
callbackRef.current = onCredential;
}, [onCredential]);

useEffect(() => {
const container = ref.current;
if (!container) return;

let pollTimer: ReturnType<typeof setInterval> | undefined;
let resizeTimer: ReturnType<typeof setTimeout> | undefined;
let observer: ResizeObserver | undefined;
let drawnWidth = 0;

const draw = () => {
const google = window.google;
if (!google?.accounts) return;
const width = googleButtonWidth(container.clientWidth);
if (width === drawnWidth) return;
drawnWidth = width;
container.innerHTML = '';
google.accounts.id.renderButton(container, { theme: 'outline', size: 'large', text, width });
};

const start = () => {
window.google?.accounts.id.initialize({
client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID,
callback: (response) => callbackRef.current(response),
});
draw();
if (typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(() => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(draw, GOOGLE_BUTTON_RESIZE_DEBOUNCE_MS);
});
observer.observe(container);
}
};

if (window.google?.accounts) {
start();
} else {
const startedAt = Date.now();
pollTimer = setInterval(() => {
if (window.google?.accounts) {
clearInterval(pollTimer);
start();
} else if (Date.now() - startedAt >= GOOGLE_SCRIPT_TIMEOUT_MS) {
clearInterval(pollTimer);
console.warn('Google sign-in script did not load; the Google button is unavailable.');
}
}, GOOGLE_SCRIPT_POLL_MS);
}

return () => {
clearInterval(pollTimer);
clearTimeout(resizeTimer);
observer?.disconnect();
window.google?.accounts?.id?.cancel();
};
}, [text]);

return ref;
}
3 changes: 2 additions & 1 deletion frontend/src/types/google.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ interface GoogleId {
theme: 'outline' | 'filled_blue' | 'filled_black';
size: 'large' | 'medium' | 'small';
text?: 'signin_with' | 'signup_with' | 'continue_with';
width?: string;
/** Button width in pixels (Google accepts 200–400); percentages are ignored. */
width?: number;
shape?: 'rectangular' | 'pill';
logo_alignment?: 'left' | 'center';
}
Expand Down
Loading