I am building a multi-tenant NextJS app that uses next-auth for account authentication, tRPC for API's, and postgresql for a data store.
I am trying to find a way to dynamically update/set/mutate a session value based on some client-side interaction
The approach I am taking is similar to the one described in this article:
a User is granted access to an Organization through a Membership
a User may have a Membership to >1 Organization
a User can change which Organization they are "logged in" to through some client-side UI.
When the user authenticates, I want to:
set session.user.orgId to some orgId (if they belong to an org)
When the user changes the org they are accessing through some client-side UI, I want to:
update session.user.orgId = newOrgId (validating they have proper permissions before doing so, of course).
I have searched the net for ways to update/mutate session values, and as far as I can tell, it's only possible using next-auth's callbacks:
...
callbacks: {
async session({ session, user, token }) {
// we can modify session here, i.e `session.orgId = 'blah'`
// or look up a value in the db and attach it here.
return session
},
...
}
However, there is no clear way to trigger this update from the client, outside of the authentication flow. I.E, if the user clicks to change their org in some UI, how do I validate the change + update the session value, without requiring the user to re-authenticate?
Hack into NextAuth's PrismaAdapter and trpc's createContext.
For example:
File: src/pages/api/auth/[...nextauth].ts
import NextAuth, { Awaitable, type NextAuthOptions } from "next-auth";
import { PrismaAdapter } from "#next-auth/prisma-adapter";
import type { AdapterSession, AdapterUser } from "next-auth/adapters";
import { prisma } from "../../../server/db/client";
import { MembershipRole } from "#prisma/client";
...
const adapter = PrismaAdapter(prisma);
adapter.createSession = (session: {
sessionToken: string;
userId: string;
expires: Date;
}): Awaitable<AdapterSession> => {
return prisma.user
.findUniqueOrThrow({
where: {
id: session.userId,
},
select: {
memberships: {
where: {
isActiveOrg: true,
},
select: {
role: true,
organization: true,
},
},
},
})
.then((userWithOrg) => {
const membership = userWithOrg.memberships[0];
const orgId = membership?.organization.id;
return prisma.session.create({
data: {
expires: session.expires,
sessionToken: session.sessionToken,
user: {
connect: { id: session.userId },
},
organization: {
connect: {
id: orgId,
},
},
role: membership?.role as MembershipRole,
},
});
});
};
// the authOptions to user with NextAuth
export const authOptions: NextAuthOptions = {
// Include user.id on session
callbacks: {
session({ session, user }) {
if (session.user) {
session.user.id = user.id;
}
return session;
},
},
adapter: adapter,
// Configure one or more authentication providers
providers: [
...
],
};
export default NextAuth(authOptions);
File: src/server/trpc/context.ts
import type { inferAsyncReturnType } from "#trpc/server";
import type { CreateNextContextOptions } from "#trpc/server/adapters/next";
import type { Session } from "next-auth";
import { getServerAuthSession } from "../common/get-server-auth-session";
import { prisma } from "../db/client";
type CreateContextOptions = {
session: Session | null;
};
/** Use this helper for:
* - testing, so we dont have to mock Next.js' req/res
* - trpc's `createSSGHelpers` where we don't have req/res
**/
export const createContextInner = async (opts: CreateContextOptions) => {
return {
session: opts.session,
prisma,
};
};
/**
* This is the actual context you'll use in your router
* #link https://trpc.io/docs/context
**/
export const createContext = async (opts: CreateNextContextOptions) => {
const { req, res } = opts;
const sessionToken = req.cookies["next-auth.session-token"];
const prismaSession = await prisma.session.findUniqueOrThrow({
where: {
sessionToken: sessionToken,
},
select: {
orgId: true,
},
});
const orgId = prismaSession.orgId;
// Get the session from the server using the unstable_getServerSession wrapper function
const session = (await getServerAuthSession({ req, res })) as Session;
const sessionWithOrg = {
session: {
user: {
// need this otherwise createContextInner doesn't accept for a possible null session.user.id
id: session?.user?.id || "",
orgId: orgId,
...session?.user,
},
expires: session?.expires,
},
};
const context = await createContextInner(sessionWithOrg);
return context;
};
export type Context = inferAsyncReturnType<typeof createContext>;
Related
import { DataTypes } from "sequelize";
import dbConnect from "./dbConnect";
async function UserModel() {
const sequelize = await dbConnect();
const User = sequelize.define(
"User",
{
userKey: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
name: {
type: DataTypes.STRING(50),
allowNull: false,
},
username: {
type: DataTypes.STRING(50),
allowNull: false,
},
},
{
freezeTableName: true, // enforces that table name = model name
}
);
return User;
}
export default UserModel;
The above module returns the UserModel correctly, but I need to resolve it twice --
const users = await (await User()).findAll();
-- in the module that imports it b/c I'm calling a method that also returns a promise.
I'd like to call it like this --
const users = await User().findAll();
-- but everything I've tried has failed.
See usage here:
export async function getServerSideProps() {
const users = await (await User()).findAll();
return {
props: {
users,
},
};
}
Can you shed some light on how to do this?
I am using NextJS with NextAuth with google and email providers. Unfortunately, the session returns only few fields that does not include userId of the user from the database.
I created however a function that I intend to use with every getServerSideProps request. The function returns the following:
{
user: {
name: 'daniel sas',
email: 'emailofuser#gmail.com',
image: 'https://lh3.gooleusercontent.com/a/AEdFTp6r44ZwqcfJORNnuYtbVv_LYbab-wv5Uyxk=s96-c',
userId: 'clbcpc0hi0002sb1wsiea3q5d'
},
expires: '2022-12-17T20:18:52.580Z'
}
The problem is I am getting an error that does not allow me to pass the props in the page:
Error: Your `getServerSideProps` function did not return an object. Did you forget to add a `return`?
In the function I get the user by the email, and attach the userId.
import { getSession } from "next-auth/react";
import prisma from './prisma'
// This function get the email and returns a new session object that includes
// the userId
export const requireAuthentication = async context => {
const session = await getSession(context);
const errorOrUserNotFound = () => {
return {
redirect: {
destination: '/signup',
permanent: false
}
}
}
// If there is no user or there is an error ret to signup page
if (!session) {
errorOrUserNotFound();
}
// If the user is not found return same redirect to signup
else {
try {
const user = await prisma.user.findUnique({where: { email: session.user.email }});
if (!user) return errorOrUserNotFound();
// Must return a new session here that contains the userId...
else {
const newSession = {
user: {
...session.user,
userId: user.id
},
expires: session.expires
};
console.log(newSession);
return {
props: {
session: newSession
}
}
}
}
catch (error) {
if (error) {
console.log(error);
}
}
}
}
The react component looks like this. In the getServerSideProps i return the await function. The problem is that when I log the prop in the serverside, I get the following:
{
props: { session: { user: [Object], expires: '2022-12-17T20:18:52.580Z' } }
}
However, if i log the props in the clientside, I get an empty object...
//Clientside compoen
import { getSession } from "next-auth/react"
import { Fragment, useState } from "react";
import { requireAuthentication } from "../../lib/requireAuthentication";
import CreateListModal from "./CreateListModal";
const DashboardPage = props => {
const [loading, setloading] = useState(false);
console.log(props);
return (
<section className="border-4 border-orange-800 max-w-5xl mx-auto">
<CreateListModal userId={props.userId} loading={loading} setloading={setloading} />
</section>
)
}
export const getServerSideProps = async context => {
const session = await getSession(context);
const reqAuth = await requireAuthentication(context);
console.log(reqAuth);
return reqAuth
}
export default DashboardPage;
I am deploying the application on Heroku, but not able to login while looking at the problem the keystone-session is rejected so added the secure and samesite attribute,but the issue remains same as it is not added to the cookie.
const sessionConfig = {
maxAge: 60 * 60 * 24 * 360, // How long they stay signed in?
secret: process.env.COOKIE_SECRET,
secure: true,
sameSite: 'none',
};
Error :
Some cookies are misusing the “SameSite“ attribute, so it won’t work as expected
Cookie “keystonejs-session” has been rejected because it is in a cross-site context and its “SameSite” is “Lax” or “Strict”.
keystone.js file
import { createAuth } from '#keystone-next/auth';
import { config, createSchema } from '#keystone-next/keystone/schema';
import {
withItemData,
statelessSessions,
} from '#keystone-next/keystone/session';
import { permissionsList } from './schemas/fields';
import { Role } from './schemas/Role';
import { OrderItem } from './schemas/OrderItem';
import { Order } from './schemas/Order';
import { CartItem } from './schemas/CartItem';
import { ProductImage } from './schemas/ProductImage';
import { Product } from './schemas/Product';
import { User } from './schemas/User';
import 'dotenv/config';
import { insertSeedData } from './seed-data';
import { sendPasswordResetEmail } from './lib/mail';
import { extendGraphqlSchema } from './mutations';
function check(name: string) {}
const databaseURL =
process.env.DATABASE_URL || 'mongodb://localhost/keystone-sick-fits-tutorial';
const sessionConfig = {
maxAge: 60 * 60 * 24 * 360, // How long they stay signed in?
secret: process.env.COOKIE_SECRET,
};
const { withAuth } = createAuth({
listKey: 'User',
identityField: 'email',
secretField: 'password',
initFirstItem: {
fields: ['name', 'email', 'password'],
// TODO: Add in inital roles here
},
passwordResetLink: {
async sendToken(args) {
// send the email
await sendPasswordResetEmail(args.token, args.identity);
},
},
});
export default withAuth(
config({
// #ts-ignore
server: {
cors: {
origin: [process.env.FRONTEND_URL],
credentials: true,
},
},
db: {
adapter: 'mongoose',
url: databaseURL,
async onConnect(keystone) {
console.log('Connected to the database!');
if (process.argv.includes('--seed-data')) {
await insertSeedData(keystone);
}
},
},
lists: createSchema({
// Schema items go in here
User,
Product,
ProductImage,
CartItem,
OrderItem,
Order,
Role,
}),
extendGraphqlSchema,
ui: {
// Show the UI only for poeple who pass this test
isAccessAllowed: ({ session }) =>
// console.log(session);
!!session?.data,
},
session: withItemData(statelessSessions(sessionConfig), {
// GraphQL Query
User: `id name email role { ${permissionsList.join(' ')} }`,
}),
})
);
Using NextAuth for GraphQL authentication with Apollo client in Next.js encounter the error
Hooks can only be called inside of the body of a function.
import NextAuth from 'next-auth';
import Providers from 'next-auth/providers';
import { useMutation, useApolloClient } from '#apollo/client';
import { LOGIN_MUTATION } from '../../../graphql/mutations';
import { getErrorMessage } from '../../../lib';
export default (req, res) =>
NextAuth(req, res, {
providers: [
Providers.Credentials({
name: 'Credentials',
credentials: {
identifier: { label: "Email", type: "text" },
password: { label: "Password", type: "password" }
},
authorize: async (credentials) => {
const client = useApolloClient();
const [errorMsg, setErrorMsg] = useState();
const [login] = useMutation(LOGIN_MUTATION);
try {
await client.resetStore();
const { data: { login: { user, jwt } } } = await login({
variables: {
identifier: credentials.identifier,
password: credentials.password
}
});
if (user) {
return user;
}
} catch (error) {
setErrorMsg(getErrorMessage(error));
}
}
})
],
site: process.env.NEXTAUTH_URL || "http://localhost:3000",
session: {
jwt: true,
maxAge: 1 * 3 * 60 * 60,
updateAge: 24 * 60 * 60,
},
callbacks: {},
pages: {
signIn: '/auth/signin'
},
debug: process.env.NODE_ENV === "development",
secret: process.env.NEXT_PUBLIC_AUTH_SECRET,
jwt: {
secret: process.env.NEXT_PUBLIC_JWT_SECRET,
}
});
I am wondering is there anyway to make this work with apollo?
Thank you for the helps.
As in the comments rightfully pointed out, you can't use hooks in server-side code. You would have to create a new ApolloClient like this:
const client = new ApolloClient()
Then you can do queries like this for example:
const { data } = await client.query({
query: "Your query",
variables: { someVariable: true }
});
Best would be the to move the creation of the client to a separate external file as a function and import it in your server-side code whenever needed. Like done here for example.
Edit:
As #rob-art correctly remarks in the comments, for a [mutation][2], the code should look more like this:
const { data } = await client.mutate({
mutation: "Your query",
variables: { someVariable: true }
});
I want to test that when i type a value in an input(inputA), anoter input(inputB) gets updated with a value.
inputA accepts a postal code e.g: "10999", after inputB shows a location: "Berlin"
This works on the actual app, i type in inputA, and inputB gets updated.
When ome types on inputA, an action is dispatched and then inputB gets a new value from the redux state.
This is my test code, any ideas why it doesnt updates the input with placeholder of "Ort" on the test, but it does on the actual app?
import { render, withIntl, withStore, configureStore, withState } from "test-utils-react-testing-library";
import { screen, fireEvent, withHistory, withRoute, within } from "#testing-library/react";
import configureMockStore from 'redux-mock-store';
import ProfileForm from "./ProfileForm";
import PersonalDetails from "../PersonalDetails/PersonalDetails";
const STATE = {
locations: { locations: {} },
streets: { streets: {} },
password: {}
};
const mockStore = configureMockStore();
const STORE = mockStore({
streets: {
isFetching: false,
},
locations: {
locations: {
isFetching: false,
},
},
user: {
session: {
impersonated_access_token: "",
},
updateError: "error",
},
});
const props = {
id: "user1",
user: { email: "max#muster.de" },
locations: {},
onSubmit: jest.fn(),
};
beforeEach(jest.resetAllMocks);
describe("ProfileForm", () => {
describe("on personal details change", () => {
it("auto selects only location when postalcode becomes selected", () => {
const locations = { electricity: { [PLZ_1]: [LOCATION_OBJ_1] } };
const user = { postalcode: null };
render(<ProfileForm {...props} user={user} locations={locations} />, [...decorators, withStore(STORE)]);
const input = screen.getByPlaceholderText("PLZ");
fireEvent.change(input, { target: { value: "10999" } })
screen.debug(screen.getByPlaceholderText("PLZ"))
screen.debug(screen.getByPlaceholderText("Ort"))
expect(screen.getByPlaceholderText("Ort")).toHaveValue("Berlin");
});
});
I guess your input hasn't been updated yet.
Try to use waitfor:
https://testing-library.com/docs/dom-testing-library/api-async#waitfor
import { waitFor } from "#testing-library/react";
const inputNode = screen. getByPlaceholderText("Ort");
// keep in mind that you need to make your test async like this
// it("auto selects only location when postalcode becomes selected", async () => {
await waitFor(() => expect(inputNode).toHaveValue("Berlin"));
If it won't work, try to add timeout:
await waitFor(() => expect(inputNode).toHaveValue("Berlin"), { timeout: 4000 });
I've encountered a similar proplem and found that changes in the microtask queue aren't always flushed, so the changes are not applied/rendered until the test is finished running. What worked for me, was to call jest.useFakeTimers() at the beginning of your testcase, and then await act(async () => { jest.runOnlyPendingTimers() }); after the call to fireEvent.<some-event>(...)
In your case:
it("auto selects only location when postalcode becomes selected", async () => {
jest.useFakeTimers();
const locations = { electricity: { [PLZ_1]: [LOCATION_OBJ_1] } };
const user = { postalcode: null };
render(<ProfileForm {...props} user={user} locations={locations} />, [...decorators, withStore(STORE)]);
const input = screen.getByPlaceholderText("PLZ");
fireEvent.change(input, { target: { value: "10999" } })
await act(async () => {
jest.runOnlyPendingTimers();
});
screen.debug(screen.getByPlaceholderText("PLZ"))
screen.debug(screen.getByPlaceholderText("Ort"))
expect(screen.getByPlaceholderText("Ort")).toHaveValue("Berlin");
});
Tried, but get this error: Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function. No idea where that comes from :(
Try to use findBy instead of getBy.
https://testing-library.com/docs/dom-testing-library/api-queries#findby
import { screen, waitFor } from "#testing-library/react";
const inputNode = await screen.findByPlaceholderText("Ort");
// or with timeout: await screen.findByPlaceholderText("Ort", { timeout: 4000 });
await waitFor(() => expect(inputNode).toHaveValue("Berlin"));