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
* 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)
* [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 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);
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 { 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<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
@@ -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<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: {
@@ -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<BillingLocation>("lokacije").findOne({ _id: locationID })
const billLocation = await db.collection<BillingLocation>("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<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
@@ -196,4 +209,4 @@ export const deleteBillById = async (locationID:string, billID:string) => {
});
return(post.modifiedCount);
}
});

View File

@@ -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;

View File

@@ -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<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: {
@@ -68,6 +73,8 @@ export async function updateOrAddLocation(locationId?: string, yearMonth?: strin
} else if(yearMonth) {
await db.collection<BillingLocation>("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<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
const billLocation = await db.collection<BillingLocation>("lokacije").findOne({ _id: locationID });
const billLocation = await db.collection<BillingLocation>("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<BillingLocation>("lokacije").deleteOne({ _id: locationID });
const post = await db.collection<BillingLocation>("lokacije").deleteOne({ _id: locationID, userId });
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';
export type AuthenticatedUser = {
id: string;
} & DefaultSession['user'];
declare module 'next-auth' {
interface Session {
user: {
id: string;
} & DefaultSession['user'];
user: AuthenticatedUser
}
}

View File

@@ -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<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) => {
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<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) {
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<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
@@ -100,4 +114,4 @@ export const deleteBillById = async (locationID:string, billID:string) => {
});
return(post.modifiedCount);
}
});

View File

@@ -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<BillingLocation>("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 () => {
})
}
<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>
);
}