From 8a90c584176aeeca841f36ff4cd3d6f66f497f55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Dere=C5=BEi=C4=87?= Date: Mon, 8 Jan 2024 16:32:08 +0100 Subject: [PATCH] multi-user support --- README.md | 8 ++++-- app/lib/auth.ts | 19 ++++++++++++++- app/lib/billActions.ts | 33 +++++++++++++++++-------- app/lib/db-types.ts | 2 ++ app/lib/locationActions.ts | 47 +++++++++++++++++++++++++++++------- app/lib/types/User.ts | 6 ----- app/lib/types/next-auth.d.ts | 8 +++--- app/lib/yearMonthActions.ts | 32 +++++++++++++++++------- app/page.tsx | 20 ++------------- 9 files changed, 117 insertions(+), 58 deletions(-) delete mode 100644 app/lib/types/User.ts diff --git a/README.md b/README.md index b1ebbd2..62d3091 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # ToDo * infinite scroll * https://stackoverflow.com/questions/67624601/how-to-implement-infinite-scroll-in-next-js -* multi-user support * bill amount entry * monthly bill amount summery * build & deploy via docker @@ -16,4 +15,9 @@ Authentication consists of the following parts: Source: * [How to Implement Google Authentication in a Next.js App Using NextAuth](https://www.telerik.com/blogs/how-to-implement-google-authentication-nextjs-app-using-nextauth) -* [Next Js 14 Authentication on Edge Runtime](https://www.youtube.com/watch?v=rEopVx0FKGI) \ No newline at end of file +* [Next Js 14 Authentication on Edge Runtime](https://www.youtube.com/watch?v=rEopVx0FKGI) + +# Multi-User Support +Each location record is marked with a user ID. + +All the actions user `withUser` to fetch user ID, which is then used in all the DB operations. \ No newline at end of file diff --git a/app/lib/auth.ts b/app/lib/auth.ts index 41880db..fa146d9 100644 --- a/app/lib/auth.ts +++ b/app/lib/auth.ts @@ -1,6 +1,7 @@ import NextAuth, { NextAuthConfig } from 'next-auth'; import GoogleProvider from 'next-auth/providers/google'; import { Session } from 'next-auth'; +import { AuthenticatedUser } from './types/next-auth'; const authConfig: NextAuthConfig = { callbacks: { @@ -45,4 +46,20 @@ const authConfig: NextAuthConfig = { }, }; -export const { auth, handlers: { GET, POST } } = NextAuth(authConfig); \ No newline at end of file +export const { auth, handlers: { GET, POST } } = NextAuth(authConfig); + +export const withUser = (fn: (user: AuthenticatedUser, ...args:any) => Promise) => async (...args:any) => { + const session = await auth(); + + if(!session) { + return({ + errors: { + message: "Not authenticated", + }, + message: "Not authenticated", + }); + } + const { user } = session; + + return(fn(user, ...args)); +} \ No newline at end of file diff --git a/app/lib/billActions.ts b/app/lib/billActions.ts index fff0572..74e640a 100644 --- a/app/lib/billActions.ts +++ b/app/lib/billActions.ts @@ -6,6 +6,8 @@ import { redirect } from 'next/navigation'; import clientPromise from './mongodb'; import { BillAttachment, BillingLocation } from './db-types'; import { ObjectId } from 'mongodb'; +import { auth, withUser } from '@/app/lib/auth'; +import { AuthenticatedUser } from './types/next-auth'; export type State = { errors?: { @@ -69,7 +71,9 @@ const serializeAttachment = async (billAttachment: File | null) => { * @param formData form data * @returns */ -export async function updateOrAddBill(locationId: string, billId?:string, prevState:State, formData: FormData) { +export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationId: string, billId?:string, prevState:State, formData: FormData) => { + + const { id: userId } = user; const validatedFields = UpdateBill.safeParse({ billName: formData.get('billName'), @@ -115,7 +119,8 @@ export async function updateOrAddBill(locationId: string, billId?:string, prevSt // find a location with the given locationID const post = await db.collection("lokacije").updateOne( { - _id: locationId // find a location with the given locationID + _id: locationId, // find a location with the given locationID + userId // make sure that the location belongs to the user }, { $set: mongoDbSet @@ -128,7 +133,8 @@ export async function updateOrAddBill(locationId: string, billId?:string, prevSt // find a location with the given locationID const post = await db.collection("lokacije").updateOne( { - _id: locationId // find a location with the given locationID + _id: locationId, // find a location with the given locationID + userId // make sure that the location belongs to the user }, { $push: { @@ -147,19 +153,22 @@ export async function updateOrAddBill(locationId: string, billId?:string, prevSt revalidatePath('/'); // go to the bill list redirect('/'); -} +}) export async function gotoHome() { revalidatePath('/'); redirect('/'); } -export const fetchBillById = async (locationID:string, billID:string) => { +export const fetchBillById = withUser(async (user:AuthenticatedUser, locationID:string, billID:string) => { + + const { id: userId } = user; + const client = await clientPromise; const db = client.db("rezije"); // find a location with the given locationID - const billLocation = await db.collection("lokacije").findOne({ _id: locationID }) + const billLocation = await db.collection("lokacije").findOne({ _id: locationID, userId }) if(!billLocation) { console.log(`Location ${locationID} not found`); @@ -175,16 +184,20 @@ export const fetchBillById = async (locationID:string, billID:string) => { } return(bill); -} +}) + +export const deleteBillById = withUser(async (user:AuthenticatedUser, locationID:string, billID:string) => { + + const { id: userId } = user; -export const deleteBillById = async (locationID:string, billID:string) => { const client = await clientPromise; const db = client.db("rezije"); // find a location with the given locationID const post = await db.collection("lokacije").updateOne( { - _id: locationID // find a location with the given locationID + _id: locationID, // find a location with the given locationID + userId // make sure that the location belongs to the user }, { // remove the bill with the given billID @@ -196,4 +209,4 @@ export const deleteBillById = async (locationID:string, billID:string) => { }); return(post.modifiedCount); -} \ No newline at end of file +}); \ No newline at end of file diff --git a/app/lib/db-types.ts b/app/lib/db-types.ts index e453422..02ee9b8 100644 --- a/app/lib/db-types.ts +++ b/app/lib/db-types.ts @@ -11,6 +11,8 @@ export interface BillAttachment { /** bill object in the form returned by MongoDB */ export interface BillingLocation { _id: string; + userId: string; + userEmail?: string | null; name: string; /** the value is encoded as yyyymm (i.e. 202301) */ yearMonth: number; diff --git a/app/lib/locationActions.ts b/app/lib/locationActions.ts index ff60bb6..5a1b780 100644 --- a/app/lib/locationActions.ts +++ b/app/lib/locationActions.ts @@ -6,6 +6,8 @@ import { redirect } from 'next/navigation'; import clientPromise from './mongodb'; import { BillingLocation } from './db-types'; import { ObjectId } from 'mongodb'; +import { auth, withUser } from '@/app/lib/auth'; +import { AuthenticatedUser } from './types/next-auth'; export type State = { errors?: { @@ -30,7 +32,7 @@ const UpdateLocation = FormSchema.omit({ _id: true }); * @param formData form data * @returns */ -export async function updateOrAddLocation(locationId?: string, yearMonth?: string, prevState:State, formData: FormData) { +export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locationId?: string, yearMonth?: string, prevState:State, formData: FormData) => { const validatedFields = UpdateLocation.safeParse({ locationName: formData.get('locationName'), @@ -54,10 +56,13 @@ export async function updateOrAddLocation(locationId?: string, yearMonth?: strin const client = await clientPromise; const db = client.db("rezije"); + const { id: userId, email: userEmail } = user; + if(locationId) { await db.collection("lokacije").updateOne( { - _id: locationId // find a location with the given locationID + _id: locationId, // find a location with the given locationID + userId // make sure the location belongs to the user }, { $set: { @@ -68,6 +73,8 @@ export async function updateOrAddLocation(locationId?: string, yearMonth?: strin } else if(yearMonth) { await db.collection("lokacije").insertOne({ _id: (new ObjectId()).toHexString(), + userId, + userEmail, name: locationName, notes: locationNotes, yearMonth: parseInt(yearMonth), // ToDo: get the current year and month @@ -79,14 +86,34 @@ export async function updateOrAddLocation(locationId?: string, yearMonth?: strin revalidatePath('/'); // go to the bill list redirect('/'); -} +}); + + +export const fetchAllLocations = withUser(async (user:AuthenticatedUser, locationID:string) => { -export const fetchLocationById = async (locationID:string) => { const client = await clientPromise; const db = client.db("rezije"); + const { id: userId } = user; + + const locations = await db.collection("lokacije") + .find({ userId }) + .sort({ yearMonth: -1, name: 1 }) // sort by yearMonth descending + .limit(20) + .toArray(); + + return(locations); +}) + +export const fetchLocationById = withUser(async (user:AuthenticatedUser, locationID:string) => { + + const client = await clientPromise; + const db = client.db("rezije"); + + const { id: userId } = user; + // find a location with the given locationID - const billLocation = await db.collection("lokacije").findOne({ _id: locationID }); + const billLocation = await db.collection("lokacije").findOne({ _id: locationID, userId}); if(!billLocation) { console.log(`Location ${locationID} not found`); @@ -94,14 +121,16 @@ export const fetchLocationById = async (locationID:string) => { } return(billLocation); -} +}) + +export const deleteLocationById = withUser(async (user:AuthenticatedUser, locationID:string) => { -export const deleteLocationById = async (locationID:string) => { const client = await clientPromise; const db = client.db("rezije"); + const { id: userId } = user; // find a location with the given locationID - const post = await db.collection("lokacije").deleteOne({ _id: locationID }); + const post = await db.collection("lokacije").deleteOne({ _id: locationID, userId }); return(post.deletedCount); -} \ No newline at end of file +}) \ No newline at end of file diff --git a/app/lib/types/User.ts b/app/lib/types/User.ts deleted file mode 100644 index 0fbd6c8..0000000 --- a/app/lib/types/User.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type User = { - id: string; - name: string; - email: string; - password: string; - }; \ No newline at end of file diff --git a/app/lib/types/next-auth.d.ts b/app/lib/types/next-auth.d.ts index 8300efc..7bd4b96 100644 --- a/app/lib/types/next-auth.d.ts +++ b/app/lib/types/next-auth.d.ts @@ -1,9 +1,11 @@ import NextAuth, { DefaultSession } from 'next-auth'; +export type AuthenticatedUser = { + id: string; +} & DefaultSession['user']; + declare module 'next-auth' { interface Session { - user: { - id: string; - } & DefaultSession['user']; + user: AuthenticatedUser } } \ No newline at end of file diff --git a/app/lib/yearMonthActions.ts b/app/lib/yearMonthActions.ts index 335012d..6bb6b1a 100644 --- a/app/lib/yearMonthActions.ts +++ b/app/lib/yearMonthActions.ts @@ -5,6 +5,8 @@ import { redirect } from 'next/navigation'; import clientPromise from './mongodb'; import { ObjectId } from 'mongodb'; import { BillingLocation } from './db-types'; +import { AuthenticatedUser } from './types/next-auth'; +import { withUser } from './auth'; /** * Server-side action which adds a new month to the database @@ -14,7 +16,8 @@ import { BillingLocation } from './db-types'; * @param formData form data * @returns */ -export async function addYearMonth(yearMonthString: string) { +export const addYearMonth = withUser(async (user:AuthenticatedUser, yearMonthString: string) => { + const { id: userId } = user; // update the bill in the mongodb const client = await clientPromise; @@ -24,7 +27,10 @@ export async function addYearMonth(yearMonthString: string) { const prevYearMonth = (yearMonth - 1) % 100 === 0 ? yearMonth - 89 : yearMonth - 1; // find all locations for the previous month - const prevMonthLocations = await db.collection("lokacije").find({ yearMonth: prevYearMonth }); + const prevMonthLocations = await db.collection("lokacije").find({ + userId, // make sure that the locations belongs to the user + yearMonth: prevYearMonth + }); const newMonthLocationsCursor = prevMonthLocations.map((prevLocation) => { return({ @@ -52,18 +58,23 @@ export async function addYearMonth(yearMonthString: string) { revalidatePath('/'); // go to the bill list redirect('/'); -} +}); export async function gotoHome() { redirect('/'); } -export const fetchBillById = async (locationID:string, billID:string) => { +export const fetchBillById = withUser(async (user:AuthenticatedUser, locationID:string, billID:string) => { + const { id: userId } = user; + const client = await clientPromise; const db = client.db("rezije"); // find a location with the given locationID - const billLocation = await db.collection("lokacije").findOne({ _id: locationID }) + const billLocation = await db.collection("lokacije").findOne({ + _id: locationID, + userId // make sure that the location belongs to the user + }) if(!billLocation) { console.log(`Location ${locationID} not found`); @@ -79,16 +90,19 @@ export const fetchBillById = async (locationID:string, billID:string) => { } return(bill); -} +}) + +export const deleteBillById = withUser(async (user:AuthenticatedUser, locationID:string, billID:string) => { + const { id: userId } = user; -export const deleteBillById = async (locationID:string, billID:string) => { const client = await clientPromise; const db = client.db("rezije"); // find a location with the given locationID const post = await db.collection("lokacije").updateOne( { - _id: locationID // find a location with the given locationID + _id: locationID, // find a location with the given locationID + userId // make sure that the location belongs to the user }, { // remove the bill with the given billID @@ -100,4 +114,4 @@ export const deleteBillById = async (locationID:string, billID:string) => { }); return(post.modifiedCount); -} \ No newline at end of file +}); \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index 0e33f79..1192427 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -6,7 +6,7 @@ import clientPromise from './lib/mongodb'; import { BillingLocation } from './lib/db-types'; import { PageFooter } from './ui/PageFooter'; import { auth } from '@/app/lib/auth'; -import { redirect } from 'next/navigation'; +import { fetchAllLocations } from './lib/locationActions'; const getNextYearMonth = (yearMonth:number) => { return(yearMonth % 100 === 12 ? yearMonth + 89 : yearMonth + 1); @@ -14,16 +14,7 @@ const getNextYearMonth = (yearMonth:number) => { export const Page = async () => { - const session = await auth(); - - const client = await clientPromise; - const db = client.db("rezije"); - - const locations = await db.collection("lokacije") - .find({}) - .sort({ yearMonth: -1, name: 1 }) // sort by yearMonth descending - .limit(20) - .toArray(); + const locations = await fetchAllLocations(); // if the database is in it's initial state, show the add location button for the current month if(locations.length === 0) { @@ -61,13 +52,6 @@ export const Page = async () => { }) } -
    -
  • session.expires = { session?.expires }
  • -
  • session.user.id = { session?.user?.id }
  • -
  • session.user.email = { session?.user?.email }
  • -
  • session.user.name = { session?.user?.name }
  • -
  • session.user.image = { session?.user?.image }
  • -
); }