Unify User and ServerUser

Fix #65
This commit is contained in:
Stan Wohlwend 2024-06-12 14:08:03 +02:00
parent 7901d87a12
commit 72eda48c55
21 changed files with 714 additions and 481 deletions

View File

@ -5,7 +5,7 @@ import { useEffect, useState } from "react";
export default function Page() {
const user = useUser({ or: 'redirect' });
const connection = user.useConnection('spotify', { or: 'redirect' });
const connection = user.useConnectedAccount('spotify', { or: 'redirect' });
const token = connection.useAccessToken();
const [playList, setPlayList] = useState<any>();
@ -25,7 +25,7 @@ export default function Page() {
return (
<>
<Button onClick={async () => console.log(await (await user.getConnection('spotify', { or: 'redirect', scopes: ['playlist-read-private'] })).getAccessToken())}>
<Button onClick={async () => console.log(await (await user.getConnectedAccount('spotify', { or: 'redirect', scopes: ['playlist-read-private'] })).getAccessToken())}>
Get Spotify Playlist
</Button>
<div>
@ -34,4 +34,4 @@ export default function Page() {
</div>
</>
);
}
}

View File

@ -3,7 +3,7 @@
import { stackServerApp } from "src/stack";
export const createTeam = async (data) => {
const user = await stackServerApp.getServerUser();
const user = await stackServerApp.getUser();
if (!user) {
throw new Error('Unauthorized');
}

View File

@ -80,11 +80,9 @@ if (error) {
}
```
## `CurrentServerUser` Object
## `CurrentServerUser`
You can call `stackServerApp.getServerUser()` to get the `CurrentServerUser` object. `CurrentServerUser` has all the attributes and methods of `CurrentUser` and some additional ones shown below.
Note the `CurrentServerUser` should only be used on the server side because it contains server only metadata.
If you call `getUser()` on a `StackServerApp`, you will get the `CurrentServerUser` object. It contains all the fields and methods of `CurrentUser`, but also has some additional data.
### Attributes

View File

@ -9,7 +9,7 @@ import { UserTable } from "@/components/data-table/user-table";
export default function PageClient() {
const stackAdminApp = useAdminApp();
const allUsers = stackAdminApp.useServerUsers();
const allUsers = stackAdminApp.useUsers();
return (
<PageLayout title="Users">

View File

@ -0,0 +1,31 @@
import { smartRouteHandler } from "@/route-handlers/smart-route-handler";
import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
import { deindent, typedCapitalize } from "@stackframe/stack-shared/dist/utils/strings";
import * as yup from "yup";
export const POST = smartRouteHandler({
request: yup.object({
auth: yup.object({
type: yup.mixed(),
user: yup.mixed(),
project: yup.mixed(),
}).nullable(),
method: yup.string().oneOf(["POST"]).required(),
body: yup.mixed(),
}),
response: yup.object({
statusCode: yup.number().oneOf([200]).required(),
bodyType: yup.string().oneOf(["text"]).required(),
body: yup.string().required(),
}),
handler: async (req) => {
captureError("check-feature-support", new StackAssertionError(`${req.auth?.user?.primaryEmail || "User"} tried to check support of unsupported feature: ${JSON.stringify(req.body, null, 2)}`, { req }));
return {
statusCode: 200,
bodyType: "text",
body: deindent`
${req.body?.featureName ?? "This feature"} is not yet supported. Please reach out to Stack support for more information.
`,
};
},
});

View File

@ -1,3 +1,4 @@
import { serverUserInclude } from "@/lib/users";
import { createPrismaCrudHandlers } from "@/route-handlers/prisma-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { usersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
@ -22,9 +23,7 @@ export const usersCrudHandlers = createPrismaCrudHandlers(usersCrud, "projectUse
},
};
},
include: async () => ({
projectUserOAuthAccounts: true,
}),
include: async () => serverUserInclude,
createNotFoundError: () => new KnownErrors.UserNotFound(),
crudToPrisma: async (crud, { auth }) => {
const projectId = auth.project.id;

View File

@ -166,7 +166,7 @@ export function TeamMemberTable(props: { members: ServerTeamMember[], team: Serv
useEffect(() => {
async function load() {
const promises = props.members.map(async member => {
const user = await member.getUser();
const user = member.user;
const permissions = await user.listPermissions(props.team, { direct: true });
return {
user,

View File

@ -207,8 +207,8 @@ export function PermissionListField<F extends FieldValues>(props: {
/>
</FormControl>
<FieldLabel>
{permission.id}
{inheritedFrom && <span className="text-gray-500"> {inheritedFrom}</span>}
{permission.id}
{inheritedFrom && <span className="text-gray-500 ml-1">{inheritedFrom}</span>}
</FieldLabel>
<FormMessage />
</label>

View File

@ -93,6 +93,10 @@ export type ProjectDB = Prisma.ProjectGetPayload<{ include: FullProjectInclude }
};
export async function whyNotProjectAdmin(projectId: string, adminAccessToken: string): Promise<"unparsable-access-token" | "access-token-expired" | "wrong-project-id" | "not-admin" | null> {
if (!adminAccessToken) {
return "unparsable-access-token";
}
let decoded;
try {
decoded = await decodeAccessToken(adminAccessToken);
@ -100,7 +104,7 @@ export async function whyNotProjectAdmin(projectId: string, adminAccessToken: st
if (error instanceof KnownErrors.AccessTokenExpired) {
return "access-token-expired";
}
console.warn("Failed to decode a user-provided access token. This may not be an error (for example, it could happen if the client changed Stack app hosts), but could indicate one.", error);
console.warn("Failed to decode a user-provided admin access token. This may not be an error (for example, it could happen if the client changed Stack app hosts), but could indicate one.", error);
return "unparsable-access-token";
}
const { userId, projectId: accessTokenProjectId } = decoded;

View File

@ -3,14 +3,19 @@ import { TeamJson } from "@stackframe/stack-shared/dist/interface/clientInterfac
import { ServerTeamCustomizableJson, ServerTeamJson, ServerTeamMemberJson } from "@stackframe/stack-shared/dist/interface/serverInterface";
import { filterUndefined } from "@stackframe/stack-shared/dist/utils/objects";
import { Prisma } from "@prisma/client";
import { getServerUserFromDbType } from "./users";
import { serverUserInclude } from "./users";
export const fullTeamMemberInclude = {
// TODO technically we can split this; listUserTeams only needs `team`, and listServerTeams only needs `projectUser`; listTeams needs neither
// note: this is a function to prevent circular dependencies between the teams and users file
export const createFullTeamMemberInclude = () => ({
team: true,
} as const satisfies Prisma.TeamMemberInclude;
projectUser: {
include: serverUserInclude,
},
} as const satisfies Prisma.TeamMemberInclude);
export type ServerTeamMemberDB = Prisma.TeamMemberGetPayload<{ include: {
projectUser: true,
}, }>;
export type ServerTeamMemberDB = Prisma.TeamMemberGetPayload<{ include: ReturnType<typeof createFullTeamMemberInclude> }>;
export async function listUserTeams(projectId: string, userId: string): Promise<TeamJson[]> {
const members = await prismaClient.teamMember.findMany({
@ -18,7 +23,7 @@ export async function listUserTeams(projectId: string, userId: string): Promise<
projectId,
projectUserId: userId,
},
include: fullTeamMemberInclude,
include: createFullTeamMemberInclude(),
});
return members.map((member) => ({
@ -56,9 +61,7 @@ export async function listServerTeamMembers(projectId: string, teamId: string):
projectId,
teamId,
},
include: {
projectUser: true
},
include: createFullTeamMemberInclude(),
});
return members.map((member) => getServerTeamMemberFromDbType(member));
@ -133,9 +136,26 @@ export async function removeUserFromTeam(projectId: string, teamId: string, user
});
}
export function getClientTeamFromServerTeam(team: ServerTeamJson): TeamJson {
return {
id: team.id,
displayName: team.displayName,
createdAtMillis: team.createdAtMillis,
};
}
export function getServerTeamFromDbType(team: Prisma.TeamGetPayload<{}>): ServerTeamJson {
return {
id: team.teamId,
displayName: team.displayName,
createdAtMillis: team.createdAt.getTime(),
};
}
export function getServerTeamMemberFromDbType(member: ServerTeamMemberDB): ServerTeamMemberJson {
return {
userId: member.projectUserId,
user: getServerUserFromDbType(member.projectUser),
teamId: member.teamId,
displayName: member.projectUser.displayName,
};

View File

@ -1,15 +1,18 @@
import { UserJson, ServerUserJson, KnownError, KnownErrors } from "@stackframe/stack-shared";
import { UserJson, ServerUserJson, KnownErrors } from "@stackframe/stack-shared";
import { Prisma } from "@prisma/client";
import { prismaClient } from "@/prisma-client";
import { getProject } from "@/lib/projects";
import { filterUndefined } from "@stackframe/stack-shared/dist/utils/objects";
import { UserUpdateJson } from "@stackframe/stack-shared/dist/interface/clientInterface";
import { ServerUserUpdateJson } from "@stackframe/stack-shared/dist/interface/serverInterface";
import { addUserToTeam, createServerTeam } from "./teams";
import { addUserToTeam, createServerTeam, getClientTeamFromServerTeam, getServerTeamFromDbType } from "./teams";
export type ServerUserDB = Prisma.ProjectUserGetPayload<{ include: {
export const serverUserInclude = {
projectUserOAuthAccounts: true,
}, }>;
selectedTeam: true,
} as const satisfies Prisma.ProjectUserInclude;
export type ServerUserDB = Prisma.ProjectUserGetPayload<{ include: typeof serverUserInclude }>;
export async function getClientUser(projectId: string, userId: string): Promise<UserJson | null> {
return await updateClientUser(projectId, userId, {});
@ -24,9 +27,7 @@ export async function listServerUsers(projectId: string): Promise<ServerUserJson
where: {
projectId,
},
include: {
projectUserOAuthAccounts: true,
},
include: serverUserInclude,
});
return users.map((u) => getServerUserFromDbType(u));
@ -68,9 +69,7 @@ export async function updateServerUser(
projectUserId: userId,
},
},
include: {
projectUserOAuthAccounts: true,
},
include: serverUserInclude,
data: filterUndefined({
displayName: update.displayName,
primaryEmail: update.primaryEmail,
@ -125,6 +124,7 @@ function getClientUserFromServerUser(serverUser: ServerUserJson): UserJson {
hasPassword: serverUser.hasPassword,
oauthProviders: serverUser.oauthProviders,
selectedTeamId: serverUser.selectedTeamId,
selectedTeam: serverUser.selectedTeam && getClientTeamFromServerTeam(serverUser.selectedTeam),
};
}
@ -146,6 +146,7 @@ export function getServerUserFromDbType(
authWithEmail: user.authWithEmail,
oauthProviders: user.projectUserOAuthAccounts.map((a) => a.oauthProviderConfigId),
selectedTeamId: user.selectedTeamId,
selectedTeam: user.selectedTeam && getServerTeamFromDbType(user.selectedTeam),
};
}

View File

@ -33,6 +33,7 @@ export type UserJson = UserCustomizableJson & {
authWithEmail: boolean,
oauthProviders: string[],
selectedTeamId: string | null,
selectedTeam: TeamJson | null,
};
export type UserUpdateJson = Partial<UserCustomizableJson>;
@ -435,6 +436,18 @@ export class StackClientInterface {
return Result.ok(res);
}
public async checkFeatureSupport(options: { featureName?: string } & ReadonlyJson): Promise<never> {
const res = await this.sendClientRequest("/check-feature-support", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(options),
}, null);
throw new StackAssertionError(await res.text());
}
async sendForgotPasswordEmail(
email: string,
redirectUrl: string,

View File

@ -29,7 +29,9 @@ export type ServerOrglikeJson = OrglikeJson & {};
export type ServerTeamCustomizableJson = ServerOrglikeCustomizableJson;
export type ServerTeamJson = ServerOrglikeJson;
export type ServerTeamMemberJson = TeamMemberJson
export type ServerTeamMemberJson = TeamMemberJson & {
user: ServerUserJson,
};
export type ServerPermissionDefinitionCustomizableJson = {
readonly id: string,

View File

@ -46,12 +46,12 @@ export function suspendIfSsr(caller?: string) {
2. The component is rendered in the root (outermost) layout.tsx or template.tsx file. Next.js does not wrap those files in a Suspense boundary, even if there is a loading.tsx file in the same folder. To fix it, wrap your layout inside a route group like this:
- app
- layout.tsx // contains <html> and <body>, alongside providers and other components that don't need ${caller ?? "this code path"}
- loading.tsx // required for suspense
- (main)
- layout.tsx // contains the main layout of your app, like a sidebar or a header, and can use ${caller ?? "this code path"}
- route.tsx // your actual main page
- the rest of your app
- - layout.tsx // contains <html> and <body>, alongside providers and other components that don't need ${caller ?? "this code path"}
- - loading.tsx // required for suspense
- - (main)
- - - layout.tsx // contains the main layout of your app, like a sidebar or a header, and can use ${caller ?? "this code path"}
- - - route.tsx // your actual main page
- - - the rest of your app
For more information on this approach, see Next's documentation on route groups: https://nextjs.org/docs/app/building-your-application/routing/route-groups

View File

@ -50,7 +50,7 @@ export default async function StackHandler<HasTokenStore extends boolean>({
}
async function redirectIfHasUser() {
const user = await app.getServerUser();
const user = await app.getUser();
if (user) {
redirect(app.urls.afterSignIn);
}

View File

@ -47,7 +47,7 @@ export default function MagicLinkSignIn() {
<FormWarningText text={errors.email?.message?.toString()} />
<Button disabled={sent} style={{ marginTop: '1.5rem' }} type="submit">
{sent ? 'Email sent' : 'Send magic link'}
{sent ? 'Email sent!' : 'Send magic link'}
</Button>
</form>
);

View File

@ -30,7 +30,7 @@ function TeamIcon(props: { displayName: string }) {
export default function TeamSwitcher(props: TeamSwitcherProps) {
const user = useUser();
const router = useRouter();
const selectedTeam = user?.useSelectedTeam();
const selectedTeam = user?.selectedTeam;
const rawTeams = user?.useTeams();
const teams = useMemo(() => rawTeams?.sort((a, b) => b.id === selectedTeam?.id ? 1 : -1), [rawTeams, selectedTeam]);

View File

@ -2,16 +2,15 @@ import { CurrentUser, GetUserOptions as AppGetUserOptions, StackClientApp, Curre
import { StackContext } from "../providers/stack-provider-client";
import { useContext } from "react";
type GetUserOptions = AppGetUserOptions<true> & {
projectIdMustMatch?: string,
};
/**
* Returns the current user object. Equivalent to `useStackApp().useUser()`.
*
* @returns the current user
*/
type GetUserOptions = AppGetUserOptions & {
projectIdMustMatch?: string,
}
export function useUser(options: GetUserOptions & { or: 'redirect' | 'throw', projectIdMustMatch: "internal" }): CurrentInternalUser;
export function useUser(options: GetUserOptions & { or: 'redirect' | 'throw' }): CurrentUser;
export function useUser(options: GetUserOptions & { projectIdMustMatch: "internal" }): CurrentInternalUser | null;

File diff suppressed because it is too large Load Diff

View File

@ -21,6 +21,6 @@ export default function StackProvider({
}
function UserFetcher(props: { app: StackClientApp<true> }) {
const userPromise = props.app.getUser().then((user) => user?.toJson() ?? null);
const userPromise = props.app.getUser().then((user) => user?.toClientJson() ?? null);
return <UserSetter userJsonPromise={userPromise} />;
}

File diff suppressed because one or more lines are too long