From 4f025507b260ddedf87592dba2972f0b0f8045c2 Mon Sep 17 00:00:00 2001 From: tifpham021 Date: Fri, 11 Sep 2026 16:53:27 -0400 Subject: [PATCH 1/3] Fix unauthenticated page tagline visibility --- app/login/page.tsx | 5 +++-- app/signup/page.tsx | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/login/page.tsx b/app/login/page.tsx index fd1c7532..986fe3c3 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -20,12 +20,13 @@ export default function LoginPage() { const { primary_color = "#FFFFFF", secondary_color = "#FFFFFF", - tagline = "Welcome", + tagline: configuredTagline, } = useOrganizationConfig([ OrganizationConfigKey.PrimaryColor, OrganizationConfigKey.SecondaryColor, OrganizationConfigKey.Tagline, ]); + const tagline = configuredTagline?.trim() || "Welcome"; const org = useActiveOrganization(); const logo = org?.organization.data?.logo; @@ -97,7 +98,7 @@ export default function LoginPage() {

Login

-

+

{tagline}

Sign Up

-

{tagline}

+

{tagline}

Date: Sun, 13 Sep 2026 02:16:53 -0400 Subject: [PATCH 2/3] fixed logo visibility on unauthenticated pages --- app/forgotpassword/page.tsx | 30 ++-- app/login/page.tsx | 21 ++- app/resetpassword/page.tsx | 30 ++-- app/signup/page.tsx | 23 ++- components/OrganizationLogo.tsx | 25 +++ .../UnauthenticatedOrganizationLogo.tsx | 39 +++++ components/navigation/Logo.tsx | 11 +- lib/organizationBranding.ts | 10 ++ tests/e2e/unauthenticated-branding.spec.ts | 148 ++++++++++++++++++ .../unit/api/organizationconfig/route.test.ts | 82 +++++++++- tests/unit/components/NavigationLogo.test.tsx | 36 +++++ .../unit/components/OrganizationLogo.test.tsx | 35 +++++ 12 files changed, 425 insertions(+), 65 deletions(-) create mode 100644 components/OrganizationLogo.tsx create mode 100644 components/UnauthenticatedOrganizationLogo.tsx create mode 100644 lib/organizationBranding.ts create mode 100644 tests/e2e/unauthenticated-branding.spec.ts create mode 100644 tests/unit/components/NavigationLogo.test.tsx create mode 100644 tests/unit/components/OrganizationLogo.test.tsx diff --git a/app/forgotpassword/page.tsx b/app/forgotpassword/page.tsx index 313cafbf..0ca5869a 100644 --- a/app/forgotpassword/page.tsx +++ b/app/forgotpassword/page.tsx @@ -6,21 +6,23 @@ import useOrganizationConfig from "@/lib/hooks/useOrganizationConfig"; import BogTextInput from "@/components/bog/BogTextInput/BogTextInput"; import BogButton from "@/components/bog/BogButton/BogButton"; import { OrganizationConfigKey } from "@/lib/schema"; -import { useActiveOrganization } from "@/lib/hooks/useActiveOrganization"; import authClient from "@/lib/authClient"; import LeftArrowIcon from "@/components/LeftArrowIcon"; +import UnauthenticatedOrganizationLogo from "@/components/UnauthenticatedOrganizationLogo"; export default function ForgotPasswordPage() { const router = useRouter(); const [email, setEmail] = useState(""); - const { primary_color = "#FFFFFF", secondary_color = "#FFFFFF" } = - useOrganizationConfig([ - OrganizationConfigKey.PrimaryColor, - OrganizationConfigKey.SecondaryColor, - ]); - const org = useActiveOrganization(); - const logo = org?.organization.data?.logo; + const { + primary_color = "#FFFFFF", + secondary_color = "#FFFFFF", + logo_url: logoUrl, + } = useOrganizationConfig([ + OrganizationConfigKey.PrimaryColor, + OrganizationConfigKey.SecondaryColor, + OrganizationConfigKey.LogoUrl, + ]); const handleEmailSubmit = async () => { const { error } = await authClient.requestPasswordReset({ @@ -46,20 +48,12 @@ export default function ForgotPasswordPage() { >
-
- {logo && ( - Organization Logo - )} -
+
diff --git a/app/login/page.tsx b/app/login/page.tsx index 986fe3c3..8f79a2d6 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -6,6 +6,7 @@ import authClient from "@/lib/authClient"; import useOrganizationConfig from "@/lib/hooks/useOrganizationConfig"; import BogTextInput from "@/components/bog/BogTextInput/BogTextInput"; import BogButton from "@/components/bog/BogButton/BogButton"; +import UnauthenticatedOrganizationLogo from "@/components/UnauthenticatedOrganizationLogo"; import { OrganizationConfigKey } from "@/lib/schema"; import { useActiveOrganization } from "@/lib/hooks/useActiveOrganization"; @@ -21,14 +22,15 @@ export default function LoginPage() { primary_color = "#FFFFFF", secondary_color = "#FFFFFF", tagline: configuredTagline, + logo_url: logoUrl, } = useOrganizationConfig([ OrganizationConfigKey.PrimaryColor, OrganizationConfigKey.SecondaryColor, OrganizationConfigKey.Tagline, + OrganizationConfigKey.LogoUrl, ]); const tagline = configuredTagline?.trim() || "Welcome"; const org = useActiveOrganization(); - const logo = org?.organization.data?.logo; const handleLogin = async () => { setLoading(true); @@ -79,26 +81,21 @@ export default function LoginPage() { >
-
- {logo && ( - Organization Logo - )} -
+

Login

-

+

{tagline}

{ const token = new URLSearchParams(window.location.search).get("token"); @@ -88,20 +90,12 @@ export default function ResetPasswordPage() { >
-
- {logo && ( - Organization Logo - )} -
+
{isExpired ? ( diff --git a/app/signup/page.tsx b/app/signup/page.tsx index bb10f3dc..1bbe3f7d 100644 --- a/app/signup/page.tsx +++ b/app/signup/page.tsx @@ -6,6 +6,7 @@ import BogTextInput from "@/components/bog/BogTextInput/BogTextInput"; import BogButton from "@/components/bog/BogButton/BogButton"; import authClient from "@/lib/authClient"; import useOrganizationConfig from "@/lib/hooks/useOrganizationConfig"; +import UnauthenticatedOrganizationLogo from "@/components/UnauthenticatedOrganizationLogo"; import { OrganizationConfigKey } from "@/lib/schema"; import { getSlugFromHost } from "@/lib/clientAuthUtils"; import { useActiveOrganization } from "@/lib/hooks/useActiveOrganization"; @@ -22,14 +23,15 @@ export default function SignupPage() { primary_color = "#FFFFFF", secondary_color = "#FFFFFF", tagline: configuredTagline, + logo_url: logoUrl, } = useOrganizationConfig([ OrganizationConfigKey.PrimaryColor, OrganizationConfigKey.SecondaryColor, OrganizationConfigKey.Tagline, + OrganizationConfigKey.LogoUrl, ]); const tagline = configuredTagline?.trim() || "Welcome"; const org = useActiveOrganization(); - const logo = org?.organization.data?.logo; const handleSignup = async () => { setLoading(true); @@ -101,26 +103,23 @@ export default function SignupPage() { >
-
- {logo && ( - Organization Logo - )} -
+

Sign Up

-

{tagline}

+

+ {tagline} +

+ ); +} diff --git a/components/UnauthenticatedOrganizationLogo.tsx b/components/UnauthenticatedOrganizationLogo.tsx new file mode 100644 index 00000000..32dc5c8c --- /dev/null +++ b/components/UnauthenticatedOrganizationLogo.tsx @@ -0,0 +1,39 @@ +import OrganizationLogo from "@/components/OrganizationLogo"; +import { resolveOrganizationLogo } from "@/lib/organizationBranding"; +import { cn } from "@/lib/utils"; + +type UnauthenticatedOrganizationLogoProps = { + logoUrl?: string | null; +}; + +export default function UnauthenticatedOrganizationLogo({ + logoUrl, +}: UnauthenticatedOrganizationLogoProps) { + const { isCustom } = resolveOrganizationLogo(logoUrl); + + return ( +
+ + {!isCustom && ( +
+ bits of good + sunset +
+ )} +
+ ); +} diff --git a/components/navigation/Logo.tsx b/components/navigation/Logo.tsx index 596e3336..3f7148a5 100644 --- a/components/navigation/Logo.tsx +++ b/components/navigation/Logo.tsx @@ -4,6 +4,8 @@ import { useEffect, useState } from "react"; import { OrganizationConfigKey } from "@/lib/schema"; import useOrganizationConfig from "@/lib/hooks/useOrganizationConfig"; import { cn } from "@/lib/utils"; +import OrganizationLogo from "@/components/OrganizationLogo"; +import { resolveOrganizationLogo } from "@/lib/organizationBranding"; type SunsetLogoSize = "sm" | "md"; @@ -27,13 +29,14 @@ export function SunsetLogo({ size = "md", className }: SunsetLogoProps) { const bogHeightClass = "h-[10px]"; const sunsetHeightClass = "h-[15px]"; - const useCustomLogo = mounted && customLogoSrc && customLogoSrc.trim() !== ""; + const useCustomLogo = + mounted && resolveOrganizationLogo(customLogoSrc).isCustom; if (useCustomLogo) { return (
- Organization logo @@ -43,7 +46,7 @@ export function SunsetLogo({ size = "md", className }: SunsetLogoProps) { return (
- Logo +
{ + const url = new URL(route.request().url()); + expect(url.searchParams.get("organizationSlug")).toBe("servicestart"); + const keys = url.searchParams.getAll("keys"); + expect(keys).toContain(OrganizationConfigKey.LogoUrl); + const config: Record = { + [OrganizationConfigKey.LogoUrl]: logoUrl, + [OrganizationConfigKey.Tagline]: tagline, + [OrganizationConfigKey.PrimaryColor]: "#FD8033", + [OrganizationConfigKey.SecondaryColor]: "#FB3552", + }; + await route.fulfill({ + json: Object.fromEntries(keys.map((key) => [key, config[key]])), + }); + }); +} + +async function expectLoadedLogo(page: Page, src: string) { + const logo = page.getByTestId("organization-logo"); + await expect(logo).toHaveAttribute("src", src); + await expect(logo).toBeVisible(); + await expect + .poll(() => + logo.evaluate( + (element: HTMLImageElement) => + element.complete && element.naturalWidth > 0, + ), + ) + .toBe(true); + await expect(logo).toHaveCSS("object-fit", "contain"); + const placement = await logo.evaluate((element) => { + const logoBounds = element.getBoundingClientRect(); + const panelBounds = + element.parentElement!.parentElement!.getBoundingClientRect(); + return { + width: logoBounds.width, + height: logoBounds.height, + leftInset: logoBounds.left - panelBounds.left, + bottomInset: panelBounds.bottom - logoBounds.bottom, + }; + }); + expect(placement.width).toBeCloseTo(72, 0); + expect(placement.height).toBeCloseTo(72, 0); + expect(placement.leftInset).toBeCloseTo(20, 0); + expect(placement.bottomInset).toBeCloseTo(20, 0); + for (const name of ["bits of good", "sunset"]) { + const wordmark = page.getByRole("img", { name, exact: true }); + if (src === "/logo.svg") { + await expect(wordmark).toBeVisible(); + await expect + .poll(() => + wordmark.evaluate( + (element: HTMLImageElement) => + element.complete && element.naturalWidth > 0, + ), + ) + .toBe(true); + } else { + await expect(wordmark).toHaveCount(0); + } + } +} + +test.describe("Unauthenticated organization branding", () => { + for (const path of unauthenticatedPages) { + test(`loads the configured logo while signed out on ${path}`, async ({ + page, + }) => { + expect(await page.context().cookies()).toEqual([]); + await configureBranding(page, " /bog.svg "); + await page.goto(path); + await expectLoadedLogo(page, "/bog.svg"); + }); + + for (const logoUrl of [null, "", " "]) { + test(`loads the fallback for logo ${JSON.stringify(logoUrl)} on ${path}`, async ({ + page, + }) => { + await configureBranding(page, logoUrl); + const configResponse = page.waitForResponse((response) => + new URL(response.url()).pathname.endsWith("/organizationConfig"), + ); + await page.goto(path); + await configResponse; + await expectLoadedLogo(page, "/logo.svg"); + }); + } + } + + for (const path of ["/login", "/signup"]) { + test(`renders a readable configured tagline on ${path}`, async ({ + page, + }) => { + await configureBranding(page, "/bog.svg"); + await page.goto(path); + const tagline = page.getByTestId("organization-tagline"); + await expect(tagline).toHaveText("Our organization welcomes you"); + await expect(tagline).toBeVisible(); + const colors = await tagline.evaluate((element) => ({ + foreground: getComputedStyle(element).color, + background: getComputedStyle(element.parentElement!).backgroundColor, + })); + expect(colors.background).toBe("rgb(255, 255, 255)"); + const channels = colors.foreground.match(/\d+/g)!.slice(0, 3).map(Number); + const linear = channels.map((channel) => { + const value = channel / 255; + return value <= 0.04045 + ? value / 12.92 + : ((value + 0.055) / 1.055) ** 2.4; + }); + const luminance = + 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]; + expect(1.05 / (luminance + 0.05)).toBeGreaterThanOrEqual(4.5); + }); + + test(`uses the default tagline for blank configuration on ${path}`, async ({ + page, + }) => { + await configureBranding(page, null, " "); + const configResponse = page.waitForResponse((response) => + new URL(response.url()).pathname.endsWith("/organizationConfig"), + ); + await page.goto(path); + await configResponse; + await expect(page.getByTestId("organization-tagline")).toHaveText( + "Welcome", + ); + }); + } +}); diff --git a/tests/unit/api/organizationconfig/route.test.ts b/tests/unit/api/organizationconfig/route.test.ts index 12e7429a..ebe1d7a3 100644 --- a/tests/unit/api/organizationconfig/route.test.ts +++ b/tests/unit/api/organizationconfig/route.test.ts @@ -3,7 +3,12 @@ import { describe, it, expect } from "vitest"; import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import db from "@/lib/db"; -import { organizationConfig, OrganizationConfigKey } from "@/lib/schema"; +import { + organizationConfig, + OrganizationConfigKey, + organizations, +} from "@/lib/schema"; +import { OrganizationConfigService } from "@/lib/services/OrganizationConfigService"; import { addMember, buildTestUser, @@ -14,6 +19,81 @@ import { } from "@/tests/unit/testUtils"; describe("GET /api/organizationConfig", () => { + it("publicly returns the admin-configured logo and tagline for the requested tenant", async () => { + const org = await createOrganization("cfg-branding"); + const otherOrg = await createOrganization("cfg-other-branding"); + await OrganizationConfigService.setConfig( + otherOrg.id, + OrganizationConfigKey.LogoUrl, + "/sunset.svg", + ); + const { user, session, headers } = + await signUpAndGetSession(buildTestUser()); + await setActiveOrganization(session.id, org.id); + await addMember(user.id, org.id, "admin"); + + // The BetterAuth record deliberately disagrees with the branding config. + await db + .update(organizations) + .set({ logo: "/wrong-logo.svg" }) + .where(eq(organizations.id, org.id)); + + for (const [key, value] of [ + [OrganizationConfigKey.LogoUrl, "/bog.svg"], + [OrganizationConfigKey.Tagline, "Configured organization tagline"], + ] as const) { + const saved = await testApi.organizationConfig.$put( + { json: { key, value } }, + { headers }, + ); + expect(saved.status).toBe(200); + } + + // No auth headers: this is how the signed-out pages retrieve branding. + const response = await testApi.organizationConfig.$get({ + query: { + keys: [OrganizationConfigKey.LogoUrl, OrganizationConfigKey.Tagline], + organizationSlug: org.slug, + }, + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + logo_url: "/bog.svg", + tagline: "Configured organization tagline", + }); + + const hostResponse = await testApi.organizationConfig.$get( + { query: { keys: [OrganizationConfigKey.LogoUrl] } }, + { headers: { host: `${org.slug}.servicestart.com` } }, + ); + expect(hostResponse.status).toBe(200); + expect(await hostResponse.json()).toEqual({ logo_url: "/bog.svg" }); + + const otherResponse = await testApi.organizationConfig.$get({ + query: { + keys: [OrganizationConfigKey.LogoUrl, OrganizationConfigKey.Tagline], + organizationSlug: otherOrg.slug, + }, + }); + expect(otherResponse.status).toBe(200); + expect(await otherResponse.json()).toEqual({ + logo_url: "/sunset.svg", + tagline: "", + }); + }); + + it("returns null publicly when no logo is configured", async () => { + const org = await createOrganization("cfg-no-logo"); + const response = await testApi.organizationConfig.$get({ + query: { + keys: [OrganizationConfigKey.LogoUrl], + organizationSlug: org.slug, + }, + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ logo_url: null }); + }); + it("returns 200 and empty object when keys is []", async () => { const response = await testApi.organizationConfig.$get({ query: { diff --git a/tests/unit/components/NavigationLogo.test.tsx b/tests/unit/components/NavigationLogo.test.tsx new file mode 100644 index 00000000..be247102 --- /dev/null +++ b/tests/unit/components/NavigationLogo.test.tsx @@ -0,0 +1,36 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SunsetLogo } from "@/components/navigation/Logo"; + +const branding = vi.hoisted(() => ({ logo_url: null as string | null })); +vi.mock("@/lib/hooks/useOrganizationConfig", () => ({ + default: () => branding, +})); + +afterEach(cleanup); + +describe("Navigation logo", () => { + it("renders a configured logo using the shared URL normalization", () => { + branding.logo_url = " /bog.svg "; + render(); + expect( + screen + .getByRole("img", { name: "Organization logo" }) + .getAttribute("src"), + ).toBe("/bog.svg"); + expect(screen.queryByRole("img", { name: "bits of good" })).toBeNull(); + }); + + it.each([null, " "])( + "keeps the existing default logo lockup for %s", + (logoUrl) => { + branding.logo_url = logoUrl; + render(); + expect( + screen.getByRole("img", { name: "Logo" }).getAttribute("src"), + ).toBe("/logo.svg"); + expect(screen.getByRole("img", { name: "bits of good" })).toBeTruthy(); + expect(screen.getByRole("img", { name: "sunset" })).toBeTruthy(); + }, + ); +}); diff --git a/tests/unit/components/OrganizationLogo.test.tsx b/tests/unit/components/OrganizationLogo.test.tsx new file mode 100644 index 00000000..6e448615 --- /dev/null +++ b/tests/unit/components/OrganizationLogo.test.tsx @@ -0,0 +1,35 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import OrganizationLogo from "@/components/OrganizationLogo"; + +afterEach(cleanup); + +describe("OrganizationLogo", () => { + it("renders the configured URL directly and preserves image proportions", () => { + render( + , + ); + + const logo = screen.getByRole("img", { name: "Organization logo" }); + expect(logo.getAttribute("src")).toBe( + "https://example.com/organization-logo.png", + ); + expect(logo.classList.contains("object-contain")).toBe(true); + expect(logo.classList.contains("h-24")).toBe(true); + }); + + it.each([undefined, null, "", " "])( + "renders the fallback for an unconfigured logo (%s)", + (logoUrl) => { + render(); + expect( + screen + .getByRole("img", { name: "Organization logo" }) + .getAttribute("src"), + ).toBe("/logo.svg"); + }, + ); +}); From 06466a110393a96a08fcaecd4f1fd27e9e92ea68 Mon Sep 17 00:00:00 2001 From: tifpham021 Date: Sun, 13 Sep 2026 02:26:40 -0400 Subject: [PATCH 3/3] fixed postgres startup race that was causing CI migration error --- .github/workflows/ci.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd33b673..62edd40a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,19 @@ jobs: - name: Create test database run: pnpm run db:test:create + - name: Wait for test database + run: | + # TCP readiness excludes the socket-only initialization server. + for attempt in {1..30}; do + if docker exec servicestart-test-db \ + pg_isready -h 127.0.0.1 -U test -d postgres; then + exit 0 + fi + sleep 2 + done + docker logs servicestart-test-db + exit 1 + - name: Migrate test database run: pnpm run db:test:migrate