From 8ee5e70e1ade25de6ddc09471639a899a300b292 Mon Sep 17 00:00:00 2001 From: Goostaf Date: Thu, 26 Feb 2026 22:01:32 +0100 Subject: [PATCH 1/3] Allow organization admins to access their items --- src/actions/expenses.ts | 53 ++++++++++++--- src/actions/invoices.ts | 68 +++++++++++++------ src/actions/nameLists.ts | 38 ++++++++--- src/actions/zettleSales.ts | 37 +++++++--- .../org/[orgId]/expenses/edit/page.tsx | 13 ++-- .../[locale]/org/[orgId]/expenses/page.tsx | 13 ++-- .../org/[orgId]/expenses/view/page.tsx | 11 +-- .../org/[orgId]/invoices/edit/page.tsx | 12 ++-- .../[locale]/org/[orgId]/invoices/page.tsx | 12 +++- .../org/[orgId]/invoices/view/page.tsx | 12 ++-- .../org/[orgId]/name-lists/edit/page.tsx | 12 ++-- .../[locale]/org/[orgId]/name-lists/page.tsx | 12 +++- .../org/[orgId]/name-lists/view/page.tsx | 12 ++-- .../[locale]/org/[orgId]/settings/page.tsx | 12 ++-- .../org/[orgId]/zettle-sales/edit/page.tsx | 8 ++- .../org/[orgId]/zettle-sales/page.tsx | 12 +++- .../org/[orgId]/zettle-sales/view/page.tsx | 8 ++- .../InvoicesTable/InvoicesTable.tsx | 58 +++++++++------- .../NameListTable/NameListTable.tsx | 14 ++-- src/components/Navigation/Navigation.tsx | 12 ++++ .../ZettleSalesTable/ZettleSalesTable.tsx | 16 +++-- src/services/sessionService.ts | 24 +++++++ 22 files changed, 338 insertions(+), 131 deletions(-) diff --git a/src/actions/expenses.ts b/src/actions/expenses.ts index 6095c08..86c26d1 100644 --- a/src/actions/expenses.ts +++ b/src/actions/expenses.ts @@ -99,34 +99,47 @@ export async function editExpense( throw new Error('Expense does not exist'); } + const [divisionTreasurer, localAdmin] = await Promise.all([ + SessionService.isDivisionTreasurer(), + SessionService.isOrgLocalAdmin(existing.organizationId) + ]); + const isAdmin = divisionTreasurer || localAdmin; + // Check if user has permission to edit this expense // User can edit if: - // 1. They created the expense (for personal expenses) - // 2. They belong to the group that owns the expense (for group expenses) + // 1. They are an admin + // 2. They created the expense (for personal expenses) + // 3. They belong to the group that owns the expense (for group expenses) const userGroups = await SessionService.getGroups(); const canEdit = - existing.gammaUserId === gammaUserId || // User created it + isAdmin || + existing.gammaUserId === gammaUserId || (existing.gammaGroupId !== null && - userGroups.some((g) => g.group.id === existing.gammaGroupId)); // User belongs to the group + userGroups.some((g) => g.group.id === existing.gammaGroupId)); if (!canEdit) { throw new Error('User does not have permission to edit this expense'); } - if (existing.paidAt !== null || existing.status === RequestStatus.APPROVED) { + if (!isAdmin && (existing.paidAt !== null || existing.status === RequestStatus.APPROVED)) { throw new Error( 'Expense cannot be edited after it has been paid or approved' ); } // Validate the new group if specified + // Admins keep the existing group when they don't belong to it let group = null; if (gammaGroupId !== null) { - group = userGroups.find((g) => g.group.id === gammaGroupId)?.group; - if (group === undefined) { - throw new Error( - 'Group does not exist or user does not have access to it' - ); + group = userGroups.find((g) => g.group.id === gammaGroupId)?.group ?? null; + if (group === null && !isAdmin) { + throw new Error('Group does not exist or user does not have access to it'); + } + if (group === null && isAdmin) { + // Admin editing without group membership, preserve existing superGroup + group = existing.gammaGroupId === gammaGroupId + ? { id: gammaGroupId, superGroup: { id: existing.gammaSuperGroupId! } } as any + : null; } } @@ -223,6 +236,16 @@ export async function createPersonalExpense( ); } +async function assertOrgAdminForExpense(existing: NonNullable>>) { + const [divisionTreasurer, localAdmin] = await Promise.all([ + SessionService.isDivisionTreasurer(), + SessionService.isOrgLocalAdmin(existing.organizationId) + ]); + if (!divisionTreasurer && !localAdmin) { + throw new Error('User does not have admin permission for this expense'); + } +} + export async function markExpenseAsPaid(expenseId: number) { const existing = await ExpenseService.getById(expenseId); @@ -230,6 +253,8 @@ export async function markExpenseAsPaid(expenseId: number) { throw new Error('Expense does not exist'); } + await assertOrgAdminForExpense(existing); + return ExpenseService.markAsPaid(expenseId); } @@ -240,6 +265,8 @@ export async function markExpenseAsUnpaid(expenseId: number) { throw new Error('Expense does not exist'); } + await assertOrgAdminForExpense(existing); + return ExpenseService.markAsUnpaid(expenseId); } @@ -250,6 +277,8 @@ export async function deleteExpense(expenseId: number) { throw new Error('Expense does not exist'); } + await assertOrgAdminForExpense(existing); + return ExpenseService.delete(expenseId); } @@ -260,6 +289,8 @@ export async function requestExpenseRevision(expenseId: number) { throw new Error('Expense does not exist'); } + await assertOrgAdminForExpense(existing); + return ExpenseService.requestRevision(expenseId); } @@ -270,6 +301,8 @@ export async function approveExpense(expenseId: number) { throw new Error('Expense does not exist'); } + await assertOrgAdminForExpense(existing); + return ExpenseService.approve(expenseId); } diff --git a/src/actions/invoices.ts b/src/actions/invoices.ts index b0737c9..9db7acb 100644 --- a/src/actions/invoices.ts +++ b/src/actions/invoices.ts @@ -84,34 +84,42 @@ export async function editInvoice( throw new Error('Invoice does not exist'); } + const [divisionTreasurer, localAdmin] = await Promise.all([ + SessionService.isDivisionTreasurer(), + SessionService.isOrgLocalAdmin(existing.organizationId) + ]); + const isAdmin = divisionTreasurer || localAdmin; + // Check if user has permission to edit this invoice - // User can edit if: - // 1. They created the invoice (for personal invoices) - // 2. They belong to the group that owns the invoice (for group invoices) const userGroups = await SessionService.getGroups(); const canEdit = - existing.gammaUserId === gammaUserId || // User created it + isAdmin || + existing.gammaUserId === gammaUserId || (existing.gammaGroupId !== null && - userGroups.some((g) => g.group.id === existing.gammaGroupId)); // User belongs to the group + userGroups.some((g) => g.group.id === existing.gammaGroupId)); if (!canEdit) { throw new Error('User does not have permission to edit this invoice'); } - if (existing.sentAt !== null || existing.status === RequestStatus.APPROVED) { + if (!isAdmin && (existing.sentAt !== null || existing.status === RequestStatus.APPROVED)) { throw new Error( 'Invoice cannot be edited after it has been sent or approved' ); } // Validate the new group if specified + // Admins keep the existing group when they don't belong to it let group = null; if (gammaGroupId !== null) { - group = userGroups.find((g) => g.group.id === gammaGroupId)?.group; - if (group === undefined) { - throw new Error( - 'Group does not exist or user does not have access to it' - ); + group = userGroups.find((g) => g.group.id === gammaGroupId)?.group ?? null; + if (group === null && !isAdmin) { + throw new Error('Group does not exist or user does not have access to it'); + } + if (group === null && isAdmin) { + group = existing.gammaGroupId === gammaGroupId + ? { id: gammaGroupId, superGroup: { id: existing.gammaSuperGroupId! } } as any + : null; } } @@ -185,22 +193,40 @@ export async function createPersonalInvoice( ); } -export async function markInvoiceAsSent(expenseId: number) { - return InvoiceService.markAsSent(expenseId); +async function assertOrgAdminForInvoice(invoiceId: number) { + const existing = await InvoiceService.getById(invoiceId); + if (existing === null) throw new Error('Invoice does not exist'); + + const [divisionTreasurer, localAdmin] = await Promise.all([ + SessionService.isDivisionTreasurer(), + SessionService.isOrgLocalAdmin(existing.organizationId) + ]); + if (!divisionTreasurer && !localAdmin) { + throw new Error('User does not have admin permission for this invoice'); + } +} + +export async function markInvoiceAsSent(invoiceId: number) { + await assertOrgAdminForInvoice(invoiceId); + return InvoiceService.markAsSent(invoiceId); } -export async function markInvoiceAsNotSent(expenseId: number) { - return InvoiceService.markAsNotSent(expenseId); +export async function markInvoiceAsNotSent(invoiceId: number) { + await assertOrgAdminForInvoice(invoiceId); + return InvoiceService.markAsNotSent(invoiceId); } -export async function deleteInvoice(expenseId: number) { - return InvoiceService.delete(expenseId); +export async function deleteInvoice(invoiceId: number) { + await assertOrgAdminForInvoice(invoiceId); + return InvoiceService.delete(invoiceId); } -export async function requestInvoiceRevision(expenseId: number) { - return InvoiceService.requestRevision(expenseId); +export async function requestInvoiceRevision(invoiceId: number) { + await assertOrgAdminForInvoice(invoiceId); + return InvoiceService.requestRevision(invoiceId); } -export async function approveInvoice(expenseId: number) { - return InvoiceService.approve(expenseId); +export async function approveInvoice(invoiceId: number) { + await assertOrgAdminForInvoice(invoiceId); + return InvoiceService.approve(invoiceId); } diff --git a/src/actions/nameLists.ts b/src/actions/nameLists.ts index 9e29ea9..474eee5 100644 --- a/src/actions/nameLists.ts +++ b/src/actions/nameLists.ts @@ -86,29 +86,36 @@ export async function editNameList( throw new Error('Name list does not exist'); } - // Check if user has permission to edit this name list - // User can edit if: - // 1. They created the name list (for personal name lists) - // 2. They belong to the group that owns the name list (for group name lists) + const [divisionTreasurer, localAdmin] = await Promise.all([ + SessionService.isDivisionTreasurer(), + SessionService.isOrgLocalAdmin(existing.organizationId) + ]); + const isAdmin = divisionTreasurer || localAdmin; + const userGroups = await SessionService.getGroups(); const canEdit = - existing.gammaUserId === gammaUserId || // User created it + isAdmin || + existing.gammaUserId === gammaUserId || (existing.gammaGroupId !== null && - userGroups.some((g) => g.group.id === existing.gammaGroupId)); // User belongs to the group + userGroups.some((g) => g.group.id === existing.gammaGroupId)); if (!canEdit) { throw new Error('User does not have permission to edit this name list'); } - console.log('Editing name list with ID:', id, 'and gammaGroupId:', gammaGroupId); - // Validate the new group if specified + // Admins keep the existing group when they don't belong to it let group = null; if (gammaGroupId !== null) { - group = userGroups.find((g) => g.group.id === gammaGroupId)?.group; - if (group === undefined) { + group = userGroups.find((g) => g.group.id === gammaGroupId)?.group ?? null; + if (group === null && !isAdmin) { throw new Error('Group does not exist or user does not have access to it'); } + if (group === null && isAdmin) { + group = existing.gammaGroupId === gammaGroupId + ? { id: gammaGroupId, superGroup: { id: existing.gammaSuperGroupId! } } as any + : null; + } } await NameListService.edit( @@ -125,5 +132,16 @@ export async function editNameList( } export async function deleteNameList(id: number) { + const existing = await NameListService.getById(id); + if (existing === null) throw new Error('Name list does not exist'); + + const [divisionTreasurer, localAdmin] = await Promise.all([ + SessionService.isDivisionTreasurer(), + SessionService.isOrgLocalAdmin(existing.organizationId) + ]); + if (!divisionTreasurer && !localAdmin) { + throw new Error('User does not have admin permission for this name list'); + } + await NameListService.delete(id); } diff --git a/src/actions/zettleSales.ts b/src/actions/zettleSales.ts index f2047dd..162dcd3 100644 --- a/src/actions/zettleSales.ts +++ b/src/actions/zettleSales.ts @@ -50,28 +50,36 @@ export async function editZettleSale( throw new Error('Zettle sale does not exist'); } - // Check if user has permission to edit this Zettle sale - // User can edit if they belong to the group that owns the sale + const [divisionTreasurer, localAdmin] = await Promise.all([ + SessionService.isDivisionTreasurer(), + SessionService.isOrgLocalAdmin(existing.organizationId) + ]); + const isAdmin = divisionTreasurer || localAdmin; + const userGroups = await SessionService.getGroups(); const canEdit = - existing.gammaGroupId !== null && - userGroups.some((g) => g.group.id === existing.gammaGroupId); + isAdmin || + (existing.gammaGroupId !== null && + userGroups.some((g) => g.group.id === existing.gammaGroupId)); if (!canEdit) { throw new Error('User does not have permission to edit this Zettle sale'); } - // Validate the new group + // Resolve group. + // Admins may not be members, so fall back to existing superGroup const group = userGroups.find((g) => g.group.id === gammaGroupId)?.group; - if (group === undefined) { + const gammaSuperGroupId = + group?.superGroup.id ?? + (isAdmin ? existing.gammaSuperGroupId : undefined); + + if (!gammaSuperGroupId) { throw new Error('Group does not exist or user does not have access to it'); } - console.log('Editing Zettle sale with ID:', id, 'and gammaGroupId:', gammaGroupId); - await ZettleSaleService.edit( id, - group.superGroup.id, + gammaSuperGroupId, gammaGroupId, name, amount, @@ -80,5 +88,16 @@ export async function editZettleSale( } export async function deleteZettleSale(id: number) { + const existing = await ZettleSaleService.getById(id); + if (existing === null) throw new Error('Zettle sale does not exist'); + + const [divisionTreasurer, localAdmin] = await Promise.all([ + SessionService.isDivisionTreasurer(), + SessionService.isOrgLocalAdmin(existing.organizationId) + ]); + if (!divisionTreasurer && !localAdmin) { + throw new Error('User does not have admin permission for this Zettle sale'); + } + await ZettleSaleService.delete(id); } diff --git a/src/app/[locale]/org/[orgId]/expenses/edit/page.tsx b/src/app/[locale]/org/[orgId]/expenses/edit/page.tsx index 8b0c6c7..61f1e55 100644 --- a/src/app/[locale]/org/[orgId]/expenses/edit/page.tsx +++ b/src/app/[locale]/org/[orgId]/expenses/edit/page.tsx @@ -31,24 +31,25 @@ export default async function Page(props: { notFound(); } const personal = expense.gammaGroupId === null; - const divisionTreasurer = await SessionService.isDivisionTreasurer(); + const isAdmin = + (await SessionService.isDivisionTreasurer()) || + (await SessionService.isOrgLocalAdmin(Number(orgId))); const group = - !personal && !divisionTreasurer + !personal && !isAdmin ? (await SessionService.getGroups()).find( (g) => g.group.id === expense.gammaGroupId )?.group : undefined; - if (!personal && !divisionTreasurer && group === undefined) { + if (!personal && !isAdmin && group === undefined) { notFound(); } const groups = (await SessionService.getGroups()).map((g) => g.group); const user = (await SessionService.getGammaUser())?.user; - const canEdit = - divisionTreasurer || group || user?.id === expense.gammaUserId; + const canEdit = isAdmin || group || user?.id === expense.gammaUserId; if (!canEdit) { notFound(); @@ -81,4 +82,4 @@ export default async function Page(props: { /> ); -} \ No newline at end of file +} diff --git a/src/app/[locale]/org/[orgId]/expenses/page.tsx b/src/app/[locale]/org/[orgId]/expenses/page.tsx index be4c5da..8a10ece 100644 --- a/src/app/[locale]/org/[orgId]/expenses/page.tsx +++ b/src/app/[locale]/org/[orgId]/expenses/page.tsx @@ -21,9 +21,14 @@ export default async function Page(props: { const groups = await SessionService.getGroups(); const superGroups = await GammaService.getAllSuperGroups(); - const divisionTreasurer = await SessionService.isDivisionTreasurer(); + const orgIdNum = Number(orgId); + const [divisionTreasurer, localAdmin] = await Promise.all([ + SessionService.isDivisionTreasurer(), + SessionService.isOrgLocalAdmin(orgIdNum) + ]); + const isAdmin = divisionTreasurer || localAdmin; const expenses = await GammaService.includeUserInfo( - await (divisionTreasurer + await (isAdmin ? ExpenseService.getAll(Number(orgId)) : SessionService.getExpenses(Number(orgId))) ); @@ -54,8 +59,8 @@ export default async function Page(props: { e={expenses} locale={locale} treasurerPostId={process.env.TREASURER_POST_ID} - allEditable={divisionTreasurer} - orgId={Number(orgId)} + allEditable={isAdmin} + orgId={orgIdNum} /> diff --git a/src/app/[locale]/org/[orgId]/expenses/view/page.tsx b/src/app/[locale]/org/[orgId]/expenses/view/page.tsx index 4380175..6aa4e58 100644 --- a/src/app/[locale]/org/[orgId]/expenses/view/page.tsx +++ b/src/app/[locale]/org/[orgId]/expenses/view/page.tsx @@ -34,24 +34,25 @@ export default async function Page(props: { notFound(); } const personal = expense.gammaGroupId === null; - const divisionTreasurer = await SessionService.isDivisionTreasurer(); + const isAdmin = + (await SessionService.isDivisionTreasurer()) || + (await SessionService.isOrgLocalAdmin(Number(orgId))); const group = - !personal && !divisionTreasurer + !personal && !isAdmin ? (await SessionService.getGroups()).find( (g) => g.group.id === expense.gammaGroupId )?.group : undefined; - if (!personal && !divisionTreasurer && group === undefined) { + if (!personal && !isAdmin && group === undefined) { notFound(); } const groups = (await SessionService.getGroups()).map((g) => g.group); const user = (await SessionService.getGammaUser())?.user; - const canEdit = - divisionTreasurer || group || user?.id === expense.gammaUserId; + const canEdit = isAdmin || group || user?.id === expense.gammaUserId; const org = await OrgService.getById(Number(orgId)); if (!org) { diff --git a/src/app/[locale]/org/[orgId]/invoices/edit/page.tsx b/src/app/[locale]/org/[orgId]/invoices/edit/page.tsx index 3e8e526..6cc116b 100644 --- a/src/app/[locale]/org/[orgId]/invoices/edit/page.tsx +++ b/src/app/[locale]/org/[orgId]/invoices/edit/page.tsx @@ -26,22 +26,26 @@ export default async function Page(props: { if (invoice === null) notFound(); const personal = invoice.gammaGroupId === null; - const divisionTreasurer = await SessionService.isDivisionTreasurer(); + const [divisionTreasurer, localAdmin] = await Promise.all([ + SessionService.isDivisionTreasurer(), + SessionService.isOrgLocalAdmin(+orgId) + ]); + const isAdmin = divisionTreasurer || localAdmin; const group = - !personal && !divisionTreasurer + !personal && !isAdmin ? (await SessionService.getGroups()).find( (g) => g.group.id === invoice.gammaGroupId )?.group : undefined; - if (!personal && !divisionTreasurer && group === undefined) { + if (!personal && !isAdmin && group === undefined) { notFound(); } const user = (await SessionService.getGammaUser())?.user; const canEdit = - divisionTreasurer || + isAdmin || group !== undefined || user?.id === invoice.gammaUserId; diff --git a/src/app/[locale]/org/[orgId]/invoices/page.tsx b/src/app/[locale]/org/[orgId]/invoices/page.tsx index eb21d3c..b623b55 100644 --- a/src/app/[locale]/org/[orgId]/invoices/page.tsx +++ b/src/app/[locale]/org/[orgId]/invoices/page.tsx @@ -22,9 +22,14 @@ export default async function Page(props: { const superGroups = await GammaService.getAllSuperGroups(); - const divisionTreasurer = await SessionService.isDivisionTreasurer(); + const orgIdNum = Number(orgId); + const [divisionTreasurer, localAdmin] = await Promise.all([ + SessionService.isDivisionTreasurer(), + SessionService.isOrgLocalAdmin(orgIdNum) + ]); + const isAdmin = divisionTreasurer || localAdmin; const invoices = await GammaService.includeUserInfo( - await (divisionTreasurer + await (isAdmin ? InvoiceService.getAll(Number(orgId)) : SessionService.getInvoices(Number(orgId))) ); @@ -53,7 +58,8 @@ export default async function Page(props: { e={invoices} locale={locale} superGroups={superGroups} - orgId={Number(orgId)} + allEditable={isAdmin} + orgId={orgIdNum} /> ); diff --git a/src/app/[locale]/org/[orgId]/invoices/view/page.tsx b/src/app/[locale]/org/[orgId]/invoices/view/page.tsx index 9d2b10d..e91dfb5 100644 --- a/src/app/[locale]/org/[orgId]/invoices/view/page.tsx +++ b/src/app/[locale]/org/[orgId]/invoices/view/page.tsx @@ -35,22 +35,26 @@ export default async function Page(props: { if (invoice === null) notFound(); const personal = invoice.gammaGroupId === null; - const divisionTreasurer = await SessionService.isDivisionTreasurer(); + const [divisionTreasurer, localAdmin] = await Promise.all([ + SessionService.isDivisionTreasurer(), + SessionService.isOrgLocalAdmin(+orgId) + ]); + const isAdmin = divisionTreasurer || localAdmin; const group = - !personal && !divisionTreasurer + !personal && !isAdmin ? (await SessionService.getGroups()).find( (g) => g.group.id === invoice.gammaGroupId )?.group : undefined; - if (!personal && !divisionTreasurer && group === undefined) { + if (!personal && !isAdmin && group === undefined) { notFound(); } const user = (await SessionService.getGammaUser())?.user; const canEdit = - divisionTreasurer || + isAdmin || group !== undefined || user?.id === invoice.gammaUserId; diff --git a/src/app/[locale]/org/[orgId]/name-lists/edit/page.tsx b/src/app/[locale]/org/[orgId]/name-lists/edit/page.tsx index 1f51ec2..945ae33 100644 --- a/src/app/[locale]/org/[orgId]/name-lists/edit/page.tsx +++ b/src/app/[locale]/org/[orgId]/name-lists/edit/page.tsx @@ -28,7 +28,11 @@ export default async function Page(props: { const nameList = await NameListService.getById(+id); if (nameList === null) notFound(); const personal = nameList === null || nameList.gammaGroupId === null; - const divisionTreasurer = await SessionService.isDivisionTreasurer(); + const [divisionTreasurer, localAdmin] = await Promise.all([ + SessionService.isDivisionTreasurer(), + SessionService.isOrgLocalAdmin(+orgId) + ]); + const isAdmin = divisionTreasurer || localAdmin; const group = !personal ? (await SessionService.getGroups()).find( @@ -36,7 +40,7 @@ export default async function Page(props: { )?.group : undefined; - if (!personal && !divisionTreasurer && group === undefined) { + if (!personal && !isAdmin && group === undefined) { notFound(); } @@ -44,7 +48,7 @@ export default async function Page(props: { personal || group === undefined ? undefined : await GammaService.getSuperGroup(group.superGroup.id); - if (!personal && !divisionTreasurer && sg === undefined) { + if (!personal && !isAdmin && sg === undefined) { notFound(); } @@ -53,7 +57,7 @@ export default async function Page(props: { const user = (await SessionService.getGammaUser())?.user; const canEdit = - divisionTreasurer || + isAdmin || group !== undefined || user?.id === nameList.gammaUserId; diff --git a/src/app/[locale]/org/[orgId]/name-lists/page.tsx b/src/app/[locale]/org/[orgId]/name-lists/page.tsx index 67b3547..b95aa72 100644 --- a/src/app/[locale]/org/[orgId]/name-lists/page.tsx +++ b/src/app/[locale]/org/[orgId]/name-lists/page.tsx @@ -22,9 +22,14 @@ export default async function Page(props: { const superGroups = await GammaService.getAllSuperGroups(); - const divisionTreasurer = await SessionService.isDivisionTreasurer(); + const orgIdNum = Number(orgId); + const [divisionTreasurer, localAdmin] = await Promise.all([ + SessionService.isDivisionTreasurer(), + SessionService.isOrgLocalAdmin(orgIdNum) + ]); + const isAdmin = divisionTreasurer || localAdmin; const lists = await GammaService.includeUserInfo( - await (divisionTreasurer + await (isAdmin ? NameListService.getAll(Number(orgId)) : SessionService.getNameLists(Number(orgId))) ); @@ -53,7 +58,8 @@ export default async function Page(props: { e={lists} locale={locale} superGroups={superGroups} - orgId={Number(orgId)} + allEditable={isAdmin} + orgId={orgIdNum} /> ); diff --git a/src/app/[locale]/org/[orgId]/name-lists/view/page.tsx b/src/app/[locale]/org/[orgId]/name-lists/view/page.tsx index 6fc8f06..652f6a9 100644 --- a/src/app/[locale]/org/[orgId]/name-lists/view/page.tsx +++ b/src/app/[locale]/org/[orgId]/name-lists/view/page.tsx @@ -32,7 +32,11 @@ export default async function Page(props: { const nameList = await NameListService.getById(+id); if (nameList === null) notFound(); const personal = nameList === null || nameList.gammaGroupId === null; - const divisionTreasurer = await SessionService.isDivisionTreasurer(); + const [divisionTreasurer, localAdmin] = await Promise.all([ + SessionService.isDivisionTreasurer(), + SessionService.isOrgLocalAdmin(+orgId) + ]); + const isAdmin = divisionTreasurer || localAdmin; const group = !personal ? (await SessionService.getGroups()).find( @@ -40,7 +44,7 @@ export default async function Page(props: { )?.group : undefined; - if (!personal && !divisionTreasurer && group === undefined) { + if (!personal && !isAdmin && group === undefined) { notFound(); } @@ -48,7 +52,7 @@ export default async function Page(props: { personal || group === undefined ? undefined : await GammaService.getSuperGroup(group.superGroup.id); - if (!personal && !divisionTreasurer && sg === undefined) { + if (!personal && !isAdmin && sg === undefined) { notFound(); } @@ -62,7 +66,7 @@ export default async function Page(props: { const user = (await SessionService.getGammaUser())?.user; const canEdit = - divisionTreasurer || + isAdmin || group !== undefined || user?.id === nameList.gammaUserId; diff --git a/src/app/[locale]/org/[orgId]/settings/page.tsx b/src/app/[locale]/org/[orgId]/settings/page.tsx index 2a8f138..82e3335 100644 --- a/src/app/[locale]/org/[orgId]/settings/page.tsx +++ b/src/app/[locale]/org/[orgId]/settings/page.tsx @@ -15,12 +15,16 @@ import OrganizationSettingsForm from './OrganizationSettingsForm'; export default async function Page(props: { params: Promise<{ locale: string; orgId: string }>; }) { - const divisionTreasurer = await SessionService.isDivisionTreasurer(); - if (!divisionTreasurer) { + const { locale, orgId } = await props.params; + const orgIdNum = Number(orgId); + + const [divisionTreasurer, localAdmin] = await Promise.all([ + SessionService.isDivisionTreasurer(), + SessionService.isOrgLocalAdmin(orgIdNum) + ]); + if (!divisionTreasurer && !localAdmin) { notFound(); } - - const { locale, orgId } = await props.params; const l = i18nService.getLocale(locale); const organization = await OrgService.getById(Number(orgId)); diff --git a/src/app/[locale]/org/[orgId]/zettle-sales/edit/page.tsx b/src/app/[locale]/org/[orgId]/zettle-sales/edit/page.tsx index c53c825..f6992cb 100644 --- a/src/app/[locale]/org/[orgId]/zettle-sales/edit/page.tsx +++ b/src/app/[locale]/org/[orgId]/zettle-sales/edit/page.tsx @@ -29,10 +29,16 @@ export default async function Page(props: { notFound(); } + const [divisionTreasurer, localAdmin] = await Promise.all([ + SessionService.isDivisionTreasurer(), + SessionService.isOrgLocalAdmin(+orgId) + ]); + const isAdmin = divisionTreasurer || localAdmin; + const groups = (await SessionService.getGroups()).map((g) => g.group); const user = (await SessionService.getGammaUser())?.user; - const canEdit = user?.id === sale.gammaUserId; + const canEdit = isAdmin || user?.id === sale.gammaUserId; if (!canEdit) { notFound(); diff --git a/src/app/[locale]/org/[orgId]/zettle-sales/page.tsx b/src/app/[locale]/org/[orgId]/zettle-sales/page.tsx index e85ba3c..6c3b514 100644 --- a/src/app/[locale]/org/[orgId]/zettle-sales/page.tsx +++ b/src/app/[locale]/org/[orgId]/zettle-sales/page.tsx @@ -22,9 +22,14 @@ export default async function Page(props: { const superGroups = await GammaService.getAllSuperGroups(); - const divisionTreasurer = await SessionService.isDivisionTreasurer(); + const orgIdNum = Number(orgId); + const [divisionTreasurer, localAdmin] = await Promise.all([ + SessionService.isDivisionTreasurer(), + SessionService.isOrgLocalAdmin(orgIdNum) + ]); + const isAdmin = divisionTreasurer || localAdmin; const sales = await GammaService.includeUserInfo( - await (divisionTreasurer + await (isAdmin ? ZettleSaleService.getAll(Number(orgId)) : SessionService.getZettleSales(Number(orgId))) ); @@ -53,7 +58,8 @@ export default async function Page(props: { e={sales} superGroups={superGroups} locale={locale} - orgId={Number(orgId)} + allEditable={isAdmin} + orgId={orgIdNum} /> ); diff --git a/src/app/[locale]/org/[orgId]/zettle-sales/view/page.tsx b/src/app/[locale]/org/[orgId]/zettle-sales/view/page.tsx index c22539f..a487b8e 100644 --- a/src/app/[locale]/org/[orgId]/zettle-sales/view/page.tsx +++ b/src/app/[locale]/org/[orgId]/zettle-sales/view/page.tsx @@ -30,10 +30,16 @@ export default async function Page(props: { notFound(); } + const [divisionTreasurer, localAdmin] = await Promise.all([ + SessionService.isDivisionTreasurer(), + SessionService.isOrgLocalAdmin(+orgId) + ]); + const isAdmin = divisionTreasurer || localAdmin; + const groups = (await SessionService.getGroups()).map((g) => g.group); const user = (await SessionService.getGammaUser())?.user; - const canEdit = user?.id === sale.gammaUserId; + const canEdit = isAdmin || user?.id === sale.gammaUserId; const org = await OrgService.getById(+orgId); if (!org) { diff --git a/src/components/InvoicesTable/InvoicesTable.tsx b/src/components/InvoicesTable/InvoicesTable.tsx index 7209e76..457d2f7 100644 --- a/src/components/InvoicesTable/InvoicesTable.tsx +++ b/src/components/InvoicesTable/InvoicesTable.tsx @@ -76,11 +76,13 @@ const InvoicesTable = ({ e, locale, superGroups, + allEditable = false, orgId }: { e: Invoice[]; locale: string; superGroups?: { superGroup: GammaSuperGroup; members: GammaGroupMember[] }[]; + allEditable?: boolean; orgId: number; }) => { const l = i18nService.getLocale(locale); @@ -173,7 +175,7 @@ const InvoicesTable = ({ id: 'actions', cell: (info) => { const invoice = info.row.original; - return ; + return ; } }) ]; @@ -232,9 +234,11 @@ const InvoiceActions = ({ id, status, description, - locale + locale, + allEditable = false }: InvoiceRow & { locale: string; + allEditable?: boolean; }) => { const router = useRouter(); const l = i18nService.getLocale(locale); @@ -279,34 +283,38 @@ const InvoiceActions = ({ - {status === 'FINISHED' ? ( - - {l.invoice.markNotSent} - - ) : ( + {allEditable && ( <> - - {' '} - {status === RequestStatus.APPROVED - ? l.invoice.markSent - : l.invoice.approveMarkSent} - - {status !== RequestStatus.APPROVED && ( - - {l.invoice.approveSending} - - )} - {status !== RequestStatus.REJECTED && ( - - {l.economy.requestRevision} + {status === 'FINISHED' ? ( + + {l.invoice.markNotSent} + ) : ( + <> + + {' '} + {status === RequestStatus.APPROVED + ? l.invoice.markSent + : l.invoice.approveMarkSent} + + {status !== RequestStatus.APPROVED && ( + + {l.invoice.approveSending} + + )} + {status !== RequestStatus.REJECTED && ( + + {l.economy.requestRevision} + + )} + )} + + + {l.general.delete} + )} - - - {l.general.delete} - diff --git a/src/components/NameListTable/NameListTable.tsx b/src/components/NameListTable/NameListTable.tsx index 91b2c22..badf788 100644 --- a/src/components/NameListTable/NameListTable.tsx +++ b/src/components/NameListTable/NameListTable.tsx @@ -54,11 +54,13 @@ const NameListTable = ({ e, superGroups, locale, + allEditable = false, orgId }: { e: NameList[]; superGroups?: { superGroup: GammaSuperGroup; members: GammaGroupMember[] }[]; locale: string; + allEditable?: boolean; orgId: number; }) => { const l = i18nService.getLocale(locale); @@ -138,7 +140,7 @@ const NameListTable = ({ id: 'actions', cell: (info) => { const list = info.row.original; - return ; + return ; } }) ]; @@ -189,7 +191,7 @@ const NameListTable = ({ ); }; -const NameListActions = ({ id, locale }: { id: number; locale: string }) => { +const NameListActions = ({ id, locale, allEditable = false }: { id: number; locale: string; allEditable?: boolean }) => { const l = i18nService.getLocale(locale); const router = useRouter(); @@ -207,9 +209,11 @@ const NameListActions = ({ id, locale }: { id: number; locale: string }) => { - - {l.general.delete} - + {allEditable && ( + + {l.general.delete} + + )} ); diff --git a/src/components/Navigation/Navigation.tsx b/src/components/Navigation/Navigation.tsx index 271c25e..1cc96b9 100644 --- a/src/components/Navigation/Navigation.tsx +++ b/src/components/Navigation/Navigation.tsx @@ -28,6 +28,10 @@ const Navigation = async ({ const divisionTreasurer = await SessionService.isDivisionTreasurer(); const organizations = await OrgService.getAll(); + const orgLocalAdmin = orgId !== undefined + ? await SessionService.isOrgLocalAdmin(orgId) + : false; + const orgPrefix = orgId !== undefined ? `/org/${orgId}` : '/'; return ( @@ -102,6 +106,14 @@ const Navigation = async ({ {' '} {l.categories.receiptCreator} + {(divisionTreasurer || orgLocalAdmin) && ( + + + + {' '} + Settings + + )} )} diff --git a/src/components/ZettleSalesTable/ZettleSalesTable.tsx b/src/components/ZettleSalesTable/ZettleSalesTable.tsx index 8ff791e..661a4c8 100644 --- a/src/components/ZettleSalesTable/ZettleSalesTable.tsx +++ b/src/components/ZettleSalesTable/ZettleSalesTable.tsx @@ -51,11 +51,13 @@ const ZettleSalesTable = ({ e, superGroups, locale, + allEditable = false, orgId }: { e: ZettleSale[]; superGroups?: { superGroup: GammaSuperGroup; members: GammaGroupMember[] }[]; locale: string; + allEditable?: boolean; orgId: number; }) => { const l = i18nService.getLocale(locale); @@ -117,7 +119,7 @@ const ZettleSalesTable = ({ id: 'actions', cell: (info) => { const sale = info.row.original; - return ; + return ; } }) ]; @@ -171,11 +173,13 @@ const ZettleSalesTable = ({ const SaleActions = ({ id, locale, - orgId + orgId, + allEditable = false }: { id: number; locale: string; orgId: number; + allEditable?: boolean; }) => { const l = i18nService.getLocale(locale); const router = useRouter(); @@ -203,9 +207,11 @@ const SaleActions = ({ > {l.general.edit} - - {l.general.delete} D - + {allEditable && ( + + {l.general.delete} D + + )} ); diff --git a/src/services/sessionService.ts b/src/services/sessionService.ts index 04ebe11..cd4bdaf 100644 --- a/src/services/sessionService.ts +++ b/src/services/sessionService.ts @@ -8,6 +8,7 @@ import InvoiceService from './invoiceService'; import NameListService from './nameListService'; import BankAccountService from './bankAccountService'; import ZettleSaleService from './zettleSaleService'; +import OrgService from './orgService'; /** * Service for handling the session of the current user @@ -199,4 +200,27 @@ export default class SessionService { ? await BankAccountService.getAll(groups.map((g) => g.group.id)) : []; } + + static async isOrgLocalAdmin(orgId: number, s?: Session | null) { + const session = s ?? (await this.getSession()); + if (!session?.user?.id) return false; + + // Dev bypass mirrors isDivisionTreasurer behaviour + if ( + process.env.NODE_ENV !== 'production' && + process.env.ADMIN_TEST_MODE === 'true' + ) + return false; // Don't short-circuit, let isDivisionTreasurer handle it + + const org = await OrgService.getById(orgId); + if (!org) return false; + + const activeGroupsWithPosts = await this.getActiveGroupsWithPosts(session); + return activeGroupsWithPosts.some( + (g) => + g.post.id === process.env.TREASURER_POST_ID && + g.group.superGroup!.id === org.ownerGammaSuperGroupId + ); + } + } From bd093d90e5ff532c6aaafd5d6bd72cfc01514ce0 Mon Sep 17 00:00:00 2001 From: Goostaf Date: Tue, 10 Mar 2026 17:44:50 +0100 Subject: [PATCH 2/3] Allow owners to delete their own items --- src/actions/expenses.ts | 44 +++++++++++++++++++++++++++----------- src/actions/invoices.ts | 14 ++++++++++++ src/actions/nameLists.ts | 7 ++++++ src/actions/zettleSales.ts | 7 ++++++ 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/src/actions/expenses.ts b/src/actions/expenses.ts index 86c26d1..118851e 100644 --- a/src/actions/expenses.ts +++ b/src/actions/expenses.ts @@ -236,14 +236,11 @@ export async function createPersonalExpense( ); } -async function assertOrgAdminForExpense(existing: NonNullable>>) { - const [divisionTreasurer, localAdmin] = await Promise.all([ - SessionService.isDivisionTreasurer(), - SessionService.isOrgLocalAdmin(existing.organizationId) - ]); - if (!divisionTreasurer && !localAdmin) { - throw new Error('User does not have admin permission for this expense'); - } +async function isOrgAdminForExpense(existing: NonNullable>>) { + return ( + await SessionService.isDivisionTreasurer() || + await SessionService.isOrgLocalAdmin(existing.organizationId) + ); } export async function markExpenseAsPaid(expenseId: number) { @@ -253,7 +250,9 @@ export async function markExpenseAsPaid(expenseId: number) { throw new Error('Expense does not exist'); } - await assertOrgAdminForExpense(existing); + if (!(await isOrgAdminForExpense(existing))) { + throw new Error('User does not have admin permission for this expense'); + } return ExpenseService.markAsPaid(expenseId); } @@ -265,7 +264,9 @@ export async function markExpenseAsUnpaid(expenseId: number) { throw new Error('Expense does not exist'); } - await assertOrgAdminForExpense(existing); + if (!(await isOrgAdminForExpense(existing))) { + throw new Error('User does not have admin permission for this expense'); + } return ExpenseService.markAsUnpaid(expenseId); } @@ -277,7 +278,20 @@ export async function deleteExpense(expenseId: number) { throw new Error('Expense does not exist'); } - await assertOrgAdminForExpense(existing); + const gammaUserId = (await SessionService.getUser())?.id; + + // Allow the creator to delete their own expense if it hasn't been paid or approved + if (gammaUserId && existing.gammaUserId === gammaUserId) { + if (existing.paidAt !== null || existing.status === RequestStatus.APPROVED) { + throw new Error('Expense cannot be deleted after it has been paid or approved'); + } + return ExpenseService.delete(expenseId); + } + + // Otherwise require org admin + if (!(await isOrgAdminForExpense(existing))) { + throw new Error('User does not have admin permission for this expense'); + } return ExpenseService.delete(expenseId); } @@ -289,7 +303,9 @@ export async function requestExpenseRevision(expenseId: number) { throw new Error('Expense does not exist'); } - await assertOrgAdminForExpense(existing); + if (!(await isOrgAdminForExpense(existing))) { + throw new Error('User does not have admin permission for this expense'); + } return ExpenseService.requestRevision(expenseId); } @@ -301,7 +317,9 @@ export async function approveExpense(expenseId: number) { throw new Error('Expense does not exist'); } - await assertOrgAdminForExpense(existing); + if (!(await isOrgAdminForExpense(existing))) { + throw new Error('User does not have admin permission for this expense'); + } return ExpenseService.approve(expenseId); } diff --git a/src/actions/invoices.ts b/src/actions/invoices.ts index 9db7acb..d0c0588 100644 --- a/src/actions/invoices.ts +++ b/src/actions/invoices.ts @@ -217,6 +217,20 @@ export async function markInvoiceAsNotSent(invoiceId: number) { } export async function deleteInvoice(invoiceId: number) { + const existing = await InvoiceService.getById(invoiceId); + if (existing === null) throw new Error('Invoice does not exist'); + + const gammaUserId = (await SessionService.getUser())?.id; + + // Allow the creator to delete their own invoice if it hasn't been sent or approved + if (gammaUserId && existing.gammaUserId === gammaUserId) { + if (existing.sentAt !== null || existing.status === RequestStatus.APPROVED) { + throw new Error('Invoice cannot be deleted after it has been sent or approved'); + } + return InvoiceService.delete(invoiceId); + } + + // Otherwise require org admin await assertOrgAdminForInvoice(invoiceId); return InvoiceService.delete(invoiceId); } diff --git a/src/actions/nameLists.ts b/src/actions/nameLists.ts index 474eee5..5454e46 100644 --- a/src/actions/nameLists.ts +++ b/src/actions/nameLists.ts @@ -135,6 +135,13 @@ export async function deleteNameList(id: number) { const existing = await NameListService.getById(id); if (existing === null) throw new Error('Name list does not exist'); + const gammaUserId = (await SessionService.getUser())?.id; + + // Allow creator to delete their own name list + if (gammaUserId && existing.gammaUserId === gammaUserId) { + return NameListService.delete(id); + } + const [divisionTreasurer, localAdmin] = await Promise.all([ SessionService.isDivisionTreasurer(), SessionService.isOrgLocalAdmin(existing.organizationId) diff --git a/src/actions/zettleSales.ts b/src/actions/zettleSales.ts index 162dcd3..56ff006 100644 --- a/src/actions/zettleSales.ts +++ b/src/actions/zettleSales.ts @@ -91,6 +91,13 @@ export async function deleteZettleSale(id: number) { const existing = await ZettleSaleService.getById(id); if (existing === null) throw new Error('Zettle sale does not exist'); + const gammaUserId = (await SessionService.getUser())?.id; + + // Allow creator to delete their own sale + if (gammaUserId && existing.gammaUserId === gammaUserId) { + return ZettleSaleService.delete(id); + } + const [divisionTreasurer, localAdmin] = await Promise.all([ SessionService.isDivisionTreasurer(), SessionService.isOrgLocalAdmin(existing.organizationId) From 6b2403afa5fa2617c80bdbb94821290179ea7d42 Mon Sep 17 00:00:00 2001 From: Goostaf Date: Tue, 10 Mar 2026 17:51:40 +0100 Subject: [PATCH 3/3] Remove org-local settings --- .../settings/OrganizationSettingsForm.tsx | 37 ------------ .../[locale]/org/[orgId]/settings/page.tsx | 59 ------------------- src/components/Navigation/Navigation.tsx | 12 ---- 3 files changed, 108 deletions(-) delete mode 100644 src/app/[locale]/org/[orgId]/settings/OrganizationSettingsForm.tsx delete mode 100644 src/app/[locale]/org/[orgId]/settings/page.tsx diff --git a/src/app/[locale]/org/[orgId]/settings/OrganizationSettingsForm.tsx b/src/app/[locale]/org/[orgId]/settings/OrganizationSettingsForm.tsx deleted file mode 100644 index 7a10b80..0000000 --- a/src/app/[locale]/org/[orgId]/settings/OrganizationSettingsForm.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import { - Box, - Fieldset, - Input, - Heading, - createListCollection, - IconButton, - Text, - Table -} from '@chakra-ui/react'; -import { Field } from '@/components/ui/field'; -import { Button } from '@/components/ui/button'; -import i18nService from '@/services/i18nService'; -import OrgService from '@/services/orgService'; - -export default function OrganizationSettingsForm({ - locale, - organization -}: {locale: string, organization: NonNullable>>}) { - const l = i18nService.getLocale(locale); - - return
- - - - - - - -
; -} diff --git a/src/app/[locale]/org/[orgId]/settings/page.tsx b/src/app/[locale]/org/[orgId]/settings/page.tsx deleted file mode 100644 index 82e3335..0000000 --- a/src/app/[locale]/org/[orgId]/settings/page.tsx +++ /dev/null @@ -1,59 +0,0 @@ -'use server'; - -import { Box, Flex, Heading, Text, VStack } from '@chakra-ui/react'; -import { - BreadcrumbCurrentLink, - BreadcrumbLink, - BreadcrumbRoot -} from '@/components/ui/breadcrumb'; -import i18nService from '@/services/i18nService'; -import SessionService from '@/services/sessionService'; -import { notFound } from 'next/navigation'; -import OrgService from '@/services/orgService'; -import OrganizationSettingsForm from './OrganizationSettingsForm'; - -export default async function Page(props: { - params: Promise<{ locale: string; orgId: string }>; -}) { - const { locale, orgId } = await props.params; - const orgIdNum = Number(orgId); - - const [divisionTreasurer, localAdmin] = await Promise.all([ - SessionService.isDivisionTreasurer(), - SessionService.isOrgLocalAdmin(orgIdNum) - ]); - if (!divisionTreasurer && !localAdmin) { - notFound(); - } - const l = i18nService.getLocale(locale); - - const organization = await OrgService.getById(Number(orgId)); - if (!organization) { - notFound(); - } - - return ( - <> - - {l.home.title} - {organization.name} - - - - - - - - Organization - - - Manage your organization settings - - - - - - - - ); -} diff --git a/src/components/Navigation/Navigation.tsx b/src/components/Navigation/Navigation.tsx index 1cc96b9..271c25e 100644 --- a/src/components/Navigation/Navigation.tsx +++ b/src/components/Navigation/Navigation.tsx @@ -28,10 +28,6 @@ const Navigation = async ({ const divisionTreasurer = await SessionService.isDivisionTreasurer(); const organizations = await OrgService.getAll(); - const orgLocalAdmin = orgId !== undefined - ? await SessionService.isOrgLocalAdmin(orgId) - : false; - const orgPrefix = orgId !== undefined ? `/org/${orgId}` : '/'; return ( @@ -106,14 +102,6 @@ const Navigation = async ({ {' '} {l.categories.receiptCreator} - {(divisionTreasurer || orgLocalAdmin) && ( - - - - {' '} - Settings - - )} )}