diff --git a/.env.example b/.env.example index 0e5c3937..01ebec36 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 514c8d2b..fb36e31a 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 12a58bc5..02d12609 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 36d4765c..0d9b24cf 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 b41b4ece..2925a0cd 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 727b88c0..343a0705 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 00000000..ed4ab045 --- /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 00000000..fa4c1102 --- /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 eeb4ca21..aba11088 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 2db62bab..8d74204b 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 2326fc76..5c8b2dd4 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 a8963708..feddbf7a 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 eda045a2..71d4bda6 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 ( + + + + ); +}