Skip to content

Commit 456c189

Browse files
committed
fix(branding): share org color defaults across auth pages
1 parent 362eb56 commit 456c189

11 files changed

Lines changed: 375 additions & 25 deletions

File tree

‎app/forgotpassword/page.tsx‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,17 @@ import { OrganizationConfigKey } from "@/lib/schema";
99
import { useActiveOrganization } from "@/lib/hooks/useActiveOrganization";
1010
import authClient from "@/lib/authClient";
1111
import LeftArrowIcon from "@/components/LeftArrowIcon";
12+
import { resolveBranding } from "@/lib/branding";
1213

1314
export default function ForgotPasswordPage() {
1415
const router = useRouter();
1516
const [email, setEmail] = useState("");
1617

17-
const { primary_color = "#FFFFFF", secondary_color = "#FFFFFF" } =
18-
useOrganizationConfig([
19-
OrganizationConfigKey.PrimaryColor,
20-
OrganizationConfigKey.SecondaryColor,
21-
]);
18+
const config = useOrganizationConfig([
19+
OrganizationConfigKey.PrimaryColor,
20+
OrganizationConfigKey.SecondaryColor,
21+
]);
22+
const { primary_color, secondary_color } = resolveBranding(config);
2223
const org = useActiveOrganization();
2324
const logo = org?.organization.data?.logo;
2425

‎app/login/page.tsx‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import useOrganizationConfig from "@/lib/hooks/useOrganizationConfig";
77
import BogTextInput from "@/components/bog/BogTextInput/BogTextInput";
88
import BogButton from "@/components/bog/BogButton/BogButton";
99
import { OrganizationConfigKey } from "@/lib/schema";
10-
10+
import { resolveBranding } from "@/lib/branding";
1111
import { useActiveOrganization } from "@/lib/hooks/useActiveOrganization";
1212
import { getSlugFromHost } from "@/lib/clientAuthUtils";
1313

@@ -17,15 +17,13 @@ export default function LoginPage() {
1717
const [password, setPassword] = useState("");
1818
const [loading, setLoading] = useState(false);
1919

20-
const {
21-
primary_color = "#FFFFFF",
22-
secondary_color = "#FFFFFF",
23-
tagline = "Welcome",
24-
} = useOrganizationConfig([
20+
const config = useOrganizationConfig([
2521
OrganizationConfigKey.PrimaryColor,
2622
OrganizationConfigKey.SecondaryColor,
2723
OrganizationConfigKey.Tagline,
2824
]);
25+
const { primary_color, secondary_color } = resolveBranding(config);
26+
const { tagline = "Welcome" } = config;
2927
const org = useActiveOrganization();
3028
const logo = org?.organization.data?.logo;
3129

‎app/resetpassword/page.tsx‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { useActiveOrganization } from "@/lib/hooks/useActiveOrganization";
1010
import authClient from "@/lib/authClient";
1111
import XIcon from "@/components/XIcon";
1212
import LeftArrowIcon from "@/components/LeftArrowIcon";
13+
import { resolveBranding } from "@/lib/branding";
1314

1415
export default function ResetPasswordPage() {
1516
const router = useRouter();
@@ -19,11 +20,11 @@ export default function ResetPasswordPage() {
1920
const [isExpired, setIsExpired] = useState(false);
2021
const [passwordsMatch, setPasswordsMatch] = useState(true);
2122

22-
const { primary_color = "#FFFFFF", secondary_color = "#FFFFFF" } =
23-
useOrganizationConfig([
24-
OrganizationConfigKey.PrimaryColor,
25-
OrganizationConfigKey.SecondaryColor,
26-
]);
23+
const config = useOrganizationConfig([
24+
OrganizationConfigKey.PrimaryColor,
25+
OrganizationConfigKey.SecondaryColor,
26+
]);
27+
const { primary_color, secondary_color } = resolveBranding(config);
2728
const org = useActiveOrganization();
2829
const logo = org?.organization.data?.logo;
2930

‎app/signup/page.tsx‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import useOrganizationConfig from "@/lib/hooks/useOrganizationConfig";
99
import { OrganizationConfigKey } from "@/lib/schema";
1010
import { getSlugFromHost } from "@/lib/clientAuthUtils";
1111
import { useActiveOrganization } from "@/lib/hooks/useActiveOrganization";
12+
import { resolveBranding } from "@/lib/branding";
1213

1314
export default function SignupPage() {
1415
const router = useRouter();
@@ -18,15 +19,13 @@ export default function SignupPage() {
1819
const [password, setPassword] = useState("");
1920
const [loading, setLoading] = useState(false);
2021

21-
const {
22-
primary_color = "#FFFFFF",
23-
secondary_color = "#FFFFFF",
24-
tagline = "Welcome",
25-
} = useOrganizationConfig([
22+
const config = useOrganizationConfig([
2623
OrganizationConfigKey.PrimaryColor,
2724
OrganizationConfigKey.SecondaryColor,
2825
OrganizationConfigKey.Tagline,
2926
]);
27+
const { primary_color, secondary_color } = resolveBranding(config);
28+
const { tagline = "Welcome" } = config;
3029
const org = useActiveOrganization();
3130
const logo = org?.organization.data?.logo;
3231

‎lib/branding.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { OrganizationConfigKey } from "@/lib/schema";
2+
3+
export const DEFAULT_BRANDING = {
4+
[OrganizationConfigKey.PrimaryColor]: "#FD8033",
5+
[OrganizationConfigKey.SecondaryColor]: "#FB3552",
6+
} as const satisfies Record<
7+
OrganizationConfigKey.PrimaryColor | OrganizationConfigKey.SecondaryColor,
8+
string
9+
>;
10+
11+
export type BrandingConfig = {
12+
[OrganizationConfigKey.PrimaryColor]: string;
13+
[OrganizationConfigKey.SecondaryColor]: string;
14+
};
15+
16+
export function resolveBranding(
17+
config: Partial<Record<OrganizationConfigKey, string>>,
18+
): BrandingConfig {
19+
return {
20+
[OrganizationConfigKey.PrimaryColor]:
21+
config[OrganizationConfigKey.PrimaryColor] ??
22+
DEFAULT_BRANDING[OrganizationConfigKey.PrimaryColor],
23+
[OrganizationConfigKey.SecondaryColor]:
24+
config[OrganizationConfigKey.SecondaryColor] ??
25+
DEFAULT_BRANDING[OrganizationConfigKey.SecondaryColor],
26+
};
27+
}

‎lib/services/OrganizationConfigService.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
DEFAULT_ADMIN_LAYOUT,
99
DEFAULT_MEMBER_LAYOUT,
1010
} from "@/lib/dashboard/constants";
11+
import { DEFAULT_BRANDING } from "@/lib/branding";
1112

1213
export const ALLOWED_NAVBAR_VARIANTS = [
1314
"vertical-sidebar",
@@ -107,7 +108,7 @@ async function getPrimaryColor(organizationId: string) {
107108
)
108109
.limit(1);
109110

110-
return row?.value ?? "#FD8033";
111+
return row?.value ?? DEFAULT_BRANDING[OrganizationConfigKey.PrimaryColor];
111112
}
112113

113114
async function setPrimaryColor(organizationId: string, color: string) {
@@ -162,7 +163,7 @@ async function getSecondaryColor(organizationId: string) {
162163
)
163164
.limit(1);
164165

165-
return row?.value ?? "#FB3552";
166+
return row?.value ?? DEFAULT_BRANDING[OrganizationConfigKey.SecondaryColor];
166167
}
167168

168169
async function setSecondaryColor(organizationId: string, color: string) {

‎tests/e2e/login.spec.ts‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,24 @@ import {
44
expectPageDoesNotRedirect,
55
} from "./testUtils";
66
import { buildTestUser, signUpAndGetSession } from "../unit/testUtils";
7+
import { DEFAULT_BRANDING } from "@/lib/branding";
8+
import { OrganizationConfigKey } from "@/lib/schema";
9+
10+
function hexToRgb(hex: string): string {
11+
const normalized = hex.replace("#", "");
12+
const full =
13+
normalized.length === 3
14+
? normalized
15+
.split("")
16+
.map((char) => char + char)
17+
.join("")
18+
: normalized;
19+
const value = Number.parseInt(full, 16);
20+
const r = (value >> 16) & 255;
21+
const g = (value >> 8) & 255;
22+
const b = value & 255;
23+
return `rgb(${r}, ${g}, ${b})`;
24+
}
725

826
test.describe("Login Page", () => {
927
test("login", async ({ page }) => {
@@ -30,15 +48,27 @@ test.describe("Login Page", () => {
3048
await expectPageDoesNotRedirect(page, "/logo.svg");
3149
});
3250

33-
test("login page background uses a linear gradient", async ({ page }) => {
51+
test("login page background uses documented default branding", async ({
52+
page,
53+
}) => {
3454
await page.goto("/login");
3555

3656
const loginPage = page.getByTestId("page");
37-
3857
await expect(loginPage).toBeVisible();
58+
3959
const background = await loginPage.evaluate(
4060
(e) => getComputedStyle(e).backgroundImage,
4161
);
62+
const normalized = background.replace(/\s/g, "");
63+
const primary = hexToRgb(
64+
DEFAULT_BRANDING[OrganizationConfigKey.PrimaryColor],
65+
).replace(/\s/g, "");
66+
const secondary = hexToRgb(
67+
DEFAULT_BRANDING[OrganizationConfigKey.SecondaryColor],
68+
).replace(/\s/g, "");
69+
4270
expect(background).toMatch(/linear-gradient\s*\(/i);
71+
expect(normalized).toContain(primary);
72+
expect(normalized).toContain(secondary);
4373
});
4474
});

‎tests/unit/api/organizationconfig/route.test.ts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
signUpAndGetSession,
1313
testApi,
1414
} from "@/tests/unit/testUtils";
15+
import { DEFAULT_BRANDING } from "@/lib/branding";
1516

1617
describe("GET /api/organizationConfig", () => {
1718
it("returns 200 and empty object when keys is []", async () => {
@@ -387,3 +388,79 @@ describe("PUT /api/organizationConfig", () => {
387388
expect(data[OrganizationConfigKey.DashboardLayout]).toEqual(layout);
388389
});
389390
});
391+
392+
describe("GET /api/organizationConfig branding", () => {
393+
it("returns documented defaults when no branding rows exist", async () => {
394+
await createOrganization("cfg-api-brand-default");
395+
396+
const response = await testApi.organizationConfig.$get({
397+
query: {
398+
keys: ["primary_color", "secondary_color"],
399+
organizationSlug: "cfg-api-brand-default",
400+
},
401+
});
402+
403+
expect(response.status).toBe(200);
404+
const data = await response.json();
405+
expect(data).toEqual({
406+
primary_color: DEFAULT_BRANDING[OrganizationConfigKey.PrimaryColor],
407+
secondary_color: DEFAULT_BRANDING[OrganizationConfigKey.SecondaryColor],
408+
});
409+
});
410+
411+
it("returns a configured primary color with the default secondary", async () => {
412+
const org = await createOrganization("cfg-api-brand-partial");
413+
await db.insert(organizationConfig).values({
414+
id: randomUUID(),
415+
organizationId: org.id,
416+
key: OrganizationConfigKey.PrimaryColor,
417+
value: "#000000",
418+
});
419+
420+
const response = await testApi.organizationConfig.$get({
421+
query: {
422+
keys: ["primary_color", "secondary_color"],
423+
organizationSlug: "cfg-api-brand-partial",
424+
},
425+
});
426+
427+
expect(response.status).toBe(200);
428+
const data = await response.json();
429+
expect(data).toEqual({
430+
primary_color: "#000000",
431+
secondary_color: DEFAULT_BRANDING[OrganizationConfigKey.SecondaryColor],
432+
});
433+
});
434+
435+
it("does not override a fully configured organization", async () => {
436+
const org = await createOrganization("cfg-api-brand-full");
437+
await db.insert(organizationConfig).values([
438+
{
439+
id: randomUUID(),
440+
organizationId: org.id,
441+
key: OrganizationConfigKey.PrimaryColor,
442+
value: "#123456",
443+
},
444+
{
445+
id: randomUUID(),
446+
organizationId: org.id,
447+
key: OrganizationConfigKey.SecondaryColor,
448+
value: "#654321",
449+
},
450+
]);
451+
452+
const response = await testApi.organizationConfig.$get({
453+
query: {
454+
keys: ["primary_color", "secondary_color"],
455+
organizationSlug: "cfg-api-brand-full",
456+
},
457+
});
458+
459+
expect(response.status).toBe(200);
460+
const data = await response.json();
461+
expect(data).toEqual({
462+
primary_color: "#123456",
463+
secondary_color: "#654321",
464+
});
465+
});
466+
});

‎tests/unit/lib/branding.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// @vitest-environment node
2+
import { describe, expect, it } from "vitest";
3+
import { OrganizationConfigKey } from "@/lib/schema";
4+
import { DEFAULT_BRANDING, resolveBranding } from "@/lib/branding";
5+
6+
describe("resolveBranding", () => {
7+
it("returns documented defaults when no branding keys are present", () => {
8+
const branding = resolveBranding({});
9+
10+
expect(branding[OrganizationConfigKey.PrimaryColor]).toBe("#FD8033");
11+
expect(branding[OrganizationConfigKey.SecondaryColor]).toBe("#FB3552");
12+
expect(branding).toEqual({
13+
[OrganizationConfigKey.PrimaryColor]:
14+
DEFAULT_BRANDING[OrganizationConfigKey.PrimaryColor],
15+
[OrganizationConfigKey.SecondaryColor]:
16+
DEFAULT_BRANDING[OrganizationConfigKey.SecondaryColor],
17+
});
18+
});
19+
20+
it("keeps a configured primary color and fills in the default secondary", () => {
21+
const branding = resolveBranding({
22+
[OrganizationConfigKey.PrimaryColor]: "#000000",
23+
});
24+
25+
expect(branding[OrganizationConfigKey.PrimaryColor]).toBe("#000000");
26+
expect(branding[OrganizationConfigKey.SecondaryColor]).toBe(
27+
DEFAULT_BRANDING[OrganizationConfigKey.SecondaryColor],
28+
);
29+
});
30+
31+
it("keeps a configured secondary color and fills in the default primary", () => {
32+
const branding = resolveBranding({
33+
[OrganizationConfigKey.SecondaryColor]: "#111111",
34+
});
35+
36+
expect(branding[OrganizationConfigKey.PrimaryColor]).toBe(
37+
DEFAULT_BRANDING[OrganizationConfigKey.PrimaryColor],
38+
);
39+
expect(branding[OrganizationConfigKey.SecondaryColor]).toBe("#111111");
40+
});
41+
42+
it("does not override a fully configured organization", () => {
43+
const branding = resolveBranding({
44+
[OrganizationConfigKey.PrimaryColor]: "#123456",
45+
[OrganizationConfigKey.SecondaryColor]: "#654321",
46+
});
47+
48+
expect(branding[OrganizationConfigKey.PrimaryColor]).toBe("#123456");
49+
expect(branding[OrganizationConfigKey.SecondaryColor]).toBe("#654321");
50+
});
51+
52+
it("treats an empty string as an explicit value, not a missing one", () => {
53+
const branding = resolveBranding({
54+
[OrganizationConfigKey.PrimaryColor]: "",
55+
});
56+
57+
expect(branding[OrganizationConfigKey.PrimaryColor]).toBe("");
58+
expect(branding[OrganizationConfigKey.SecondaryColor]).toBe(
59+
DEFAULT_BRANDING[OrganizationConfigKey.SecondaryColor],
60+
);
61+
});
62+
});

0 commit comments

Comments
 (0)