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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion web/apps/app/.env.production
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions web/apps/app/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -58,6 +65,9 @@ export function CredentialsForm({
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<CredentialFailure | null>(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).
Expand All @@ -73,6 +83,7 @@ export function CredentialsForm({
}
setSubmitting(true);
setError(null);
setArrivalShown(false);
const failure = await action(email, password);
if (failure) {
setError(failure);
Expand All @@ -88,6 +99,30 @@ export function CredentialsForm({
return (
<>
<p className="mb-6 mt-1 text-sm text-muted">{selfHost ? selfHostSubtitle : subtitle}</p>
{arrivalShown && initialError && (
<ErrorMessage
className="mb-5"
message={
initialError.action ? (
<>
{initialError.message}
<a
className="font-medium underline underline-offset-2"
href={initialError.action.href}
target="_blank"
rel="noreferrer"
>
{initialError.action.label}
</a>
{initialError.action.after}
</>
) : (
initialError.message
)
}
/>
)}
<ProviderButtons next={next} />
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
{error?.message && <ErrorMessage message={error.message} />}
<div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 (
<div aria-hidden>
<Skeleton className="mb-6 mt-1 h-5 w-40" />
{providers.length > 0 && (
<>
<div className="space-y-3">
{providers.map((provider) => (
<Skeleton key={provider} className="h-9 w-full" />
))}
</div>
<ProviderDivider />
</>
)}
<div className="space-y-4">
{Array.from({ length: fields }, (_, i) => (
<div key={i}>
Expand Down
40 changes: 40 additions & 0 deletions web/apps/app/src/app/(auth)/_components/provider-buttons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { AUTH_PROVIDERS, type AuthProvider, authProviders, providerStartUrl } from "@bower/api";
import { Button, GitHubMark, GoogleMark } from "@bower/ui";

const MARKS: Record<AuthProvider, typeof GoogleMark> = { 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 (
<>
<div className="space-y-3">
{providers.map((provider) => {
const Mark = MARKS[provider];
return (
<Button key={provider} href={providerStartUrl(provider, next)} variant="outline" fullWidth>
<Mark className="mr-2" />
{AUTH_PROVIDERS[provider].label}
</Button>
);
})}
</div>
<ProviderDivider />
</>
);
}

export function ProviderDivider() {
return (
<div className="my-5 flex items-center gap-3 text-xs text-faint" aria-hidden>
<span className="flex-1 border-t border-hairline" />
or
<span className="flex-1 border-t border-hairline" />
</div>
);
}
56 changes: 56 additions & 0 deletions web/apps/app/src/app/(auth)/_components/provider-errors.ts
Original file line number Diff line number Diff line change
@@ -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=<code>&provider=<name>; 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<AuthProvider, string> = {
google: "https://myaccount.google.com/email",
github: "https://github.com/settings/emails",
};

const PROVIDER_NAMES: Record<AuthProvider, string> = { google: "Google", github: "GitHub" };

function isProvider(name: string | null): name is AuthProvider {
return name !== null && name in AUTH_PROVIDERS;
}

const FIXED: Record<string, ProviderErrorLine> = {
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;
}
13 changes: 12 additions & 1 deletion web/apps/app/src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string | string[] | undefined>>;

Expand All @@ -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.
Expand All @@ -29,6 +39,7 @@ export default async function Login({ searchParams }: { searchParams: SearchPara
mode="login"
rememberedEmail={remembered === null ? null : safeDecode(remembered)}
selfHost={selfHost}
initialError={initialError}
/>
</Suspense>
);
Expand Down
34 changes: 34 additions & 0 deletions web/packages/api/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthProvider, { label: string }> = {
google: { label: "Continue with Google" },
github: { label: "Continue with GitHub" },
};

export function parseAuthProviders(raw: string | undefined): AuthProvider[] {
const seen = new Set<AuthProvider>();
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
Expand Down
5 changes: 5 additions & 0 deletions web/packages/api/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
25 changes: 24 additions & 1 deletion web/packages/api/tests/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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",
);
});
7 changes: 6 additions & 1 deletion web/packages/ui/src/error-message.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
role="alert"
Expand Down
1 change: 1 addition & 0 deletions web/packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export { Switch } from "./switch";
export { Textarea } from "./textarea";
export { Spinner } from "./spinner";
export { BrandMark } from "./brand-mark";
export { GitHubMark, GoogleMark } from "./provider-marks";
export { ToastProvider, useToast } from "./toast";
export * from "./sidebar";
export * from "./theme";
Loading
Loading