Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react';
import { useAuth } from '../../context/AuthContext';
import { useAuth, type AuthFailure } from '../../context/AuthContext';
import { useLocale } from '../../context/LocaleContext';

/**
Expand All @@ -16,6 +16,11 @@ export function GoogleSignIn() {
const { configured, user, authenticating, authError, signOut, renderButton } = useAuth();
const { lang, S } = useLocale();
const buttonHost = useRef<HTMLDivElement>(null);
const errorText: Record<AuthFailure, string> = {
busy: S.signInBusy,
unavailable: S.signInUnavailable,
failed: S.signInError,
};

useEffect(() => {
const host = buttonHost.current;
Expand Down Expand Up @@ -89,7 +94,7 @@ export function GoogleSignIn() {
/>
{authError && (
<p className="nav-account-error" role="alert">
{S.signInError}
{errorText[authError]}
</p>
)}
</div>
Expand Down
42 changes: 35 additions & 7 deletions artifacts/ai-testing-academy/src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,23 @@ import {
type GoogleUser,
} from '../lib/googleIdentity';

/**
* Why a sign-in did not complete.
*
* The three are not interchangeable to the person reading them: `busy` will
* pass on its own and is worth waiting out, `unavailable` is the server's
* problem and no amount of retrying helps, and `failed` is everything else.
* Collapsing them into one line is how a server-side outage looked to a
* visitor like their own sign-in going wrong.
*/
export type AuthFailure = 'busy' | 'unavailable' | 'failed';

interface AuthContextValue {
/** False when the site was built without a client ID; sign-in stays hidden. */
configured: boolean;
user: GoogleUser | null;
authenticating: boolean;
authError: boolean;
authError: AuthFailure | null;
signOut: () => Promise<void>;
/**
* Renders Google's own button into `parent`. Google draws it itself — the
Expand All @@ -23,6 +34,20 @@ interface AuthContextValue {

const AuthContext = createContext<AuthContextValue | null>(null);

/**
* What the server's status code means for the person who just clicked.
*
* 429 is the one that matters: the API refuses sign-in when its rate limiter
* cannot count, which is a server misconfiguration that presents as a quota.
* Telling the visitor to simply try again sends them round a loop that cannot
* succeed; telling them it is busy at least matches what they are seeing.
*/
function failureFor(status: number): AuthFailure {
if (status === 429) return 'busy';
if (status >= 500) return 'unavailable';
return 'failed';
}

const BUTTON_OPTIONS: Omit<GoogleButtonOptions, 'locale'> = {
type: 'standard',
theme: 'outline',
Expand All @@ -47,7 +72,7 @@ export function AuthProvider({ children, clientId = googleClientId() }: AuthProv
const configured = resolvedClientId !== '';
const [user, setUser] = useState<GoogleUser | null>(null);
const [authenticating, setAuthenticating] = useState(false);
const [authError, setAuthError] = useState(false);
const [authError, setAuthError] = useState<AuthFailure | null>(null);

// A build-time client ID remains supported for local/offline builds. When it
// is absent, ask the same-origin API for the public ID at runtime so static
Expand All @@ -70,20 +95,23 @@ export function AuthProvider({ children, clientId = googleClientId() }: AuthProv

const handleCredential = useCallback(async (credential: string) => {
setAuthenticating(true);
setAuthError(false);
setAuthError(null);
try {
const response = await fetch('/api/auth/google', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ credential }),
});
if (!response.ok) throw new Error('The server did not accept the Google credential');
if (!response.ok) {
setAuthError(failureFor(response.status));
return;
}
const body = (await response.json()) as { user?: GoogleUser };
if (!body.user) throw new Error('The server returned no signed-in user');
setUser(body.user);
} catch {
setAuthError(true);
setAuthError('failed');
} finally {
setAuthenticating(false);
}
Expand All @@ -93,7 +121,7 @@ export function AuthProvider({ children, clientId = googleClientId() }: AuthProv
// account without being asked, so the next click is a real choice.
const signOut = useCallback(async () => {
setAuthenticating(true);
setAuthError(false);
setAuthError(null);
// Stop Google's automatic account selection immediately. The verified
// local session remains visible until our server confirms deletion below.
window.google?.accounts.id.disableAutoSelect();
Expand All @@ -105,7 +133,7 @@ export function AuthProvider({ children, clientId = googleClientId() }: AuthProv
if (!response.ok) throw new Error('The server did not end the session');
setUser(null);
} catch {
setAuthError(true);
setAuthError('failed');
} finally {
setAuthenticating(false);
}
Expand Down
2 changes: 2 additions & 0 deletions artifacts/ai-testing-academy/src/lib/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,8 @@ export const en = {
signInAria: 'Sign in with Google',
signingInStatus: 'Signing in…',
signInError: 'Sign-in failed. Please try again.',
signInBusy: 'Too many sign-in attempts right now. Please wait a minute and try again.',
signInUnavailable: 'Sign-in is temporarily unavailable on this server.',
signOutBtn: 'Sign out',
uploadPrompt: '📁 Click or drag your resume here — PDF, DOCX, or TXT',
uploadLoadedMid: ' · ',
Expand Down
2 changes: 2 additions & 0 deletions artifacts/ai-testing-academy/src/lib/locales/he.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,8 @@ export const he: Locale = {
signInAria: 'התחברות עם Google',
signingInStatus: 'מתחבר…',
signInError: 'ההתחברות נכשלה. נסה שוב.',
signInBusy: 'יותר מדי ניסיונות התחברות כרגע. יש להמתין דקה ולנסות שוב.',
signInUnavailable: 'ההתחברות אינה זמינה כרגע בשרת הזה.',
signOutBtn: 'התנתקות',
uploadPrompt: '📁 לחץ או גרור את קורות החיים לכאן — PDF, DOCX, או TXT',
uploadLoadedMid: ' · ',
Expand Down
4 changes: 2 additions & 2 deletions artifacts/api-server/.replit-artifact/artifact.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ name = "API Server"
paths = ["/api"]

[services.development]
run = "cd ../../server && uv run uvicorn app.main:app --host 0.0.0.0 --port 8080 --reload"
run = "uv run --directory ../../server uvicorn app.main:app --host 0.0.0.0 --port 8080 --reload"

[services.production]

Expand All @@ -21,7 +21,7 @@ args = ["uv", "sync", "--project", "server", "--frozen", "--no-dev"]
NODE_ENV = "production"

[services.production.run]
args = ["sh", "-c", "cd ../../server && uv run uvicorn app.main:app --host 0.0.0.0 --port 8080"]
args = ["uv", "run", "--directory", "../../server", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

[services.production.run.env]
PORT = "8080"
Expand Down
Loading
Loading