Files
James Ritchie 4c5237ca4a feat(webapp): themes refinement, new black & white themes, 2 accessibility toggles (#4547)
## What this does

Rounds out the theme work behind the existing `hasThemeSwitcher` flag.

**Two new themes.** Black and White sit alongside Dark and Light. They
inherit their neighbour's whole token set and only pin their surfaces
flat, so sections are separated by grid lines rather than layered fills.

**`System` is now configurable at both ends.** You choose which theme
the OS light setting lands on (Light or White) and which the dark
setting lands on (Dark or Black).

**Two accessibility toggles.**
- *Stronger colors* — swaps tinted status chips for solid fills, drops
decorative icon accents to monochrome, and darkens chart series that
didn't clear 3:1 on a white plot.
- *Underline links* — underlines body-text links, so an underline always
means the preference is on rather than being a hover style.

**Contrast slider.** Stores a 0–100 position within the active theme's
own range rather than a shared scale, so 35% stays 35% when you switch
themes. Each theme maps it in CSS, which keeps `system` working before
hydration.

**Appearance in the account popover.** A submenu listing the themes with
a check against the current one, plus a link through to the full set on
your profile. Picking one applies immediately rather than waiting for
the write to round-trip.

**Profile page.** Each row now saves on its own — no submit button. Name
and email show their value inline with an edit button; the email row is
read-only when an identity provider owns the address.

**A `/storybook/colors` audit page.** Renders every colour-carrying
pattern in the app once per theme plus once under Stronger colors, and
measures contrast ratios off the live DOM rather than a hard-coded
table, so it can't go stale.

---

## Demo


https://github.com/user-attachments/assets/d56cd4d8-719f-4ec5-a990-e04cdb98def1


---


## Compatibility

The stored preference shape is unchanged (`version: "1"`), and the four
new fields are all optional. The retired `classic` theme falls back to
Dark, whose palette at contrast 0 is what Classic shipped.

One deliberate change worth knowing: the default contrast moves from 50
to 0, so existing users who never touched the slider will see slightly
less contrast than before. That's what makes 0 mean "the base palette".

---

## Testing

Switched between every theme from both the account popover and the
profile page, in the expanded and collapsed rail, checking `data-theme`
follows and survives a reload. Dragged the contrast slider in each theme
and confirmed the percentage label tracks the handle and resnaps if a
save fails. Checked both accessibility toggles across the
`/storybook/colors` page, which is also where the contrast ratios were
read from. Confirmed the Appearance entry stays hidden for a non-admin
while the flag is off.

<!-- conductor-workspace-link -->

---

[Open workspace in
Conductor](https://app.conductor.build/workspace/fee50611-7623-4422-bada-ed1cba317ed1)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 19:27:52 +01:00

434 lines
11 KiB
TypeScript

import type { Prisma, User } from "@trigger.dev/database";
import type { GitHubProfile } from "remix-auth-github";
import type { GoogleProfile } from "remix-auth-google";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import type { DashboardPreferences } from "~/services/dashboardPreferences.server";
import { getDashboardPreferences } from "~/services/dashboardPreferences.server";
export type { User } from "@trigger.dev/database";
import { assertEmailAllowed } from "~/utils/email";
import { emailMatchesPattern } from "~/utils/emailPattern";
import { logger } from "~/services/logger.server";
type FindOrCreateMagicLink = {
authenticationMethod: "MAGIC_LINK";
email: string;
};
type FindOrCreateGithub = {
authenticationMethod: "GITHUB";
email: User["email"];
authenticationProfile: GitHubProfile;
authenticationExtraParams: Record<string, unknown>;
};
type FindOrCreateGoogle = {
authenticationMethod: "GOOGLE";
email: User["email"];
authenticationProfile: GoogleProfile;
authenticationExtraParams: Record<string, unknown>;
};
type FindOrCreateSso = {
authenticationMethod: "SSO";
email: User["email"];
firstName: string | null;
lastName: string | null;
};
type FindOrCreateUser =
| FindOrCreateMagicLink
| FindOrCreateGithub
| FindOrCreateGoogle
| FindOrCreateSso;
type LoggedInUser = {
user: User;
isNewUser: boolean;
};
export async function findOrCreateUser(input: FindOrCreateUser): Promise<LoggedInUser> {
switch (input.authenticationMethod) {
case "GITHUB": {
return findOrCreateGithubUser(input);
}
case "MAGIC_LINK": {
return findOrCreateMagicLinkUser(input);
}
case "GOOGLE": {
return findOrCreateGoogleUser(input);
}
case "SSO": {
return findOrCreateSsoUser(input);
}
}
}
async function findOrCreateMagicLinkUser({ email }: FindOrCreateMagicLink): Promise<LoggedInUser> {
assertEmailAllowed(email);
const existingUser = await prisma.user.findFirst({
where: {
email,
},
});
const makeAdmin = env.ADMIN_EMAILS ? emailMatchesPattern(env.ADMIN_EMAILS, email) : false;
const user = await prisma.user.upsert({
where: {
email,
},
update: {
email,
},
create: {
email,
authenticationMethod: "MAGIC_LINK",
admin: makeAdmin, // only on create, to prevent automatically removing existing admins
},
});
return {
user,
isNewUser: !existingUser,
};
}
async function findOrCreateGithubUser({
email,
authenticationProfile,
authenticationExtraParams,
}: FindOrCreateGithub): Promise<LoggedInUser> {
assertEmailAllowed(email);
const name = authenticationProfile._json.name;
let avatarUrl: string | undefined = undefined;
if (authenticationProfile.photos[0]) {
avatarUrl = authenticationProfile.photos[0].value;
}
const displayName = authenticationProfile.displayName;
const authProfile = authenticationProfile
? (authenticationProfile as unknown as Prisma.JsonObject)
: undefined;
const authExtraParams = authenticationExtraParams
? (authenticationExtraParams as unknown as Prisma.JsonObject)
: undefined;
const authIdentifier = `github:${authenticationProfile.id}`;
const existingUser = await prisma.user.findUnique({
where: {
authIdentifier,
},
});
const existingEmailUser = await prisma.user.findUnique({
where: {
email,
},
});
if (existingEmailUser && !existingUser) {
const user = await prisma.user.update({
where: {
email,
},
data: {
authenticationProfile: authProfile,
authenticationExtraParams: authExtraParams,
avatarUrl,
authIdentifier,
},
});
return {
user,
isNewUser: false,
};
}
if (existingEmailUser && existingUser) {
const user = await prisma.user.update({
where: {
id: existingUser.id,
},
data: {},
});
return {
user,
isNewUser: false,
};
}
const user = await prisma.user.upsert({
where: {
authIdentifier,
},
update: {},
create: {
authenticationProfile: authProfile,
authenticationExtraParams: authExtraParams,
name,
avatarUrl,
displayName,
authIdentifier,
email,
authenticationMethod: "GITHUB",
},
});
return {
user,
isNewUser: !existingUser,
};
}
async function findOrCreateGoogleUser({
email,
authenticationProfile,
authenticationExtraParams,
}: FindOrCreateGoogle): Promise<LoggedInUser> {
assertEmailAllowed(email);
const name = authenticationProfile._json.name;
let avatarUrl: string | undefined = undefined;
if (authenticationProfile.photos[0]) {
avatarUrl = authenticationProfile.photos[0].value;
}
const displayName = authenticationProfile.displayName;
const authProfile = authenticationProfile
? (authenticationProfile as unknown as Prisma.JsonObject)
: undefined;
const authExtraParams = authenticationExtraParams
? (authenticationExtraParams as unknown as Prisma.JsonObject)
: undefined;
const authIdentifier = `google:${authenticationProfile.id}`;
const existingUser = await prisma.user.findUnique({
where: {
authIdentifier,
},
});
const existingEmailUser = await prisma.user.findUnique({
where: {
email,
},
});
if (existingEmailUser && !existingUser) {
// Link existing email account to Google auth, preserving original authenticationMethod
const user = await prisma.user.update({
where: {
email,
},
data: {
authenticationProfile: authProfile,
authenticationExtraParams: authExtraParams,
avatarUrl,
authIdentifier,
},
});
return {
user,
isNewUser: false,
};
}
if (existingEmailUser && existingUser) {
// Check if email user and auth user are the same
if (existingEmailUser.id !== existingUser.id) {
// Different users: email is taken by one user, Google auth belongs to another
logger.warn(
`Google auth conflict: Google ID ${authenticationProfile.id} belongs to user ${existingUser.id} but email ${email} is taken by user ${existingEmailUser.id}`,
{
email,
existingEmailUserId: existingEmailUser.id,
existingAuthUserId: existingUser.id,
authIdentifier,
}
);
return {
user: existingUser,
isNewUser: false,
};
}
// Same user: update all profile fields
const user = await prisma.user.update({
where: {
id: existingUser.id,
},
data: {
email,
displayName,
name,
avatarUrl,
authenticationProfile: authProfile,
authenticationExtraParams: authExtraParams,
},
});
return {
user,
isNewUser: false,
};
}
// When the IDP user (Google) already exists, the "update" path will be taken and the email will be updated
// It's not possible that the email is already taken by a different user because that would have been handled
// by one of the if statements above.
const user = await prisma.user.upsert({
where: {
authIdentifier,
},
update: {
email,
displayName,
name,
avatarUrl,
authenticationProfile: authProfile,
authenticationExtraParams: authExtraParams,
},
create: {
authenticationProfile: authProfile,
authenticationExtraParams: authExtraParams,
name,
avatarUrl,
displayName,
authIdentifier,
email,
authenticationMethod: "GOOGLE",
},
});
return {
user,
isNewUser: !existingUser,
};
}
// Find an existing user by email (lowercased) or create a new one with the
// SSO authentication method. Mirrors the magic-link upsert shape; the
// callback route is responsible for normalising email before calling.
// Plugin writes (linking the IdP identity row) happen via the SSO plugin
// after this returns.
export async function findOrCreateSsoUser({
email,
firstName,
lastName,
}: FindOrCreateSso): Promise<LoggedInUser> {
// Validate the canonical value we actually look up and persist below —
// validating raw `email` would let case/whitespace variants slip past
// (or misapply) the allow-list policy.
const normalised = email.toLowerCase().trim();
assertEmailAllowed(normalised);
const existingUser = await prisma.user.findFirst({ where: { email: normalised } });
const fullName = [firstName, lastName].filter(Boolean).join(" ").trim() || null;
const user = await prisma.user.upsert({
where: { email: normalised },
update: {
// Existing magic-link / OAuth users keep their original
// authenticationMethod; we only refresh name/displayName when the
// user has nothing set yet so we don't clobber a customised display
// name on every SSO login.
...(existingUser?.name ? {} : { name: fullName }),
...(existingUser?.displayName ? {} : { displayName: fullName }),
},
create: {
email: normalised,
name: fullName,
displayName: fullName,
authenticationMethod: "SSO",
},
});
return { user, isNewUser: !existingUser };
}
export type UserWithDashboardPreferences = User & {
dashboardPreferences: DashboardPreferences;
};
export async function getUserById(id: User["id"]) {
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
return null;
}
const dashboardPreferences = getDashboardPreferences(user.dashboardPreferences);
return {
...user,
dashboardPreferences,
};
}
export function updateUser({
id,
name,
email,
marketingEmails,
referralSource,
onboardingData,
}: Pick<User, "id" | "name" | "email"> & {
marketingEmails?: boolean;
referralSource?: string;
onboardingData?: Prisma.InputJsonValue;
}) {
return prisma.user.update({
where: { id },
data: {
name,
email,
marketingEmails,
referralSource,
onboardingData,
confirmedBasicDetails: true,
},
});
}
/**
* One column each. `updateUser` above is the onboarding write and confirms basic
* details as a side effect, which is wrong for a profile edit.
*/
export function updateUserName({ id, name }: Pick<User, "id" | "name">) {
return prisma.user.update({
where: { id },
data: { name },
});
}
export function updateUserEmail({ id, email }: Pick<User, "id" | "email">) {
return prisma.user.update({
where: { id },
data: { email },
});
}
/**
* `updateMany` so the WHERE does the comparing: a redundant request updates zero
* rows rather than churning the row and its updatedAt.
*/
export async function updateUserMarketingEmails({
id,
marketingEmails,
}: Pick<User, "id" | "marketingEmails">) {
const { count } = await prisma.user.updateMany({
where: { id, marketingEmails: { not: marketingEmails } },
data: { marketingEmails },
});
return { changed: count > 0 };
}