From 0fc60ab36af2a78fc01e89dbe0bed7622c1180ce Mon Sep 17 00:00:00 2001 From: Josh Panka Date: Fri, 16 Oct 2026 12:39:47 -0400 Subject: [PATCH] feat: sign in with Google and GitHub from the hosted card Continue with Google and Continue with GitHub above the password form in both modes, each a navigation to the identity service's start hop carrying the in-flight authorize; which providers show is NEXT_PUBLIC_AUTH_PROVIDERS (the hosted build names both). The identity service's refusal codes render as the banner at the top of the card, the provider named and its email settings linked inside the sentence when the fix lives there. --- .env.example | 3 + docker-compose.yml | 4 ++ web/apps/app/.env.production | 4 +- web/apps/app/Dockerfile | 4 ++ .../credentials-form/credentials-form.tsx | 35 ++++++++++++ .../_components/credentials-form/fallback.tsx | 13 +++++ .../(auth)/_components/provider-buttons.tsx | 40 +++++++++++++ .../app/(auth)/_components/provider-errors.ts | 56 +++++++++++++++++++ web/apps/app/src/app/(auth)/login/page.tsx | 13 ++++- web/packages/api/src/auth.ts | 34 +++++++++++ web/packages/api/src/routes.ts | 5 ++ web/packages/api/tests/auth.test.ts | 25 ++++++++- web/packages/ui/src/error-message.tsx | 7 ++- web/packages/ui/src/index.ts | 1 + web/packages/ui/src/provider-marks.tsx | 38 +++++++++++++ 15 files changed, 278 insertions(+), 4 deletions(-) create mode 100644 web/apps/app/src/app/(auth)/_components/provider-buttons.tsx create mode 100644 web/apps/app/src/app/(auth)/_components/provider-errors.ts create mode 100644 web/packages/ui/src/provider-marks.tsx diff --git a/.env.example b/.env.example index 0e5c3937e..01ebec36d 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,6 @@ # OPENBOWER_DATA_URL=http://localhost:8003 # OPENBOWER_AUTH_INTERNAL_URL=http://auth:8001 # OPENBOWER_DATA_INTERNAL_URL=http://data:8003 +# The sign-in providers the web shows (Google, GitHub), which that locally +# run identity service must hold credentials for; empty shows none. +# NEXT_PUBLIC_AUTH_PROVIDERS=google,github diff --git a/docker-compose.yml b/docker-compose.yml index 514c8d2bb..fb36e31a3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -529,6 +529,10 @@ services: # The same value core gets (x-core-env): the login form posts # credentials to the IdP this names. NEXT_PUBLIC_AUTH_URL: ${OPENBOWER_AUTH_URL:-https://auth.openbower.ai} + # The sign-in providers to offer, a comma list the identity service + # named above has credentials for (none by default: a locally run + # hub has none until its operator registers provider apps). + NEXT_PUBLIC_AUTH_PROVIDERS: ${NEXT_PUBLIC_AUTH_PROVIDERS:-} NEXT_PUBLIC_APP_URL: http://localhost:3003 # Server-side fetches cross the compose network instead; localhost # inside this container is this container. Read by @bower/api's diff --git a/web/apps/app/.env.production b/web/apps/app/.env.production index 12a58bc58..02d12609e 100644 --- a/web/apps/app/.env.production +++ b/web/apps/app/.env.production @@ -3,6 +3,8 @@ # `next dev` does not, and keeps its localhost fallbacks. The Docker # image never sees it (the context excludes env files) and takes the # same two values as build arguments instead. A self-hoster deploying -# their own copy of the app points these at their own hosts. +# their own copy of the app points these at their own hosts, and lists +# the sign-in providers their identity service has credentials for. NEXT_PUBLIC_API_URL=https://api.openbower.ai NEXT_PUBLIC_AUTH_URL=https://auth.openbower.ai +NEXT_PUBLIC_AUTH_PROVIDERS=google,github diff --git a/web/apps/app/Dockerfile b/web/apps/app/Dockerfile index 36d4765c5..0d9b24cf4 100644 --- a/web/apps/app/Dockerfile +++ b/web/apps/app/Dockerfile @@ -32,8 +32,12 @@ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ FROM deps AS build ARG NEXT_PUBLIC_API_URL ARG NEXT_PUBLIC_AUTH_URL +# The sign-in providers to offer; none unless the build says so, since +# only an identity service holding provider credentials can honor them. +ARG NEXT_PUBLIC_AUTH_PROVIDERS="" ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ NEXT_PUBLIC_AUTH_URL=$NEXT_PUBLIC_AUTH_URL \ + NEXT_PUBLIC_AUTH_PROVIDERS=$NEXT_PUBLIC_AUTH_PROVIDERS \ NEXT_TELEMETRY_DISABLED=1 COPY . . # Offline: everything was fetched above, this only links the app and diff --git a/web/apps/app/src/app/(auth)/_components/credentials-form/credentials-form.tsx b/web/apps/app/src/app/(auth)/_components/credentials-form/credentials-form.tsx index b41b4ece5..2925a0cde 100644 --- a/web/apps/app/src/app/(auth)/_components/credentials-form/credentials-form.tsx +++ b/web/apps/app/src/app/(auth)/_components/credentials-form/credentials-form.tsx @@ -5,6 +5,8 @@ import { useSearchParams } from "next/navigation"; import { Button, ErrorMessage, FieldError, Input, Label, PasswordInput } from "@bower/ui"; import { type CredentialFailure, idpResumeUrl, webRoutes, withNext } from "@bower/api"; +import { ProviderButtons } from "../provider-buttons"; +import type { ProviderErrorLine } from "../provider-errors"; import { REMEMBER_EMAIL_COOKIE, REMEMBER_MAX_AGE_SECONDS } from "./constants"; import { MODES } from "./modes"; import type { ModeName } from "./types"; @@ -30,12 +32,17 @@ export function CredentialsForm({ mode, rememberedEmail = null, selfHost = false, + initialError = null, }: { mode: ModeName; rememberedEmail?: string | null; /** Decided by the server page from ?next (a loopback redirect_uri), * so the first paint already carries the right line. */ selfHost?: boolean; + /** A line the page arrived with (a provider sign-in that failed, from + * ?auth_error=), shown as the banner until the next submit, with a + * link when the fix lives elsewhere. */ + initialError?: ProviderErrorLine | null; }) { const { subtitle, @@ -58,6 +65,9 @@ export function CredentialsForm({ const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); const [error, setError] = useState(null); + // The line the page arrived with sits above the provider buttons it is + // about, until the first submit: the form's own outcome replaces it. + const [arrivalShown, setArrivalShown] = useState(initialError !== null); const [submitting, setSubmitting] = useState(false); // `next` is read straight off the URL (no mount effect + state, which // would setState within an effect and cascade a render). @@ -73,6 +83,7 @@ export function CredentialsForm({ } setSubmitting(true); setError(null); + setArrivalShown(false); const failure = await action(email, password); if (failure) { setError(failure); @@ -88,6 +99,30 @@ export function CredentialsForm({ return ( <>

{selfHost ? selfHostSubtitle : subtitle}

+ {arrivalShown && initialError && ( + + {initialError.message} + + {initialError.action.label} + + {initialError.action.after} + + ) : ( + initialError.message + ) + } + /> + )} +
{error?.message && }
diff --git a/web/apps/app/src/app/(auth)/_components/credentials-form/fallback.tsx b/web/apps/app/src/app/(auth)/_components/credentials-form/fallback.tsx index 727b88c04..343a0705f 100644 --- a/web/apps/app/src/app/(auth)/_components/credentials-form/fallback.tsx +++ b/web/apps/app/src/app/(auth)/_components/credentials-form/fallback.tsx @@ -1,5 +1,7 @@ +import { authProviders } from "@bower/api"; import { Skeleton } from "@bower/ui"; +import { ProviderDivider } from "../provider-buttons"; import { MODES } from "./modes"; import type { ModeName } from "./types"; @@ -10,9 +12,20 @@ import type { ModeName } from "./types"; export function CredentialsFormFallback({ mode }: { mode: ModeName }) { const { confirmPassword, rememberEmail } = MODES[mode]; const fields = confirmPassword ? 3 : 2; + const providers = authProviders(); return (
+ {providers.length > 0 && ( + <> +
+ {providers.map((provider) => ( + + ))} +
+ + + )}
{Array.from({ length: fields }, (_, i) => (
diff --git a/web/apps/app/src/app/(auth)/_components/provider-buttons.tsx b/web/apps/app/src/app/(auth)/_components/provider-buttons.tsx new file mode 100644 index 000000000..ed4ab0455 --- /dev/null +++ b/web/apps/app/src/app/(auth)/_components/provider-buttons.tsx @@ -0,0 +1,40 @@ +import { AUTH_PROVIDERS, type AuthProvider, authProviders, providerStartUrl } from "@bower/api"; +import { Button, GitHubMark, GoogleMark } from "@bower/ui"; + +const MARKS: Record = { google: GoogleMark, github: GitHubMark }; + +/** One button per provider this build offers, above the credentials + * form, then the divider. Each is a full navigation to the identity + * service's start endpoint (the auth boundary rule), carrying the + * in-flight authorize so the round trip resumes it. Renders nothing + * when no provider is configured, so the card is the plain form. */ +export function ProviderButtons({ next }: { next: string | null }) { + const providers = authProviders(); + if (providers.length === 0) return null; + return ( + <> +
+ {providers.map((provider) => { + const Mark = MARKS[provider]; + return ( + + ); + })} +
+ + + ); +} + +export function ProviderDivider() { + return ( +
+ + or + +
+ ); +} diff --git a/web/apps/app/src/app/(auth)/_components/provider-errors.ts b/web/apps/app/src/app/(auth)/_components/provider-errors.ts new file mode 100644 index 000000000..fa4c11024 --- /dev/null +++ b/web/apps/app/src/app/(auth)/_components/provider-errors.ts @@ -0,0 +1,56 @@ +import { AUTH_PROVIDERS, type AuthProvider } from "@bower/api"; + +// The identity service sends a failed provider sign-in back to the login +// page as ?auth_error=&provider=; the line each code renders +// as the banner at the top of the card. One job per line: what happened, +// and what to do. When the fix lives at the provider, the sentence's own +// verb is the link (`action`), so the banner stays one element in one +// color. A code this web does not know gets the generic line (the +// service may grow codes before the web does). +export type ProviderErrorLine = { + message: string; + /** A linked verb inside the sentence: `message` is rendered, then + * `action.label` as the link, then `action.after`. */ + action?: { href: string; label: string; after: string }; +}; + +// Where each provider lets a person verify an email address. +const EMAIL_SETTINGS: Record = { + google: "https://myaccount.google.com/email", + github: "https://github.com/settings/emails", +}; + +const PROVIDER_NAMES: Record = { google: "Google", github: "GitHub" }; + +function isProvider(name: string | null): name is AuthProvider { + return name !== null && name in AUTH_PROVIDERS; +} + +const FIXED: Record = { + account_exists: { message: "An account with this email already exists. Sign in with your password." }, + state_mismatch: { message: "The sign-in attempt was stale or started in a different browser. Try again." }, + provider_unavailable: { message: "That way of signing in isn't available here." }, + provider_cancelled: { message: "Sign-in was cancelled." }, + provider_failed: { message: "The provider couldn't complete the sign-in. Try again, or use your password." }, + rate_limited: { message: "Too many sign-in attempts from your network. Wait a minute, then try again." }, + account_inactive: { message: "This account can't sign in. Contact OpenBower support." }, +}; + +const UNKNOWN: ProviderErrorLine = { message: "Sign-in didn't complete. Try again, or use your password." }; + +export function providerErrorLine(code: string | null, provider: string | null): ProviderErrorLine | null { + if (!code) return null; + if (code === "email_unverified") { + // Either provider can report an unverified address; the identity + // service says which one refused. + if (isProvider(provider)) { + const name = PROVIDER_NAMES[provider]; + return { + message: `${name} hasn't verified your primary email. `, + action: { href: EMAIL_SETTINGS[provider], label: `Verify it with ${name}`, after: ", then try again." }, + }; + } + return { message: "Your primary email isn't verified with the provider. Verify it there, then try again." }; + } + return FIXED[code] ?? UNKNOWN; +} diff --git a/web/apps/app/src/app/(auth)/login/page.tsx b/web/apps/app/src/app/(auth)/login/page.tsx index eeb4ca211..aba11088c 100644 --- a/web/apps/app/src/app/(auth)/login/page.tsx +++ b/web/apps/app/src/app/(auth)/login/page.tsx @@ -4,6 +4,7 @@ import { credentialPostIsSameSite, resolveAuthBase, resumeTargetsLoopback } from import { CredentialsForm, CredentialsFormFallback, REMEMBER_EMAIL_COOKIE, safeDecode } from "../_components/credentials-form"; import { HubSignIn } from "../_components/hub-sign-in"; +import { providerErrorLine } from "../_components/provider-errors"; type SearchParams = Promise>; @@ -19,8 +20,17 @@ export default async function Login({ searchParams }: { searchParams: SearchPara // The person arrived from their own install, which sent them here // for the credentials; the card's one line says so (the address bar // is not theirs), decided here so the first paint carries it. - const next = (await searchParams).next; + const params = await searchParams; + const next = params.next; const selfHost = resumeTargetsLoopback(typeof next === "string" ? next : null); + // A provider sign-in that failed comes back here with the code; the + // form shows the line as its banner, and is the retry. + const authError = params.auth_error; + const provider = params.provider; + const initialError = providerErrorLine( + typeof authError === "string" ? authError : null, + typeof provider === "string" ? provider : null, + ); return ( // useSearchParams (inside the form) needs a Suspense boundary; // nothing here is worth blocking pre-render on. @@ -29,6 +39,7 @@ export default async function Login({ searchParams }: { searchParams: SearchPara mode="login" rememberedEmail={remembered === null ? null : safeDecode(remembered)} selfHost={selfHost} + initialError={initialError} /> ); diff --git a/web/packages/api/src/auth.ts b/web/packages/api/src/auth.ts index 2db62babb..8d74204b7 100644 --- a/web/packages/api/src/auth.ts +++ b/web/packages/api/src/auth.ts @@ -65,6 +65,40 @@ export function returnPath(): string { return `${window.location.pathname}${window.location.search}`; } +// The upstream identity providers the IdP may offer. Which ones THIS +// build shows is NEXT_PUBLIC_AUTH_PROVIDERS (a comma list, inlined at +// build time like the origins); the IdP refuses a provider it has no +// credentials for, so a mismatch is a banner on the login page, never +// a dead button. The record is the roster: a name off the env that is +// not here is dropped. +export type AuthProvider = "google" | "github"; + +export const AUTH_PROVIDERS: Record = { + google: { label: "Continue with Google" }, + github: { label: "Continue with GitHub" }, +}; + +export function parseAuthProviders(raw: string | undefined): AuthProvider[] { + const seen = new Set(); + for (const part of (raw ?? "").split(",")) { + const name = part.trim(); + if (name in AUTH_PROVIDERS) seen.add(name as AuthProvider); + } + return [...seen]; +} + +/** The providers this build offers, in the env's order. */ +export function authProviders(): AuthProvider[] { + return parseAuthProviders(process.env.NEXT_PUBLIC_AUTH_PROVIDERS); +} + +/** Where a provider button navigates: the IdP's start endpoint, carrying + * the in-flight authorize (`next`) so the round trip resumes it. */ +export function providerStartUrl(provider: AuthProvider, next: string | null): string { + const base = buildAuthUrl(authRoutes.socialStart(provider)); + return next ? `${base}?${new URLSearchParams({ next }).toString()}` : base; +} + // A credential failure, shaped for the error taxonomy: `fields` carries // per-input messages (tier a, rendered AT the inputs) when the IdP's // validation response provides them; `message` is the form-level banner diff --git a/web/packages/api/src/routes.ts b/web/packages/api/src/routes.ts index 2326fc760..5c8b2dd44 100644 --- a/web/packages/api/src/routes.ts +++ b/web/packages/api/src/routes.ts @@ -114,6 +114,11 @@ export const authRoutes = { login: `/${AUTH_API_VERSION}/auth/login`, // POST: create the account + mint the session (the signup form posts here). signup: `/${AUTH_API_VERSION}/auth/signup`, + // GET (a full navigation): start a sign-in through an upstream + // provider; the IdP does the round trip and resumes `next`. At the + // IdP's root like its other navigation targets, not under the API + // version: the matching callback is registered at the providers. + socialStart: (provider: string) => `/social/${provider}/start`, } as const; const LIST_PREFIX = "/lists"; diff --git a/web/packages/api/tests/auth.test.ts b/web/packages/api/tests/auth.test.ts index a89637087..feddbf7ac 100644 --- a/web/packages/api/tests/auth.test.ts +++ b/web/packages/api/tests/auth.test.ts @@ -1,7 +1,14 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { credentialPostIsSameSite, idpResumeUrl, loginUrl, resumeTargetsLoopback } from "../src/auth.ts"; +import { + credentialPostIsSameSite, + idpResumeUrl, + loginUrl, + parseAuthProviders, + providerStartUrl, + resumeTargetsLoopback, +} from "../src/auth.ts"; const HOSTED_AUTH = "https://auth.example.io"; @@ -45,3 +52,19 @@ test("the login kickoff carries the page to come back to as an encoded next, or assert.equal(loginUrl("/lists/42?after=abc"), "http://localhost:8002/v1/auth/login?next=%2Flists%2F42%3Fafter%3Dabc"); assert.equal(loginUrl(""), "http://localhost:8002/v1/auth/login"); }); + +test("the providers a build offers come off the env, known names only, in order, once", () => { + assert.deepEqual(parseAuthProviders(undefined), []); + assert.deepEqual(parseAuthProviders(""), []); + assert.deepEqual(parseAuthProviders("github"), ["github"]); + assert.deepEqual(parseAuthProviders("github, google"), ["github", "google"]); + assert.deepEqual(parseAuthProviders("google,facebook,google"), ["google"]); +}); + +test("a provider button starts the identity service's round trip, carrying the authorize to resume", () => { + assert.equal(providerStartUrl("google", null), "http://localhost:8001/social/google/start"); + assert.equal( + providerStartUrl("github", "/oauth/authorize/?client_id=x"), + "http://localhost:8001/social/github/start?next=%2Foauth%2Fauthorize%2F%3Fclient_id%3Dx", + ); +}); diff --git a/web/packages/ui/src/error-message.tsx b/web/packages/ui/src/error-message.tsx index eda045a22..71d4bda61 100644 --- a/web/packages/ui/src/error-message.tsx +++ b/web/packages/ui/src/error-message.tsx @@ -1,6 +1,11 @@ +import type { ReactNode } from "react"; + import { cn } from "./cn"; -export function ErrorMessage({ message, className, id }: { message: string; className?: string; id?: string }) { +/** The form-level error banner. `message` may carry markup, so a line + * whose fix lives elsewhere can link its own verb instead of growing a + * second line in another color. */ +export function ErrorMessage({ message, className, id }: { message: ReactNode; className?: string; id?: string }) { return (
+ + + + + + ); +} + +export function GitHubMark({ className }: { className?: string }) { + return ( + + + + ); +}