Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c170bc1
feat(auth): establish server-trusted Google registration consent
baba-dev Nov 11, 2026
3b4976e
feat(auth): establish server-trusted Google registration consent
baba-dev Nov 11, 2026
fe460e2
feat(auth): establish server-trusted Google registration consent
baba-dev Nov 11, 2026
9a3f039
feat(auth): allow explicit Google signup without weakening MFA or lin…
baba-dev Nov 11, 2026
a3353bc
fix(mobile): surface Google sign-in and free signup before email form
baba-dev Nov 11, 2026
812f587
feat(auth): add explicit consent-gated Google signup to registration
baba-dev Nov 11, 2026
1f8afbd
refactor(auth): render registration capabilities from server config
baba-dev Nov 11, 2026
7055932
style(auth): mobile-first signup and Google action hierarchy
baba-dev Nov 11, 2026
987b556
test(auth): cover signup discovery, consent gate and phone widths
baba-dev Nov 11, 2026
f611023
style(auth): apply pinned Prettier formatting and remove blank mobile…
baba-dev Nov 11, 2026
a5144e5
style(auth): apply pinned Prettier formatting and remove blank mobile…
baba-dev Nov 11, 2026
25efe55
style(auth): apply pinned Prettier formatting and remove blank mobile…
baba-dev Nov 11, 2026
24145bc
style(auth): apply pinned Prettier formatting and remove blank mobile…
baba-dev Nov 11, 2026
dc58191
style(auth): apply pinned Prettier formatting and remove blank mobile…
baba-dev Nov 11, 2026
242be81
style(auth): apply pinned Prettier formatting and remove blank mobile…
baba-dev Nov 11, 2026
f6e372f
fix(auth): use React Compiler-safe form subscription and eliminate li…
baba-dev Nov 11, 2026
64e7efe
fix(auth): use React Compiler-safe form subscription and eliminate li…
baba-dev Nov 11, 2026
07aabe1
test(auth): update pricing-return assertions for visible signup CTA
baba-dev Nov 11, 2026
3a1ba5a
test(a11y): distinguish fresh-document skip navigation from SPA focus…
baba-dev Nov 11, 2026
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
44 changes: 44 additions & 0 deletions docs/android-release/google-auth-acceptance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Android / web Google authentication acceptance

MenuMinute's Play app is an Android Trusted Web Activity (Chrome), not a WebView.
The registered Google OAuth client is a **Web application** pointing at
`https://food.bababuilds.com/api/auth/callback/google`. Do not use an Android-only
OAuth redirect, and do not publish Google client secrets in app resources.

## Supported journeys

- Existing account, already linked to Google: sign in from the first screen; return to the
validated `next` path (the TWA uses `/app`).
- Existing email/password-only account: do **not** silently link by matching email.
Authenticate with password first, then explicitly link under Account Security.
- New owner: first-screen Create free account -> registration, check current Terms/Privacy
acceptance -> Continue with Google -> Google identity -> verified session -> first outlet
onboarding. A password is not required for a Google-created account.
- New owner via email/password: the existing signup, deferred verification and onboarding
remain available.
- A user protected by TOTP cannot bypass the authenticator with Google. Continue with
email/password and TOTP instead.

The backend requires `requestSignUp` and signed server-side OAuth consent state before
creating an account through Google. Linked-account sign-in never creates users implicitly.
No new database migration or Android binary change is needed.

## Production verification (not proven by CI)

1. Confirm `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` exist on the production web
service and the Google Cloud OAuth consent screen is published/available to testers.
2. Confirm the **Authorized redirect URI** is exactly
`https://food.bababuilds.com/api/auth/callback/google`.
This is unrelated to Android App Links / `assetlinks.json`.
3. Test on the installed Play closed-testing TWA using a fresh Google identity. Consent,
browser return, authenticated session, welcome onboarding and app relaunch must work.
4. Test a previously linked account and an email/password-only account with the same email:
the latter must remain unlinked until authenticated account-security linking.
5. Verify declining the legal checkbox blocks Google registration; manipulation of
`additionalData` and a missing or expired consent state cannot create a user.
6. Test both light/dark, narrow phone widths, keyboard accessibility and failure messages.
7. Verify an MFA-enabled account remains blocked by Google sign-in.
8. Record the deployed release SHA, Chrome version, device/Android version, and results.

Google rejects sign-in inside some embedded WebViews. The Play client uses Chrome TWA:
do **not** add an unreviewed native WebView or pass Google tokens to Android Java code.
99 changes: 51 additions & 48 deletions src/app/login/login-form.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useSyncExternalStore } from "react";
import { ArrowRight, ShieldCheck, UserRound, X } from "lucide-react";
import { ArrowRight, ShieldCheck, UserRound, UserRoundPlus, X } from "lucide-react";
import {
clearRememberedAccount,
completePendingLoginPreference,
Expand Down Expand Up @@ -52,15 +52,51 @@ export function LoginForm({ googleEnabled = false }: { googleEnabled?: boolean }
eyebrow="SIGN IN / WELCOME BACK"
title="Good to have you back."
description="Pick up where you left off. Your restaurant stays yours, across every device."
extra={
<div className="mm-auth-secondary">
<span>New to MenuMinute?</span>
<Link href={next ? "/register?next=" + encodeURIComponent(next) : "/register"}>
Create an account
</Link>
</div>
}
>
<div className="mm-auth-entry" aria-label="Choose how to access MenuMinute">
{googleEnabled && (
<button
type="button"
className="mm-auth-google"
disabled={busy || googleBusy}
onClick={async () => {
if (googleBusy) return;
setGoogleBusy(true);
setError("");
try {
const result = await authClient.signIn.social({
provider: "google",
callbackURL: next || "/dashboard",
errorCallbackURL: "/login" + (next ? "?next=" + encodeURIComponent(next) : ""),
});
if (result.error)
setError("Google sign-in did not finish. Please retry or sign in with email.");
} catch {
setError("Unable to connect to Google. Check your connection and try again.");
} finally {
setGoogleBusy(false);
}
}}
>
<span aria-hidden="true" className="mm-auth-google-g">
G
</span>
{googleBusy ? "Connecting to Google…" : "Continue with Google"}
</button>
)}
<Link
className="mm-auth-create-account"
href={next ? "/register?next=" + encodeURIComponent(next) : "/register"}
>
<UserRoundPlus size={18} aria-hidden="true" />
<span>Create a free account</span>
<ArrowRight size={18} aria-hidden="true" />
</Link>
<p className="mm-auth-entry-help">New restaurant? Start free — no payment card needed.</p>
</div>
<div className="mm-auth-email-divider">
<span>Or sign in with email</span>
</div>
<form
className="mm-auth-form"
aria-busy={busy}
Expand Down Expand Up @@ -136,7 +172,12 @@ export function LoginForm({ googleEnabled = false }: { googleEnabled?: boolean }
<p className="mm-auth-message mm-auth-message--error" role="alert">
{params.get("error") === "mfa_requires_password"
? "This account is protected by an authenticator. Sign in with your email, password and verification code."
: "Google sign-in could not be completed. Sign in with your password first, then connect Google in Account Security."}
: params.get("error") === "account_not_linked"
? "That Google account is not connected yet. Sign in with your password, then link Google in Account Security."
: params.get("error") === "signup_disabled" ||
params.get("error") === "terms_required"
? "New to MenuMinute? Choose Create a free account to register with Google or email."
: "Google sign-in could not be completed. If your account is not yet linked, sign in with your password first."}
</p>
)}
{error && (
Expand Down Expand Up @@ -230,44 +271,6 @@ export function LoginForm({ googleEnabled = false }: { googleEnabled?: boolean }
{!busy && <ArrowRight size={18} aria-hidden="true" />}
</button>
</form>
{googleEnabled && (
<div className="mm-auth-social">
<p>Or sign in with a connected account</p>
<button
type="button"
className="mm-auth-google"
disabled={busy || googleBusy}
onClick={async () => {
if (googleBusy) return;
setGoogleBusy(true);
setError("");
try {
const result = await authClient.signIn.social({
provider: "google",
callbackURL: next || "/dashboard",
errorCallbackURL: "/login",
});
if (result.error)
setError(
"Google sign-in failed. Sign in with your password to manage connected accounts."
);
} catch {
setError("Unable to start Google sign-in. Please use your password.");
} finally {
setGoogleBusy(false);
}
}}
>
<span aria-hidden="true" className="mm-auth-google-g">
G
</span>
{googleBusy ? "Connecting to Google…" : "Continue with Google"}
</button>
<small>
New here? Create your account with email first, then connect Google in Account Security.
</small>
</div>
)}
</AuthShell>
);
}
215 changes: 10 additions & 205 deletions src/app/register/page.tsx
Original file line number Diff line number Diff line change
@@ -1,211 +1,16 @@
"use client";
import { Suspense, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { authClient } from "@/lib/auth-client";
import { AuthShell } from "@/components/auth/auth-shell";
import { RegistrationChallenge } from "@/components/auth/turnstile";
import { safeReturnPath } from "@/lib/auth-return";
import { TERMS_VERSION } from "@/content/legal";
const schema = z
.object({
name: z.string().trim().min(2, "Enter your name (at least 2 characters).").max(100),
email: z.email("Enter a valid email address.").max(254),
password: z.string().min(8, "Use at least 8 characters.").max(128),
confirmPassword: z.string().min(1, "Confirm your password."),
acceptTerms: z.literal(true, { error: "Accept the Terms and Privacy Policy to continue." }),
})
.refine((v) => v.password === v.confirmPassword, {
message: "Passwords do not match.",
path: ["confirmPassword"],
});
type Values = z.infer<typeof schema>;
function RegistrationForm() {
const router = useRouter(),
params = useSearchParams(),
next = safeReturnPath(params.get("next"), "");
const [busy, setBusy] = useState(false),
[error, setError] = useState(""),
[challenge, setChallenge] = useState("");
const [challengeRetry, setChallengeRetry] = useState(0);
const [showPassword, setShowPassword] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Values>({
resolver: zodResolver(schema),
shouldFocusError: true,
});
return (
<AuthShell
eyebrow="CREATE YOUR ACCOUNT / FREE QR MENU"
title="Your next chapter starts here."
description="Create a verified account, then set up your first outlet. No payment card required."
extra={
<div className="mm-auth-secondary">
<span>Already have an account?</span>
<Link href={next ? "/login?next=" + encodeURIComponent(next) : "/login"}>Sign in</Link>
</div>
}
>
<form
className="mm-auth-form"
noValidate
aria-busy={busy}
onSubmit={handleSubmit(async (values) => {
setBusy(true);
setError("");
try {
const response = await authClient.signUp.email({
name: values.name,
email: values.email,
password: values.password,
termsVersion: TERMS_VERSION,
termsAcceptedAt: new Date(), // Server replaces this with a trusted timestamp.
acceptTerms: values.acceptTerms,
// The link signs the owner in on the same browser; no second login is needed.
callbackURL: next || "/dashboard",
fetchOptions: challenge
? { headers: { "x-captcha-response": challenge } }
: undefined,
} as Parameters<typeof authClient.signUp.email>[0]);
if (response.error) {
setChallenge("");
setChallengeRetry((value) => value + 1);
setError(response.error.message ?? "Unable to register. Try again.");
return;
}
// Signed in already: go straight to setup. Verification is nagged, not required.
router.replace(next || "/onboarding");
router.refresh();
} catch {
setChallenge("");
setChallengeRetry((value) => value + 1);
setError("Unable to create an account. Your entries remain here.");
} finally {
setBusy(false);
}
})}
>
{error && (
<p role="alert" className="mm-auth-message mm-auth-message--error" tabIndex={-1}>
{error}
</p>
)}
<label className="mm-auth-field" htmlFor="name">
<span id="registration-name-label">Your name</span>
<input
{...register("name")}
id="name"
aria-labelledby="registration-name-label"
type="text"
maxLength={100}
autoComplete="name"
placeholder="Restaurant Owner"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? "registration-name-error" : undefined}
/>
{errors.name && (
<small id="registration-name-error" role="alert">
{errors.name.message}
</small>
)}
</label>
<label className="mm-auth-field" htmlFor="email">
<span id="registration-email-label">Email address</span>
<input
{...register("email")}
id="email"
aria-labelledby="registration-email-label"
type="email"
autoComplete="email"
maxLength={254}
placeholder="you@restaurant.com"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "registration-email-error" : undefined}
/>
{errors.email && (
<small id="registration-email-error" role="alert">
{errors.email.message}
</small>
)}
</label>
<label className="mm-auth-field" htmlFor="password">
<span id="registration-password-label">Password</span>
<input
{...register("password")}
id="password"
aria-labelledby="registration-password-label"
type={showPassword ? "text" : "password"}
autoComplete="new-password"
minLength={8}
maxLength={128}
placeholder="At least 8 characters"
aria-invalid={!!errors.password}
aria-describedby={errors.password ? "registration-password-error" : undefined}
/>
{errors.password && (
<small id="registration-password-error" role="alert">
{errors.password.message}
</small>
)}
</label>
<label className="mm-auth-field" htmlFor="confirmPassword">
<span id="registration-confirm-label">Confirm password</span>
<input
{...register("confirmPassword")}
id="confirmPassword"
aria-labelledby="registration-confirm-label"
type={showPassword ? "text" : "password"}
autoComplete="new-password"
aria-invalid={!!errors.confirmPassword}
aria-describedby={errors.confirmPassword ? "registration-confirm-error" : undefined}
/>
{errors.confirmPassword && (
<small id="registration-confirm-error" role="alert">
{errors.confirmPassword.message}
</small>
)}
</label>
<label className="mm-auth-secondary">
<input type="checkbox" {...register("acceptTerms")} /> I agree to the{" "}
<Link href="/terms" target="_blank" rel="noopener noreferrer">
Terms of Service
</Link>{" "}
and acknowledge the{" "}
<Link href="/privacy" target="_blank" rel="noopener noreferrer">
Privacy Policy
</Link>
.
</label>
{errors.acceptTerms && <small role="alert">{errors.acceptTerms.message}</small>}
<label className="mm-auth-secondary">
<input
type="checkbox"
checked={showPassword}
onChange={(event) => setShowPassword(event.target.checked)}
/>{" "}
Show passwords
</label>
<RegistrationChallenge onToken={setChallenge} resetKey={challengeRetry} />
<button
className="mm-auth-submit"
disabled={busy || (!!process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY && !challenge)}
>
{busy ? "Creating account…" : "Create free account"}
</button>
</form>
</AuthShell>
);
}
import { Suspense } from "react";
import { env } from "@/lib/config/env";
import { RegistrationForm } from "./register-form";

export const metadata = {
title: "Create an account — MenuMinute",
robots: { index: false, follow: false },
};

export default function RegisterPage() {
return (
<Suspense>
<RegistrationForm />
<RegistrationForm googleEnabled={Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET)} />
</Suspense>
);
}
Loading
Loading