Files
webstudio/apps/builder/app/shared/db/user.server.ts
Oleg Isonen 9963c95ef8 fix: Sync OAuth profile fields on login (#6376)
Closes #6375

## Summary

- Refresh username, avatar, and provider fields when an existing user
logs in through OAuth.
- Avoid unnecessary database writes when profile fields are unchanged.
- Preserve existing development-login behavior.
- Add regression coverage for changed GitHub profile data.

## Testing

- `pnpm --filter @webstudio-is/builder exec vitest run --project
core-server app/shared/db/user.server.test.ts`
- `pnpm --filter @webstudio-is/builder typecheck`
2026-09-14 12:29:10 +02:00

210 lines
5.0 KiB
TypeScript

import type { Database } from "@webstudio-is/postgrest/index.server";
import { createId } from "@webstudio-is/sdk";
import {
AuthorizationError,
type AppContext,
} from "@webstudio-is/trpc-interface/index.server";
import type { GitHubProfile } from "remix-auth-github";
import type { GoogleProfile } from "remix-auth-google";
import { z } from "zod";
export type User = Omit<
Database["public"]["Tables"]["User"]["Row"],
"projectsTags"
> & {
projectsTags: Array<ProjectTag>;
};
const formatUser = (user: Database["public"]["Tables"]["User"]["Row"]) => {
return {
...user,
projectsTags: (user.projectsTags || []) as User["projectsTags"],
};
};
export const getUserById = async (context: AppContext, id: User["id"]) => {
const dbUser = await context.postgrest.client
.from("User")
.select()
.eq("id", id)
.single();
if (dbUser.error) {
console.error(dbUser.error);
throw new Error("User not found");
}
return formatUser(dbUser.data);
};
const genericCreateAccount = async (
context: AppContext,
userData: {
email: string;
username: string;
image: string;
provider: string;
},
syncProfile = false
): Promise<User> => {
const dbUser = await context.postgrest.client
.from("User")
.select()
.eq("email", userData.email)
.single();
if (dbUser.error == null) {
let user = dbUser.data;
if (
syncProfile &&
(user.username !== userData.username ||
user.image !== userData.image ||
user.provider !== userData.provider)
) {
const updatedUser = await context.postgrest.client
.from("User")
.update({
username: userData.username,
image: userData.image,
provider: userData.provider,
})
.eq("id", user.id)
.select()
.single();
if (updatedUser.error) {
console.error(updatedUser.error);
throw new Error("Failed to update user profile");
}
user = updatedUser.data;
}
// Ensure the user has a default workspace — it may be missing if
// the original workspace insert failed after user creation.
const existingWorkspace = await context.postgrest.client
.from("Workspace")
.select("id")
.eq("userId", dbUser.data.id)
.eq("isDefault", true)
.maybeSingle();
if (existingWorkspace.data === null) {
const ws = await context.postgrest.client
.from("Workspace")
.insert({
name: "My workspace",
isDefault: true,
userId: dbUser.data.id,
})
.select("id")
.single();
if (ws.error) {
console.error("Failed to lazily create default workspace", ws.error);
}
}
return formatUser(user);
}
// https://github.com/PostgREST/postgrest/blob/bfbd033c6e9f38cfbc8b1cfe19ee009a9379e3dd/docs/references/errors.rst#L234
if (dbUser.error.code !== "PGRST116") {
console.error(dbUser.error);
throw new Error("User not found");
}
const userId = createId();
const newUser = await context.postgrest.client
.from("User")
.insert({
id: userId,
...userData,
})
.select()
.single();
if (newUser.error) {
console.error(newUser.error);
throw new Error("Failed to create user");
}
// Every user gets a default workspace that cannot be deleted.
const workspace = await context.postgrest.client
.from("Workspace")
.insert({
name: "My workspace",
isDefault: true,
userId,
})
.select("id")
.single();
if (workspace.error) {
console.error(workspace.error);
throw new Error("Failed to create default workspace");
}
return formatUser(newUser.data);
};
export const createOrLoginWithOAuth = async (
context: AppContext,
profile: GoogleProfile | GitHubProfile
): Promise<User> => {
const userData = {
email: (profile.emails ?? [])[0]?.value,
username: profile.displayName,
image: (profile.photos ?? [])[0]?.value,
provider: profile.provider,
};
const newUser = await genericCreateAccount(context, userData, true);
return newUser;
};
export const createOrLoginWithDev = async (
context: AppContext,
email: string
): Promise<User> => {
const userData = {
email,
username: "admin",
image: "",
provider: "dev",
};
const newUser = await genericCreateAccount(context, userData);
return newUser;
};
export const userProjectTag = z.object({
id: z.string(),
label: z.string().min(1).max(100),
});
export type ProjectTag = z.infer<typeof userProjectTag>;
export const updateUserProjectsTags = async (
{ tags }: { tags: ProjectTag[] },
context: AppContext
) => {
if (context.authorization.type !== "user") {
throw new AuthorizationError(
"Only logged in users can update project tags"
);
}
const result = await context.postgrest.client
.from("User")
.update({ projectsTags: tags })
.eq("id", context.authorization.userId)
.select()
.single();
if (result.error) {
throw result.error;
}
return result.data.projectsTags as ProjectTag[];
};