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
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 && (
-

- )}
-
+
diff --git a/app/login/page.tsx b/app/login/page.tsx
index fd1c7532..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";
@@ -20,14 +21,16 @@ export default function LoginPage() {
const {
primary_color = "#FFFFFF",
secondary_color = "#FFFFFF",
- tagline = "Welcome",
+ 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);
@@ -78,26 +81,21 @@ export default function LoginPage() {
>
-
- {logo && (
-

- )}
-
+
Login
-
+
{tagline}
{
const token = new URLSearchParams(window.location.search).get("token");
@@ -88,20 +90,12 @@ export default function ResetPasswordPage() {
>
-
- {logo && (
-

- )}
-
+
{isExpired ? (
diff --git a/app/signup/page.tsx b/app/signup/page.tsx
index 52d54897..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";
@@ -21,14 +22,16 @@ export default function SignupPage() {
const {
primary_color = "#FFFFFF",
secondary_color = "#FFFFFF",
- tagline = "Welcome",
+ 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);
@@ -100,26 +103,23 @@ export default function SignupPage() {
>
-
- {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 && (
+
+

+

+
+ )}
+
+ );
+}
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 (
-

@@ -43,7 +46,7 @@ export function SunsetLogo({ size = "md", className }: SunsetLogoProps) {
return (
-

+

{
+ 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");
+ },
+ );
+});