multi-user support

This commit is contained in:
2024-01-08 16:32:08 +01:00
parent 9314d78c9c
commit 8a90c58417
9 changed files with 117 additions and 58 deletions

View File

@@ -1,7 +1,6 @@
# ToDo # ToDo
* infinite scroll * infinite scroll
* https://stackoverflow.com/questions/67624601/how-to-implement-infinite-scroll-in-next-js * https://stackoverflow.com/questions/67624601/how-to-implement-infinite-scroll-in-next-js
* multi-user support
* bill amount entry * bill amount entry
* monthly bill amount summery * monthly bill amount summery
* build & deploy via docker * build & deploy via docker
@@ -16,4 +15,9 @@ Authentication consists of the following parts:
Source: 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) * [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) * [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.

View File

@@ -1,6 +1,7 @@
import NextAuth, { NextAuthConfig } from 'next-auth'; import NextAuth, { NextAuthConfig } from 'next-auth';
import GoogleProvider from 'next-auth/providers/google'; import GoogleProvider from 'next-auth/providers/google';
import { Session } from 'next-auth'; import { Session } from 'next-auth';
import { AuthenticatedUser } from './types/next-auth';
const authConfig: NextAuthConfig = { const authConfig: NextAuthConfig = {
callbacks: { callbacks: {
@@ -45,4 +46,20 @@ const authConfig: NextAuthConfig = {
}, },
}; };
export const { auth, handlers: { GET, POST } } = NextAuth(authConfig); export const { auth, handlers: { GET, POST } } = NextAuth(authConfig);
export const withUser = (fn: (user: AuthenticatedUser, ...args:any) => Promise<any>) => async (...args:any) => {
const session = await auth();
if(!session) {
return({
errors: {
message: "Not authenticated",
},
message: "Not authenticated",
});
}
const { user } = session;
return(fn(user, ...args));
}

View File

@@ -6,6 +6,8 @@ import { redirect } from 'next/navigation';
import clientPromise from './mongodb'; import clientPromise from './mongodb';
import { BillAttachment, BillingLocation } from './db-types'; import { BillAttachment, BillingLocation } from './db-types';
import { ObjectId } from 'mongodb'; import { ObjectId } from 'mongodb';
import { auth, withUser } from '@/app/lib/auth';
import { AuthenticatedUser } from './types/next-auth';
export type State = { export type State = {
errors?: { errors?: {
@@ -69,7 +71,9 @@ const serializeAttachment = async (billAttachment: File | null) => {
* @param formData form data * @param formData form data
* @returns * @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({ const validatedFields = UpdateBill.safeParse({
billName: formData.get('billName'), billName: formData.get('billName'),
@@ -115,7 +119,8 @@ export async function updateOrAddBill(locationId: string, billId?:string, prevSt
// find a location with the given locationID // find a location with the given locationID
const post = await db.collection<BillingLocation>("lokacije").updateOne( const post = await db.collection<BillingLocation>("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 $set: mongoDbSet
@@ -128,7 +133,8 @@ export async function updateOrAddBill(locationId: string, billId?:string, prevSt
// find a location with the given locationID // find a location with the given locationID
const post = await db.collection<BillingLocation>("lokacije").updateOne( const post = await db.collection<BillingLocation>("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: { $push: {
@@ -147,19 +153,22 @@ export async function updateOrAddBill(locationId: string, billId?:string, prevSt
revalidatePath('/'); revalidatePath('/');
// go to the bill list // go to the bill list
redirect('/'); redirect('/');
} })
export async function gotoHome() { export async function gotoHome() {
revalidatePath('/'); revalidatePath('/');
redirect('/'); 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 client = await clientPromise;
const db = client.db("rezije"); const db = client.db("rezije");
// find a location with the given locationID // find a location with the given locationID
const billLocation = await db.collection<BillingLocation>("lokacije").findOne({ _id: locationID }) const billLocation = await db.collection<BillingLocation>("lokacije").findOne({ _id: locationID, userId })
if(!billLocation) { if(!billLocation) {
console.log(`Location ${locationID} not found`); console.log(`Location ${locationID} not found`);
@@ -175,16 +184,20 @@ export const fetchBillById = async (locationID:string, billID:string) => {
} }
return(bill); 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 client = await clientPromise;
const db = client.db("rezije"); const db = client.db("rezije");
// find a location with the given locationID // find a location with the given locationID
const post = await db.collection<BillingLocation>("lokacije").updateOne( const post = await db.collection<BillingLocation>("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 // remove the bill with the given billID
@@ -196,4 +209,4 @@ export const deleteBillById = async (locationID:string, billID:string) => {
}); });
return(post.modifiedCount); return(post.modifiedCount);
} });

View File

@@ -11,6 +11,8 @@ export interface BillAttachment {
/** bill object in the form returned by MongoDB */ /** bill object in the form returned by MongoDB */
export interface BillingLocation { export interface BillingLocation {
_id: string; _id: string;
userId: string;
userEmail?: string | null;
name: string; name: string;
/** the value is encoded as yyyymm (i.e. 202301) */ /** the value is encoded as yyyymm (i.e. 202301) */
yearMonth: number; yearMonth: number;

View File

@@ -6,6 +6,8 @@ import { redirect } from 'next/navigation';
import clientPromise from './mongodb'; import clientPromise from './mongodb';
import { BillingLocation } from './db-types'; import { BillingLocation } from './db-types';
import { ObjectId } from 'mongodb'; import { ObjectId } from 'mongodb';
import { auth, withUser } from '@/app/lib/auth';
import { AuthenticatedUser } from './types/next-auth';
export type State = { export type State = {
errors?: { errors?: {
@@ -30,7 +32,7 @@ const UpdateLocation = FormSchema.omit({ _id: true });
* @param formData form data * @param formData form data
* @returns * @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({ const validatedFields = UpdateLocation.safeParse({
locationName: formData.get('locationName'), locationName: formData.get('locationName'),
@@ -54,10 +56,13 @@ export async function updateOrAddLocation(locationId?: string, yearMonth?: strin
const client = await clientPromise; const client = await clientPromise;
const db = client.db("rezije"); const db = client.db("rezije");
const { id: userId, email: userEmail } = user;
if(locationId) { if(locationId) {
await db.collection<BillingLocation>("lokacije").updateOne( await db.collection<BillingLocation>("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: { $set: {
@@ -68,6 +73,8 @@ export async function updateOrAddLocation(locationId?: string, yearMonth?: strin
} else if(yearMonth) { } else if(yearMonth) {
await db.collection<BillingLocation>("lokacije").insertOne({ await db.collection<BillingLocation>("lokacije").insertOne({
_id: (new ObjectId()).toHexString(), _id: (new ObjectId()).toHexString(),
userId,
userEmail,
name: locationName, name: locationName,
notes: locationNotes, notes: locationNotes,
yearMonth: parseInt(yearMonth), // ToDo: get the current year and month yearMonth: parseInt(yearMonth), // ToDo: get the current year and month
@@ -79,14 +86,34 @@ export async function updateOrAddLocation(locationId?: string, yearMonth?: strin
revalidatePath('/'); revalidatePath('/');
// go to the bill list // go to the bill list
redirect('/'); redirect('/');
} });
export const fetchAllLocations = withUser(async (user:AuthenticatedUser, locationID:string) => {
export const fetchLocationById = async (locationID:string) => {
const client = await clientPromise; const client = await clientPromise;
const db = client.db("rezije"); const db = client.db("rezije");
const { id: userId } = user;
const locations = await db.collection<BillingLocation>("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 // find a location with the given locationID
const billLocation = await db.collection<BillingLocation>("lokacije").findOne({ _id: locationID }); const billLocation = await db.collection<BillingLocation>("lokacije").findOne({ _id: locationID, userId});
if(!billLocation) { if(!billLocation) {
console.log(`Location ${locationID} not found`); console.log(`Location ${locationID} not found`);
@@ -94,14 +121,16 @@ export const fetchLocationById = async (locationID:string) => {
} }
return(billLocation); return(billLocation);
} })
export const deleteLocationById = withUser(async (user:AuthenticatedUser, locationID:string) => {
export const deleteLocationById = async (locationID:string) => {
const client = await clientPromise; const client = await clientPromise;
const db = client.db("rezije"); const db = client.db("rezije");
const { id: userId } = user;
// find a location with the given locationID // find a location with the given locationID
const post = await db.collection<BillingLocation>("lokacije").deleteOne({ _id: locationID }); const post = await db.collection<BillingLocation>("lokacije").deleteOne({ _id: locationID, userId });
return(post.deletedCount); return(post.deletedCount);
} })

View File

@@ -1,6 +0,0 @@
export type User = {
id: string;
name: string;
email: string;
password: string;
};

View File

@@ -1,9 +1,11 @@
import NextAuth, { DefaultSession } from 'next-auth'; import NextAuth, { DefaultSession } from 'next-auth';
export type AuthenticatedUser = {
id: string;
} & DefaultSession['user'];
declare module 'next-auth' { declare module 'next-auth' {
interface Session { interface Session {
user: { user: AuthenticatedUser
id: string;
} & DefaultSession['user'];
} }
} }

View File

@@ -5,6 +5,8 @@ import { redirect } from 'next/navigation';
import clientPromise from './mongodb'; import clientPromise from './mongodb';
import { ObjectId } from 'mongodb'; import { ObjectId } from 'mongodb';
import { BillingLocation } from './db-types'; 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 * Server-side action which adds a new month to the database
@@ -14,7 +16,8 @@ import { BillingLocation } from './db-types';
* @param formData form data * @param formData form data
* @returns * @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 // update the bill in the mongodb
const client = await clientPromise; const client = await clientPromise;
@@ -24,7 +27,10 @@ export async function addYearMonth(yearMonthString: string) {
const prevYearMonth = (yearMonth - 1) % 100 === 0 ? yearMonth - 89 : yearMonth - 1; const prevYearMonth = (yearMonth - 1) % 100 === 0 ? yearMonth - 89 : yearMonth - 1;
// find all locations for the previous month // find all locations for the previous month
const prevMonthLocations = await db.collection<BillingLocation>("lokacije").find({ yearMonth: prevYearMonth }); const prevMonthLocations = await db.collection<BillingLocation>("lokacije").find({
userId, // make sure that the locations belongs to the user
yearMonth: prevYearMonth
});
const newMonthLocationsCursor = prevMonthLocations.map((prevLocation) => { const newMonthLocationsCursor = prevMonthLocations.map((prevLocation) => {
return({ return({
@@ -52,18 +58,23 @@ export async function addYearMonth(yearMonthString: string) {
revalidatePath('/'); revalidatePath('/');
// go to the bill list // go to the bill list
redirect('/'); redirect('/');
} });
export async function gotoHome() { export async function gotoHome() {
redirect('/'); 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 client = await clientPromise;
const db = client.db("rezije"); const db = client.db("rezije");
// find a location with the given locationID // find a location with the given locationID
const billLocation = await db.collection<BillingLocation>("lokacije").findOne({ _id: locationID }) const billLocation = await db.collection<BillingLocation>("lokacije").findOne({
_id: locationID,
userId // make sure that the location belongs to the user
})
if(!billLocation) { if(!billLocation) {
console.log(`Location ${locationID} not found`); console.log(`Location ${locationID} not found`);
@@ -79,16 +90,19 @@ export const fetchBillById = async (locationID:string, billID:string) => {
} }
return(bill); 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 client = await clientPromise;
const db = client.db("rezije"); const db = client.db("rezije");
// find a location with the given locationID // find a location with the given locationID
const post = await db.collection<BillingLocation>("lokacije").updateOne( const post = await db.collection<BillingLocation>("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 // remove the bill with the given billID
@@ -100,4 +114,4 @@ export const deleteBillById = async (locationID:string, billID:string) => {
}); });
return(post.modifiedCount); return(post.modifiedCount);
} });

View File

@@ -6,7 +6,7 @@ import clientPromise from './lib/mongodb';
import { BillingLocation } from './lib/db-types'; import { BillingLocation } from './lib/db-types';
import { PageFooter } from './ui/PageFooter'; import { PageFooter } from './ui/PageFooter';
import { auth } from '@/app/lib/auth'; import { auth } from '@/app/lib/auth';
import { redirect } from 'next/navigation'; import { fetchAllLocations } from './lib/locationActions';
const getNextYearMonth = (yearMonth:number) => { const getNextYearMonth = (yearMonth:number) => {
return(yearMonth % 100 === 12 ? yearMonth + 89 : yearMonth + 1); return(yearMonth % 100 === 12 ? yearMonth + 89 : yearMonth + 1);
@@ -14,16 +14,7 @@ const getNextYearMonth = (yearMonth:number) => {
export const Page = async () => { export const Page = async () => {
const session = await auth(); const locations = await fetchAllLocations();
const client = await clientPromise;
const db = client.db("rezije");
const locations = await db.collection<BillingLocation>("lokacije")
.find({})
.sort({ yearMonth: -1, name: 1 }) // sort by yearMonth descending
.limit(20)
.toArray();
// if the database is in it's initial state, show the add location button for the current month // if the database is in it's initial state, show the add location button for the current month
if(locations.length === 0) { if(locations.length === 0) {
@@ -61,13 +52,6 @@ export const Page = async () => {
}) })
} }
<PageFooter /> <PageFooter />
<ul>
<li>session.expires = { session?.expires }</li>
<li>session.user.id = { session?.user?.id }</li>
<li>session.user.email = { session?.user?.email }</li>
<li>session.user.name = { session?.user?.name }</li>
<li>session.user.image = { session?.user?.image }</li>
</ul>
</main> </main>
); );
} }