mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-29 08:07:48 +09:00
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`
210 lines
5.0 KiB
TypeScript
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[];
|
|
};
|