import React from "react"; import { Image, Pressable, View } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import { useColorScheme } from "nativewind"; import { EntropyMark } from "./entropy-mark"; import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"; export interface SocialConnectionsProps { onApple?: () => void; onGoogle?: () => void; onMicrosoft?: () => void; /** * "Continue with Entropy" — the first-party identity provider. * * Listed first because it is the one that always works: the other three are * upstream connectors configured inside Entropy Access, so they light up as * that configuration lands, while this one needs nothing but the IdP itself. */ onEntropy?: () => void; /** * Whether to offer "Continue with Entropy" at all. Defaults to `true`. * * Unlike the other three, this one is not a staged-rollout item that can sit * there unhandled until its connector lands. It either applies — the consumer * is a relying party — or it is nonsense, which is the case on Entropy * Access's own sign-in surfaces: offering to sign in to the identity provider * with the identity provider is a button that can never mean anything. * * A flag rather than "render only when `onEntropy` is supplied", because * rendering an unhandled provider is deliberate here — see the note on the * component — and simple-finance renders the whole row with no handlers at * all. Inferring intent from a missing handler would silently take that * button away from consumers that want the placeholder. */ showEntropy?: boolean; /** * Whether to offer "Continue with Apple". Defaults to `true`. * * ## Why these three needed flags of their own * * Apple, Google and Microsoft used to render unconditionally, so a consumer * could suppress the whole row or none of it. That is the wrong granularity * for an identity provider whose upstream connectors are configured per * tenant: a tenant with only a Google connector still drew Apple and * Microsoft, as two squares that respond to a press by doing nothing. * * `undefined` handlers do not hide a button, and that is deliberate and * unchanged — a handler-less provider is the "coming soon" placeholder the * consumer apps use during a staged rollout, and simple-finance renders the * whole row with no handlers at all. So intent cannot be inferred from a * missing handler; it has to be said. Same shape and same default as * {@link SocialConnectionsProps.showEntropy}, so existing callers are * unaffected. */ showApple?: boolean; /** Whether to offer "Continue with Google". Defaults to `true`. See {@link SocialConnectionsProps.showApple}. */ showGoogle?: boolean; /** Whether to offer "Continue with Microsoft". Defaults to `true`. See {@link SocialConnectionsProps.showApple}. */ showMicrosoft?: boolean; /** * Providers this component has no brand mark for, rendered after the four it * does — Okta, Keycloak, GitHub, another Access instance, anything an * operator configures. * * ## Why these are a caller-supplied list rather than more `ProviderKey`s * * The four above are a closed set with bundled glyphs and a fixed order, so * they can be named in the type. What an operator connects is open: adding a * key here for every provider anyone might configure is a release of this * package per connector, and the whole point of the identity provider's * connector table is that it does not need one. * * Ordered as given, after the built-ins, so the row is stable — the * built-ins keep the positions users have learned and new connectors append. * * `iconUrl` is optional and points at whatever the operator supplied. Absent, * unreachable, or malformed, the button falls back to a neutral glyph rather * than an empty square: a connector with no icon must still be pressable and * still be labelled. */ custom?: readonly CustomProvider[]; } /** * A provider this package does not ship a mark for. * * `key` only has to be unique within the row; it is not a {@link ProviderKey} * and is never matched against one. Prefix collisions with the built-ins are * impossible because the rendered React keys are namespaced. */ export interface CustomProvider { key: string; /** What the tooltip and the screen reader say: "Continue with {label}". */ label: string; /** * Absolute URL of the provider's mark. Rendered at 22px square, matching the * bundled glyphs. */ iconUrl?: string | null; onPress?: () => void; } /** The federated providers this row can draw. */ export type ProviderKey = "entropy" | "apple" | "google" | "microsoft"; export interface Provider { key: ProviderKey; label: string; /** Ionicons glyph, or `null` for Entropy, which draws its own brand mark. */ icon: "logo-apple" | "logo-google" | "logo-microsoft" | null; } const PROVIDERS: readonly Provider[] = [ // The real `>_` company mark, not a stand-in glyph. This is a first-party // sign-in sitting next to three brand marks; anything generic reads as a // placeholder rather than as an option worth picking. { key: "entropy", label: "Entropy", icon: null }, { key: "apple", label: "Apple", icon: "logo-apple" }, { key: "google", label: "Google", icon: "logo-google" }, { key: "microsoft", label: "Microsoft", icon: "logo-microsoft" }, ]; /** Which providers each `show*` flag governs. Every key is required, so a new * provider in {@link PROVIDERS} cannot be added without deciding its flag. */ export type ProviderVisibility = Record; /** * The providers to render, in {@link PROVIDERS} order. * * Exported and pure so the visibility rules are testable without a render * runtime — the component itself pulls in `@expo/vector-icons` and reanimated, * which the unit-suite transform does not process. * * Order comes from `PROVIDERS`, not from the caller's flags, so the row cannot * reshuffle between renders or between consumers. */ export function visibleProviders( shown: ProviderVisibility ): readonly Provider[] { return PROVIDERS.filter(({ key }) => shown[key]); } /** * What a caller-supplied provider's button should draw. * * Pure, exported and separate from the component for the same reason * {@link visibleProviders} is: the fallback rule is the part worth testing, and * the component pulls in `@expo/vector-icons` and reanimated, which the unit * suite's transform does not process. * * A blank or whitespace-only URL counts as absent, not as a URL to fetch: an * operator who clears the field in a form leaves `""` behind far more often * than `null`, and `` is a request that fails * silently on web and renders an empty square on native. */ export function customIcon( iconUrl: string | null | undefined, failed: boolean ): { kind: "image"; uri: string } | { kind: "fallback" } { const uri = iconUrl?.trim(); if (!uri || failed) { return { kind: "fallback" }; } return { kind: "image", uri }; } /** * Button row for federated sign-in: Entropy / Apple / Google / Microsoft. * * Each handler is optional — a provider with no handler still renders but fires * no action, which is the "coming soon" UX the consumer apps use during a * staged rollout. All four route through ONE endpoint on the server * (`/api/auth/oidc/start?connector=…`); the difference between them is a hint * passed to Access, not a separate flow. * * To render only some providers, use the `show*` flags — one per provider, all * defaulting to `true`. A consumer that knows which upstream connectors are * configured (Entropy Access does, per tenant) should pass them, or it will * draw brand marks for providers that cannot complete a sign-in. * * The buttons are icon-only, so each carries a tooltip AND an * `accessibilityLabel`. The two are not redundant: the tooltip is for a sighted * user hovering on web or long-pressing on native, the label is what a screen * reader announces. Without the tooltip these are four unlabelled squares, and * a user has to guess which mark is which. */ export function SocialConnections({ onApple, onGoogle, onMicrosoft, onEntropy, showEntropy = true, showApple = true, showGoogle = true, showMicrosoft = true, custom = [], }: SocialConnectionsProps): React.JSX.Element { const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; const handlers: Record void) | undefined> = { entropy: onEntropy, apple: onApple, google: onGoogle, microsoft: onMicrosoft, }; // One map rather than four `key !== "x" || showX` clauses: the filter used // to name `entropy` explicitly, which read fine for one exception and would // be four boolean conditions for four. `ProviderVisibility` requires every // key, so adding a provider to PROVIDERS without deciding its flag is a type // error rather than a button nobody can turn off. const shown: ProviderVisibility = { entropy: showEntropy, apple: showApple, google: showGoogle, microsoft: showMicrosoft, }; return ( {visibleProviders(shown).map( ({ key, icon, label }) => ( {icon ? ( ) : ( // driftScale 0 collapses the echo offsets onto the mark. The // drift is a boot-animation effect and at 18px it just reads as // a smear, which next to three crisp brand glyphs looks like a // rendering fault rather than a logo. )} {`Continue with ${label}`} ) )} {custom.map((provider) => ( ))} ); } /** * One caller-supplied provider. * * Its own component because it holds state the built-ins do not: whether the * remote icon failed. A broken URL has to degrade to the neutral glyph, and * `onError` can only be observed per image. */ function CustomProviderButton({ provider, isDark, }: { provider: CustomProvider; isDark: boolean; }): React.JSX.Element { const [iconFailed, setIconFailed] = React.useState(false); const tint = isDark ? "#FFFFFF" : "#000000"; const icon = customIcon(provider.iconUrl, iconFailed); return ( {icon.kind === "image" ? ( setIconFailed(true)} accessibilityIgnoresInvertColors /> ) : ( // The neutral fallback. A key rather than a globe or a puzzle // piece: this is a sign-in credential, and the glyph should say so // without implying a vendor. )} {`Continue with ${provider.label}`} ); } export default SocialConnections;